/**
 * session-verify — utilities for verifying a Claude JSONL file actually
 * belongs to a given CTM tab. Prevents cross-contamination during relink.
 *
 * Core idea: every Claude JSONL row has structural fields (sessionId, cwd,
 * timestamp) that uniquely identify its conversation. By reading just the
 * first row (a few KB) we can authoritatively check whether a candidate
 * file is a legitimate continuation for a given tab, rather than relying on
 * filesystem heuristics (birthtime, mtime, size) that break under
 * mass-spawn races.
 */

'use strict';

const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');

// Maximum bytes to read looking for the first-row JSON.
// Claude's first row is typically < 1KB; 4KB gives generous slack.
const DEFAULT_HEADER_BYTES = 4096;

/**
 * Read and parse the first row of a Claude JSONL file.
 * Returns { sessionId, cwd, timestamp, parentUuid, version, gitBranch } or null.
 * Safe to call on missing / corrupt / empty files — returns null in all error cases.
 */
function readJsonlHeader(filePath, maxBytes = DEFAULT_HEADER_BYTES) {
  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 slice = buf.subarray(0, bytesRead).toString('utf8');
    const nl = slice.indexOf('\n');
    const firstLine = nl >= 0 ? slice.slice(0, nl) : slice;
    if (!firstLine.trim()) return null;
    const obj = JSON.parse(firstLine);
    return {
      sessionId: obj.sessionId || null,
      cwd: obj.cwd || null,
      timestamp: obj.timestamp || null,
      parentUuid: obj.parentUuid || null,
      version: obj.version || null,
      gitBranch: obj.gitBranch || null,
      type: obj.type || null,
    };
  } catch {
    return null;
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

/**
 * Normalize cwd for comparison — strip trailing slashes, resolve double slashes,
 * and tolerate the leading `//` prefix CTM sometimes stores in project_path.
 */
function normalizeCwd(p) {
  if (!p) return '';
  return String(p).replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
}

/**
 * Verify that a candidate JSONL file is a legitimate relink target for a tab.
 *
 * tabContext shape:
 *   {
 *     cwd: string,              // tab's working directory
 *     createdAt: number,        // tab's createdAt (ms epoch)
 *     currentAgentSessionId?: string,  // tab's current mapping (optional, for skip-self)
 *     expectedSessionId?: string,      // filename-derived id (optional sanity check)
 *   }
 *
 * Returns { ok: true, header } on success.
 * Returns { ok: false, reason: string, details: ... } on failure.
 */
function verifyCandidateForTab(candidateFilePath, tabContext) {
  const hdr = readJsonlHeader(candidateFilePath);
  if (!hdr) {
    return { ok: false, reason: 'unreadable', details: { filePath: candidateFilePath } };
  }
  // sessionId header must match filename — if not, the file was mis-indexed
  const expectedSid = path.basename(candidateFilePath, '.jsonl');
  if (hdr.sessionId && hdr.sessionId !== expectedSid) {
    return {
      ok: false,
      reason: 'sessionid_header_mismatch',
      details: { filename: expectedSid, header: hdr.sessionId },
    };
  }
  // cwd must match tab's cwd
  if (tabContext.cwd && hdr.cwd) {
    if (normalizeCwd(hdr.cwd) !== normalizeCwd(tabContext.cwd)) {
      return {
        ok: false,
        reason: 'cwd_mismatch',
        details: { tab: tabContext.cwd, file: hdr.cwd },
      };
    }
  }
  // timestamp must be at or after tab.createdAt - 5s (tolerance for clock skew)
  if (tabContext.createdAt && hdr.timestamp) {
    const fileTs = Date.parse(hdr.timestamp);
    if (Number.isFinite(fileTs) && fileTs < tabContext.createdAt - 5000) {
      return {
        ok: false,
        reason: 'file_predates_tab',
        details: { tabCreatedAt: tabContext.createdAt, fileTimestamp: hdr.timestamp },
      };
    }
  }
  return { ok: true, header: hdr };
}

/**
 * Return file paths open by a given process. Uses `lsof -p <pid> -Fn`.
 * On systems without lsof (or on error / timeout), returns an empty array.
 * Never throws.
 */
function lsofOpenFiles(pid, timeoutMs = 2000) {
  return new Promise((resolve) => {
    if (!Number.isFinite(pid) || pid <= 0) { resolve([]); return; }
    const child = execFile(
      'lsof',
      ['-p', String(pid), '-Fn'],
      { timeout: timeoutMs, maxBuffer: 1024 * 1024 },
      (err, stdout) => {
        if (err || !stdout) { resolve([]); return; }
        const files = [];
        for (const line of String(stdout).split('\n')) {
          // `-Fn` prefixes path lines with 'n'
          if (line.startsWith('n/')) files.push(line.slice(1));
        }
        resolve(files);
      }
    );
    // Extra safety: kill the subprocess if it outlives the timeout
    setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, timeoutMs + 500);
  });
}

/**
 * Given a PTY process PID, return the single JSONL session id it has open
 * under ~/.claude/projects/, or null if ambiguous / unavailable.
 */
async function lsofSessionIdForPty(pid) {
  const files = await lsofOpenFiles(pid);
  const jsonls = files.filter(f => /\.jsonl$/.test(f) && f.includes('/.claude/projects/'));
  if (jsonls.length !== 1) return null;
  return path.basename(jsonls[0], '.jsonl');
}

/**
 * Extract the first user message content from a JSONL file.
 * Reads up to maxBytes and scans line-by-line until a type:"user" row is found.
 * Returns the text content (string) or null.
 */
function readFirstUserMessage(filePath, maxBytes = 65536) {
  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 !== 'user') continue;
      const msg = row.message || {};
      let content = msg.content;
      if (Array.isArray(content)) {
        content = content.filter(c => c && typeof c === 'object' && c.type === 'text')
          .map(c => c.text || '').join(' ');
      }
      if (typeof content === 'string' && content.trim()) return content;
    }
    return null;
  } catch {
    return null;
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

/**
 * Compute word-overlap ratio between two strings (0..1).
 * Used to detect whether a candidate JSONL holds the same conversation as
 * the tab's expected first_message. Low overlap = cross-link.
 */
function wordOverlap(a, b) {
  if (!a || !b) return 0;
  const tokens = s => new Set(
    String(s).toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length >= 3)
  );
  const sa = tokens(a);
  const sb = tokens(b);
  if (sa.size === 0 || sb.size === 0) return 0;
  let common = 0;
  for (const w of sa) if (sb.has(w)) common++;
  return common / Math.min(sa.size, sb.size);
}

module.exports = {
  readJsonlHeader,
  readFirstUserMessage,
  verifyCandidateForTab,
  normalizeCwd,
  wordOverlap,
  lsofOpenFiles,
  lsofSessionIdForPty,
};
