/**
 * locard.ts — the forensic detective.
 *
 * Job kinds it handles: `evidence_chain` (extract one or more E-NNNN from a
 * chunk or list of chunks). Output: rows in public.evidence + case markdowns.
 *
 * The runtime owns the writer (`writeEvidence`); Locard just produces the
 * structured argument JSON the writer accepts. That keeps the schema gate
 * (sa-security #2) airtight: the LLM can never bypass validation.
 */
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { audit } from "../lib/audit";
import { callClaude } from "../lib/claude";
import { env } from "../lib/env";
import { queryOne } from "../lib/pg";
import { writeEvidence, type WriteEvidenceArgs } from "../tools/write_evidence";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "locard.md");

interface ChunkRow {
  chunk_pk: number;
  chunk_id: string;
  doc_id: string;
  page: number;
  type: string;
  bbox: { x: number; y: number; w: number; h: number } | null;
  content_en: string | null;
  content_pt: string | null;
}

async function loadChunk(doc_id: string, chunk_id: string): Promise<ChunkRow | null> {
  return queryOne<ChunkRow>(
    `SELECT chunk_pk, chunk_id, doc_id, page, type, bbox, content_en, content_pt
       FROM public.chunks
      WHERE doc_id = $1 AND chunk_id = $2`,
    [doc_id, chunk_id],
  );
}

function preferredContent(chunk: ChunkRow): string {
  // Use whatever language the chunk has more of. (Source language is usually
  // English for older FBI/military docs; PT-BR for the bilingual reading layer.)
  const en = (chunk.content_en ?? "").trim();
  const pt = (chunk.content_pt ?? "").trim();
  return en.length >= pt.length ? en : pt;
}

function buildPrompt(chunk: ChunkRow, claim?: string): string {
  const body = preferredContent(chunk);
  const parts = [
    `doc_id: ${chunk.doc_id}`,
    `chunk_id: ${chunk.chunk_id}`,
    `page: ${chunk.page}`,
    `chunk_type: ${chunk.type}`,
    chunk.bbox
      ? `bbox: ${JSON.stringify(chunk.bbox)}`
      : `bbox: null  (chunk has no spatial bbox; use null in your output)`,
    "",
    claim ? `claim_to_substantiate: ${claim}` : "claim_to_substantiate: <none — extract anything credibly defensible>",
    "",
    "chunk_text:",
    "---",
    body,
    "---",
    "",
    "Emit ONE JSON object matching the schema in the system prompt, or the literal word NO_EVIDENCE.",
  ];
  return parts.join("\n");
}

function extractJson(text: string): unknown {
  const t = text.trim();
  if (t === "NO_EVIDENCE") return null;
  // Strip a markdown code fence if Sonnet was creative.
  const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
  const first = stripped.indexOf("{");
  const last = stripped.lastIndexOf("}");
  if (first === -1 || last === -1) {
    throw new Error(`locard returned no JSON object: ${t.slice(0, 160)}`);
  }
  return JSON.parse(stripped.slice(first, last + 1));
}

export interface LocardTask {
  job_id: string;
  doc_id: string;
  chunk_id: string;
  claim?: string;
  budget_cap_usd?: number;
}

export async function runLocard(task: LocardTask): Promise<
  | { evidence_id: string; case_file: string }
  | { skipped: true; reason: string }
> {
  const chunk = await loadChunk(task.doc_id, task.chunk_id);
  if (!chunk) {
    return { skipped: true, reason: `chunk_not_found: ${task.doc_id}#${task.chunk_id}` };
  }
  const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
  const prompt = buildPrompt(chunk, task.claim);

  await audit({
    event: "detective_dispatched",
    job_id: task.job_id,
    detective: "locard@detective",
    doc_id: task.doc_id,
    chunk_id: task.chunk_id,
  });

  const llm = await callClaude({
    prompt,
    systemPrompt,
    model: env.CLAUDE_MODEL,
    allowedTools: [],
    timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000,
    budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB,
  });

  await audit({
    event: "detective_completed",
    job_id: task.job_id,
    detective: "locard@detective",
    cost_usd: llm.costUsd,
    tokens_in: llm.tokensIn,
    tokens_out: llm.tokensOut,
    duration_ms: llm.durationMs,
  });

  // Surface the raw response in container logs so we can diagnose the prompt
  // when validation downstream rejects (free-tier-style models sometimes drop
  // required fields).
  console.error(`[locard] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`);

  const parsed = extractJson(llm.text);
  if (parsed === null) {
    return { skipped: true, reason: "NO_EVIDENCE" };
  }

  // Pin doc/chunk to what the orchestrator asked for — Locard isn't allowed
  // to write evidence to a different chunk than the one we handed it.
  const args = parsed as Partial<WriteEvidenceArgs>;
  args.source_doc_id = task.doc_id;
  args.source_chunk_id = task.chunk_id;
  if (!args.page) args.page = chunk.page;
  if (!args.bbox && chunk.bbox) args.bbox = chunk.bbox;

  // Grade fallback: if Locard forgot to emit one but provided custody_steps,
  // infer the highest grade that fits. Better than rejecting outright.
  if (!args.grade && Array.isArray(args.custody_steps)) {
    const n = args.custody_steps.length;
    args.grade = n >= 3 ? "A" : n >= 2 ? "B" : n >= 1 ? "C" : undefined;
  }

  const result = await writeEvidence(args as WriteEvidenceArgs, {
    job_id: task.job_id,
    detective: "locard@detective",
  });
  return result;
}
