/**
 * Precision/recall/F1 for {@link techStepClassifier}'s output against a
 * hand-labeled evaluation set (`tech-step-eval-dataset.ts`) — the objective
 * counterpart to the "inspected by eye" verdict every corpus change used to
 * get before this module existed. Every future edit to
 * `services/tech-step-intent-service`'s `training_data.py` (including the
 * LLM-assisted suggestions the worker in `services/tech-step-llm-worker`
 * proposes) is expected to run
 * through `tech-step-eval.test.ts`'s regression gate, which calls
 * {@link computeTechStepMetrics} — a corpus change that raises recall on one
 * technique but silently tanks another's precision should fail loudly here,
 * not get merged on the strength of a few manually-checked examples.
 *
 * Pure (no DB/model access) so it's unit-testable on its own — same
 * convention as `tech-step-matcher.ts`'s own pure helpers (`normalizeText`,
 * `splitIntoClauses`): this module only ever receives already-resolved
 * `TechStep.key` strings, never DB ids or a live classifier instance, so it
 * has nothing to mock to test.
 */

/** True/false-positive/negative counts for one technique (or the aggregate across all of them), plus the precision/recall/F1 derived from them. */
export interface TechStepMetrics {
  truePositives: number;
  falsePositives: number;
  falseNegatives: number;
  precision: number;
  recall: number;
  f1: number;
}

/**
 * One evaluation case's outcome — what {@link TechStepEvalCase.expectedKeys}
 * said should be found, against what the classifier actually returned for
 * that case (already mapped from `TechStepMatch.techStepId` back to
 * `TechStep.key`, see `tech-step-eval.test.ts`).
 *
 * Both lists are *multisets*, not sets — a description that names the same
 * technique twice (rare, but not impossible: "faire cuire, puis... remettre
 * à cuire") is expected to produce two matches, and comparing as plain sets
 * would silently treat a classifier that only found one of them as a
 * perfect match.
 */
export interface TechStepEvalOutcome {
  expectedKeys: string[];
  actualKeys: string[];
}

/** {@link computeTechStepMetrics}'s result — the aggregate across every case, plus a breakdown per technique so a regression hiding behind a healthy overall F1 (one technique's recall collapsing, offset by another's improving) is still visible. */
export interface TechStepEvalResult {
  overall: TechStepMetrics;
  byKey: Record<string, TechStepMetrics>;
}

interface RawCounts {
  tp: number;
  fp: number;
  fn: number;
}

function emptyCounts(): RawCounts {
  return { tp: 0, fp: 0, fn: 0 };
}

/** Counts occurrences of each key in a multiset, e.g. `["cook", "cook", "bake"]` -> `{cook: 2, bake: 1}`. */
function countByKey(keys: string[]): Map<string, number> {
  const counts = new Map<string, number>();
  for (const key of keys) {
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  return counts;
}

/**
 * Standard vacuous-truth convention for the `0/0` cases: precision defaults
 * to `1` when nothing was predicted for a key (`tp + fp === 0` — no false
 * accusation to be precise about), recall defaults to `1` when nothing was
 * expected (`tp + fn === 0` — nothing to have missed). Neither inflates F1
 * on its own: a technique the classifier fully misses still has `recall =
 * 0` (there *were* expected occurrences, just none matched), which is what
 * pulls F1 down to `0` for that case regardless of precision's vacuous `1`.
 */
function toMetrics(counts: RawCounts): TechStepMetrics {
  const { tp, fp, fn } = counts;
  const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
  const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
  const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
  return { truePositives: tp, falsePositives: fp, falseNegatives: fn, precision, recall, f1 };
}

/**
 * Aggregates every {@link TechStepEvalOutcome} into one overall
 * precision/recall/F1 plus a per-technique breakdown.
 *
 * Counted key by key, multiset-style, per outcome: for a given technique,
 * `min(expectedCount, actualCount)` true positives, any actual occurrences
 * beyond that are false positives, any expected occurrences short of that
 * are false negatives — generalizes the usual set-based TP/FP/FN definition
 * to handle a technique mentioned (or matched) more than once in the same
 * step without over- or under-counting it.
 */
export function computeTechStepMetrics(outcomes: TechStepEvalOutcome[]): TechStepEvalResult {
  const overallCounts = emptyCounts();
  const countsByKey = new Map<string, RawCounts>();

  for (const outcome of outcomes) {
    const expectedCounts = countByKey(outcome.expectedKeys);
    const actualCounts = countByKey(outcome.actualKeys);
    const allKeys = new Set([...expectedCounts.keys(), ...actualCounts.keys()]);

    for (const key of allKeys) {
      const expected = expectedCounts.get(key) ?? 0;
      const actual = actualCounts.get(key) ?? 0;
      const tp = Math.min(expected, actual);
      const fp = Math.max(0, actual - expected);
      const fn = Math.max(0, expected - actual);

      overallCounts.tp += tp;
      overallCounts.fp += fp;
      overallCounts.fn += fn;

      const keyCounts = countsByKey.get(key) ?? emptyCounts();
      keyCounts.tp += tp;
      keyCounts.fp += fp;
      keyCounts.fn += fn;
      countsByKey.set(key, keyCounts);
    }
  }

  const byKey: Record<string, TechStepMetrics> = {};
  for (const [key, counts] of countsByKey) {
    byKey[key] = toMetrics(counts);
  }

  return { overall: toMetrics(overallCounts), byKey };
}
