/**
 * relink-audit — diagnostic tool that inspects tab↔JSONL mappings and reports
 * cross-contamination, staleness, or orphaned state.
 *
 * Used by:
 *   - GET /api/sessions/relink-audit (live server)
 *   - scripts/audit-relink.js       (CLI, read-only against any DB path)
 *
 * READ-ONLY. Does not mutate DB or filesystem.
 */

'use strict';

const fs = require('fs');
const path = require('path');
const { readJsonlHeader, normalizeCwd, readFirstUserMessage, wordOverlap } = require('./session-verify.js');
const { detectAgentType, normalizeAgentType } = require('./agent-capabilities.js');
const {
  codexMessageFromEntry,
  codexRolloutIdFromPath,
  extractResumeTarget,
} = require('./session-history.js');

function parseJsonArgs(raw) {
  if (Array.isArray(raw)) return raw;
  try {
    const parsed = JSON.parse(raw || '[]');
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function readCodexHeader(filePath, maxBytes = 64 * 1024) {
  let fd;
  try {
    fd = fs.openSync(filePath, 'r');
    const buf = Buffer.alloc(maxBytes);
    const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0);
    if (bytesRead <= 0) return null;
    const text = buf.subarray(0, bytesRead).toString('utf8');
    for (const line of text.split('\n')) {
      if (!line || line[0] !== '{') continue;
      let row;
      try { row = JSON.parse(line); } catch { continue; }
      if (row?.type !== 'session_meta') continue;
      const payload = row.payload || {};
      return {
        sessionId: payload.id || codexRolloutIdFromPath(filePath) || null,
        cwd: payload.cwd || null,
        timestamp: payload.timestamp || row.timestamp || null,
        parentUuid: null,
        version: null,
        gitBranch: payload.git_branch || null,
        type: row.type || null,
      };
    }
    return null;
  } catch {
    return null;
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

function readAgentHeader(filePath, agentType) {
  const type = normalizeAgentType(agentType || '') || agentType;
  if (type === 'codex') return readCodexHeader(filePath);
  return readJsonlHeader(filePath);
}

function readCodexFirstUserMessage(filePath, maxBytes = 256 * 1024) {
  let fd;
  try {
    fd = fs.openSync(filePath, 'r');
    const buf = Buffer.alloc(maxBytes);
    const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0);
    if (bytesRead <= 0) return null;
    const text = buf.subarray(0, bytesRead).toString('utf8');
    for (const line of text.split('\n')) {
      if (!line || line[0] !== '{') continue;
      let row;
      try { row = JSON.parse(line); } catch { continue; }
      const msg = codexMessageFromEntry(row);
      if (msg?.role === 'user' && msg.text) return msg.text;
    }
    return null;
  } catch {
    return null;
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

function readAgentFirstUserMessage(filePath, agentType) {
  const type = normalizeAgentType(agentType || '') || agentType;
  if (type === 'codex') return readCodexFirstUserMessage(filePath);
  return readFirstUserMessage(filePath);
}

/**
 * Main entry point.
 * @param {Object} opts
 * @param {import('better-sqlite3').Database} opts.db
 * @param {string} [opts.projectsDir] — defaults to ~/.claude/projects
 * @returns {{ tabs: Array, summary: Object }}
 */
function auditRelink({ db, projectsDir }) {
  const claudeProjectsDir = projectsDir || path.join(process.env.HOME || '', '.claude', 'projects');
  const hasStartupTasks = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='startup_tasks'").get();
  if (!hasStartupTasks) {
    return { tabs: [], summary: { total: 0, clean: 0, cross_linked: 0, stale: 0, orphan: 0, unclassified: 0 } };
  }

  const stCols = new Set(db.prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name));
  const ctmCols = new Set(db.prepare('PRAGMA table_info(ctm_sessions)').all().map(c => c.name));
  const stSelect = (name, alias = name, fallback = "''") => stCols.has(name)
    ? `st.${name} AS ${alias}`
    : `${fallback} AS ${alias}`;
  const ctmSelect = (name, alias = name, fallback = "''") => ctmCols.has(name)
    ? `cs.${name} AS ${alias}`
    : `${fallback} AS ${alias}`;
  const hasAgentSessions = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_sessions'").get();
  const agentSessionsListExpr = hasAgentSessions
    ? `(SELECT group_concat(agent_session_id || ':' || COALESCE(file_size, 0), ';')
         FROM agent_sessions
        WHERE ctm_session_id = st.ctm_session_id) AS agent_sessions_list`
    : "'' AS agent_sessions_list";

  const rows = db.prepare(`
    SELECT
      st.ctm_session_id,
      ${stSelect('cmd')},
      ${stSelect('label')},
      ${stSelect('args', 'args', "'[]'")},
      ${stSelect('agent_session_id', 'startup_agent_id')},
      ${stSelect('cwd')},
      ${stSelect('created_at')},
      ${ctmSelect('provider')},
      ${ctmSelect('project_path')},
      ${agentSessionsListExpr}
    FROM startup_tasks st
    LEFT JOIN ctm_sessions cs ON cs.id = st.ctm_session_id
    ORDER BY created_at
  `).all();

  const agentCols = hasAgentSessions
    ? new Set(db.prepare('PRAGMA table_info(agent_sessions)').all().map(c => c.name))
    : new Set();
  const agentSelect = (name, fallback = "''") => agentCols.has(name) ? name : `${fallback} AS ${name}`;
  const agentRows = hasAgentSessions ? db.prepare(`
    SELECT agent_session_id, ctm_session_id,
      ${agentSelect('provider')},
      ${agentSelect('provider_resume_id')},
      ${agentSelect('jsonl_path')},
      ${agentSelect('file_size', '0')},
      ${agentSelect('first_message')},
      ${agentSelect('project_path')},
      ${agentSelect('modified_at')}
    FROM agent_sessions
    WHERE agent_session_id IS NOT NULL AND agent_session_id != ''
  `).all() : [];
  const agentsByCtm = new Map();
  const ownerByAgent = new Map();
  for (const agent of agentRows) {
    if (!agentsByCtm.has(agent.ctm_session_id)) agentsByCtm.set(agent.ctm_session_id, []);
    agentsByCtm.get(agent.ctm_session_id).push(agent);
    ownerByAgent.set(agent.agent_session_id, agent);
  }

  // Build a set of all ctm_session_ids — used to detect classic cross-link signature:
  // tab A's agent_session_id == tab B's ctm_session_id (means A got linked to B's original JSONL).
  const allCtmIds = new Set(rows.map(r => r.ctm_session_id));

  const startupByCtm = new Map();
  for (const row of rows) {
    const agentType = detectAgentType(row.cmd || '') || normalizeAgentType(row.provider || '') || null;
    const args = parseJsonArgs(row.args);
    startupByCtm.set(row.ctm_session_id, {
      ...row,
      agentType,
      args,
      argsResumeId: extractResumeTarget(agentType, args)?.sessionId || null,
    });
  }

  // Also load session_conversations to compare first_message content when needed
  const hasSessionConversations = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_conversations'").get();
  const convoRows = hasSessionConversations
    ? db.prepare('SELECT ctm_session_id, first_message, user_msg_count FROM session_conversations').all()
    : [];
  const convoByCtm = {};
  for (const c of convoRows) convoByCtm[c.ctm_session_id] = c;

  const results = [];
  for (const row of rows) {
    const startup = startupByCtm.get(row.ctm_session_id) || {};
    const agentType = startup.agentType || detectAgentType(row.cmd || '') || null;
    const tabCwd = row.cwd || '';
    const tabCreatedAt = row.created_at ? Date.parse(row.created_at + (row.created_at.includes('Z') ? '' : 'Z')) : NaN;
    const cwdEntry = tabCwd ? tabCwd.replace(/\//g, '-').replace(/^-+/, '-') : '';
    const projectDir = cwdEntry ? path.join(claudeProjectsDir, cwdEntry) : '';

    // Parse startup_tasks.args to extract original --resume target
    const argsResumeId = startup.argsResumeId || null;

    // Collect all agent_sessions rows that claim this ctm_session_id
    const agentList = agentsByCtm.get(row.ctm_session_id) || [];

    // Tab's expected first user message (from session_conversations cache)
    const tabFirstMsg = convoByCtm[row.ctm_session_id]?.first_message || '';

    // Check each agent_session's JSONL content
    const agents = [];
    for (const ag of agentList) {
      const agType = normalizeAgentType(ag.provider || '') || agentType;
      const filePath = ag.jsonl_path || (agType === 'codex' ? '' : path.join(projectDir, `${ag.agent_session_id}.jsonl`));
      const exists = fs.existsSync(filePath);
      let header = null;
      let stat = null;
      let fileFirstUserMsg = null;
      let contentOverlap = null;
      if (exists) {
        try { stat = fs.statSync(filePath); } catch {}
        header = readAgentHeader(filePath, agType);
        // Only bother reading first user message if we have a tab message to compare
        if (tabFirstMsg && tabFirstMsg.length >= 10) {
          fileFirstUserMsg = readAgentFirstUserMessage(filePath, agType);
          if (fileFirstUserMsg) {
            contentOverlap = wordOverlap(tabFirstMsg, fileFirstUserMsg);
          }
        }
      }
      const cwdMismatch = header && header.cwd && tabCwd &&
        normalizeCwd(header.cwd) !== normalizeCwd(tabCwd);
      // Timestamp check: file must have been written at or after tab was created.
      // A file that predates the tab by >5s CANNOT be a legitimate continuation.
      let timestampBeforeTab = false;
      if (header && header.timestamp && Number.isFinite(tabCreatedAt)) {
        const fileTs = Date.parse(header.timestamp);
        if (Number.isFinite(fileTs) && fileTs < tabCreatedAt - 5000) {
          timestampBeforeTab = true;
        }
      }
      // Cross-link signature: this agent_session_id is also another tab's ctm_session_id.
      // Almost always indicates the relink grabbed another tab's original JSONL.
      const isOtherTabsCtmId = allCtmIds.has(ag.agent_session_id) && ag.agent_session_id !== row.ctm_session_id;
      // Content-mismatch signal: tab's expected first_message doesn't overlap with file's first user msg.
      // Threshold: <50% word overlap indicates unrelated conversations. 50%+ means
      // significant shared vocabulary (likely same conversation or natural continuation).
      const contentMismatch = contentOverlap !== null && contentOverlap < 0.5;
      agents.push({
        agent_session_id: ag.agent_session_id,
        file_size: ag.file_size,
        file_exists: exists,
        file_size_on_disk: stat ? stat.size : null,
        modifiedAt: stat ? new Date(stat.mtimeMs).toISOString() : null,
        header_session_id: header ? header.sessionId : null,
        header_cwd: header ? header.cwd : null,
        header_timestamp: header ? header.timestamp : null,
        cwd_matches_tab: !cwdMismatch,
        timestamp_before_tab: timestampBeforeTab,
        content_overlap: contentOverlap,
        content_mismatch: contentMismatch,
        is_startup_target: ag.agent_session_id === row.startup_agent_id,
        is_args_target: ag.agent_session_id === argsResumeId,
        is_self_ref: ag.agent_session_id === row.ctm_session_id,
        is_other_tabs_ctm_id: isOtherTabsCtmId,
      });
    }

    // Verdict logic:
    //   clean        — startup target exists, its cwd matches tab, no other cwd-mismatched agents
    //   cross_linked — startup target's header cwd does NOT match tab cwd (pointing at wrong conversation)
    //   stale        — startup target file missing or size=0
    //   orphan       — no startup target set
    //   unclassified — couldn't determine (missing data)
    let verdict = 'unclassified';
    let details = {};
    const startupAgent = agents.find(a => a.is_startup_target);
    const argsTargetOwner = argsResumeId && argsResumeId !== row.startup_agent_id
      ? ownerByAgent.get(argsResumeId)
      : null;
    const inverseStartup = argsTargetOwner?.ctm_session_id
      ? startupByCtm.get(argsTargetOwner.ctm_session_id)
      : null;
    const isTwoWayCodexSwap = agentType === 'codex'
      && argsTargetOwner
      && argsTargetOwner.ctm_session_id !== row.ctm_session_id
      && inverseStartup?.argsResumeId
      && inverseStartup.argsResumeId === row.startup_agent_id;

    if (isTwoWayCodexSwap) {
      verdict = 'cross_linked';
      details.reason = 'codex startup resume target and agent_session_id are swapped with another tab';
      details.ctm_session_id = row.ctm_session_id;
      details.other_ctm_session_id = argsTargetOwner.ctm_session_id;
      details.args_resume_id = argsResumeId;
      details.startup_agent_id = row.startup_agent_id;
      details.other_args_resume_id = inverseStartup.argsResumeId;
      details.suggested_fix = `swap Codex agent owners so ${row.ctm_session_id.slice(0, 8)} owns ${argsResumeId.slice(0, 8)} and ${argsTargetOwner.ctm_session_id.slice(0, 8)} owns ${row.startup_agent_id.slice(0, 8)}`;
    } else if (!row.startup_agent_id) {
      verdict = 'orphan';
      details.reason = 'startup_tasks.agent_session_id is NULL';
    } else if (!startupAgent) {
      verdict = 'stale';
      details.reason = 'startup agent_session_id not present in agent_sessions table';
    } else if (!startupAgent.file_exists) {
      verdict = 'stale';
      details.reason = 'startup target JSONL missing on disk';
    } else if (startupAgent.header_cwd && tabCwd && !startupAgent.cwd_matches_tab) {
      verdict = 'cross_linked';
      details.reason = 'startup target JSONL has cwd different from tab cwd';
      details.tab_cwd = tabCwd;
      details.file_cwd = startupAgent.header_cwd;
    } else if (startupAgent.content_mismatch) {
      // Strongest signal: startup target's first user message doesn't match the tab's
      // stored first_message. Conversations are provably unrelated.
      //
      // Extra evidence: if there's a self-ref agent (file ID = tab ID) with MUCH
      // higher content overlap, it's almost certainly the tab's real conversation
      // and the startup target is a wrong link.
      verdict = 'cross_linked';
      details.reason = 'startup target JSONL has different conversation content than tab';
      details.content_overlap = startupAgent.content_overlap;
      const selfRef = agents.find(a => a.is_self_ref);
      if (selfRef && selfRef.content_overlap !== null && selfRef.content_overlap > (startupAgent.content_overlap + 0.3)) {
        details.suggested_fix = `reset startup target to self-ref ${selfRef.agent_session_id} (overlap ${selfRef.content_overlap.toFixed(2)} vs startup ${startupAgent.content_overlap.toFixed(2)})`;
      }
    } else if (startupAgent.is_other_tabs_ctm_id) {
      // Hard signal: the startup target is ANOTHER tab's ctm_session_id — almost
      // always indicates a cross-link (relink grabbed another tab's original JSONL).
      verdict = 'cross_linked';
      details.reason = 'startup target is another tab\'s ctm_session_id';
      details.other_tab_id = startupAgent.agent_session_id;
    } else if (agents.some(a => !a.is_startup_target && a.is_other_tabs_ctm_id)) {
      // Residue from past cross-link: orphan agent_sessions row pointing at another tab.
      verdict = 'cross_linked';
      details.reason = 'one or more agent_sessions rows point at another tab\'s ctm_session_id';
      details.cross_link_agents = agents.filter(a => a.is_other_tabs_ctm_id).map(a => a.agent_session_id);
    } else if (agents.some(a => a.file_exists && a.header_cwd && !a.cwd_matches_tab)) {
      // Orphan agent_sessions with wrong cwd
      verdict = 'cross_linked';
      details.reason = 'one or more agent_sessions rows reference JSONLs with wrong cwd';
      details.wrong_cwd_agents = agents.filter(a => a.header_cwd && !a.cwd_matches_tab).map(a => a.agent_session_id);
    } else if (startupAgent.timestamp_before_tab && row.created_at) {
      // Only trust this signal for fresh tabs (NOT restart-refreshed rows).
      // Since startup_tasks.created_at resets on each restart, this check is unreliable
      // for tabs that have been around. If startup args explicitly resume the
      // same agent, an older JSONL is expected; otherwise flag only when the
      // file itself was last modified before the tab was created.
      if (startupAgent.is_args_target) {
        verdict = 'clean';
      } else if (startupAgent.modifiedAt && Date.parse(startupAgent.modifiedAt) < tabCreatedAt - 5000) {
        verdict = 'cross_linked';
        details.reason = 'startup target JSONL was last modified before tab creation (stale file)';
        details.tab_created_at = row.created_at;
        details.file_mtime = startupAgent.modifiedAt;
      } else {
        verdict = 'clean';
      }
    } else {
      verdict = 'clean';
    }

    results.push({
      ctm_session_id: row.ctm_session_id,
      label: row.label,
      cwd: tabCwd,
      project_path: row.project_path,
      created_at: row.created_at,
      args_resume_id: argsResumeId,
      startup_agent_id: row.startup_agent_id,
      agents,
      verdict,
      details,
    });
  }

  const summary = {
    total: results.length,
    clean: results.filter(r => r.verdict === 'clean').length,
    cross_linked: results.filter(r => r.verdict === 'cross_linked').length,
    stale: results.filter(r => r.verdict === 'stale').length,
    orphan: results.filter(r => r.verdict === 'orphan').length,
    unclassified: results.filter(r => r.verdict === 'unclassified').length,
  };

  return { tabs: results, summary };
}

module.exports = { auditRelink };
