/**
 * session-lineage — slug-first verification that a Claude JSONL legitimately
 * belongs to a given CTM tab.
 *
 * Background: Claude Code writes a `slug` field on every JSONL row (a stable,
 * human-readable conversation-level identifier like "starry-churning-glacier").
 * The slug persists across `--resume` boundaries: if session X is resumed as
 * session Y, Y.jsonl starts with prefix-copied rows carrying X's sessionId,
 * then switches to Y's sessionId, and slug stays the same throughout.
 *
 * This module replaces the word-overlap heuristic in session-relink, which
 * was too easily fooled by short candidate messages against long drifted
 * baselines. Layering (in order):
 *   1. slug match against the tab's canonical slug
 *   2. prefix-copy sessionId match against a known agent_session of this tab
 *   3. (future) lsof on the tab's live PTY process
 *   4. fallback refuse — safe default when no signal is usable
 *
 * Backward-compat: JSONLs without a slug field (older Claude Code versions)
 * return slug=null; verifyLineage then refuses with reason 'unverifiable_no_slug'
 * — better to leave unlinked than cross-link without evidence.
 */

'use strict';

const fs = require('fs');
const { normalizeCwd } = require('./session-verify.js');

// Claude Code JSONLs often begin with a very large file-history-snapshot
// row (tracking many backed-up files), sometimes 20KB+ each. Some files
// have 20+ such snapshot/progress rows before the first row with a slug.
// The default scan budget needs to be large enough to reach the first
// user/system row in the tail of the bookkeeping preamble.
const DEFAULT_HEADER_BYTES = 2 * 1024 * 1024; // 2 MB
const DEFAULT_PREFIX_ROWS = 50;
const MAX_LINE_LENGTH = 4 * 1024 * 1024; // skip individual rows larger than 4MB

/**
 * Read the JSONL incrementally until we find a row with `slug`, or we hit
 * our byte budget, or we run out of lines.
 *
 * Returns { slug, sessionId, parentUuid, cwd, timestamp } — any field may
 * be null if unavailable.
 *
 * Incremental reading matters because Claude Code's JSONL sometimes has
 * single rows larger than 20 KB (file-history-snapshots for tracked backups),
 * and a naive fixed-size buffer that splits mid-row fails to JSON.parse
 * subsequent rows. We buffer bytes, emit complete lines (bounded by
 * MAX_LINE_LENGTH to avoid unbounded memory), and stop as soon as we see slug.
 */
