/**
 * Shared plumbing for the contract scripts.
 *
 * Every `scripts/check-*.mjs` is a gate, not a linter: it exits non-zero with a
 * message naming the file and the numbered rule that was broken, so a failure
 * in CI reads as an instruction rather than a puzzle.
 *
 * The demo contract lives in TypeScript and these scripts are plain Node, so
 * most of what follows is a small, deliberately dumb TypeScript reader: find a
 * named declaration, brace-match its object/array literal, strip the two TS-only
 * forms a literal can legally carry (`as const`, `satisfies X`), and evaluate it
 * in a bare `vm` context.
 *
 * That reader is honest about its limits. A literal that references an imported
 * constant does not evaluate, and the scripts FAIL rather than skip: the whole
 * point of `meta` and `reward.components` is that a human can read the numbers
 * off the page beside the code, so a weight hidden behind an indirection is a
 * contract problem, not a tooling problem.
 */

import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';

export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

export const abs = (...parts) => path.join(ROOT, ...parts);
export const rel = (p) => path.relative(ROOT, p) || '.';
export const exists = (p) => fs.existsSync(p);
export const read = (p) => fs.readFileSync(p, 'utf8');
export const readJson = (p) => JSON.parse(read(p));

/* ------------------------------------------------------------------ output */

const ESC = '\u001b[';
const COLOUR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
const paint = (code, s) => (COLOUR ? `${ESC}${code}m${s}${ESC}0m` : String(s));
export const bold = (s) => paint('1', s);
export const dim = (s) => paint('2', s);
export const red = (s) => paint('31', s);
export const green = (s) => paint('32', s);
export const yellow = (s) => paint('33', s);
export const cyan = (s) => paint('36', s);

/**
 * Collects failures instead of throwing on the first one.
 *
 * A contract check that dies on failure #1 makes the author fix and re-run
 * seven times. Every rule that can still be evaluated is evaluated.
 */
export class Report {
  /** @param {string} title */
  constructor(title) {
    this.title = title;
    /** @type {{file: string, rule: string, message: string}[]} */
    this.failures = [];
    /** @type {string[]} */
    this.warnings = [];
    /** @type {string[]} */
    this.staticNotes = [];
    this.passed = 0;
  }

  /**
   * @param {string} file  repo-relative path the reader should open
   * @param {string} rule  the numbered rule, e.g. 'rule 10 (reward weights)'
   * @param {string} message  what is actually wrong
   */
  fail(file, rule, message) {
    this.failures.push({ file, rule, message });
    return false;
  }

  /** Assert, recording either a pass or a precise failure. */
  check(condition, file, rule, message) {
    if (condition) {
      this.passed += 1;
      return true;
    }
    return this.fail(file, rule, message);
  }

  warn(message) {
    this.warnings.push(message);
  }

  /** Record that a rule was verified by reading source, not by running it. */
  staticOnly(message) {
    this.staticNotes.push(message);
  }

  /** Prints the report and exits the process. Never returns. */
  finish() {
    const out = [];
    if (this.staticNotes.length) {
      out.push('');
      out.push(dim('Checked by reading source, not by executing it:'));
      for (const n of this.staticNotes) out.push(dim(`  - ${n}`));
    }
    if (this.warnings.length) {
      out.push('');
      for (const w of this.warnings) out.push(`${yellow('warn')}  ${w}`);
    }
    if (this.failures.length) {
      out.push('');
      for (const f of this.failures) {
        out.push(`${red('FAIL')}  ${bold(f.file)}`);
        out.push(`      ${cyan(f.rule)}: ${f.message}`);
      }
      out.push('');
      out.push(red(`${this.title}: ${this.failures.length} failure(s), ${this.passed} check(s) passed.`));
      console.error(out.join('\n'));
      process.exit(1);
    }
    out.push('');
    out.push(green(`${this.title}: ${this.passed} check(s) passed.`));
    console.log(out.join('\n'));
    process.exit(0);
  }
}

/** Fatal error that is the script's own problem, not the repo's. */
export function die(message) {
  console.error(`${red('FAIL')}  ${message}`);
  process.exit(1);
}

/* ------------------------------------------------------- filesystem walking */

/**
 * @param {string} dir
 * @param {(file: string) => boolean} [filter]
 * @returns {string[]} absolute paths, depth-first, stable order
 */
