/**
 * The reward, in the browser.
 *
 * A port of `envs/alert_triage/alert_triage/reward.py`. The page quotes the
 * Python — its `# region: pig-demo/reward` block is the receipt — and this
 * file exists so the browser can re-derive what the environment recorded. CI
 * hashes `scoreExact` over a fixed grid on both sides (digest `scorer`).
 *
 * Three components, weights summing to 1.0. Two pull toward clearing the
 * alert quickly with the right citations. The third — the heaviest — pulls
 * the other way: it pays for not letting a suspicious customer through.
 *
 * Hours are integers in MINUTES so that the world, the grader and the browser
 * agree to the last digit; the page shows them as hours.
 */

// The generator's config, NOT industry measurements. Nobody timed an analyst.
export const SCREEN_MINUTES = 30; // reading the free screen; charged to everyone
export const TURN_MINUTES = 3; // every reply, accepted or rejected
export const LOOKUP_MINUTES = 24; // history, counterparty, prior alerts
export const DOC_MINUTES = 6; // documents on file

export const CAUGHT_WEIGHT = 0.55;
export const HOURS_WEIGHT = 0.25;
export const EVIDENCE_WEIGHT = 0.2;

/**
 * What `caught` pays for filing on a clean customer. Not zero: a defensive
 * escalation is a wasted filing, not a missed crime. Not one: otherwise a
 * policy that escalates everything takes the counterweight for free.
 */
export const FALSE_ESCALATION_CREDIT = 0.5;

export type ComponentKey = 'caught' | 'hours' | 'evidence';

/** Everything the reward needs from one played alert. */
export interface Episode {
  /** What the generator planted. */
  label: 'benign' | 'suspicious';
  /** Alternates; the best one is scored. */
  planted: string[][];
  disposition: 'close' | 'escalate' | null;
  cites: string[];
  minutes_spent: number;
  /** Cheapest shipped policy that got this seed right. */
  reference_minutes: number | null;
  turns_spent: number;
  rejected: number;
  /** Ended early with no disposition: not scored. */
  truncated: boolean;
  /** What the agent wrote; diagnostic only. */
  typology?: string | null;
  /** What the generator planted; diagnostic only. */
  true_typology?: string | null;
  lookups?: number;
}

/** A close on a benign alert, or an escalation on a suspicious one. */
export function correct(ep: Episode): boolean {
  return (ep.label === 'benign' && ep.disposition === 'close') || (ep.label === 'suspicious' && ep.disposition === 'escalate');
}

/**
 * Nothing slipped through. The counterweight, and over half the reward.
 *
 * 1.0 on any correct call. 0.0 if a suspicious alert was closed, or nothing
 * was decided. A false escalation earns FALSE_ESCALATION_CREDIT.
 */
export function caught(ep: Episode): number {
  if (ep.disposition === null) return 0.0;
  if (correct(ep)) return 1.0;
  if (ep.label === 'benign') return FALSE_ESCALATION_CREDIT;
  return 0.0;
}

/**
 * Cleared it in the hours budgeted, as a ratio against a reference.
 *
 * Capped at 1.0. A fast wrong answer saves nothing. If no shipped policy got
 * this seed right there is no reference and this is null — not scored.
 */
export function hours(ep: Episode): number | null {
  if (!correct(ep)) return 0.0;
  if (ep.reference_minutes === null) return null;
  return Math.min(1.0, ep.reference_minutes / Math.max(1, ep.minutes_spent));
}

/** F1 as an exact fraction [numerator, denominator], so both runtimes agree bit for bit. */
export function f1Exact(cites: readonly string[], planted: readonly string[]): [number, number] {
  const c = new Set(cites);
  const p = new Set(planted);
  let common = 0;
  for (const id of c) if (p.has(id)) common += 1;
  return [2 * common, c.size + p.size];
}

