'use strict';

const fs = require('fs');
const path = require('path');

const { normalizeAgentType } = require('./agent-capabilities');
const { claudeProjectDirForCwd, normalizeCwd, samePath } = require('./resume-cwd');

function _asString(value) {
  return String(value || '').trim();
}

function _existingFile(filePath) {
  const clean = _asString(filePath);
  if (!clean) return null;
  try {
    const st = fs.statSync(clean);
    if (!st.isFile()) return null;
    return { path: clean, size: st.size || 0, modifiedAt: st.mtime ? st.mtime.toISOString() : '' };
  } catch {
    return null;
  }
}

function _pushUnique(out, value) {
  const clean = _asString(value);
  if (clean && !out.includes(clean)) out.push(clean);
}

// A Claude transcript is "real" only if it carries at least one conversation
// turn. Claude Code / CTM also write metadata-only sidecar lines (ai-title,
// agent-name, mode, permission-mode, last-prompt, …); a file that holds ONLY
// those is a stub that `claude --resume` rejects with "No conversation found".
const CONVERSATION_LINE_TYPES = new Set(['user', 'assistant', 'summary', 'system']);
// Bound the scan so a healthy multi-MB transcript early-exits on its first turn
// and a pathological metadata-only file can't force a full read. A real session
// hits a conversation line within the first handful of lines.
const CONVERSATION_SCAN_BYTE_CAP = 512 * 1024;

function _lineIsConversation(line) {
  // Cheap substring pre-filter before the JSON.parse.
  if (line.indexOf('"type"') === -1) return false;
  try {
    return CONVERSATION_LINE_TYPES.has(JSON.parse(line).type);
  } catch {
    return false;
  }
}

// Returns true when `filePath` contains at least one real conversation turn.
// Conservative on ambiguity: an unreadable file, or one whose first
// CONVERSATION_SCAN_BYTE_CAP bytes hold no turn but which is larger than the
// cap (so the scan was incomplete), is reported `true` so callers never
// overwrite a transcript they could not fully inspect.
function transcriptHasConversation(filePath, opts = {}) {
  const clean = _asString(filePath);
  if (!clean) return false;
  let st;
  try {
    st = fs.statSync(clean);
  } catch {
    return false; // missing/unstattable → no conversation (nothing to preserve)
  }
  if (!st.isFile() || st.size === 0) return false;
  let fd;
  try {
    const cap = opts.byteCap || CONVERSATION_SCAN_BYTE_CAP;
    const toRead = Math.min(st.size, cap);
    fd = fs.openSync(clean, 'r');
    const buf = Buffer.allocUnsafe(toRead);
    const read = fs.readSync(fd, buf, 0, toRead, 0);
    const text = buf.toString('utf8', 0, read);
    const fullyRead = read >= st.size;
    let lineStart = 0;
    for (let i = 0; i < text.length; i++) {
      if (text[i] !== '\n') continue;
      const line = text.slice(lineStart, i).trim();
      lineStart = i + 1;
      if (line && _lineIsConversation(line)) return true;
    }
    // Trailing line without a newline is only trustworthy on a complete read.
    if (fullyRead && lineStart < text.length) {
      const tail = text.slice(lineStart).trim();
      if (tail && _lineIsConversation(tail)) return true;
    }
    // No turn found. Definitively a stub only if we scanned the whole file;
    // otherwise assume conversation exists (never clobber a large transcript).
    return !fullyRead;
  } catch {
    return true; // unreadable → treat as real so we never overwrite it
  } finally {
    if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
  }
}

// Decide whether to (re)hydrate a live `<id>.jsonl` from its `<id>.jsonl.bak`.
// Restore when the backup carries a real conversation AND the live transcript
// is either missing OR a metadata-only stub. Never restore from an empty/stub
// backup, and never overwrite a live transcript that already has turns.
function claudeLiveTranscriptShouldRestore(jsonlPath, bakPath) {
  if (!_existingFile(bakPath)) return false;
  if (!transcriptHasConversation(bakPath)) return false;
  if (!_existingFile(jsonlPath)) return true;
  return !transcriptHasConversation(jsonlPath);
}

