#!/usr/bin/env node
/**
 * `node scripts/new-demo.mjs <slug>` — scaffold a demo.
 *
 * Copies `src/demos/_template` to `src/demos/<slug>` and `envs/_template` to
 * `envs/<package>`, substitutes the name everywhere, and wires NOTHING. There
 * is deliberately no registry to edit, no route to add and no import to insert:
 * the registry is an `import.meta.glob`, so a demo exists because its directory
 * exists. If you ever find yourself adding a line to a shared file to make a new
 * demo appear, that is a bug in the registry, not a missing step here.
 *
 * ── SUBSTITUTIONS ──────────────────────────────────────────────────────────
 *
 * For `new-demo.mjs wordle-five`, in every copied file's CONTENT and in its
 * PATH:
 *
 *     __slug__      wordle-five     the directory name, meta.slug, the URL
 *     __package__   wordle_five     the Python package under envs/
 *     __Title__     Wordle Five     a starting point for meta.title
 *     __Pascal__    WordleFive      component and type names
 *     __camel__     wordleFive      variable names
 *     __SLUG__      WORDLE_FIVE     constants
 *     _template     wordle-five     (wordle_five under envs/)
 *
 * Anything still matching `__word__` after the copy is reported, because a
 * template token that survives into a real demo compiles fine and ships a
 * placeholder to production.
 *
 * The slug must be kebab-case, must not already exist, and must not collide
 * with a top-level route.
 */

import fs from 'node:fs';
import path from 'node:path';

import { abs, cyan, dim, exists, green, red, rel } from './_lib.mjs';

const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;

/** Top-level paths the router already owns. A demo here would be unreachable. */
const RESERVED = new Set(['gallery', 'demos', 'verticals', 'honesty', 'assets', 'og', 'traces', 'fonts', 'icons']);

const slug = process.argv[2];

if (!slug || slug === '--help' || slug === '-h') {
  console.log('usage: node scripts/new-demo.mjs <slug>\n');
  console.log('  <slug>  kebab-case, e.g. support-refund-triage. Becomes the directory name,');
  console.log('          meta.slug and the URL /demos/<slug>.');
  process.exit(slug ? 0 : 1);
}

const fail = (message, hint) => {
  console.error(`${red('FAIL')}  ${message}`);
  if (hint) console.error(`      ${hint}`);
  process.exit(1);
};

if (!KEBAB.test(slug)) {
  fail(
    `"${slug}" is not kebab-case.`,
    'Lower-case letters and digits, single hyphens between words, starting with a letter. ' +
      'It is the URL, the directory name and the registry key, so it has exactly one spelling.',
  );
}
if (RESERVED.has(slug)) {
  fail(`"${slug}" is a reserved path.`, `The router already owns /${slug}. Reserved: ${[...RESERVED].join(', ')}.`);
}

const pkg = slug.replace(/-/g, '_');
const words = slug.split('-');
const substitutions = [
  ['__package__', pkg],
  ['__Pascal__', words.map(capitalise).join('')],
  ['__camel__', words.map((w, i) => (i === 0 ? w : capitalise(w))).join('')],
  ['__Title__', words.map(capitalise).join(' ')],
  ['__SLUG__', pkg.toUpperCase()],
  ['__slug__', slug],
];

const jobs = [
  { from: abs('src', 'demos', '_template'), to: abs('src', 'demos', slug), bareName: slug },
  { from: abs('envs', '_template'), to: abs('envs', pkg), bareName: pkg },
];

for (const job of jobs) {
  if (!exists(job.from)) {
    fail(
      `${rel(job.from)} does not exist.`,
      'The template is the contract in worked-example form; scaffolding from nothing would produce a demo ' +
        'that satisfies no rule in scripts/check-demos.mjs.',
    );
  }
  if (exists(job.to)) {
    fail(
      `${rel(job.to)} already exists.`,
      'Refusing to overwrite. Pick another slug, or delete the directory yourself if you meant to start over.',
    );
  }
  // An empty template directory would copy cleanly, report "Created 0 files"
  // and leave behind a demo that the registry quarantines for a reason nobody
  // connects back to this command.
  if (countFiles(job.from) === 0) {
    fail(`${rel(job.from)} is empty.`, 'There is nothing to scaffold from. Fill in the template first.');
  }
}

/* -------------------------------------------------------------------- copy */

const written = [];
const leftovers = new Map();

for (const job of jobs) {
  copyTree(job.from, job.to, job.bareName);
}

console.log(green(`Created ${written.length} files:`));
for (const file of written) console.log(dim(`  ${rel(file)}`));

if (leftovers.size > 0) {
  console.log('');
  console.log(red('Unsubstituted template tokens remain:'));
  for (const [token, files] of leftovers) {
    console.log(`  ${token}  in ${[...files].map(rel).join(', ')}`);
  }
  console.log('');
  console.log('Either add the token to the substitution table in scripts/new-demo.mjs, or fix the template.');
  process.exit(1);
}

console.log('');
console.log(`Next, in ${cyan(rel(abs('src', 'demos', slug)))}:`);
console.log('  1. meta.ts     — title, tagline, vertical, persona, rewardLine, the lucide icon name');
console.log('  2. demo.tsx    — the narrative, the anatomy, the reward, and the Surface');
console.log(`  3. ${rel(abs('envs', pkg))} — the environment the reward quotes`);
console.log('');
console.log(`Then ${cyan('pnpm check')}. Nothing else needs editing — the registry finds the demo by existence.`);
console.log(dim(`The demo will 404 until meta.ts, demo.tsx and public/og/${slug}.png all exist.`));

/* ----------------------------------------------------------------- helpers */

/** Files in a tree, recursively. Used only to reject an empty template. */
function countFiles(dir) {
  return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
    if (entry.name === '__pycache__' || entry.name === 'node_modules') return total;
    if (entry.isDirectory()) return total + countFiles(path.join(dir, entry.name));
    return total + (entry.isFile() ? 1 : 0);
  }, 0);
}

function capitalise(word) {
  return word.charAt(0).toUpperCase() + word.slice(1);
}

/** Applies the substitution table. No token is a prefix of another, so order
 * does not matter here — but keep it that way if you add one. */
function substitute(text, bareName) {
  let out = text;
  for (const [token, value] of substitutions) out = out.split(token).join(value);
  // `_template` is what the directory is literally called, so it turns up in
  // relative imports and in the Python package name inside pyproject.toml.
  return out.split('_template').join(bareName);
}

function copyTree(from, to, bareName) {
  fs.mkdirSync(to, { recursive: true });
  for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
    if (entry.name === '__pycache__' || entry.name === 'node_modules') continue;
    const source = path.join(from, entry.name);
    const target = path.join(to, substitute(entry.name, bareName));

    if (entry.isDirectory()) {
      copyTree(source, target, bareName);
      continue;
    }
    if (!entry.isFile()) continue;

    // Binary files (a fixture PNG in the template, say) are copied byte for
    // byte. Running them through the string substitution would corrupt them.
    if (isBinary(source)) {
      fs.copyFileSync(source, target);
      written.push(target);
      continue;
    }

    const body = substitute(fs.readFileSync(source, 'utf8'), bareName);
    fs.writeFileSync(target, body, 'utf8');
    written.push(target);

    for (const match of body.matchAll(/__[A-Za-z][A-Za-z0-9]*__/g)) {
      if (!leftovers.has(match[0])) leftovers.set(match[0], new Set());
      leftovers.get(match[0]).add(target);
    }
  }
}

function isBinary(file) {
  return /\.(png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|pdf|zip|gz)$/i.test(file);
}