export function walk(dir, filter = () => true) {
  if (!exists(dir)) return [];
  /** @type {string[]} */
  const out = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      if (entry.name === 'node_modules' || entry.name === '__pycache__') continue;
      out.push(...walk(full, filter));
    } else if (entry.isFile() && filter(full)) {
      out.push(full);
    }
  }
  return out;
}

export const DEMOS_DIR = abs('src', 'demos');

/** Every directory under `src/demos`, including `_template`. */
export function allDemoDirs() {
  if (!exists(DEMOS_DIR)) return [];
  return fs
    .readdirSync(DEMOS_DIR, { withFileTypes: true })
    .filter((e) => e.isDirectory())
    .map((e) => e.name)
    .sort();
}

/** Shippable demos: a leading underscore marks scaffolding, not a demo. */
export function demoSlugs() {
  return allDemoDirs().filter((name) => !name.startsWith('_'));
}

/* ----------------------------------------------------- the small TS reader */

/**
 * Splits source into code and non-code (string literal / comment) spans.
 *
 * Everything below works on this rather than on raw regex, because the metas
 * are full of prose: a `description` containing the words "as an executive"
 * is otherwise indistinguishable from a TypeScript `as` assertion.
 *
 * @param {string} src
 * @returns {{code: boolean, text: string}[]}
 */
export function segment(src) {
  /** @type {{code: boolean, text: string}[]} */
  const out = [];
  let codeStart = 0;
  let i = 0;
  const flushCode = (end) => {
    if (end > codeStart) out.push({ code: true, text: src.slice(codeStart, end) });
  };
  while (i < src.length) {
    const c = src[i];
    const next = src[i + 1];
    if (c === '/' && next === '/') {
      flushCode(i);
      const nl = src.indexOf('\n', i);
      const end = nl === -1 ? src.length : nl;
      out.push({ code: false, text: src.slice(i, end) });
      i = codeStart = end;
      continue;
    }
    if (c === '/' && next === '*') {
      flushCode(i);
      const close = src.indexOf('*/', i + 2);
      const end = close === -1 ? src.length : close + 2;
      out.push({ code: false, text: src.slice(i, end) });
      i = codeStart = end;
      continue;
    }
    if (c === "'" || c === '"' || c === '`') {
      flushCode(i);
      const end = endOfString(src, i) + 1;
      out.push({ code: false, text: src.slice(i, end) });
      i = codeStart = end;
      continue;
    }
    i += 1;
  }
  flushCode(src.length);
  return out;
}

/** Index of the closing quote of the string literal starting at `start`. */
function endOfString(src, start) {
  const quote = src[start];
  for (let j = start + 1; j < src.length; j += 1) {
    const c = src[j];
    if (c === '\\') {
      j += 1;
      continue;
    }
    if (quote === '`' && c === '$' && src[j + 1] === '{') {
      j = scanBalanced(src, j + 1) - 1;
      continue;
    }
    if (c === quote) return j;
  }
  throw new Error(`unterminated string literal starting at offset ${start}`);
}

/**
 * End offset (exclusive) of the bracket pair opening at `start`.
 * Skips strings and comments, and recurses through `${...}` in templates.
 */
export function scanBalanced(src, start) {
  const open = src[start];
  const close = open === '{' ? '}' : open === '[' ? ']' : open === '(' ? ')' : null;
  if (!close) throw new Error(`offset ${start} is ${JSON.stringify(open)}, not an opening bracket`);
  let depth = 0;
  for (let i = start; i < src.length; i += 1) {
    const c = src[i];
    const next = src[i + 1];
    if (c === '/' && next === '/') {
      const nl = src.indexOf('\n', i);
      if (nl === -1) break;
      i = nl;
      continue;
    }
    if (c === '/' && next === '*') {
      const end = src.indexOf('*/', i + 2);
      i = end === -1 ? src.length : end + 1;
      continue;
    }
    if (c === "'" || c === '"' || c === '`') {
      i = endOfString(src, i);
      continue;
    }
    if (c === open) depth += 1;
    else if (c === close) {
      depth -= 1;
      if (depth === 0) return i + 1;
    }
  }
  throw new Error(`unbalanced ${open} starting at offset ${start}`);
}