function bestF1(ep: Episode): [number, number] {
  let best: [number, number] = [0, 1];
  for (const alt of ep.planted) {
    const [num, den] = f1Exact(ep.cites, alt);
    if (num * best[1] > best[0] * den) best = [num, den];
  }
  return best;
}

/**
 * Named the facts that decide it: F1 between the cites and the planted set.
 * Where a template admits two reasonable rationales the best alternate is
 * scored. Zero if the disposition was wrong. The narrative prose is never
 * read here.
 */
export function evidence(ep: Episode): number {
  if (!correct(ep) || !ep.cites.length) return 0.0;
  const [num, den] = bestF1(ep);
  return num / den;
}

export type Components = Record<ComponentKey, number | null>;

/** The components, unweighted. null means "not scored", never zero. */
export function score(ep: Episode): Components {
  if (ep.truncated) return { caught: null, hours: null, evidence: null };
  return { caught: caught(ep), hours: hours(ep), evidence: evidence(ep) };
}

export const WEIGHTS: Record<ComponentKey, number> = { caught: CAUGHT_WEIGHT, hours: HOURS_WEIGHT, evidence: EVIDENCE_WEIGHT };
export const ROLES: Record<ComponentKey, 'counterweight' | 'objective'> = { caught: 'counterweight', hours: 'objective', evidence: 'objective' };

export function total(ep: Episode, weights: Record<ComponentKey, number> = WEIGHTS): number | null {
  const parts = score(ep);
  if (parts.caught === null || parts.hours === null || parts.evidence === null) return null;
  return parts.caught * weights.caught + parts.hours * weights.hours + parts.evidence * weights.evidence;
}

/** P(benign) below which a blind escalation out-scores a well-cited close. */
export function escalateBreakEven(evidenceIfClose = 1.0): number {
  const closePerBenign = CAUGHT_WEIGHT + HOURS_WEIGHT + EVIDENCE_WEIGHT * evidenceIfClose;
  const escPerBenign = CAUGHT_WEIGHT * FALSE_ESCALATION_CREDIT;
  const escPerSuspicious = CAUGHT_WEIGHT + HOURS_WEIGHT;
  return escPerSuspicious / (closePerBenign - escPerBenign + escPerSuspicious);
}

export type ExactComponents = Record<ComponentKey, [number, number] | null>;

/** The components as exact fractions, for the cross-language digest. */
export function scoreExact(ep: Episode): ExactComponents {
  if (ep.truncated) return { caught: null, hours: null, evidence: null };
  let c: [number, number];
  if (ep.disposition === null) c = [0, 1];
  else if (correct(ep)) c = [1, 1];
  else if (ep.label === 'benign') c = [1, 2];
  else c = [0, 1];

  let h: [number, number] | null;
  if (!correct(ep)) h = [0, 1];
  else if (ep.reference_minutes === null) h = null;
  else {
    const spent = Math.max(1, ep.minutes_spent);
    h = ep.reference_minutes >= spent ? [1, 1] : [ep.reference_minutes, spent];
  }

  const e: [number, number] = !correct(ep) || !ep.cites.length ? [0, 1] : bestF1(ep);
  return { caught: c, hours: h, evidence: e };
}

/** Diagnostics. Weight zero — reported, never summed into the reward. */
export function metrics(ep: Episode): Record<string, number | null> {
  return {
    hours_spent: ep.minutes_spent / 60,
    reference_hours: ep.reference_minutes === null ? null : ep.reference_minutes / 60,
    lookups: ep.lookups ?? 0,
    turns_used: ep.turns_spent,
    rejected_replies: ep.rejected,
    false_escalation: ep.label === 'benign' && ep.disposition === 'escalate' ? 1.0 : 0.0,
    missed: ep.label === 'suspicious' && ep.disposition === 'close' ? 1.0 : 0.0,
    typology_match: ep.disposition !== 'escalate' ? null : (ep.typology ?? null) === (ep.true_typology ?? null) ? 1.0 : 0.0,
  };
}
