/**
 * Re-deriving a recorded reward in the visitor's own browser.
 *
 * The page claims the numbers on it are real. This is the only part of the site
 * that can actually demonstrate that rather than assert it: it runs the demo's
 * own `verify()` over the recorded trace and compares the answer to the numbers
 * shipped in the fixture.
 *
 * The failure modes are asymmetric and that asymmetry is the whole design.
 * `mismatch` is a serious accusation — it says the published fixture disagrees
 * with the code that supposedly produced it. It must only ever be reached by
 * comparing two real numbers. Everything else — no verifier, a truncated trace,
 * an ungraded run, a verifier that threw — is `unverifiable`, which is an
 * honest "we can't check this here" and is NEVER a zero and NEVER a mismatch.
 */

import { isNotScored, rewardTotal } from './episode';
import type { AnyDemoModule } from './registry';
import type { DemoEpisode, RewardValues } from './types';

/**
 * Floating-point tolerance. The recorded numbers came out of Python and the
 * recomputed ones out of JavaScript; both are IEEE 754 doubles doing the same
 * arithmetic in a different order, so they agree to roughly this much and no
 * further. Anything above it is a real disagreement, not a rounding artefact.
 */
export const VERIFY_TOLERANCE = 1e-7;

export type VerifyStatus = 'match' | 'mismatch' | 'unverifiable';

export interface ComponentComparison {
  key: string;
  label: string;
  recorded: number | null;
  recomputed: number | null;
  /** `recomputed - recorded`, or `null` when either side is unscored. */
  delta: number | null;
}

export interface VerifyResult {
  status: VerifyStatus;
  /** Weighted total from re-running the grader here. */
  recomputed: number | null;
  /** Weighted total as shipped in the fixture. */
  recorded: number | null;
  /** `recomputed - recorded`. `null` when either side is unavailable. */
  delta: number | null;
  /**
   * The first component that disagrees, worst delta first. Named so the UI can
   * say WHICH term is wrong instead of just flashing red at a total.
   */
  culprit?: ComponentComparison;
  /** Every component that could be compared, plus the ones that could not. */
  components: ComponentComparison[];
  /** Why this is unverifiable, in a sentence fit to render. */
  reason?: string;
}

function unverifiable(reason: string, recorded: number | null = null): VerifyResult {
  return { status: 'unverifiable', recomputed: null, recorded, delta: null, components: [], reason };
}

/**
 * Compare a demo's browser-side grader against its recorded fixture.
 *
 * Pure and synchronous: the demo's `verify()` is required to be pure over the
 * episode, so this can run during render without a loading state.
 */
export function verifyEpisode(module: AnyDemoModule, episode: DemoEpisode): VerifyResult {
  const components = module.reward.components;
  const recordedTotal = rewardTotal(episode.rewards, components);

  if (typeof module.verify !== 'function') {
    return unverifiable('This demo does not ship a browser-side grader, so the recorded numbers cannot be re-derived here. The environment source is in the repository.', recordedTotal);
  }

  if (episode.truncated === true) {
    return unverifiable('The recorded trace is truncated, so the grader has nothing complete to score. A truncated run is unverifiable, not a zero.', recordedTotal);
  }

  let recomputedValues: RewardValues | null;
  try {
    recomputedValues = module.verify(episode);
  } catch (error: unknown) {
    // A grader that throws is a bug in the grader, not evidence about the run.
    // Reporting it as a mismatch would accuse the fixture of being wrong.
    const detail = error instanceof Error ? error.message : String(error);
    return unverifiable(`The browser-side grader could not run: ${detail}`, recordedTotal);
  }

  if (recomputedValues === null) {
    return unverifiable('The demo reported this run as unverifiable — the trace does not carry everything the grader needs.', recordedTotal);
  }

  const comparisons: ComponentComparison[] = components.map((component) => {
    const recordedRaw = episode.rewards[component.key];
    const recomputedRaw = recomputedValues[component.key];
    const recorded = isNotScored(recordedRaw) ? null : recordedRaw;
    const recomputed = isNotScored(recomputedRaw) ? null : recomputedRaw;
    return {
      key: component.key,
      label: component.label,
      recorded,
      recomputed,
      delta: recorded === null || recomputed === null ? null : recomputed - recorded,
    };
  });

  const comparable = comparisons.filter((c) => c.delta !== null);
  if (comparable.length === 0) {
    return unverifiable('This run carries no graded components to compare against, so there is nothing to verify. Not scored is not zero.', recordedTotal);
  }

  const recomputedTotal = rewardTotal(recomputedValues, components);

  // Worst first, so the culprit is the component that actually moved the total.
  const disagreeing = comparable
    .filter((c) => Math.abs(c.delta ?? 0) > VERIFY_TOLERANCE)
    .sort((a, b) => Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0));

  const totalDelta =
    recomputedTotal === null || recordedTotal === null ? null : recomputedTotal - recordedTotal;

  const totalsAgree = totalDelta !== null && Math.abs(totalDelta) <= VERIFY_TOLERANCE;
  const matched = disagreeing.length === 0 && totalsAgree;

  const result: VerifyResult = {
    status: matched ? 'match' : 'mismatch',
    recomputed: recomputedTotal,
    recorded: recordedTotal,
    delta: totalDelta,
    components: comparisons,
  };

  const culprit = disagreeing[0];
  if (culprit) result.culprit = culprit;
  return result;
}

/**
 * Just the recomputed numbers, with none of the comparison.
 *
 * `verifyEpisode` is the one to reach for — it does the comparing, and the
 * comparing is where the honesty rules live. This exists for a surface that
 * wants to render the recomputed values itself and only needs the safe call:
 * a grader that is missing, throws, or declines returns `null` rather than
 * propagating, so no caller can turn a broken verifier into a zero.
 */
export function recomputeRewards(
  module: AnyDemoModule,
  episode: DemoEpisode,
): RewardValues | null {
  if (typeof module.verify !== 'function') return null;
  if (episode.truncated === true) return null;
  try {
    return module.verify(episode);
  } catch (error: unknown) {
    console.error('[demo-kit] in-browser grader threw', error);
    return null;
  }
}

/** One line an exec can read, given a result. Keeps the wording in one place. */
export function verifySummary(result: VerifyResult): string {
  switch (result.status) {
    case 'match':
      return 'Re-computed in your browser from the recorded trace. It matches the published number.';
    case 'mismatch':
      return result.culprit
        ? `Re-computed in your browser and it disagrees on "${result.culprit.label}".`
        : 'Re-computed in your browser and it disagrees with the published number.';
    case 'unverifiable':
      return result.reason ?? 'This run cannot be re-computed in the browser.';
  }
}
