/**
 * dupin.ts — contradiction-scan detective.
 *
 * Workflow:
 *  1. The runtime grounds Dupin with a small corpus shortlist via
 *     hybridSearch. Default k=18 — Dupin needs MORE chunks than Holmes
 *     because contradictions emerge from comparing dispersed claims.
 *  2. Claude Sonnet 4.6 reads the topic + chunks, emits a JSON array of
 *     0-3 contradictions with topic/notes/positions[].
 *  3. The runtime parses + calls writeContradiction() per item.
 */
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 { hybridSearch, type SearchHit } from "../lib/search";
import {
  writeContradiction,
  type WriteContradictionArgs,
  type ContradictionPosition,
} from "../tools/write_contradiction";

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

export interface DupinTask {
  job_id: string;
  topic: string;
  doc_id?: string;
  lang?: "pt" | "en";
  context_chunks?: number;
  budget_cap_usd?: number;
}

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

function buildPrompt(task: DupinTask, hits: SearchHit[], lang: "pt" | "en"): string {
  return [
    `# Topic to scan for contradictions`,
    "",
    task.topic,
    "",
    `## Corpus shortlist (${hits.length} chunks${task.doc_id ? `, scoped to ${task.doc_id}` : ""})`,
    "",
    renderChunkBlock(hits, lang),
    "",
    "## Your task",
    "",
    "Inspect the chunks for pairs (or small groups) that cannot both be true.",
    "Emit at most 3 contradictions. Each must cite ≥ 2 distinct chunk_ids.",
    "Emit the JSON array exactly as specified by the system prompt — no prose,",
    "no code fence, no preamble. **Bilingual is mandatory:** every narrative",
    "field (topic, notes, statement) appears in both EN and PT-BR. If no",
    "genuine contradiction exists, emit the literal word `NO_CONTRADICTIONS`.",
  ].join("\n");
}

function extractJsonArray(text: string): unknown[] | null {
  const t = text.trim();
  if (/^`?NO_CONTRADICTIONS`?\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(`dupin returned no JSON array: ${t.slice(0, 200)}`);
  }
  const parsed = JSON.parse(stripped.slice(first, last + 1));
  if (!Array.isArray(parsed)) throw new Error("dupin JSON is not an array");
  return parsed;
}

function coercePositions(raw: unknown): ContradictionPosition[] {
  if (!Array.isArray(raw)) return [];
  const out: ContradictionPosition[] = [];
  for (const p of raw) {
    if (!p || typeof p !== "object") continue;
    const o = p as Record<string, unknown>;
    const doc_id = typeof o.doc_id === "string" ? o.doc_id.trim() : "";
    const chunk_id = typeof o.chunk_id === "string" ? o.chunk_id.trim() : "";
    const statement = typeof o.statement === "string" ? o.statement.trim() : "";
    const statement_pt_br = typeof o.statement_pt_br === "string" ? o.statement_pt_br.trim() : "";
    if (!doc_id || !chunk_id || !statement || !statement_pt_br) continue;
    out.push({
      doc_id, chunk_id, statement, statement_pt_br,
      stance: typeof o.stance === "string" ? o.stance.trim() : undefined,
    });
  }
  return out;
}

export async function runDupin(task: DupinTask): Promise<
  | { contradictions: Array<{ contradiction_id: string; case_file: string }> }
  | { skipped: true; reason: string }
> {
  const lang: "pt" | "en" = task.lang ?? "pt";
  const k = task.context_chunks ?? 18;

  // Pass 1: scoped to the requested doc, if any.
  let hits = await hybridSearch({
    query: task.topic,
    lang,
    doc_id: task.doc_id ?? null,
    top_k: k,
    recall_k: 80,
  });
  let scope_widened = false;

  // Pass 2: if a doc_id was set and the head is too thin for a tournament,
  // widen the scope to the whole corpus. Cross-doc contradictions are
  // valuable too (one doc says X, another says Y).
  if (task.doc_id && hits.length < 2) {
    const widened = await hybridSearch({
      query: task.topic,
      lang,
      doc_id: null,
      top_k: k,
      recall_k: 80,
    });
    if (widened.length > hits.length) {
      hits = widened;
      scope_widened = true;
    }
  }

  await audit({
    event: "dupin_grounded",
    job_id: task.job_id,
    detective: "dupin@detective",
    topic: task.topic,
    n_chunks: hits.length,
    doc_id: task.doc_id ?? null,
    scope_widened,
  });
  if (hits.length < 2) {
    return { skipped: true, reason: "insufficient_corpus" };
  }

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

  const arr = extractJsonArray(llm.text);
  if (arr === null) return { skipped: true, reason: "NO_CONTRADICTIONS" };

  const out: Array<{ contradiction_id: string; case_file: string }> = [];
  for (const raw of arr.slice(0, 3)) {
    if (!raw || typeof raw !== "object") continue;
    const o = raw as Record<string, unknown>;
    const topic = typeof o.topic === "string" ? o.topic.trim() : "";
    const topic_pt_br = typeof o.topic_pt_br === "string" ? o.topic_pt_br.trim() : "";
    const positions = coercePositions(o.positions);
    if (!topic || !topic_pt_br || positions.length < 2) continue;
    const args: WriteContradictionArgs = {
      topic, topic_pt_br,
      positions,
      notes: typeof o.notes === "string" ? o.notes.trim() : undefined,
      notes_pt_br: typeof o.notes_pt_br === "string" ? o.notes_pt_br.trim() : undefined,
      resolution_status: o.resolution_status === "resolved"
        ? "resolved"
        : o.resolution_status === "irreconcilable"
        ? "irreconcilable"
        : "open",
    };
    try {
      const r = await writeContradiction(args, { job_id: task.job_id, detective: "dupin@detective" });
      out.push(r);
    } catch (e) {
      await audit({
        event: "write_contradiction_failed",
        job_id: task.job_id,
        detective: "dupin@detective",
        error: (e as Error).message,
        topic: args.topic.slice(0, 200),
      });
    }
  }
  return { contradictions: out };
}
