import { randomUUID } from "node:crypto";
import { readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import type { AnalyticsConfig } from "./config.js";
import type { DatabaseAdapter } from "./db.js";
import { hmacHex, hashPath } from "./privacy.js";
import { ValidationError, assertAllowedKeys } from "./errors.js";
import { DEFAULT_SCAN_SOURCES, parseSources, resolveSourceRoots, type AnalyticsSource } from "./sources.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, importClaudeDesktopFile } from "./adapters/claude.js";

const MAX_SOURCE_FILE_BYTES = 50 * 1024 * 1024;

interface WarningEntry {
  code: string;
  message_code: string;
  severity: "warning" | "error";
  source_file_id?: string;
  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.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
  };
}

async function collectFilesRecursive(rootPath: string): Promise<string[]> {
  const results: string[] = [];
  const entries = await readdir(rootPath, { withFileTypes: true });
  for (const entry of entries) {
    const next = join(rootPath, entry.name);
    if (entry.isDirectory()) {
      const nested = await collectFilesRecursive(next);
      results.push(...nested);
      continue;
    }
    if (entry.isFile() && (entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))) {
      results.push(next);
    }
  }
  return results;
}

async function discoverCandidates(
  config: AnalyticsConfig,
  sources: readonly AnalyticsSource[],
  sinceEpochMs: number | undefined
): Promise<ScanCandidate[]> {
  const out: ScanCandidate[] = [];
  for (const source of sources) {
    const roots = resolveSourceRoots(source, config.hookLogPath);
    for (const root of roots) {
      try {
        const stats = await stat(root);
        if (stats.isFile()) {
          if (sinceEpochMs !== undefined && stats.mtimeMs < sinceEpochMs) continue;
          out.push({ source, absolutePath: root, sizeBytes: stats.size, mtimeMs: stats.mtimeMs });
          continue;
        }
        if (!stats.isDirectory()) continue;
        const files = await collectFilesRecursive(root);
        for (const filePath of files) {
          const lowered = basename(filePath).toLowerCase();
          if (source === "copilot" && (lowered === "apps.json" || lowered.includes("token") || lowered.includes("auth"))) {
            continue;
          }
          if (source === "claude" && (lowered.includes("token") || lowered.includes("auth") || lowered.includes("credential") || lowered.includes("oauth"))) {
            continue;
          }
          const fileStats = await stat(filePath);
          if (sinceEpochMs !== undefined && fileStats.mtimeMs < sinceEpochMs) continue;
          out.push({ source, absolutePath: filePath, sizeBytes: fileStats.size, mtimeMs: fileStats.mtimeMs });
        }
      } catch {
        // missing roots are silently ignored.
      }
    }
  }
  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");
}

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',
        last_error_code = NULL,
        last_error_severity = NULL,
        warnings_json = NULL
    `,
    [sourceFileId, source, pathHash, sizeBytes, Math.floor(mtimeMs), now, now]
  );
}

async function finalizeSourceFile(
  db: DatabaseAdapter,
  source: AnalyticsSource,
  pathHash: string,
  status: "imported" | "failed",
  now: string,
  fileHash?: string,
  errorCode?: string,
  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,
      errorCode ? "warning" : null,
      warnings ? JSON.stringify(warnings.map((entry) => sanitizeWarning(entry))) : null,
      source,
      pathHash
    ]
  );
}

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"
        }
      })
    ]
  );

  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);
    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
      );

      if (candidate.sizeBytes > MAX_SOURCE_FILE_BYTES) {
        const warning = sanitizeWarning({ code: "FILE_TOO_LARGE", message_code: "FILE_TOO_LARGE", severity: "warning" });
        warnings.push(warning);
        counters.files_failed += 1;
        await finalizeSourceFile(context.db, candidate.source, pathHash, "failed", now, undefined, "FILE_TOO_LARGE", [warning]);
        continue;
      }

      let sessionsUpserted = 0;
      let messagesUpserted = 0;
      let runtimeEventsUpserted = 0;
      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;
        warnings.push(...imported.warnings);
      } 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;
        warnings.push(...imported.warnings);
        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;
        warnings.push(...imported.warnings);
      } else if (candidate.source === "claude") {
        const lowered = candidate.absolutePath.toLowerCase();
        const imported =
          lowered.includes("desktop") || lowered.includes("appdata\\roaming\\claude")
            ? await importClaudeDesktopFile({ sourceFileId })
            : 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;
        warnings.push(...imported.warnings);
      } 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);
    } catch (error) {
      const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
      const 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: "warning", source_file_id: sourceFileId });
      warnings.push(warning);
      counters.files_failed += 1;
      await finalizeSourceFile(context.db, candidate.source, pathHash, "failed", nowIso(), undefined, code, [warning]);
    }
  }

  let status: "completed" | "partial" | "failed" = "completed";
  if (counters.files_failed > 0 && (counters.files_imported > 0 || counters.files_skipped > 0)) {
    status = "partial";
  } else if (counters.files_failed > 0 && 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
  };
}
