#!/usr/bin/env node
/**
 * The receipt checker.
 *
 * Every demo quotes the code that computes its reward, verbatim, next to the
 * number that code produced. That quote is the page's whole claim to being
 * evidence rather than marketing, so the thing it quotes has to exist and the
 * region it quotes has to resolve. A receipt panel that silently renders empty,
 * or renders the wrong forty lines because someone inserted a function above
 * the marker, is worse than no receipt at all.
 *
 * ── MARKER SYNTAX ──────────────────────────────────────────────────────────
 *
 * `RewardSpec.source` is `{ path, code, marker? }`. Without a `marker` the
 * whole file is the receipt. With one, the receipt is the region delimited by
 * a matched pair of comment lines in the source file itself:
 *
 *     # region: pig-demo/<marker>
 *     ... the quoted lines ...
 *     # endregion: pig-demo/<marker>
 *
 * Rules, all enforced here:
 *
 *   · The marker lines are EXCLUSIVE — neither appears in the quoted region.
 *   · EXACTLY ONE pair per file per marker. Zero is a broken receipt; two or
 *     more is ambiguous, and an ambiguous receipt silently quotes whichever
 *     region the reader's implementation happened to find first. Both are
 *     fatal.
 *   · `region` must come before `endregion`, and the region must be non-empty
 *     once the marker lines are removed.
 *   · The comment token is `#` (Python) or `//` (TypeScript), then optional
 *     whitespace. Everything else on the line is ignored, so
 *     `# region: pig-demo/consistency  (see PR #41)` is legal.
 *   · The `pig-demo/` prefix is required. It is what makes these greppable and
 *     stops an editor's own `#region` folding markers from being read as
 *     receipts.
 *
 * Marker names are `[a-z0-9][a-z0-9-]*`: they end up in a grep, a CI message
 * and a code comment, and mixed case in all three is a bug factory.
 */

import path from 'node:path';

import {
  Report,
  abs,
  allDemoDirs,
  demoFiles,
  evalLiteral,
  exists,
  isUnresolved,
  literalsAfter,
  read,
  rel,
} from './_lib.mjs';

const report = new Report('check-receipts');

const RULE_PATH = 'reward.source.path';
const RULE_MARKER = 'reward.source.marker';

const MARKER_NAME = /^[a-z0-9][a-z0-9-]*$/;
const markerLine = (kind, marker) =>
  new RegExp(`^[ \\t]*(?:#|//)[ \\t]*${kind}:[ \\t]*pig-demo/${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'm');

/** Every `source: { ... }` literal in a demo, wherever the demo chose to put it. */
function receipts(slug) {
  const out = [];
  for (const file of demoFiles(slug)) {
    const src = read(file);
    for (const literal of literalsAfter(src, /\bsource\s*:\s*/, '{')) {
      // `code` is legitimately an identifier here — the Python arrives through
      // a Vite `?raw` import, which cannot resolve in plain Node — so the
      // literal is read leniently and only `path` and `marker` are trusted.
      const evaluated = evalLiteral(literal.text, 'RewardSpec.source', { lenient: true });
      if (!evaluated.ok) {
        out.push({ file, error: evaluated.error });
        continue;
      }
      const value = evaluated.value;
      if (!value || typeof value !== 'object' || !('path' in value)) continue; // some other `source:` key
      const line = src.slice(0, literal.start).split('\n').length;
      out.push({ file, line, value });
    }
  }
  return out;
}

const slugs = allDemoDirs().filter((s) => !s.startsWith('_'));
if (slugs.length === 0) {
  console.log('check-receipts: no demos to check.');
  process.exit(0);
}

for (const slug of slugs) {
  const found = receipts(slug);

  if (found.length === 0) {
    report.fail(
      rel(path.join(abs('src', 'demos'), slug)),
      RULE_PATH,
      'no `reward.source` found. Every demo quotes the code that computes its reward; without a source ' +
        'the receipt panel has nothing to show and the page is asserting its numbers rather than proving them.',
    );
    continue;
  }

  for (const receipt of found) {
    const where = `${rel(receipt.file)}${receipt.line ? `:${receipt.line}` : ''}`;
    if (receipt.error) {
      report.fail(where, RULE_PATH, receipt.error);
      continue;
    }
    const { path: sourcePath, marker } = receipt.value;

    if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
      report.fail(
        where,
        RULE_PATH,
        `\`path\` is ${isUnresolved(sourcePath) ? 'an imported constant' : JSON.stringify(sourcePath)}. ` +
          'It must be a literal repo-relative path, e.g. "envs/wordle_five/wordle_five/rubric.py", ' +
          'so a reader can open the same file this page quotes.',
      );
      continue;
    }

    // Repo-relative by contract. A leading slash would mean the site root,
    // which is not where the Python lives, and an absolute disk path would
    // only work on the machine that wrote it.
    const clean = sourcePath.replace(/^\.\//, '');
    if (path.isAbsolute(clean)) {
      report.fail(where, RULE_PATH, `\`path\` is absolute ("${sourcePath}"). Use a repo-relative path.`);
      continue;
    }
    const onDisk = abs(clean);
    if (!report.check(exists(onDisk), where, RULE_PATH, `\`path\` is "${sourcePath}", and ${rel(onDisk)} does not exist.`)) {
      continue;
    }

    if (marker === undefined || marker === null) continue; // whole file is the receipt

    if (typeof marker !== 'string' || !MARKER_NAME.test(marker)) {
      report.fail(
        where,
        RULE_MARKER,
        `\`marker\` is ${JSON.stringify(marker)}. Markers are lower-kebab-case (${MARKER_NAME}) because they ` +
          'appear in a grep, a CI message and a code comment, and mixed case in all three is a bug factory.',
      );
      continue;
    }

    const body = read(onDisk);
    const lines = body.split('\n');
    const opens = [];
    const closes = [];
    lines.forEach((line, i) => {
      if (markerLine('region', marker).test(line)) opens.push(i);
      if (markerLine('endregion', marker).test(line)) closes.push(i);
    });

    const syntax = `Expected exactly one "# region: pig-demo/${marker}" and one "# endregion: pig-demo/${marker}".`;
    if (opens.length !== 1 || closes.length !== 1) {
      report.fail(
        rel(onDisk),
        RULE_MARKER,
        `marker "${marker}" (referenced from ${where}) resolves to ${opens.length} region marker(s) and ` +
          `${closes.length} endregion marker(s). ${syntax} ` +
          (opens.length > 1 || closes.length > 1
            ? 'More than one pair is ambiguous: the receipt would quote whichever region the reader found first.'
            : 'A missing marker renders the receipt panel empty, which reads as the code not existing.'),
      );
      continue;
    }

    const [open] = opens;
    const [close] = closes;
    if (close <= open) {
      report.fail(
        rel(onDisk),
        RULE_MARKER,
        `marker "${marker}": the endregion is on line ${close + 1}, at or before the region on line ${open + 1}.`,
      );
      continue;
    }
    const region = lines.slice(open + 1, close);
    report.check(
      region.some((line) => line.trim() !== ''),
      rel(onDisk),
      RULE_MARKER,
      `marker "${marker}" delimits an empty region between lines ${open + 1} and ${close + 1}. The markers are ` +
        'exclusive, so a pair on adjacent lines quotes nothing.',
    );
  }
}

report.finish();
