/**
 * tetlock.ts — posterior calibration detective.
 *
 * Reads an existing hypothesis + the corpus chunks added since its last
 * calibration (proxied by updated_at) + the evidence chain. Asks Claude
 * to recompute posterior + recommend action.
 *
 * The "new evidence" pool is a hybridSearch on the hypothesis's question
 * AFTER the hypothesis's `updated_at` cutoff — chunks ingested or marked
 * searchable since are the candidate signal.
 *
 * v0 simplification: chunks don't carry an updated_at column right now.
 * So we shortlist via hybridSearch using the question text and let
 * Tetlock decide whether the chunks are NEW relative to the cited
 * evidence_refs (it sees both lists and reasons accordingly). When the
 * chunks layer grows an ingest_at column, swap this to a strict cutoff.
 */
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 { hybridSearch, type SearchHit } from "../lib/search";
import { writeCalibration, type WriteCalibrationArgs } from "../tools/write_calibration";

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

export interface TetlockTask {
  job_id: string;
  hypothesis_id: string;
  lang?: "pt" | "en";
  budget_cap_usd?: number;
}

interface HypothesisRow {
  hypothesis_id: string;
  question: string;
  position: string;
  argument_for: string | null;
  argument_against: string | null;
  prior: number | string | null;
  posterior: number | string | null;
  confidence_band: string | null;
  evidence_refs: unknown;
  updated_at: string;
}

interface EvidenceRow {
  evidence_id: string;
  grade: string;
  source_page_id: string;
  verbatim_excerpt: string;
}

function asNumber(n: number | string | null): number | null {
  if (n === null || n === undefined) return null;
  const v = typeof n === "string" ? parseFloat(n) : n;
  return Number.isFinite(v) ? v : null;
}

function bandFromPosterior(p: number): "high" | "medium" | "low" | "speculation" {
  if (p >= 0.90) return "high";
  if (p >= 0.60) return "medium";
  if (p >= 0.30) return "low";
  return "speculation";
}

function renderShortlist(hits: SearchHit[], lang: "pt" | "en", citedChunkPks: Set<number>): string {
  if (hits.length === 0) return "_(no new shortlist)_";
  return hits.map((h, i) => {
    const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || "";
    const seen = citedChunkPks.has(h.chunk_pk) ? " (already cited)" : " (NEW)";
    return [
      `--- chunk ${i + 1}${seen} ---`,
      `doc_id: ${h.doc_id}`,
      `chunk_id: ${h.chunk_id}`,
      `page: ${h.page}`,
      "",
      text.slice(0, 900),
    ].join("\n");
  }).join("\n\n");
}

function buildPrompt(
  h: HypothesisRow,
  evidence: EvidenceRow[],
  hits: SearchHit[],
  citedChunkPks: Set<number>,
  lang: "pt" | "en",
): string {
  const cur_post = asNumber(h.posterior);
  const evBlock = evidence.length === 0
    ? "_(no cataloged evidence at the previous calibration)_"
    : evidence.map((e) =>
        `--- ${e.evidence_id} (Grade ${e.grade}) — ${e.source_page_id}\n> ${e.verbatim_excerpt.slice(0, 500)}`
      ).join("\n\n");

  return [
    `# Hypothesis under recalibration`,
    "",
    `**ID.** ${h.hypothesis_id}`,
    `**Question.** ${h.question}`,
    `**Position.** ${h.position}`,
    `**Last posterior.** ${cur_post ?? "—"} · **Band.** ${h.confidence_band ?? "—"}`,
    `**Last updated.** ${h.updated_at}`,
    "",
    "## Argument for (at last calibration)",
    h.argument_for || "_(none recorded)_",
    "",
    "## Argument against (at last calibration)",
    h.argument_against || "_(none recorded)_",
    "",
    "## Evidence cited at last calibration",
    evBlock,
    "",
    `## Fresh corpus shortlist (${hits.length} chunks)`,
    "",
    "Chunks marked **NEW** were not in the evidence chain at the last",
    "calibration. Use them to update; chunks marked **already cited** are",
    "shown for continuity.",
    "",
    renderShortlist(hits, lang, citedChunkPks),
    "",
    "## Your task",
    "",
    "Recompute the posterior honestly. Emit the JSON object exactly as",
    "specified by the system prompt. **Bilingual is mandatory:** rationale",
    "appears in both EN and PT-BR; supersede_reason (when present) also",
    "bilingual. If there is NO new chunk to move the posterior on, emit",
    "`NO_NEW_EVIDENCE`.",
  ].join("\n");
}

