/**
 * poirot.ts — witness-analysis detective.
 *
 * Reads chunks where a named person appears, produces a structured
 * credibility analysis (access / bias / corroboration / verdict).
 *
 * Workflow:
 *  1. Resolve person by entity_id (e.g. "j-edgar-hoover") to entity_pk.
 *  2. Pull up to 12 chunks via entity_mentions JOIN chunks, ordered by
 *     anomaly flag DESC, page ASC. These are Poirot's evidence.
 *  3. Feed Claude with canonical_name + aliases + the chunks. Parse
 *     strict JSON object.
 *  4. writeWitnessAnalysis() validates + INSERTs + writes W-NNNN.md.
 */
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 { query, queryOne } from "../lib/pg";
import {
  writeWitnessAnalysis,
  type WriteWitnessAnalysisArgs,
  type CorroborationRef,
} from "../tools/write_witness_analysis";

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

export interface PoirotTask {
  job_id: string;
  /** entity_id slug like "j-edgar-hoover" (preferred), OR person_entity_pk. */
  person_id?: string;
  person_entity_pk?: number;
  lang?: "pt" | "en";
  context_chunks?: number;
  budget_cap_usd?: number;
}

interface PersonChunkRow {
  chunk_pk: number;
  doc_id: string;
  chunk_id: string;
  page: number;
  type: string;
  classification: string | null;
  ufo_anomaly: boolean | null;
  content_en: string | null;
  content_pt: string | null;
  surface_form: string | null;
}

function renderChunkBlock(rows: PersonChunkRow[], lang: "pt" | "en"): string {
  const blocks = rows.map((r, i) => {
    const text = (lang === "en" ? r.content_en : r.content_pt) || r.content_en || r.content_pt || "";
    return [
      `--- chunk ${i + 1} ---`,
      `doc_id: ${r.doc_id}`,
      `chunk_id: ${r.chunk_id}`,
      `page: ${r.page}`,
      r.surface_form ? `surface_form_in_chunk: ${r.surface_form}` : null,
      `type: ${r.type}`,
      r.classification ? `classification: ${r.classification}` : null,
      "",
      text.slice(0, 1100),
    ].filter(Boolean).join("\n");
  });
  return blocks.join("\n\n");
}

function buildPrompt(
  canonical_name: string,
  aliases: string[],
  rows: PersonChunkRow[],
  lang: "pt" | "en",
): string {
  const aliasLine = aliases.length > 0 ? `Aliases: ${aliases.slice(0, 8).join(", ")}` : null;
  return [
    `# Witness under analysis`,
    "",
    `**Canonical name.** ${canonical_name}`,
    aliasLine,
    "",
    `## Corpus shortlist (${rows.length} chunks where this person appears)`,
    "",
    renderChunkBlock(rows, lang),
    "",
    "## Your task",
    "",
    "Produce the structured witness analysis as specified by the system",
    "prompt. Cite chunk_ids from the shortlist above in",
    "`corroboration_refs`. **Bilingual is mandatory:** access_to_event,",
    "bias_notes, and verdict each appear in both EN and PT-BR. If the",
    "shortlist is too thin to ground an honest assessment, emit",
    "`INSUFFICIENT_TESTIMONY`.",
  ].filter(Boolean).join("\n");
}

function extractJsonObject(text: string): Record<string, unknown> | null {
  const t = text.trim();
  // The skip sentinel can appear bare, in backticks, or as the leading token
  // followed by Poirot's explanation prose. All count as "skipped".
  if (/^`?INSUFFICIENT_TESTIMONY`?\b/i.test(t)) return null;
  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(`poirot returned no JSON object: ${t.slice(0, 200)}`);
  }
  const parsed = JSON.parse(stripped.slice(first, last + 1));
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("poirot JSON is not an object");
  }
  return parsed as Record<string, unknown>;
}

function coerceCorroboration(raw: unknown): CorroborationRef[] {
  if (!Array.isArray(raw)) return [];
  const out: CorroborationRef[] = [];
  for (const r of raw) {
    if (!r || typeof r !== "object") continue;
    const o = r as Record<string, unknown>;
    const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : "";
    if (!chunk_id) continue;
    out.push({
      chunk_id,
      doc_id: typeof o.doc_id === "string" ? o.doc_id.trim() : undefined,
      supports: o.supports !== false,
    });
  }
  return out;
}

export async function runPoirot(task: PoirotTask): Promise<
  | { witness_id: string; case_file: string; credibility: string; person_entity_pk: number }
  | { skipped: true; reason: string }