// Synchronous recovery used by the interactive resume / attach paths. Returns
// { restored, reason }. The `.bak` is only ever read, never modified, so the
// operation stays reversible. A clobbered stub is preserved as a `.stub-<n>`
// sibling (best effort) so the overwrite can be inspected after the fact.
function restoreClaudeLiveTranscriptFromBak(jsonlPath, bakPath, opts = {}) {
  const live = _asString(jsonlPath);
  const bak = _asString(bakPath);
  if (!live || !bak) return { restored: false, reason: 'missing_paths' };
  const liveExisted = !!_existingFile(live);
  if (!claudeLiveTranscriptShouldRestore(live, bak)) {
    return { restored: false, reason: liveExisted ? 'live_ok' : 'no_source' };
  }
  if (liveExisted) {
    try {
      const stamp = typeof opts.now === 'number' ? opts.now : Date.now();
      fs.renameSync(live, `${live}.stub-${stamp}`);
    } catch { /* best effort — proceed to overwrite */ }
  }
  fs.copyFileSync(bak, live);
  return { restored: true, reason: liveExisted ? 'replaced_stub' : 'restored_missing' };
}

function _candidateClaudeTranscriptPaths(options = {}) {
  const { resumeId, jsonlPath, homeDir, claudeConfigDir } = options;
  const paths = [];
  const id = _asString(resumeId);
  const stored = _asString(jsonlPath);
  if (stored) {
    _pushUnique(paths, stored);
    if (stored.endsWith('.jsonl')) _pushUnique(paths, stored + '.bak');
    if (stored.endsWith('.jsonl.bak')) _pushUnique(paths, stored.replace(/\.bak$/, ''));
  }
  if (!id) return paths;

  const configDir = _asString(claudeConfigDir) || path.join(homeDir || process.env.HOME || '', '.claude');
  const projectsDir = path.join(configDir, 'projects');
  try {
    for (const entry of fs.readdirSync(projectsDir)) {
      const dir = path.join(projectsDir, entry);
      _pushUnique(paths, path.join(dir, `${id}.jsonl`));
      _pushUnique(paths, path.join(dir, `${id}.jsonl.bak`));
    }
  } catch {
    // Missing ~/.claude/projects is reported as a normal missing-transcript result.
  }
  return paths;
}

function findClaudeResumeTranscript(options = {}) {
  const paths = _candidateClaudeTranscriptPaths(options);
  for (const candidate of paths) {
    const found = _existingFile(candidate);
    if (found) {
      found.restoreFromBak = candidate.endsWith('.jsonl.bak');
      return found;
    }
  }
  return null;
}

function _candidateCwds(options = {}) {
  const candidates = [];
  const row = options.agentRow || {};
  for (const value of [
    options.projectPath,
    row.project_path,
    row.cwd,
    options.cwd,
  ]) {
    const clean = normalizeCwd(value);
    if (clean && !candidates.includes(clean)) candidates.push(clean);
  }
  return candidates;
}

function _existingDirectory(dir, fsExists = fs.existsSync, fsStat = fs.statSync) {
  const clean = normalizeCwd(dir);
  if (!clean) return false;
  try {
    return !!fsExists(clean) && fsStat(clean).isDirectory();
  } catch {
    return false;
  }
}