/** Offsets of every code (non-string, non-comment) span in `src`. */
function codeSpans(src) {
  const spans = [];
  let at = 0;
  for (const s of segment(src)) {
    spans.push({ code: s.code, start: at, end: at + s.text.length });
    at += s.text.length;
  }
  return spans;
}

/**
 * Finds the literal that follows `anchor` and returns its source text.
 *
 * @param {string} src
 * @param {RegExp} anchor  must match immediately before the opening bracket
 * @param {'{' | '['} open
 * @returns {string | null}
 */
export function literalsAfter(src, anchor, open = '{') {
  const flags = anchor.flags.includes('g') ? anchor.flags : `${anchor.flags}g`;
  const re = new RegExp(anchor.source, flags);
  const spans = codeSpans(src);
  const isCode = (offset) => {
    const hit = spans.find((s) => offset >= s.start && offset < s.end);
    return hit ? hit.code : true;
  };
  const found = [];
  let match;
  while ((match = re.exec(src)) !== null) {
    // The anchor may legally match inside a comment or a string \u2014 a docblock
    // that quotes `const meta = {`. Only a match in real code counts.
    if (!isCode(match.index)) continue;
    const from = src.indexOf(open, match.index + Math.max(match[0].length - 1, 0));
    if (from === -1) continue;
    const between = src.slice(match.index + match[0].length, from);
    if (/[;=}]/.test(between)) continue;
    const end = scanBalanced(src, from);
    found.push({ text: src.slice(from, end), start: from, end });
    // Skip past this literal so a nested anchor of the same name is not
    // reported as a second, overlapping hit.
    re.lastIndex = end;
  }
  return found;
}

/** First literal following `anchor`, or null. See {@link literalsAfter}. */
export function literalAfter(src, anchor, open = '{') {
  const [first] = literalsAfter(src, anchor, open);
  return first ? first.text : null;
}

/** Marker key on the stand-in an unresolvable identifier evaluates to. */
export const UNRESOLVED = '__pigUnresolvedIdentifier__';

export const isUnresolved = (v) => Boolean(v) && typeof v === 'object' && UNRESOLVED in v;

/**
 * A sandbox in which every free identifier resolves to a labelled stand-in.
 *
 * `RewardSpec.source.code` is legitimately an identifier — the Python is
 * imported with `?raw` and cannot exist in plain Node — so the reward literal
 * must be readable WITHOUT its `code`. Strict mode is still the default: a
 * weight that turns out to be a stand-in is a contract failure, not a skip.
 */
function lenientSandbox() {
  return new Proxy(Object.create(null), {
    has: () => true,
    get: (_target, key) => {
      if (key === Symbol.unscopables) return undefined;
      if (typeof key !== 'string') return undefined;
      return { [UNRESOLVED]: key };
    },
  });
}

/**
 * Evaluates a TypeScript object/array literal as plain data.
 *
 * `as const` and `satisfies T` are stripped from code spans only. Anything else
 * a literal might carry — an identifier, a call, a spread of an import — throws,
 * and callers turn that into a contract failure with the file named. Pass
 * `{lenient: true}` to get stand-ins for free identifiers instead.
 *
 * @param {string} text
 * @param {string} label
 * @param {{lenient?: boolean}} [options]
 */
