/**
 * The lazy half of a demo: everything that costs bundle size.
 *
 * A demo exists because this file and `meta.ts` exist. There is no registry to
 * edit, no route to add and no import to insert anywhere else — if you find
 * yourself editing a shared file to make a new demo appear, that is a bug in
 * `src/lib/demo-kit/registry.ts`, not a missing step here.
 *
 * Two rules the checker enforces, both worth understanding rather than
 * working around:
 *
 *   · Import ONLY from '@/lib/demo-kit'. The barrel is the contract. Reaching
 *     into '@/lib/demo-kit/player' or '@/components/demo/…' makes the shell
 *     impossible to change without opening every demo (rule 9).
 *   · Every step sets a non-empty `announce`. Reduced motion clamps the
 *     animation to nothing, so for a screen-reader user the announcement IS
 *     the result, not a courtesy (rule 13).
 *   · `meta.ts` never imports this file. The demo page is four tabs and Play
 *     is the one a demo is allowed not to have, so the eager half — meta,
 *     narrative, reward — has to render with the interactive half absent
 *     (rule 14).
 */

import { defineDemo, type DemoEpisode, type DemoStep, type RewardValues } from '@/lib/demo-kit';

import meta from './meta';
import Board, { empty__Pascal__, type QueueItem, type __Pascal__State } from './surface';
// The Python is quoted verbatim beside the number it produced. `?raw` is how
// the real file gets into the bundle without being reimplemented in TypeScript.
import rewardSource from './reward.py?raw';

/* ------------------------------------------------------------------ adapt */

/** Reads the queue off a recorded turn without trusting its shape. */
function itemsFrom(info: Record<string, unknown> | undefined): QueueItem[] {
  const raw = info?.['items'];
  if (!Array.isArray(raw)) return [];
  return raw.flatMap((entry): QueueItem[] => {
    if (entry === null || typeof entry !== 'object') return [];
    const row = entry as Record<string, unknown>;
    const id = typeof row['id'] === 'string' ? row['id'] : null;
    if (id === null) return [];
    return [
      {
        id,
        label: typeof row['label'] === 'string' ? row['label'] : id,
        risk: typeof row['risk'] === 'number' ? row['risk'] : 0,
        needsPerson: row['needs_person'] === true || row['needsPerson'] === true,
      },
    ];
  });
}

function escalatedFrom(info: Record<string, unknown> | undefined): string[] {
  const raw = info?.['escalated'];
  return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [];
}

/**
 * A recorded episode becomes the boards the player renders. Pure, and total:
 * a turn the environment recorded badly must degrade to a readable step, never
 * throw — the shell has an error boundary, but a page that renders the error
 * card instead of the run has still lost the argument.
 */
export function adapt(episode: DemoEpisode): DemoStep<__Pascal__State>[] {
  let items: QueueItem[] = [];

  return episode.turns.map((turn, index) => {
    const turnItems = itemsFrom(turn.info);
    // The queue is only sent once, on the first turn of most recordings, so
    // carry the last one we saw rather than blanking the board mid-replay.
    if (turnItems.length > 0) items = turnItems;

    const escalated = escalatedFrom(turn.info);
    const last = escalated[escalated.length - 1];
    const lastItem = last === undefined ? undefined : items.find((item) => item.id === last);
    const isLastTurn = index === episode.turns.length - 1;

    const state: __Pascal__State = {
      seed: episode.seed,
      items,
      escalated,
      outcome: isLastTurn && episode.outcome !== 'aborted' ? episode.outcome : 'pending',
    };

    return {
      index,
      state,
      reply: turn.reply,
      reasoning: turn.reasoning,
      call: turn.call,
      announce:
        lastItem === undefined
          ? `Turn ${index + 1}: nothing escalated, ${items.length} left in the queue.`
          : `Turn ${index + 1}: escalated ${lastItem.label}. ${escalated.length} of ${items.length} escalated.`,
      ...(lastItem === undefined ? {} : { caption: `Escalated ${lastItem.label}` }),
    };
  });
}

/* ----------------------------------------------------------------- verify */

/**
 * Re-derives the score in the browser, so the page can PROVE the recorded
 * numbers rather than assert them. Returning null means 'unverifiable' — a
 * truncated trace — and the badge renders that as neutral, never as zero.
 */
function recompute(episode: DemoEpisode): RewardValues | null {
  if (episode.truncated) return null;
  const steps = adapt(episode);
  const final = steps[steps.length - 1];
  if (!final || final.state.items.length === 0) return null;

  const needed = final.state.items.filter((item) => item.needsPerson);
  const escalated = new Set(final.state.escalated);
  const noise = [...escalated].filter((id) => !needed.some((item) => item.id === id));

  return {
    caught: needed.length === 0 ? 1 : needed.filter((item) => escalated.has(item.id)).length / needed.length,
    restraint: escalated.size === 0 ? 1 : 1 - noise.length / escalated.size,
    well_formed: 1,
  };
}

/* ------------------------------------------------------------------ demo */