> {
  const lang: "pt" | "en" = task.lang ?? "pt";
  const k = task.context_chunks ?? 12;

  let entity_pk: number | null = task.person_entity_pk ?? null;
  let canonical_name = "";
  let aliases: string[] = [];

  if (entity_pk === null && task.person_id) {
    const row = await queryOne<{ entity_pk: number; canonical_name: string; aliases: string[] | null; entity_class: string }>(
      `SELECT entity_pk, canonical_name, aliases, entity_class
         FROM public.entities WHERE entity_id = $1`,
      [task.person_id],
    );
    if (!row) return { skipped: true, reason: "person_not_found" };
    if (row.entity_class !== "person") return { skipped: true, reason: "entity_is_not_person" };
    // node-postgres returns BIGINT as a string by default; coerce so writer's
    // Number.isFinite() check passes.
    entity_pk = Number(row.entity_pk);
    canonical_name = row.canonical_name;
    aliases = row.aliases ?? [];
  } else if (entity_pk !== null) {
    const row = await queryOne<{ canonical_name: string; aliases: string[] | null; entity_class: string }>(
      `SELECT canonical_name, aliases, entity_class FROM public.entities WHERE entity_pk = $1`,
      [entity_pk],
    );
    if (!row) return { skipped: true, reason: "person_not_found" };
    if (row.entity_class !== "person") return { skipped: true, reason: "entity_is_not_person" };
    canonical_name = row.canonical_name;
    aliases = row.aliases ?? [];
  } else {
    return { skipped: true, reason: "no_person_specified" };
  }

  // Pull the most relevant chunks where this person appears.
  const rows = await query<PersonChunkRow>(
    `SELECT c.chunk_pk, c.doc_id, c.chunk_id, c.page, c.type, c.classification,
            c.ufo_anomaly,
            c.content_en, c.content_pt, em.surface_form
       FROM public.entity_mentions em
       JOIN public.chunks c ON c.chunk_pk = em.chunk_pk
      WHERE em.entity_pk = $1
        AND LENGTH(COALESCE(c.content_en, c.content_pt, '')) > 80
      ORDER BY c.ufo_anomaly DESC NULLS LAST, c.page ASC, c.order_in_page ASC
      LIMIT $2`,
    [entity_pk, k],
  );

  await audit({
    event: "poirot_grounded",
    job_id: task.job_id,
    detective: "poirot@detective",
    person_entity_pk: entity_pk,
    canonical_name,
    n_chunks: rows.length,
  });

  if (rows.length === 0) {
    return { skipped: true, reason: "no_chunks_for_person" };
  }

  const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
  const prompt = buildPrompt(canonical_name, aliases, rows, lang);
  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: "poirot@detective",
    cost_usd: llm.costUsd,
    tokens_in: llm.tokensIn,
    tokens_out: llm.tokensOut,
    duration_ms: llm.durationMs,
  });
  console.error(`[poirot] response (${llm.text.length} chars): ${llm.text.slice(0, 600)}`);

  const obj = extractJsonObject(llm.text);
  if (obj === null) return { skipped: true, reason: "INSUFFICIENT_TESTIMONY" };

  const credibility =
    obj.credibility === "high" || obj.credibility === "medium" ||
    obj.credibility === "low"  || obj.credibility === "speculation"
      ? obj.credibility as "high" | "medium" | "low" | "speculation"
      : "speculation";

  const str = (k: string): string =>
    typeof obj[k] === "string" ? (obj[k] as string).trim() : "";
  const args: WriteWitnessAnalysisArgs = {
    person_entity_pk: entity_pk,
    credibility,
    access_to_event:       str("access_to_event"),
    access_to_event_pt_br: str("access_to_event_pt_br"),
    bias_notes:            str("bias_notes"),
    bias_notes_pt_br:      str("bias_notes_pt_br"),
    corroboration_refs:    coerceCorroboration(obj.corroboration_refs),
    verdict:               str("verdict"),
    verdict_pt_br:         str("verdict_pt_br"),
  };
  if (!args.access_to_event || !args.bias_notes || !args.verdict
      || !args.access_to_event_pt_br || !args.bias_notes_pt_br || !args.verdict_pt_br) {
    return { skipped: true, reason: "incomplete_bilingual_analysis" };
  }

  // Soft-truncate before sending to the writer: the prompt asks ≤ 280 chars
  // per language but the model occasionally goes slightly over (304 chars
  // observed live with j-edgar-hoover PT-BR). Truncate at sentence boundary
  // when possible, else at the cap.
  const trimTo = (s: string, max: number): string => {
    if (s.length <= max) return s;
    const cut = s.slice(0, max);
    const lastPeriod = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("; "));
    return (lastPeriod > max * 0.6 ? cut.slice(0, lastPeriod + 1) : cut).trim();
  };
  args.verdict = trimTo(args.verdict, 280);
  args.verdict_pt_br = trimTo(args.verdict_pt_br, 280);
  args.access_to_event = trimTo(args.access_to_event, 800);
  args.access_to_event_pt_br = trimTo(args.access_to_event_pt_br, 800);
  args.bias_notes = trimTo(args.bias_notes, 800);
  args.bias_notes_pt_br = trimTo(args.bias_notes_pt_br, 800);

  // Pass the shortlist's most-represented doc_id as a fallback for chunk_id
  // resolution in case the model emits a bare "c0042" without doc_id.
  const docCount = new Map<string, number>();
  for (const r of rows) docCount.set(r.doc_id, (docCount.get(r.doc_id) ?? 0) + 1);
  let fallbackDoc = "";
  let bestN = 0;
  for (const [d, n] of docCount.entries()) if (n > bestN) { fallbackDoc = d; bestN = n; }

  return await writeWitnessAnalysis(args, {
    job_id: task.job_id,
    detective: "poirot@detective",
  }, { fallback_doc_id: fallbackDoc });
}