export function evalLiteral(text, label, options = {}) {
  const js = segment(text)
    .map((s) =>
      s.code
        ? s.text
            .replace(/\bas\s+const\b/g, '')
            .replace(/\bsatisfies\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
            .replace(/\bas\s+[A-Za-z_$][\w$.]*(?:<[^>]*>)?(?:\[\])*/g, '')
        : s.text,
    )
    .join('');
  try {
    const sandbox = options.lenient ? lenientSandbox() : Object.create(null);
    const value = vm.runInNewContext(`(${js})`, sandbox, { timeout: 2000 });
    return { ok: true, value, error: null };
  } catch (error) {
    return {
      ok: false,
      value: null,
      error:
        `the ${label} literal did not evaluate as plain data (${error.message}). ` +
        'These literals are read by tooling and by humans reading the page beside the code, ' +
        'so they must be written out, not assembled from imported constants.',
    };
  }
}

/** Members of a string-union type alias, e.g. `export type Vertical = 'a' | 'b'`. */
export function parseStringUnion(src, typeName) {
  const match = src.match(new RegExp(`\\btype\\s+${typeName}\\s*=([^;]+);`));
  if (!match || !match[1]) return null;
  const members = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
  return members.length ? members : null;
}

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

const META_ANCHORS = [
  // `export default defineMeta({...})` is the shape the template uses, and the
  // one the registry's glob reads. It has to come first: a file can also carry
  // a `meta:` key inside something else, and that would win on file order.
  /\bdefineMeta\s*(?:<[^>]*>)?\s*\(\s*/,
  /(?:export\s+)?const\s+meta\s*(?::\s*[^=]+)?=\s*/,
  /(?:export\s+)?const\s+[A-Za-z_$][\w$]*Meta\s*(?::\s*[^=]+)?=\s*/,
  /export\s+default\s*/,
  /\bmeta\s*:\s*/,
];

const CANDIDATE_META_FILES = ['meta.ts', 'meta.tsx', 'demo.tsx', 'demo.ts', 'index.ts', 'index.tsx'];

/**
 * Reads one demo's `DemoMeta` out of its source.
 * @param {string} slug
 * @returns {{file: string, meta: Record<string, any>} | {file: string, error: string}}
 */
export function loadMeta(slug) {
  const dir = path.join(DEMOS_DIR, slug);
  const tried = [];
  for (const name of CANDIDATE_META_FILES) {
    const file = path.join(dir, name);
    if (!exists(file)) continue;
    tried.push(rel(file));
    const src = read(file);
    for (const anchor of META_ANCHORS) {
      let text;
      try {
        text = literalAfter(src, anchor, '{');
      } catch (error) {
        return { file: rel(file), error: `could not brace-match the meta literal: ${error.message}` };
      }
      if (!text) continue;
      const result = evalLiteral(text, 'DemoMeta');
      if (!result.ok) return { file: rel(file), error: result.error };
      if (result.value && typeof result.value === 'object' && 'slug' in result.value) {
        return { file: rel(file), meta: result.value };
      }
    }
  }
  return {
    file: tried[0] ?? rel(path.join(dir, 'meta.ts')),
    error:
      tried.length === 0
        ? `no meta source found; expected one of ${CANDIDATE_META_FILES.join(', ')} in ${rel(dir)}`
        : `no DemoMeta object literal with a \`slug\` key found in ${tried.join(', ')}`,
  };
}

/** Every demo whose meta could be read, keyed by directory name. */
export function loadAllMetas() {
  /** @type {Map<string, Record<string, any>>} */
  const metas = new Map();
  /** @type {{slug: string, file: string, error: string}[]} */
  const errors = [];
  for (const slug of demoSlugs()) {
    const result = loadMeta(slug);
    if ('meta' in result) metas.set(slug, result.meta);
    else errors.push({ slug, file: result.file, error: result.error });
  }
  return { metas, errors };
}

/** Every .ts/.tsx file that belongs to one demo. */
export function demoFiles(slug) {
  return walk(path.join(DEMOS_DIR, slug), (f) => /\.tsx?$/.test(f));
}

/**
 * Finds one named literal anywhere inside a demo's own source.
 *
 * A demo is free to put `reward` in `reward.ts` or inline it in `demo.tsx`;
 * the contract is about the values, not the file layout. First match in
 * filename order wins, and the file it came from is returned so failures can
 * name it.
 *
 * @param {string} slug
 * @param {RegExp[]} anchors
 * @param {'{' | '['} open
 * @param {string} label
 * @param {{lenient?: boolean}} [options]
 */
export function findInDemo(slug, anchors, open, label, options = {}) {
  for (const file of demoFiles(slug)) {
    const src = read(file);
    for (const anchor of anchors) {
      let text = null;
      try {
        text = literalAfter(src, anchor, open);
      } catch (error) {
        return { file: rel(file), error: `could not brace-match the ${label} literal: ${error.message}` };
      }
      if (!text) continue;
      const result = evalLiteral(text, label, options);
      if (!result.ok) return { file: rel(file), error: result.error, text };
      return { file: rel(file), value: result.value, text };
    }
  }
  return {
    file: null,
    error: `no ${label} literal found in any .ts/.tsx file under ${rel(path.join(DEMOS_DIR, slug))}`,
  };
}

/* ------------------------------------------------------------------ verticals */

export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts');

const VERTICAL_ANCHORS = [
  /(?:export\s+)?const\s+VERTICALS\s*(?::\s*[^=]+)?=\s*/,
  /(?:export\s+)?const\s+verticals\s*(?::\s*[^=]+)?=\s*/,
];

const asVertical = (id, v) => ({
  id,
  title: v?.title ?? v?.label ?? v?.name ?? null,
  // `anxiety` is this repo's field for the one line a vertical page leads
  // with, and it is the only prose in a VerticalEntry short enough to be a
  // meta description.
  description: v?.description ?? v?.blurb ?? v?.tagline ?? v?.summary ?? v?.anxiety ?? null,
});

/**
 * The vertical records the site groups demos by.
 *
 * Tolerant about field names on purpose: this file belongs to another lane, and
 * a prerender that hard-codes `label` then silently bakes an empty <title> when
 * the author wrote `name` is worse than one that looks for both.
 */
export function loadVerticals() {
  if (!exists(VERTICALS_FILE)) return { list: [], error: `${rel(VERTICALS_FILE)} does not exist` };
  const src = read(VERTICALS_FILE);
  for (const anchor of VERTICAL_ANCHORS) {
    for (const open of ['[', '{']) {
      let text = null;
      try {
        text = literalAfter(src, anchor, open);
      } catch {
        text = null;
      }
      if (!text) continue;
      const result = evalLiteral(text, 'verticals');
      if (!result.ok) return { list: [], error: result.error };
      const value = result.value;
      const list = Array.isArray(value)
        ? value.map((v) => asVertical(v?.id ?? v?.slug ?? v?.key, v))
        : value && typeof value === 'object'
          ? Object.entries(value).map(([id, v]) => asVertical(id, v))
          : [];
      const clean = list.filter((v) => typeof v.id === 'string' && v.id.length > 0);
      if (clean.length) return { list: clean, error: null };
    }
  }
  return { list: [], error: `no \`verticals\` or \`VERTICALS\` literal found in ${rel(VERTICALS_FILE)}` };
}

/* --------------------------------------------------------------------- traces */

export const MANIFEST_FILE = abs('public', 'traces', 'manifest.json');

/**
 * Normalises `public/traces/manifest.json` into `slug -> {runs, extras}`.
 *
 * Four shapes are accepted because the manifest is authored by hand and all
 * four are things a reasonable person writes. Anything else fails loudly rather
 * than quietly reporting zero runs, which would turn rule 7 into a no-op.
 */
export function loadManifest() {
  if (!exists(MANIFEST_FILE)) return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} does not exist` };
  let raw;
  try {
    raw = readJson(MANIFEST_FILE);
  } catch (error) {
    return { byDemo: new Map(), error: `${rel(MANIFEST_FILE)} is not valid JSON: ${error.message}` };
  }
  /** @type {Map<string, {runs: any[], extras: Record<string, any>}>} */
  const byDemo = new Map();
  // Unwrap the two envelopes the shell's own reader accepts. `{runs: [...]}`
  // has to be unwrapped BEFORE the record branch below, or "runs" is read as a
  // demo slug and every real demo reports zero runs.
  let container = raw;
  if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
    if (raw.demos && typeof raw.demos === 'object') container = raw.demos;
    else if (Array.isArray(raw.runs)) container = raw.runs;
  }

  if (Array.isArray(container)) {
    // A flat array of runs, each carrying its own `demo`/`slug`.
    for (const run of container) {
      const slug = run?.demo ?? run?.slug;
      if (typeof slug !== 'string') continue;
      if (!byDemo.has(slug)) byDemo.set(slug, { runs: [], extras: {} });
      byDemo.get(slug).runs.push(run);
    }
  } else if (container && typeof container === 'object') {
    for (const [slug, value] of Object.entries(container)) {
      if (Array.isArray(value)) byDemo.set(slug, { runs: value, extras: {} });
      else if (value && typeof value === 'object' && Array.isArray(value.runs)) {
        const { runs, ...extras } = value;
        byDemo.set(slug, { runs, extras });
      }
    }
  }
  if (byDemo.size === 0) {
    return {
      byDemo,
      error:
        `${rel(MANIFEST_FILE)} did not parse into any demo runs. Expected one of: ` +
        '{"<slug>": RunRef[]}, {"<slug>": {"runs": RunRef[], ...}}, {"demos": {...}}, ' +
        'or a flat RunRef[] where each run carries a "demo" field.',
    };
  }
  return { byDemo, error: null };
}

/** Resolve a site-absolute trace path ('/traces/x.json') to a disk path. */
export function traceFile(sitePath) {
  const clean = String(sitePath).split('?')[0].split('#')[0];
  return abs('public', clean.replace(/^\/+/, ''));
}

/* -------------------------------------------------------------- static server */

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.mjs': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.webp': 'image/webp',
  '.avif': 'image/avif',
  '.ico': 'image/x-icon',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff',
  '.ttf': 'font/ttf',
  '.txt': 'text/plain; charset=utf-8',
  '.map': 'application/json; charset=utf-8',
  '.xml': 'application/xml; charset=utf-8',
};

/**
 * Serves a directory the way the deploy host will: a real file if one exists,
 * `index.html` otherwise.
 *
 * Prerender MUST hit the SPA fallback, so this deliberately does not 404 on an
 * unknown route — but it DOES 404 on a missing file under a path with an
 * extension, because serving HTML where a script was requested is how you
 * prerender a blank page and never find out why.
 *
 * @param {string} dir
 * @returns {Promise<{origin: string, close: () => Promise<void>}>}
 */
export function serveStatic(dir) {
  const root = path.resolve(dir);
  const server = http.createServer((req, res) => {
    const url = new URL(req.url ?? '/', 'http://localhost');
    const decoded = decodeURIComponent(url.pathname);
    const safe = path.normalize(decoded).replace(/^(\.\.[/\\])+/, '');
    let file = path.join(root, safe);
    if (!file.startsWith(root)) {
      res.writeHead(403).end('forbidden');
      return;
    }
    if (exists(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
    if (!exists(file)) {
      if (/\.[a-z0-9]+$/i.test(safe)) {
        res.writeHead(404, { 'content-type': 'text/plain' }).end(`not found: ${safe}`);
        return;
      }
      file = path.join(root, 'index.html');
    }
    const body = fs.readFileSync(file);
    res.writeHead(200, {
      'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
      'content-length': body.length,
      'cache-control': 'no-store',
    });
    res.end(body);
  });
  return new Promise((resolve, reject) => {
    server.on('error', reject);
    server.listen(0, '127.0.0.1', () => {
      const address = server.address();
      resolve({
        origin: `http://127.0.0.1:${address.port}`,
        close: () => new Promise((done) => server.close(() => done())),
      });
    });
  });
}

