/**
 * claude.ts — spawn `claude -p` subprocess with a system prompt + per-call budget.
 *
 * Pattern matches scripts/reextract/run.py and web/lib/chat/claude-code.ts:
 * we use the Claude Code CLI (OAuth Max 20x quota) instead of paying the
 * Anthropic API directly. Returns parsed JSON and the model-reported cost
 * so the orchestrator can enforce the per-job budget cap.
 */
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { env } from "./env";

const HERE = path.dirname(fileURLToPath(import.meta.url));
const HOUSE_STYLE_PATH = path.resolve(HERE, "..", "..", "prompts", "_house-style.md");

// Lazy-load the house style once per process. Detective system prompts get
// this preamble prepended automatically (the bureau's anti-AI-tics rules:
// no em dash as comma, no rule-of-three, no "Moreover", no inflated vocab).
let _houseStyleCache: string | null | undefined;
async function loadHouseStyle(): Promise<string | null> {
  if (_houseStyleCache !== undefined) return _houseStyleCache;
  try {
    _houseStyleCache = await readFile(HOUSE_STYLE_PATH, "utf-8");
  } catch {
    _houseStyleCache = null;
  }
  return _houseStyleCache;
}

export interface ClaudeCallArgs {
  /** Free-form prompt body to send. */
  prompt: string;
  /** Optional system prompt — kept separate from the user prompt so the model
   *  treats the persona/discipline as inviolable. */
  systemPrompt?: string;
  /** "sonnet" by default. "opus" for the agentic principal step. */
  model?: string;
  /** Tool allowlist passed to the CLI. Empty array → no tools. */
  allowedTools?: string[];
  /** Subprocess hard timeout (ms). Default: JOB_TIMEOUT_SECONDS / 2. */
  timeoutMs?: number;
  /** Soft budget cap for THIS call — orchestrator should subtract from the job
   *  budget and abort if the running total exceeds the per-job cap. */
  budgetCapUsd?: number;
  /** Working directory the CLI can read from. The runtime mounts wiki+case;
   *  this scopes claude's filesystem visibility. */
  addDir?: string;
}

export interface ClaudeCallResult {
  /** Plain-text reply (cli.result). */
  text: string;
  /** Cost reported by the CLI (cli.total_cost_usd) or 0 when missing. */
  costUsd: number;
  /** Token counts from CLI usage if present. */
  tokensIn?: number;
  tokensOut?: number;
  /** True when the CLI signalled is_error (the model said "I cannot ..."). */
  isError: boolean;
  /** Wall-clock duration. */
  durationMs: number;
  /** Raw JSON from the CLI for callers that need finer detail. */
  raw: unknown;
}

export async function callClaude(args: ClaudeCallArgs): Promise<ClaudeCallResult> {
  const model = args.model ?? env.CLAUDE_MODEL;
  const timeoutMs = args.timeoutMs ?? Math.max(30_000, env.JOB_TIMEOUT_SECONDS * 500);
  const tools = args.allowedTools ?? [];

  const cliArgs = [
    "-p",
    "--model", model,
    "--output-format", "json",
    "--max-turns", "8",
  ];
  if (tools.length > 0) {
    cliArgs.push("--allowedTools", tools.join(","));
  } else {
    // The CLI rejects `--allowedTools ""` ("argument missing"). When no tools
    // are wanted, omit the allowlist and instead refuse every tool via
    // `--disallowedTools` so the model truly cannot reach for Read/Bash/etc.
    cliArgs.push("--disallowedTools", "Read,Write,Edit,Bash,Glob,Grep,Task,WebSearch,WebFetch");
  }
  if (args.addDir) cliArgs.push("--add-dir", args.addDir);
  // Pipe the prompt via stdin. The `-- <positional arg>` path doesn't survive
  // long multi-line content reliably (the CLI complained "Input must be
  // provided either through stdin or as a prompt argument when using --print"
  // for prompts past a few KB). Stdin is unambiguous.
  // Compose system prompt: house style (anti-AI-tics) + detective persona.
  // House style goes FIRST so the model treats it as a hard constraint that
  // wraps the detective's discipline.
  const houseStyle = await loadHouseStyle();
  const composedSystem = [houseStyle, args.systemPrompt]
    .filter((s): s is string => typeof s === "string" && s.trim().length > 0)
    .join("\n\n---\n\n");
  const fullPrompt = composedSystem
    ? `${composedSystem}\n\n---\n\n${args.prompt}`
    : args.prompt;

  const t0 = Date.now();
  return await new Promise<ClaudeCallResult>((resolve, reject) => {
    const child = spawn("claude", cliArgs, {
      stdio: ["pipe", "pipe", "pipe"],
      env: {
        ...process.env,
        // CLAUDE_CODE_OAUTH_TOKEN is read by the CLI itself.
      },
    });
    child.stdin.write(fullPrompt);
    child.stdin.end();
    let stdout = "";
    let stderr = "";
    child.stdout.on("data", (c) => (stdout += c.toString()));
    child.stderr.on("data", (c) => (stderr += c.toString()));
    const timer = setTimeout(() => {
      child.kill("SIGKILL");
      reject(new Error(`claude CLI timeout > ${timeoutMs}ms`));
    }, timeoutMs);
    child.on("error", (e) => {
      clearTimeout(timer);
      reject(new Error(`claude CLI spawn failed: ${e.message}`));
    });
    child.on("close", (code) => {
      clearTimeout(timer);
      if (code !== 0) {
        const tail = stderr.slice(-400) || `rc=${code}`;
        return reject(new Error(`claude rc=${code}: ${tail}`));
      }
      let parsed: Record<string, unknown> = {};
      try {
        parsed = JSON.parse(stdout);
      } catch {
        return reject(new Error(`claude stdout not JSON: ${stdout.slice(0, 200)}`));
      }
      const cost = Number(parsed.total_cost_usd ?? 0);
      if (args.budgetCapUsd && cost > args.budgetCapUsd) {
        // The model already spent the money but we surface the breach so the
        // orchestrator can abort the rest of the job.
        return resolve({
          text: String(parsed.result ?? ""),
          costUsd: cost,
          tokensIn: (parsed.usage as { input_tokens?: number } | undefined)?.input_tokens,
          tokensOut: (parsed.usage as { output_tokens?: number } | undefined)?.output_tokens,
          isError: true,
          durationMs: Date.now() - t0,
          raw: parsed,
        });
      }
      const usage = parsed.usage as { input_tokens?: number; output_tokens?: number } | undefined;
      const resultText = String(parsed.result ?? "");
      const isError = Boolean(parsed.is_error);
      const isQuota = isError && (
        /weekly limit|usage limit|rate.?limit/i.test(resultText) ||
        Number(parsed.api_error_status) === 429
      );
      // Quota exhaustion is a recoverable state (it resets); surface it as a
      // typed error the orchestrator can identify, instead of pretending the
      // call succeeded with an empty body.
      if (isQuota) {
        const e = new Error(`claude_quota_exhausted: ${resultText || "weekly limit"}`);
        (e as Error & { isQuota?: boolean; durationMs?: number; costUsd?: number }).isQuota = true;
        (e as Error & { durationMs?: number }).durationMs = Date.now() - t0;
        return reject(e);
      }
      resolve({
        text: resultText,
        costUsd: cost,
        tokensIn: usage?.input_tokens,
        tokensOut: usage?.output_tokens,
        isError,
        durationMs: Date.now() - t0,
        raw: parsed,
      });
    });
  });
}