export default defineDemo<__Pascal__State>({
  meta,

  narrative: {
    thesis:
      'The queue is not the problem. Deciding which three of four hundred items are worth a person is the ' +
      'problem, and that decision is exactly the kind of judgement a reward can be written down for.',
    anxiety: 'What stops it escalating everything so it never misses one?',

    /**
     * ── THE FOUR CLAIMS ──────────────────────────────────────────────────
     *
     * The demo page is four tabs — Play, Watch, Reward, Evidence — and this is
     * the one sentence each of them has to earn. You do not choose the tabs or
     * their order; the shell does, and it drops Play when there is no
     * `interactive` and Watch when there are no recorded runs. You only write
     * what each one asserts.
     *
     * Write all four even if this demo will not render all four. A tab whose
     * claim you cannot write is a tab with nothing in it, and finding that out
     * here is cheaper than finding it out in review.
     *
     * A good claim is:
     *
     *   · a SENTENCE, not a label. "The reward" is a heading. "Move one weight
     *     and the ranking of the runs re-orders under it" is a claim.
     *   · falsifiable BY THE TAB IT SITS ON. The reader should be able to look
     *     at the surface below it and agree or disagree within a few seconds.
     *   · about this environment, not about reinforcement learning. The thesis
     *     above is where the general argument goes.
     *   · addressed to the person in `meta.persona`, in their words.
     *
     * The four below are real claims for this worked example. Replace them —
     * do not delete the shape.
     */
    claims: {
      // Play: what the visitor learns by doing the task themselves, and why
      // doing it first makes the other three tabs mean something.
      play:
        'Work the queue yourself for thirty seconds and you will feel the trade the agent is being scored on: ' +
        'every item you escalate has to be worth someone opening it.',
      // Watch: what the recorded run proves that a claim about the run cannot.
      watch:
        'This is a real recorded run, replayed one turn at a time — the reasoning, the model call and the cost ' +
        'of each decision are exactly what came off the wire.',
      // Reward: what changes on screen when the reader changes what "good"
      // means. Name the thing that moves.
      reward:
        'Take the weight off restraint and the run that escalated everything climbs to the top of the ranking. ' +
        'That is not a bug in the score; it is the score doing what you asked.',
      // Evidence: what the reader can go and check, and what this does not show.
      evidence:
        'The grader is a short Python function, printed here with the command that ran it, so you can disagree ' +
        'with the number by reading the code rather than by trusting us.',
    },
    limits: [
      {
        text: 'The grader knows which items needed a person because the dataset says so. A real queue has no such column, and building one is most of the work.',
      },
      {
        text: 'Escalating wrongly and missing something both cost the same here. In production they do not, and the weights should say so.',
      },
    ],
  },

  /**
   * The four boxes. `check-demos` rule 6 fails an empty one for a `spec` demo
   * as loudly as for a `live` one: a specification with a blank grader is a
   * coming-soon card wearing a contract.
   */
  anatomy: {
    task: 'Work one queue of items and escalate only the ones that genuinely need a person.',
    actions: 'Escalate an item by id, or end the turn. Any other output is refused and counts as a malformed action.',
    grader: 'Compares the escalated set to the labelled set. It counts; it does not judge.',
    score: 'Three fifths for the ones caught, a quarter for not escalating anything else, and the rest is a well-formed-output gate.',
  },

  reward: {
    components: [
      {
        key: 'caught',
        label: 'Caught what mattered',
        description: 'The share of the items that genuinely needed a person which the agent escalated.',
        weight: 0.6,
        role: 'objective',
      },
      {
        key: 'restraint',
        label: 'Left the rest alone',
        description:
          'The share of everything it escalated that was worth escalating. This is the term that makes ' +
          'escalating the whole queue a losing strategy rather than a safe one.',
        weight: 0.25,
        role: 'counterweight',
      },
      {
        key: 'well_formed',
        label: 'Answered in the required form',
        description: 'One point unless the run emitted an action the environment could not parse.',
        weight: 0.15,
        role: 'gate',
      },
    ],
    metrics: [
      {
        key: 'escalations_that_landed',
        label: 'Escalations that landed',
        description: 'A count, not a rate. Rendered beside the reward and never summed into it.',
      },
    ],
    source: {
      path: 'src/demos/_template/reward.py',
      code: rewardSource,
      marker: 'score',
    },
  },

  provenance: {
    envPackage: '__package__',
    tasksetId: '__slug__',
    verifiersVersion: '0.3.2.dev12',
    command: 'uv run python envs/probe.py --env __package__',
    credits: [
      {
        label: 'verifiers — the environment API this mirrors',
        href: 'https://github.com/PrimeIntellect-ai/verifiers',
      },
    ],
  },

  adapt,
  Surface: Board,

  /**
   * ── ADDING PLAY ──────────────────────────────────────────────────────────
   *
   * There is deliberately no `interactive` here, because a scaffolded demo
   * starts as `spec`. Add one and the shell grows a Play tab and OPENS ON IT —
   * that is the whole shape of the page, so it is worth doing:
   *
   *     interactive: {
   *       init: (seed: number) => empty__Pascal__(seed),   // pure in the seed
   *       Controls: __Pascal__Controls,                    // its own module
   *     },
   *
   * `init` must be deterministic in the seed: the shell re-inits on reset and
   * on a shared link, and a board that comes back different has quietly told
   * the visitor the environment is not reproducible.
   *
   * `Controls` gets `{ state, onChange, seed }` and nothing else. It owns no
   * state the board does not — hand the next board to `onChange` and let the
   * shell re-render, or Play and the replay will drift apart. Put it in its own
   * file and import it here, never from `meta.ts` (rule 14).
   */

  verify: recompute,
});

/** Re-exported so a scaffolded demo's tests can build a board without the shell. */
export { empty__Pascal__ };