/* ------------------------------------------------------------------- routing */

/** The public origin the baked tags point at. */
export const SITE_ORIGIN = (process.env.PIG_DEMO_ORIGIN ?? 'https://demo.primeintellectgrowth.com').replace(/\/+$/, '');

/**
 * Every route pattern the app declares, read out of the router source.
 *
 * Hard-coding `['/', '/d/:slug']` here is the exact failure this function
 * exists to avoid: the router moves, prerender keeps emitting the old paths,
 * and every shared link previews as the homepage again — which is the bug
 * prerendering was added to fix in the first place.
 */
export function discoverRoutePatterns() {
  const files = walk(abs('src'), (f) => /\.tsx?$/.test(f));
  /** @type {Set<string>} */
  const patterns = new Set();
  for (const file of files) {
    const src = read(file);
    for (const m of src.matchAll(/<Route\b[^>]*?\bpath\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*['"]([^'"]*)['"]\s*\})/g)) {
      const value = m[1] ?? m[2] ?? m[3];
      if (typeof value === 'string') patterns.add(value);
    }
    // `path:` is a common key name, so only trust it in a file that is
    // demonstrably a router config.
    if (/createBrowserRouter|createHashRouter|createMemoryRouter|RouteObject/.test(src)) {
      for (const m of src.matchAll(/\bpath\s*:\s*(?:"([^"]*)"|'([^']*)')/g)) {
        const value = m[1] ?? m[2];
        if (typeof value === 'string') patterns.add(value);
      }
    }
  }
  return [...patterns];
}

