import { randomUUID } from "node:crypto";
import { readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import type { AnalyticsConfig } from "./config.js";
import type { DatabaseAdapter } from "./db.js";
import { hmacHex, hashPath, redactPath } from "./privacy.js";
import { ValidationError, assertAllowedKeys } from "./errors.js";
import { DEFAULT_SCAN_SOURCES, parseSources, resolveCodexSessionIndexPath, resolveSourceRootDescriptors, type AnalyticsSource } from "./sources.js";
import { CodexSessionIndexSyncError, synchronizeCodexSessionIndex } from "./codex-session-index.js";
import { importCodexFile } from "./adapters/codex.js";
import { importVsCodeCopilotFile } from "./adapters/vscode-copilot.js";
import { importHookLogLines, type HookImportLine } from "./adapters/hook-log.js";
import { importClaudeCodeFile } from "./adapters/claude.js";
import { importCursorFile } from "./adapters/cursor.js";
import { importAntigravityFile } from "./adapters/antigravity.js";
import { isFileImportError } from "./adapters/file-import-contract.js";

const MAX_SOURCE_FILE_BYTES = 50 * 1024 * 1024;

interface WarningEntry {
  code: string;
  message_code: string;
  severity: "info" | "warning" | "error";
  source_file_id?: string;
  source?: AnalyticsSource;
  file_outcome?: "recovered" | "failed";
  details?: Record<string, number | string | boolean>;
}

interface ScanInput {
  sources?: AnalyticsSource[];
  since?: string;
  force: boolean;
  dry_run: boolean;
}

interface ScanCandidate {
  source: AnalyticsSource;
  absolutePath: string;
  sizeBytes: number;
  mtimeMs: number;
}

interface HookIncrementalResult {
  sessionsUpserted: number;
  messagesUpserted: number;
  runtimeEventsUpserted: number;
  warnings: WarningEntry[];
  nextOffset: number;
  nextLineNo: number;
  nextMtimeMs: number;
  hasTruncatedLastLine: boolean;
}

function nowIso(): string {
  return new Date().toISOString();
}

function sanitizeWarning(input: WarningEntry): WarningEntry {
  return {
    code: input.code,
    message_code: input.code,
    severity: input.severity,
    ...(input.source_file_id ? { source_file_id: input.source_file_id } : {}),
    ...(input.source ? { source: input.source } : {}),
    ...(input.file_outcome ? { file_outcome: input.file_outcome } : {}),
    ...(input.details ? { details: input.details } : {})
  };
}

function parseSinceToEpochMs(value: string | undefined): number | undefined {
  if (value === undefined) return undefined;
  const ms = Date.parse(value);
  return Number.isFinite(ms) ? ms : undefined;
}

export function parseScanInput(args: unknown): ScanInput {
  if (!args || typeof args !== "object" || Array.isArray(args)) {
    return { force: false, dry_run: false };
  }
  const obj = args as Record<string, unknown>;
  assertAllowedKeys(obj, ["sources", "since", "force", "dry_run"]);
  const since = obj.since;
  const force = obj.force;
  const dry_run = obj.dry_run;
  if (since !== undefined && typeof since !== "string") {
    throw new ValidationError("since must be a string.");
  }
  if (force !== undefined && typeof force !== "boolean") {
    throw new ValidationError("force must be a boolean.");
  }
  if (dry_run !== undefined && typeof dry_run !== "boolean") {
    throw new ValidationError("dry_run must be a boolean.");
  }
  return {
    sources: parseSources(obj.sources),
    since,
    force: force ?? false,
    dry_run: dry_run ?? false
  };
}

function isCandidateFileName(source: AnalyticsSource, fileName: string): boolean {
  // Cursor sessions live inside a SQLite file that is always literally named `state.vscdb`
  // (never `.json`/`.jsonl`), so it needs its own exact-name check instead of the
  // extension-based check used by every other file-backed source.
  if (source === "cursor") {
    return fileName === "state.vscdb";
  }
  return fileName.endsWith(".json") || fileName.endsWith(".jsonl");
}

// Antigravity roots also contain config/agents/plugins/history.jsonl/conversations(*.pb) that must
// never be treated as chat sessions -- only files matching the exact
// `brain/<conversation-id>/.system_generated/logs/transcript(_full)?.jsonl` shape are real
// transcripts. This needs the *full path*, not just the filename, so it is checked separately
// from `isCandidateFileName` (which only sees `entry.name`) inside `collectFilesRecursive`.
const ANTIGRAVITY_TRANSCRIPT_PATTERN = /\/brain\/[^/]+\/\.system_generated\/logs\/transcript(_full)?\.jsonl$/iu;

function isCandidatePath(source: AnalyticsSource, fullPath: string, fileName: string): boolean {
  if (source === "antigravity") {
    const normalized = fullPath.replace(/\\/gu, "/");
    return ANTIGRAVITY_TRANSCRIPT_PATTERN.test(normalized);
  }
  return isCandidateFileName(source, fileName);
}

// When both `transcript.jsonl` and `transcript_full.jsonl` exist in the same `logs/` directory for
// the same conversation, only `transcript.jsonl` is imported -- importing both would double-count
// the same conversation. This is a scanner-side candidate-list de-duplication so the adapter only
// ever sees one file per conversation-id.
function dedupeAntigravityTranscripts(files: string[]): string[] {
  const byDir = new Map<string, string[]>();
  for (const file of files) {
    const dir = dirname(file);
    const list = byDir.get(dir) ?? [];
    list.push(file);
    byDir.set(dir, list);
  }
  const out: string[] = [];
  for (const list of byDir.values()) {
    const preferred = list.find((f) => basename(f).toLowerCase() === "transcript.jsonl");
    out.push(preferred ?? list[0]);
  }
  return out;
}

function logDirectoryScanWarning(path: string, error: unknown): void {
  const detail = error instanceof Error ? error.message : "UNKNOWN_ERROR";
  // stdout is reserved for MCP JSON-RPC framing; stderr is the only safe channel for
  // diagnostics. Path is redacted to respect the "minimize path disclosure" privacy rule.
  console.error(JSON.stringify({ ok: false, warning_code: "DIRECTORY_SCAN_ERROR", path: redactPath(path), detail }));
}

async function collectFilesRecursive(rootPath: string, source: AnalyticsSource): Promise<string[]> {
  const results: string[] = [];
  // A single unreadable subdirectory (permission error, cache/lock weirdness, overly long path,
  // etc.) must only discard files under *this* subdirectory, not everything already collected
  // from sibling subdirectories under the same root. Each recursive call has its own catch
  // around its own readdir, so a failure here never propagates up and wipes out results the
  // caller already pushed.
  const entries = await readdir(rootPath, { withFileTypes: true }).catch((error: unknown) => {
    logDirectoryScanWarning(rootPath, error);
    return null;
  });
  if (!entries) return results;
  for (const entry of entries) {
    const next = join(rootPath, entry.name);
    if (entry.isDirectory()) {
      // node_modules / .git never contain genuine session data for any source, but a
      // broad root (e.g. a plugin cache directory) can easily contain a vendored dependency
      // tree with thousands of large JSON files -- recursing into those wastes scan time and,
      // for pretty-printed multi-line JSON, floods warnings with false per-line parse failures.
      if (entry.name === "node_modules" || entry.name === ".git") continue;
      const nested = await collectFilesRecursive(next, source);
      results.push(...nested);
      continue;
    }
    if (entry.isFile() && isCandidatePath(source, next, entry.name)) {
      results.push(next);
    }
  }
  return results;
}

function isExcludedFileName(source: AnalyticsSource, loweredName: string): boolean {
  if (source === "copilot") {
    return loweredName === "apps.json" || loweredName.includes("token") || loweredName.includes("auth");
  }
  if (source === "claude" || source === "cursor") {
    return (
      loweredName.includes("token") ||
      loweredName.includes("auth") ||
      loweredName.includes("credential") ||
      loweredName.includes("oauth")
    );
  }
  return false;
}

async function discoverCandidates(
  config: AnalyticsConfig,
  sources: readonly AnalyticsSource[],
  sinceEpochMs: number | undefined
): Promise<ScanCandidate[]> {
  const out: ScanCandidate[] = [];
  for (const source of sources) {
    const roots = resolveSourceRootDescriptors(source, config.hookLogPath);
    for (const rootDescriptor of roots) {
      const root = rootDescriptor.path;
      let rootStats;
      try {
        rootStats = await stat(root);
      } catch {
        // missing roots are silently ignored.
        continue;
      }
      if (rootStats.isFile()) {
        if (sinceEpochMs === undefined || rootStats.mtimeMs >= sinceEpochMs) {
          out.push({ source, absolutePath: root, sizeBytes: rootStats.size, mtimeMs: rootStats.mtimeMs });
        }
        continue;
      }
      if (!rootStats.isDirectory()) continue;

      const rawFiles = await collectFilesRecursive(root, source);
      const files = source === "antigravity" ? dedupeAntigravityTranscripts(rawFiles) : rawFiles;
      for (const filePath of files) {
        const lowered = basename(filePath).toLowerCase();
        if (isExcludedFileName(source, lowered)) continue;
        try {
          const fileStats = await stat(filePath);
          if (sinceEpochMs !== undefined && fileStats.mtimeMs < sinceEpochMs) continue;
          out.push({ source, absolutePath: filePath, sizeBytes: fileStats.size, mtimeMs: fileStats.mtimeMs });
        } catch (error) {
          // A file that disappeared/became unreadable between discovery and stat should not
          // discard the other files already found under this root either.
          logDirectoryScanWarning(filePath, error);
        }
      }
    }
  }
  return out;
}

function parseCompleteHookLinesFromOffset(raw: Buffer, startOffset: number, startLineNo: number): {
  entries: HookImportLine[];
  nextOffset: number;
  nextLineNo: number;
  hasTruncatedLastLine: boolean;
} {
  const entries: HookImportLine[] = [];
  let cursor = Math.max(0, startOffset);
  let lineNo = Math.max(0, startLineNo);
  const len = raw.length;

  while (cursor < len) {
    const nl = raw.indexOf(0x0a, cursor);
    if (nl < 0) {
      return { entries, nextOffset: cursor, nextLineNo: lineNo, hasTruncatedLastLine: cursor < len };
    }
    const lineEnd = nl;
    const contentEnd = lineEnd > cursor && raw[lineEnd - 1] === 0x0d ? lineEnd - 1 : lineEnd;
    const line = raw.toString("utf8", cursor, contentEnd);
    lineNo += 1;
    entries.push({ lineNo, rawLine: line });
    cursor = nl + 1;
  }
  return { entries, nextOffset: cursor, nextLineNo: lineNo, hasTruncatedLastLine: false };
}

async function importHookLogIncremental(
  db: DatabaseAdapter,
  candidate: ScanCandidate,
  sourceFileId: string,
  machineId: string,
  hmacSalt: string,
  existing: { last_read_offset?: number | null; last_read_line_no?: number | null } | undefined
): Promise<HookIncrementalResult> {
  const raw = await readFile(candidate.absolutePath);
  let startOffset = Number(existing?.last_read_offset ?? 0);
  let startLineNo = Number(existing?.last_read_line_no ?? 0);
  if (!Number.isFinite(startOffset) || startOffset < 0) startOffset = 0;
  if (!Number.isFinite(startLineNo) || startLineNo < 0) startLineNo = 0;
  if (candidate.sizeBytes < startOffset) {
    startOffset = 0;
    startLineNo = 0;
  }

  const parsed = parseCompleteHookLinesFromOffset(raw, startOffset, startLineNo);
  const imported = await importHookLogLines({ db, sourceFileId, machineId, hmacSalt }, parsed.entries);
  return {
    sessionsUpserted: imported.counters.sessions_upserted,
    messagesUpserted: imported.counters.messages_upserted,
    runtimeEventsUpserted: imported.counters.runtime_events_upserted,
    warnings: imported.warnings,
    nextOffset: parsed.nextOffset,
    nextLineNo: parsed.nextLineNo,
    nextMtimeMs: Math.floor(candidate.mtimeMs),
    hasTruncatedLastLine: parsed.hasTruncatedLastLine
  };
}

async function maybeRotateHookLog(absolutePath: string, hasTruncatedLastLine: boolean): Promise<void> {
  if (hasTruncatedLastLine) return;
  const st = await stat(absolutePath);
  if (st.size <= 5 * 1024 * 1024) return;
  const rotated = `${absolutePath}.1`;
  try {
    await unlink(rotated);
  } catch {
    // ignore if not exists
  }
  await rename(absolutePath, rotated);
  await writeFile(absolutePath, "", "utf8");
}

export async function upsertSourceFilePending(
  db: DatabaseAdapter,
  sourceFileId: string,
  source: AnalyticsSource,
  pathHash: string,
  sizeBytes: number,
  mtimeMs: number,
  now: string
): Promise<void> {
  await db.run(
    `
      INSERT INTO source_files(
        id, source, path_hash, file_hash, size_bytes, mtime_ms, first_seen_at, last_seen_at, last_status
      ) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'pending')
      ON CONFLICT(source, path_hash) DO UPDATE SET
        size_bytes = excluded.size_bytes,
        mtime_ms = excluded.mtime_ms,
        last_seen_at = excluded.last_seen_at,
        last_status = 'pending'
    `,
    [sourceFileId, source, pathHash, sizeBytes, Math.floor(mtimeMs), now, now]
  );
}

export async function finalizeSourceFile(
  db: DatabaseAdapter,
  source: AnalyticsSource,
  pathHash: string,
  status: "imported" | "failed",
  now: string,
  fileHash?: string,
  errorCode?: string,
  errorSeverity?: WarningEntry["severity"],
  warnings?: WarningEntry[]
): Promise<void> {
  await db.run(
    `
      UPDATE source_files
      SET last_status = ?,
          file_hash = COALESCE(?, file_hash),
          last_imported_at = ?,
          last_seen_at = ?,
          last_error_code = ?,
          last_error_severity = ?,
          warnings_json = ?
      WHERE source = ? AND path_hash = ?
    `,
    [
      status,
      fileHash ?? null,
      now,
      now,
      errorCode ?? null,
      errorSeverity ?? null,
      warnings ? JSON.stringify(warnings.map((entry) => sanitizeWarning(entry))) : null,
      source,
      pathHash
    ]
  );
}

function contextualizeWarnings(entries: WarningEntry[], source: AnalyticsSource, fileOutcome: "recovered" | "failed", sourceFileId: string): WarningEntry[] {
  return entries.map((entry) => sanitizeWarning({ ...entry, source, file_outcome: fileOutcome, source_file_id: sourceFileId }));
}

export async function markRunningScansInterrupted(db: DatabaseAdapter): Promise<void> {
  const now = nowIso();
  await db.run(
    "UPDATE scan_runs SET status = 'interrupted', completed_at = ? WHERE status = 'running' AND completed_at IS NULL",
    [now]
  );
}

export async function runAnalyticsScan(
  context: { config: AnalyticsConfig; db: DatabaseAdapter },
  args: unknown
): Promise<Record<string, unknown>> {
  const input = parseScanInput(args);
  const sinceEpochMs = parseSinceToEpochMs(input.since);
  if (input.since !== undefined && sinceEpochMs === undefined) {
    throw new ValidationError("since must be a valid ISO 8601 value.");
  }

  const sources = input.sources ?? [...DEFAULT_SCAN_SOURCES];

  const hmacSaltRow = await context.db.get<{ value: string }>("SELECT value FROM settings WHERE key = 'hmac_salt'");
  const machineIdRow = await context.db.get<{ value: string }>("SELECT value FROM settings WHERE key = 'machine_id'");
  if (!hmacSaltRow?.value) {
    throw new Error("Missing hmac_salt setting.");
  }
  if (!machineIdRow?.value) {
    throw new Error("Missing machine_id setting.");
  }
  const hmacSalt = hmacSaltRow.value;
  const machineId = machineIdRow.value;
  const candidates = await discoverCandidates(context.config, sources, sinceEpochMs);
  const warnings: WarningEntry[] = [];

  const counters = {
    files_seen: candidates.length,
    files_imported: 0,
    files_skipped: 0,
    files_failed: 0,
    sessions_upserted: 0,
    messages_upserted: 0,
    runtime_events_upserted: 0
  };

  if (input.dry_run) {
    return {
      ok: true,
      dry_run: true,
      status: "completed",
      sources,
      ...counters,
      warnings
    };
  }

  const scanRunId = randomUUID();
  const startedAt = nowIso();
  await context.db.run(
    `
      INSERT INTO scan_runs(
        id, started_at, status, sources_json, dry_run, force, warnings_json, metadata_json
      ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?)
    `,
    [
      scanRunId,
      startedAt,
      JSON.stringify(sources),
      input.dry_run ? 1 : 0,
      input.force ? 1 : 0,
      "[]",
      JSON.stringify({
        adapter_versions: {
          codex: "0.1.0",
          copilot: "0.1.0",
          hook_log: "0.1.0",
          claude: "0.1.0-postmvp",
          cursor: "0.1.0-postmvp-unofficial-schema",
          antigravity: "0.1.0-postmvp-unofficial-schema"
        }
      })
    ]
  );

  for (const candidate of candidates) {
    const now = nowIso();
    const pathHash = hashPath(candidate.absolutePath, hmacSalt);
    const sourceFileId = hmacHex(`${candidate.source}:${pathHash}`, hmacSalt);
    const existing = await context.db.get<{ size_bytes: number; mtime_ms: number | null; last_status: string; last_read_offset?: number | null; last_read_line_no?: number | null }>(
      "SELECT size_bytes, mtime_ms, last_status, last_read_offset, last_read_line_no FROM source_files WHERE source = ? AND path_hash = ?",
      [candidate.source, pathHash]
    );

    const unchanged =
      !!existing &&
      existing.size_bytes === candidate.sizeBytes &&
      (existing.mtime_ms ?? -1) === Math.floor(candidate.mtimeMs) &&
      existing.last_status === "imported";
    const incrementalHookCandidate = candidate.source === "hook_log";

    if (unchanged && !input.force && !incrementalHookCandidate) {
      counters.files_skipped += 1;
      await context.db.run(
        "UPDATE source_files SET last_seen_at = ? WHERE source = ? AND path_hash = ?",
        [now, candidate.source, pathHash]
      );
      continue;
    }

    try {
      await upsertSourceFilePending(
        context.db,
        sourceFileId,
        candidate.source,
        pathHash,
        candidate.sizeBytes,
        candidate.mtimeMs,
        now
      );

      // Cursor uses paged SQLite access and Codex uses a bounded JSONL stream. Neither loads the
      // complete candidate into memory, unlike the remaining whole-file adapters.
      if (candidate.source !== "cursor" && candidate.source !== "codex" && candidate.sizeBytes > MAX_SOURCE_FILE_BYTES) {
        const warning = sanitizeWarning({ code: "FILE_TOO_LARGE", message_code: "FILE_TOO_LARGE", severity: "error", source_file_id: sourceFileId });
        const fileWarnings = contextualizeWarnings([warning], candidate.source, "failed", sourceFileId);
        warnings.push(...fileWarnings);
        counters.files_failed += 1;
        await finalizeSourceFile(context.db, candidate.source, pathHash, "failed", now, undefined, "FILE_TOO_LARGE", "error", fileWarnings);
        continue;
      }

      let sessionsUpserted = 0;
      let messagesUpserted = 0;
      let runtimeEventsUpserted = 0;
      const fileWarnings: WarningEntry[] = [];
      if (candidate.source === "codex") {
        const imported = await importCodexFile(
          { db: context.db, sourceFileId, machineId, hmacSalt },
          candidate.absolutePath
        );
        sessionsUpserted = imported.counters.sessions_upserted;
        messagesUpserted = imported.counters.messages_upserted;
        runtimeEventsUpserted = imported.counters.runtime_events_upserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
      } else if (candidate.source === "hook_log") {
        if (input.force) {
          await context.db.run("DELETE FROM runtime_events WHERE source = 'hook_log' AND source_file_id = ?", [sourceFileId]);
        }
        const imported = await importHookLogIncremental(
          context.db,
          candidate,
          sourceFileId,
          machineId,
          hmacSalt,
          input.force ? undefined : existing
        );
        sessionsUpserted = imported.sessionsUpserted;
        messagesUpserted = imported.messagesUpserted;
        runtimeEventsUpserted = imported.runtimeEventsUpserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
        await context.db.run(
          "UPDATE source_files SET last_read_offset = ?, last_read_line_no = ?, last_read_mtime_ms = ? WHERE source = ? AND path_hash = ?",
          [imported.nextOffset, imported.nextLineNo, imported.nextMtimeMs, candidate.source, pathHash]
        );
        await maybeRotateHookLog(candidate.absolutePath, imported.hasTruncatedLastLine);
      } else if (candidate.source === "copilot") {
        const lowered = candidate.absolutePath.toLowerCase();
        const clientSurface = lowered.includes("insiders") ? "vscode_insiders" : "vscode";
        const imported = await importVsCodeCopilotFile(
          { db: context.db, clientSurface, sourceFileId, machineId, hmacSalt },
          candidate.absolutePath
        );
        sessionsUpserted = imported.counters.sessions_upserted;
        messagesUpserted = imported.counters.messages_upserted;
        runtimeEventsUpserted = imported.counters.runtime_events_upserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
      } else if (candidate.source === "claude") {
        const imported = await importClaudeCodeFile(
          { db: context.db, sourceFileId, machineId, hmacSalt },
          candidate.absolutePath
        );
        sessionsUpserted = imported.counters.sessions_upserted;
        messagesUpserted = imported.counters.messages_upserted;
        runtimeEventsUpserted = imported.counters.runtime_events_upserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
      } else if (candidate.source === "cursor") {
        const imported = await importCursorFile(
          { db: context.db, sourceFileId, machineId, hmacSalt },
          candidate.absolutePath
        );
        sessionsUpserted = imported.counters.sessions_upserted;
        messagesUpserted = imported.counters.messages_upserted;
        runtimeEventsUpserted = imported.counters.runtime_events_upserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
      } else if (candidate.source === "antigravity") {
        const normalized = candidate.absolutePath.replace(/\\/gu, "/").toLowerCase();
        const clientSurface: "cli" | "code" = normalized.includes("/antigravity-cli/") ? "cli" : "code";
        const imported = await importAntigravityFile(
          { db: context.db, sourceFileId, machineId, hmacSalt, clientSurface },
          candidate.absolutePath
        );
        sessionsUpserted = imported.counters.sessions_upserted;
        messagesUpserted = imported.counters.messages_upserted;
        runtimeEventsUpserted = imported.counters.runtime_events_upserted;
        const contextualWarnings = contextualizeWarnings(imported.warnings, candidate.source, "recovered", sourceFileId);
        warnings.push(...contextualWarnings);
        fileWarnings.push(...contextualWarnings);
      } else if (candidate.absolutePath.endsWith(".json")) {
        const raw = await readFile(candidate.absolutePath, "utf8");
        JSON.parse(raw);
      } else {
        const raw = await readFile(candidate.absolutePath, "utf8");
        const lines = raw.split(/\r?\n/u);
        for (let i = 0; i < lines.length; i += 1) {
          const line = lines[i]?.trim();
          if (!line) continue;
          try {
            JSON.parse(line);
          } catch {
            throw new Error(`INVALID_JSON_LINE:${i + 1}`);
          }
        }
      }

      const fileHash = hmacHex(`${candidate.sizeBytes}:${Math.floor(candidate.mtimeMs)}:${pathHash}`, hmacSalt);
      counters.files_imported += 1;
      counters.sessions_upserted += sessionsUpserted;
      counters.messages_upserted += messagesUpserted;
      counters.runtime_events_upserted += runtimeEventsUpserted;
      await finalizeSourceFile(context.db, candidate.source, pathHash, "imported", now, fileHash, undefined, undefined, fileWarnings);
    } catch (error) {
      const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
      const code = isFileImportError(error) ? error.code : (message.startsWith("INVALID_JSON_LINE:") ? "INVALID_JSON" : "FILE_READ_ERROR");
      const warning =
        code === "INVALID_JSON"
          ? sanitizeWarning({
              code: "INVALID_JSON_LINE_SKIPPED",
              message_code: "INVALID_JSON_LINE_SKIPPED",
              severity: "warning",
              source_file_id: sourceFileId,
              details: { line_no: Number.parseInt(message.split(":")[1] ?? "0", 10) || 0 }
            })
          : sanitizeWarning({ code, message_code: code, severity: "error", source_file_id: sourceFileId });
      const fileWarnings = isFileImportError(error)
        ? [...error.warnings.map((entry) => sanitizeWarning(entry)), warning]
        : [warning];
      const contextualWarnings = contextualizeWarnings(fileWarnings, candidate.source, "failed", sourceFileId);
      warnings.push(...contextualWarnings);
      counters.files_failed += 1;
      await finalizeSourceFile(context.db, candidate.source, pathHash, "failed", nowIso(), undefined, code, "error", contextualWarnings);
    }
  }

  // The Codex index is metadata, not a transcript: it is deliberately synchronized once per
  // Codex scan after candidate processing, including when every transcript was incrementally skipped.
  let codexSessionIndexSyncFailed = false;
  if (sources.includes("codex")) {
    try {
      const indexSync = await synchronizeCodexSessionIndex(context.db, hmacSalt, resolveCodexSessionIndexPath());
      warnings.push(...indexSync.warnings.map((warning) => sanitizeWarning({ ...warning, source: "codex" })));
    } catch (error) {
      if (!(error instanceof CodexSessionIndexSyncError)) throw error;
      codexSessionIndexSyncFailed = true;
      warnings.push(sanitizeWarning({
        code: "CODEX_SESSION_INDEX_SYNC_FAILED",
        message_code: "CODEX_SESSION_INDEX_SYNC_FAILED",
        severity: "error",
        source: "codex"
      }));
    }
  }

  let status: "completed" | "partial" | "failed" = "completed";
  if ((counters.files_failed > 0 || codexSessionIndexSyncFailed) && (counters.files_imported > 0 || counters.files_skipped > 0)) {
    status = "partial";
  } else if ((counters.files_failed > 0 || codexSessionIndexSyncFailed) && counters.files_imported === 0 && counters.files_skipped === 0) {
    status = "failed";
  }

  await context.db.run(
    `
      UPDATE scan_runs
      SET completed_at = ?,
          status = ?,
          files_seen = ?,
          files_imported = ?,
          files_skipped = ?,
          files_failed = ?,
          sessions_upserted = ?,
          messages_upserted = ?,
          runtime_events_upserted = ?,
          warnings_json = ?
      WHERE id = ?
    `,
    [
      nowIso(),
      status,
      counters.files_seen,
      counters.files_imported,
      counters.files_skipped,
      counters.files_failed,
      counters.sessions_upserted,
      counters.messages_upserted,
      counters.runtime_events_upserted,
      JSON.stringify(warnings),
      scanRunId
    ]
  );

  return {
    ok: true,
    dry_run: false,
    scan_run_id: scanRunId,
    status,
    sources,
    ...counters,
    warnings
  };
}