function readFirstSlug(filePath, maxBytes = DEFAULT_HEADER_BYTES) {
  const empty = { slug: null, sessionId: null, parentUuid: null, cwd: null, timestamp: null };
  let fd;
  try {
    fd = fs.openSync(filePath, 'r');
    const CHUNK = 64 * 1024;
    const chunk = Buffer.alloc(CHUNK);
    let carry = '';
    let offset = 0;
    let firstRowMeta = null;
    let skippedLine = false;
    while (offset < maxBytes) {
      const want = Math.min(CHUNK, maxBytes - offset);
      const n = fs.readSync(fd, chunk, 0, want, offset);
      if (n <= 0) break;
      offset += n;
      carry += chunk.subarray(0, n).toString('utf8');

      // Guard: an extremely long line (no newline) shouldn't blow up RAM.
      // If we've accumulated >4MB without a newline, drop the carry and skip
      // ahead to the next newline if/when it appears.
      if (carry.length > MAX_LINE_LENGTH && carry.indexOf('\n') === -1) {
        carry = '';
        skippedLine = true;
        continue;
      }

      let nlIdx;
      while ((nlIdx = carry.indexOf('\n')) !== -1) {
        const line = carry.slice(0, nlIdx);
        carry = carry.slice(nlIdx + 1);
        if (skippedLine) { skippedLine = false; continue; } // recover from dropped giant line
        if (!line || line[0] !== '{') continue;
        let row;
        try { row = JSON.parse(line); } catch { continue; }
        if (!firstRowMeta) {
          firstRowMeta = {
            slug: row.slug || null,
            sessionId: row.sessionId || null,
            parentUuid: typeof row.parentUuid === 'string' ? row.parentUuid : null,
            cwd: row.cwd || null,
            timestamp: row.timestamp || null,
          };
        }
        if (row.slug) {
          return {
            slug: row.slug,
            sessionId: row.sessionId || firstRowMeta.sessionId,
            parentUuid: typeof row.parentUuid === 'string' ? row.parentUuid : firstRowMeta.parentUuid,
            cwd: row.cwd || firstRowMeta.cwd,
            timestamp: row.timestamp || firstRowMeta.timestamp,
          };
        }
      }
    }
    // Final flush: if there's no trailing newline, try to parse the carry.
    if (carry && carry[0] === '{') {
      try {
        const row = JSON.parse(carry);
        if (row.slug) {
          return {
            slug: row.slug,
            sessionId: row.sessionId || (firstRowMeta && firstRowMeta.sessionId) || null,
            parentUuid: typeof row.parentUuid === 'string' ? row.parentUuid : (firstRowMeta && firstRowMeta.parentUuid) || null,
            cwd: row.cwd || (firstRowMeta && firstRowMeta.cwd) || null,
            timestamp: row.timestamp || (firstRowMeta && firstRowMeta.timestamp) || null,
          };
        }
      } catch {}
    }
    return firstRowMeta || empty;
  } catch {
    return empty;
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

/**
 * Scan the first N rows of a JSONL and return the distinct sessionId values
 * that appear. In the `--resume` flow, the first rows are prefix-copied from
 * the parent session (carrying the parent's sessionId), then the file switches
 * to its own sessionId. Returning both identifies the lineage.
 */
function readPrefixSessionIds(filePath, maxRows = DEFAULT_PREFIX_ROWS, maxBytes = DEFAULT_HEADER_BYTES * 8) {
  const seen = [];
  let fd;
  try {
    fd = fs.openSync(filePath, 'r');
    const buf = Buffer.alloc(maxBytes);
    const n = fs.readSync(fd, buf, 0, maxBytes, 0);
    if (n <= 0) return seen;
    const text = buf.subarray(0, n).toString('utf8');
    let rowCount = 0;
    for (const line of text.split('\n')) {
      if (!line || line[0] !== '{') continue;
      if (rowCount >= maxRows) break;
      rowCount++;
      let row;
      try { row = JSON.parse(line); } catch { continue; }
      const sid = row.sessionId;
      if (sid && !seen.includes(sid)) seen.push(sid);
    }
  } catch {
    // fall through
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
  return seen;
}

/**
 * Compute the canonical slug for a CTM tab.
 *
 * Preference order:
 *   1. Self-ref agent_sessions row (agent_session_id == ctm_session_id).
 *      This is the tab's original conversation; its slug is authoritative.
 *   2. Earliest non-empty slug among agent_sessions rows for this tab.
 *      Covers tabs where the self-ref was deleted or never existed (e.g.
 *      tabs created via --resume of an already-continued session).
 *
 * Returns the slug string, or null if no row has a populated slug.
 *
 * Backward-compat: empty slug values (un-backfilled) are skipped, not treated
 * as the canonical. This lets the migration progressively backfill without
 * poisoning the lookup during the transition.
 */
function canonicalSlugForTab(db, ctmSessionId) {
  try {
    const selfRef = db.prepare(
      "SELECT slug FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id = ? AND slug IS NOT NULL AND slug != ''"
    ).get(ctmSessionId, ctmSessionId);
    if (selfRef && selfRef.slug) return selfRef.slug;
    const earliest = db.prepare(
      "SELECT slug FROM agent_sessions WHERE ctm_session_id = ? AND slug IS NOT NULL AND slug != '' ORDER BY created_at ASC LIMIT 1"
    ).get(ctmSessionId);
    return (earliest && earliest.slug) || null;
  } catch {
    return null;
  }
}

/**
 * Fetch all known agent_session_ids for a tab — the "chain" against which
 * prefix-copy sessionIds are checked.
 */
function knownAgentIdsForTab(db, ctmSessionId) {
  try {
    const rows = db.prepare(
      'SELECT agent_session_id FROM agent_sessions WHERE ctm_session_id = ?'
    ).all(ctmSessionId);
    return new Set(rows.map((r) => r.agent_session_id));
  } catch {
    return new Set();
  }
}

/**
 * Verify that a candidate JSONL legitimately belongs to the given CTM tab.
 *
 * tabContext:
 *   {
 *     ctmSessionId: string  (required)
 *     cwd:          string  (tab's cwd for structural check)
 *     createdAt:    number  (ms epoch for timestamp sanity)
 *   }
 *
 * Returns:
 *   { ok: true, reason: 'slug_match' | 'prefix_copy', details?: object }
 *   { ok: false, reason: string, details?: object }
 *
 * Reasons for rejection include:
 *   'unreadable', 'cwd_mismatch', 'unverifiable_no_slug', 'slug_mismatch',
 *   'no_canonical_slug'.
 */
function verifyLineage(candidatePath, tabContext, db) {
  const header = readFirstSlug(candidatePath);
  if (!header.sessionId && !header.slug) {
    return { ok: false, reason: 'unreadable', details: { filePath: candidatePath } };
  }

  // Structural gate: cwd must match. Preserves the one check from the old
  // verifier that is authoritatively cheap.
  if (tabContext.cwd && header.cwd &&
      normalizeCwd(header.cwd) !== normalizeCwd(tabContext.cwd)) {
    return {
      ok: false,
      reason: 'cwd_mismatch',
      details: { tab: tabContext.cwd, file: header.cwd },
    };
  }

  const canonicalSlug = canonicalSlugForTab(db, tabContext.ctmSessionId);

  // Layer 1: slug match. The primary, cheap, reliable signal.
  if (canonicalSlug && header.slug && canonicalSlug === header.slug) {
    return { ok: true, reason: 'slug_match', details: { slug: canonicalSlug } };
  }

  // Layer 2: prefix-copy. Read the candidate's first N sessionIds; if any
  // match an existing agent_session of this tab, the candidate is a
  // --resume of our chain.
  const knownIds = knownAgentIdsForTab(db, tabContext.ctmSessionId);
  if (knownIds.size > 0) {
    const prefixIds = readPrefixSessionIds(candidatePath);
    for (const sid of prefixIds) {
      if (knownIds.has(sid)) {
        return { ok: true, reason: 'prefix_copy', details: { matchedAgentId: sid } };
      }
    }
  }

  // Layer 3: lsof — deferred. Wiring it in requires passing the tab's PTY
  // PID into tabContext; the scheduler job has it on `session.ptyProcess.pid`.
  // For now we leave the layer open and refuse below. When wired, this is:
  //   if (tabContext.ptyPid && (await lsofSessionIdForPty(ptyPid)) === expectedSid) return ok
  // (Non-async now; relink is synchronous.)

  // Refusal cases. Order matters for diagnostics:
  if (!header.slug) {
    // Backward-compat: older Claude Code might not write slug. We can't
    // confirm, so we refuse — safer than cross-linking.
    return { ok: false, reason: 'unverifiable_no_slug', details: { filePath: candidatePath } };
  }
  if (!canonicalSlug) {
    return { ok: false, reason: 'no_canonical_slug', details: { ctmSessionId: tabContext.ctmSessionId } };
  }
  return {
    ok: false,
    reason: 'slug_mismatch',
    details: { canonical: canonicalSlug, candidate: header.slug },
  };
}

module.exports = {
  readFirstSlug,
  readPrefixSessionIds,
  canonicalSlugForTab,
  knownAgentIdsForTab,
  verifyLineage,
};