function resolveClaudeResumeCwd(options = {}) {
  const transcriptPath = _asString(options.transcriptPath);
  const currentCwd = normalizeCwd(options.cwd);
  if (!transcriptPath || !currentCwd) {
    return { cwd: currentCwd, changed: false, reason: '' };
  }

  const transcriptProjectDir = normalizeCwd(path.dirname(transcriptPath));
  const projectOpts = {
    homeDir: options.homeDir,
    claudeConfigDir: options.claudeConfigDir,
  };
  const currentProjectDir = claudeProjectDirForCwd(currentCwd, projectOpts);
  if (samePath(currentProjectDir, transcriptProjectDir)) {
    return {
      cwd: currentCwd,
      changed: false,
      reason: '',
      transcriptProjectDir,
      cwdProjectDir: currentProjectDir,
    };
  }

  const fsExists = options.fsExists || fs.existsSync;
  const fsStat = options.fsStat || fs.statSync;
  for (const candidate of _candidateCwds(options)) {
    if (!_existingDirectory(candidate, fsExists, fsStat)) continue;
    const candidateProjectDir = claudeProjectDirForCwd(candidate, projectOpts);
    if (samePath(candidateProjectDir, transcriptProjectDir)) {
      return {
        cwd: candidate,
        changed: !samePath(candidate, currentCwd),
        reason: 'claude_project_transcript_match',
        transcriptProjectDir,
        cwdProjectDir: currentProjectDir,
        effectiveProjectDir: candidateProjectDir,
      };
    }
  }

  return {
    cwd: currentCwd,
    changed: false,
    blocked: true,
    reason: 'claude_resume_project_mismatch',
    transcriptProjectDir,
    cwdProjectDir: currentProjectDir,
  };
}

function validateResumeTarget(options = {}) {
  const agentType = normalizeAgentType(options.agentType || options.provider || '');
  const resumeId = _asString(options.resumeId);
  if (!resumeId) return { ok: true };
  if (agentType !== 'claude') return { ok: true };

  const transcript = findClaudeResumeTranscript({
    resumeId,
    jsonlPath: options.jsonlPath || options.agentRow?.jsonl_path,
    homeDir: options.homeDir,
    claudeConfigDir: options.claudeConfigDir,
  });
  if (transcript) {
    const cwdResolution = resolveClaudeResumeCwd({
      ...options,
      transcriptPath: transcript.path,
    });
    if (cwdResolution.blocked) {
      const shortId = resumeId.slice(0, 8);
      return {
        ok: false,
        code: 'claude_resume_project_mismatch',
        resumeId,
        storedPath: transcript.path,
        transcriptPath: transcript.path,
        transcriptProjectDir: cwdResolution.transcriptProjectDir || '',
        cwdProjectDir: cwdResolution.cwdProjectDir || '',
        cwd: normalizeCwd(options.cwd),
        message:
          `Cannot resume Claude session ${shortId}: the transcript exists, but CTM would launch ` +
          `Claude Code from a different project directory. Launching from the wrong cwd makes ` +
          `Claude Code report "No conversation found". CTM needs the original project cwd or a ` +
          `matching agent_sessions.project_path before starting this resume.`,
      };
    }
    return {
      ok: true,
      transcriptPath: transcript.path,
      transcriptSize: transcript.size,
      transcriptModifiedAt: transcript.modifiedAt,
      restoreFromBak: !!transcript.restoreFromBak,
      effectiveCwd: cwdResolution.changed ? cwdResolution.cwd : undefined,
      cwdChanged: !!cwdResolution.changed,
      cwdChangeReason: cwdResolution.reason || '',
      transcriptProjectDir: cwdResolution.transcriptProjectDir || '',
      cwdProjectDir: cwdResolution.cwdProjectDir || '',
    };
  }

  const storedPath = _asString(options.jsonlPath || options.agentRow?.jsonl_path);
  const shortId = resumeId.slice(0, 8);
  return {
    ok: false,
    code: 'missing_claude_transcript',
    resumeId,
    storedPath,
    message:
      `Cannot resume Claude session ${shortId}: the local Claude transcript file is missing. ` +
      `CTM can still show its cached conversation history, but Claude Code needs ` +
      `~/.claude/projects/<project>/${resumeId}.jsonl to resume. ` +
      `Start a new session from the cached history, or restore the missing transcript from backup.`,
  };
}

module.exports = {
  findClaudeResumeTranscript,
  resolveClaudeResumeCwd,
  validateResumeTarget,
  transcriptHasConversation,
  claudeLiveTranscriptShouldRestore,
  restoreClaudeLiveTranscriptFromBak,
};