function extractJsonObject(text: string): Record<string, unknown> | null {
  const t = text.trim();
  if (/^`?NO_NEW_EVIDENCE`?\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(`tetlock 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("tetlock JSON is not an object");
  }
  return parsed as Record<string, unknown>;
}

export async function runTetlock(task: TetlockTask): Promise<
  | { hypothesis_id: string; case_file: string; new_posterior: number; recommended_action: string }
  | { skipped: true; reason: string }
> {
  const h = await queryOne<HypothesisRow>(
    `SELECT hypothesis_id, question, position, argument_for, argument_against,
            prior, posterior, confidence_band, evidence_refs, updated_at
       FROM public.hypotheses WHERE hypothesis_id = $1`,
    [task.hypothesis_id],
  );
  if (!h) return { skipped: true, reason: "hypothesis_not_found" };

  const refIds: string[] = [];
  if (Array.isArray(h.evidence_refs)) {
    for (const r of h.evidence_refs as Array<Record<string, unknown>>) {
      if (typeof r?.evidence_id === "string") refIds.push(r.evidence_id);
    }
  }
  const evidence = refIds.length > 0
    ? await query<EvidenceRow>(
        `SELECT evidence_id, grade, source_page_id, verbatim_excerpt
           FROM public.evidence WHERE evidence_id = ANY($1::text[]) ORDER BY evidence_id`,
        [refIds],
      )
    : [];

  // Pull citedChunkPks (FK on evidence) so we can tag "NEW" vs "already cited"
  // in the shortlist.
  const citedChunkPks = new Set<number>();
  if (refIds.length > 0) {
    const rows = await query<{ source_chunk_pk: number | null }>(
      `SELECT source_chunk_pk FROM public.evidence WHERE evidence_id = ANY($1::text[])`,
      [refIds],
    );
    for (const r of rows) if (r.source_chunk_pk !== null) citedChunkPks.add(r.source_chunk_pk);
  }

  // Fresh corpus shortlist on the question.
  const lang: "pt" | "en" = task.lang ?? "pt";
  const hits = await hybridSearch({
    query: h.question, lang,
    doc_id: null, top_k: 12, recall_k: 60,
  });

  await audit({
    event: "tetlock_grounded",
    job_id: task.job_id,
    detective: "tetlock@detective",
    hypothesis_id: h.hypothesis_id,
    n_evidence_prev: evidence.length,
    n_shortlist: hits.length,
    n_already_cited: citedChunkPks.size,
  });

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

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

  const new_posterior = Number(obj.new_posterior);
  if (!Number.isFinite(new_posterior) || new_posterior < 0 || new_posterior > 1) {
    return { skipped: true, reason: "bad_new_posterior" };
  }
  const old_posterior = asNumber(h.posterior);
  const delta = old_posterior !== null ? new_posterior - old_posterior : 0;

  const actionRaw = typeof obj.recommended_action === "string" ? obj.recommended_action.toLowerCase() : "";
  const action: WriteCalibrationArgs["recommended_action"] =
    actionRaw === "downgrade" || actionRaw === "upgrade" ||
    actionRaw === "supersede" || actionRaw === "keep" ? actionRaw : "keep";

  const args: WriteCalibrationArgs = {
    hypothesis_id: h.hypothesis_id,
    new_posterior,
    new_confidence_band: bandFromPosterior(new_posterior),
    delta,
    rationale:               typeof obj.rationale === "string" ? obj.rationale.trim() : "",
    rationale_pt_br:         typeof obj.rationale_pt_br === "string" ? obj.rationale_pt_br.trim() : "",
    recommended_action: action,
    supersede_reason:        typeof obj.supersede_reason === "string" ? obj.supersede_reason.trim() : undefined,
    supersede_reason_pt_br:  typeof obj.supersede_reason_pt_br === "string" ? obj.supersede_reason_pt_br.trim() : undefined,
    old_posterior,
    old_confidence_band: h.confidence_band,
  };
  if (!args.rationale || !args.rationale_pt_br) {
    return { skipped: true, reason: "no_rationale_bilingual" };
  }
  if (action === "supersede" && (!args.supersede_reason || !args.supersede_reason_pt_br)) {
    return { skipped: true, reason: "supersede_reason_missing_bilingual" };
  }

  return await writeCalibration(args, {
    job_id: task.job_id,
    detective: "tetlock@detective",
  });
}