/**
 * Which set fills a pattern's one parameter.
 *
 * Decided by the STATIC segment in front of it, not by the parameter's name.
 * This router calls both of them `:slug` — `demos/:slug` and `verticals/:slug`
 * — so a name-based reader confidently prerenders `/verticals/wordle`, twelve
 * 404s, and no vertical pages at all.
 */
function fillFor(pattern) {
  const head = pattern.replace(/^\/+/, '').split('/')[0]?.toLowerCase() ?? '';
  if (/^(demos?|d)$/.test(head)) return 'demo';
  if (/^(verticals?|v|sectors?)$/.test(head)) return 'vertical';
  const param = pattern.match(/:([A-Za-z0-9_]+)/)?.[1] ?? '';
  if (/^(vertical|verticalId|sector|category)$/.test(param)) return 'vertical';
  if (/^(slug|demo|demoSlug|id)$/.test(param)) return 'demo';
  return null;
}

/**
 * Expands the router's patterns against the real data into concrete paths.
 *
 * Child paths are taken as written. This router nests everything exactly one
 * level under `/`, so a child's `path` is already the full path; a second level
 * of nesting would need the parent prefix joined on, and this would silently
 * emit the wrong routes rather than fail. If you nest deeper, fix this.
 *
 * @param {{metas: Map<string, any>, verticals: {id: string}[]}} data
 * @returns {{routes: {path: string, kind: string, slug?: string, id?: string}[], errors: string[]}}
 */
export function expandRoutes(data) {
  const patterns = discoverRoutePatterns();
  /** @type {string[]} */
  const errors = [];
  /** @type {Map<string, any>} */
  const routes = new Map();

  const add = (p, record) => {
    const normalised = p === '/' || p === '' ? '/' : `/${p.replace(/^\/+|\/+$/g, '')}`;
    if (!routes.has(normalised)) routes.set(normalised, { path: normalised, ...record });
  };

  if (patterns.length === 0) {
    errors.push(
      'no route patterns found under src/. Looked for `<Route path=...>` and, in files that mention ' +
        'createBrowserRouter/RouteObject, `path: ...`. Refusing to guess a route list.',
    );
    return { routes: [], errors };
  }

  const demoSlugList = [...data.metas.keys()];
  const verticalIds = data.verticals.map((v) => v.id);

  for (const pattern of patterns) {
    if (pattern.includes('*')) continue; // the catch-all becomes 404.html, not a route
    const params = [...pattern.matchAll(/:([A-Za-z0-9_]+)\??/g)].map((m) => m[1]);
    if (params.length === 0) {
      add(pattern, pattern === '/' || pattern === '' ? { kind: 'home' } : { kind: 'static' });
      continue;
    }
    if (params.length > 1) {
      errors.push(`route pattern "${pattern}" has more than one parameter; prerender cannot expand it.`);
      continue;
    }
    const fill = fillFor(pattern);
    const substitute = (value) => pattern.replace(/:[A-Za-z0-9_]+\??/, value);
    if (fill === 'vertical') {
      if (!verticalIds.length) errors.push(`route "${pattern}" needs verticals, but none were readable from ${rel(VERTICALS_FILE)}.`);
      for (const id of verticalIds) add(substitute(id), { kind: 'vertical', id });
    } else if (fill === 'demo') {
      if (!demoSlugList.length) errors.push(`route "${pattern}" needs demos, but no demo meta was readable under src/demos/.`);
      for (const slug of demoSlugList) add(substitute(slug), { kind: 'demo', slug });
    } else {
      errors.push(
        `route pattern "${pattern}" has a parameter prerender cannot fill. Put it under /demos/ or ` +
          '/verticals/, or teach fillFor() in scripts/_lib.mjs about it.',
      );
    }
  }
  if (!routes.has('/')) add('/', { kind: 'home' });
  return { routes: [...routes.values()].sort((a, b) => (a.path < b.path ? -1 : 1)), errors };
}

/* ----------------------------------------------------------------- sizes */

export const gzipSize = (buf) => zlib.gzipSync(buf, { level: 9 }).length;

export const kb = (bytes) => `${(bytes / 1024).toFixed(1)} kB`;

/** Padded plain-text table. Beats taking a dependency to print eight rows. */
export function table(headers, rows) {
  const all = [headers, ...rows];
  const widths = headers.map((_, i) => Math.max(...all.map((r) => String(r[i] ?? '').length)));
  const line = (cells, pad = ' ') =>
    cells
      .map((c, i) => (i === 0 ? String(c ?? '').padEnd(widths[i], pad) : String(c ?? '').padStart(widths[i], pad)))
      .join('  ');
  return [line(headers), line(widths.map((w) => '-'.repeat(w)), '-'), ...rows.map((r) => line(r))].join('\n');
}
