#!/usr/bin/env node
// ── §5.2 — zero module identifiers in core ─────────────────────────────────
//
// Phase 3's acceptance criterion 1, as a check rather than a review promise: no
// `shard`, `uoLink`, `cliloc`, `atlas` or `towncrier` anywhere in core's source
// (MODULE_API.md §5.2, MODULE_SYSTEM.md §2.7.1 slice 4). The extraction is only
// worth what this is worth — a boundary nothing enforces grows a hole the first
// time someone is in a hurry, and the hole looks exactly like the code that was
// there before.
//
// **It reads code, not prose, and that is the whole design.** Four things are
// checked, and each is a thing a module owns:
//
//   1. file and directory names
//   2. import and require SPECIFIERS — the path, not the file's contents
//   3. route path literals — the string handed to .get/.post/.put/.patch/
//      .delete/.use
//   4. declared identifiers — function, const, class, and object property names
//
// Comments and string content in general are NOT read. Core's own English may
// legitimately say "shard": `About.jsx` did until slice 4 rewrote it, and a
// comment explaining *what moved and why* — `AcceptInvite.jsx` has one — is
// worth more than the word costs. A literal word grep would fail on both, prove
// nothing about the boundary, and teach people to phrase around it. The
// boundary this defends is structural: core must not NAME a module's files,
// import them, route to them, or declare their symbols. It may talk about them.
//
// Two things learned the hard way, both of which this file would have got wrong:
//
//   • **Match on word boundaries, not substrings.** `defaultImage` contains
//     "ultIma"; `atlas` is inside "atlasSomething" legitimately only when it is
//     the same word. The tokeniser below splits identifiers on camelCase and
//     separators and compares WHOLE words, so `shardStatus` is a hit and
//     `defaultImage` is not. A substring pass flagged four innocent lines in
//     this repo on its first run.
//   • **Strip comments and strings with a character walk, not a regexp.** The
//     module's own `checkImports.js` flagged the comments that explain what it
//     catches. A comment contains quotes (`-- '' when randomised`), a string
//     contains `//` (any URL), and a regexp literal contains both. Doing it in
//     one pass, in order, is the only way that comes out right — and this file
//     has its own test suite (`server/test/checkModuleIdentifiers.test.js`)
//     because a check that silently stops checking is worse than no check.

const fs = require('fs')
const path = require('path')
const { execFileSync } = require('child_process')

const ROOT = path.resolve(__dirname, '..')

// The trees core owns. `modules/` is deliberately absent — that is where a
// module's own code lives, and it is the one place these words belong.
const TREES = [
  path.join(ROOT, 'server', 'src'),
  path.join(ROOT, 'server', 'scripts'),
  path.join(ROOT, 'server', 'db'),
  path.join(ROOT, 'client', 'src'),
]

const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', '.git'])
const CODE = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'])

// The words a module owns. Lower-cased whole words, compared against the
// tokeniser's output — so `uoLink`, `uo_link` and `uo-link` all reduce to the
// two tokens `uo` and `link`, and the pair is what is matched.
const RESERVED = new Set(['shard', 'shards', 'cliloc', 'clilocs', 'atlas', 'towncrier'])
// Sequences of tokens that are reserved together but innocent apart: "uo" and
// "link" each appear in ordinary core code ("link" especially), and only the
// pair names the sidecar.
const RESERVED_PAIRS = [['uo', 'link'], ['town', 'crier'], ['spawn', 'atlas'], ['serv', 'uo']]
// Standalone `uo` is reserved too: it is the module id, and a core file called
// `uo.js` or a route `/uo` is the boundary being crossed in the plainest way.
const RESERVED_ALONE = new Set(['uo', 'uolink', 'servuo', 'ultima'])

// ── The grandfathering exemptions ───────────────────────────────────────────
//
// Exactly three, and all three are the SAME mechanism: core's per-module legacy
// allowlists (MODULE_API.md §6.5). A table prefix, a set of stream ids and an
// announce leg all predate the module system, are stored in live rows, and are
// read by a shipped Android client — so `uo` keeps them, and keeping them means
// core holds a map whose KEY is the module id. There is no way to write that
// down without naming the module; that is what grandfathering is.
//
// Nothing else may be added here without the same kind of reason. In particular
// this is not an escape hatch for "core still needs this for now" — that is the
// state slice 4 exists to end.
//
// Each entry must MATCH something. An exemption that no longer fires is deleted
// by the check itself (`unused exemption` below), because a stale one is how an
// allowlist quietly becomes permission for whatever drifts into it later.
const EXEMPT = [
  {
    file: 'server/src/modules/loader.js',
    name: 'uo',
    kind: 'property name',
    why: 'LEGACY_TABLE_PREFIXES — the grandfathered shard_/uo_link_ table prefixes (API §6.5)',
  },
  {
    file: 'server/src/modules/registries.js',
    name: 'uo',
    kind: 'property name',
    why: 'LEGACY_STREAM_IDS and LEGACY_LEGS — grandfathered stream ids and the towncrier leg (API §6.5)',
  },
]

const isExempt = (hit) =>
  EXEMPT.some((e) => e.file === hit.file && e.name === hit.name && e.kind === hit.kind)

/**
 * Split a name into lower-case words: camelCase humps, and runs separated by
 * `-`, `_`, `.`, `/` or digits.
 *
 * `shardStatus` → [shard, status]; `uo_link_config` → [uo, link, config];
 * `defaultImage` → [default, image] — which is the point: the substring
 * "ultIma" inside it is not a word and never appears here.
 */
function tokenize(name) {
  return String(name)
    .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
    .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
    .split(/[^A-Za-z]+/)
    .filter(Boolean)
    .map((w) => w.toLowerCase())
}

/** Does this name contain a reserved word, as a word? */
function reservedWordIn(name) {
  const words = tokenize(name)
  for (const w of words) {
    if (RESERVED.has(w) || RESERVED_ALONE.has(w)) return w
  }
  for (const [a, b] of RESERVED_PAIRS) {
    for (let i = 0; i < words.length - 1; i++) {
      if (words[i] === a && words[i + 1] === b) return `${a}-${b}`
    }
  }
  return null
}

/**
 * Blank out comments, and MASK string/template/regexp contents, in one
 * left-to-right pass.
 *
 * Masking rather than deleting: the checks that run afterwards need to know
 * WHERE a string was (a route path literal is a string) while not reading what
 * is in an arbitrary one. So a string's delimiters and length survive and its
 * body becomes spaces, except that the string-literal check below re-reads the
 * original text at the same offsets. Comments are replaced by spaces so every
 * offset in the returned text still lines up with the input — line numbers stay
 * honest without a second pass.
 */
function maskCode(src) {
  const out = Array.from(src)
  const blank = (from, to) => {
    for (let i = from; i < to && i < out.length; i++) if (out[i] !== '\n') out[i] = ' '
  }
  let i = 0
  while (i < src.length) {
    const c = src[i]
    const next = src[i + 1]
    if (c === '/' && next === '/') {
      let j = i
      while (j < src.length && src[j] !== '\n') j++
      blank(i, j)
      i = j
      continue
    }
    if (c === '/' && next === '*') {
      const end = src.indexOf('*/', i + 2)
      const j = end === -1 ? src.length : end + 2
      blank(i, j)
      i = j
      continue
    }
    if (c === '-' && next === '-' && src[i + 2] === ' ') {
      // SQL line comment; harmless in JS, where `-- ` cannot start an expression.
      let j = i
      while (j < src.length && src[j] !== '\n') j++
      blank(i, j)
      i = j
      continue
    }
    if (c === '"' || c === "'" || c === '`') {
      let j = i + 1
      while (j < src.length) {
        if (src[j] === '\\') { j += 2; continue }
        if (src[j] === c) break
        j++
      }
      blank(i + 1, j) // keep the quotes, blank the body
      i = j + 1
      continue
    }
    i++
  }
  return out.join('')
}

// ── the four checks ─────────────────────────────────────────────────────────

const SPECIFIER = /(?:require\(\s*|from\s+|import\(\s*)(['"])([^'"]+)\1/g
const ROUTE = /\.(?:get|post|put|patch|delete|use|all)\(\s*(['"`])([^'"`]*)\1/g
const DECLARED = /\b(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g
const PROPERTY = /(?:^|[{,]\s*)([A-Za-z_$][\w$]*)\s*:/gm

function lineOf(src, index) {
  return src.slice(0, index).split('\n').length
}

/**
 * Check one file. `src` is the raw text; `masked` has comments blanked and
 * string bodies blanked at the same offsets, so a regexp run over `masked`
 * finds only real code — and the captured offsets index back into `src` when a
 * check legitimately needs the string's content (specifiers and route paths).
 */
function checkFile(rel, src) {
  const hits = []
  const masked = maskCode(src)
  const add = (kind, name, index) => {
    const word = reservedWordIn(name)
    if (word) hits.push({ file: rel, line: lineOf(src, index), kind, name, word })
  }

  for (const m of masked.matchAll(SPECIFIER)) {
    // Read the specifier out of the ORIGINAL text: its body was masked, and a
    // path is the one string whose content is structural.
    const start = m.index + m[0].indexOf(m[1]) + 1
    add('import specifier', src.slice(start, start + m[2].length), m.index)
  }
  for (const m of masked.matchAll(ROUTE)) {
    const start = m.index + m[0].indexOf(m[1]) + 1
    add('route path', src.slice(start, start + m[2].length), m.index)
  }
  for (const m of masked.matchAll(DECLARED)) add('declared identifier', m[1], m.index)
  for (const m of masked.matchAll(PROPERTY)) add('property name', m[1], m.index)

  return hits
}

function walk(dir, out = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    if (SKIP_DIRS.has(entry.name)) continue
    const full = path.join(dir, entry.name)
    if (entry.isDirectory()) walk(full, out)
    else out.push(full)
  }
  return out
}

/**
 * The files core SHIPS, which is what the boundary is about — not whatever
 * happens to be in a working tree.
 *
 * `git ls-files` rather than a walk, because an untracked local artifact is not
 * core's source and must not fail anyone's build. This is not hypothetical: an
 * operator-supplied `server/db/data/spawnAtlas.art.json` is gitignored, sits in
 * the tree of anyone who has run the atlas import, and would otherwise report a
 * file-name violation that no commit could fix. The walk stays as the fallback
 * for an export with no git in it, where over-reporting is the safer failure.
 */
function sourceFiles() {
  try {
    const out = execFileSync('git', ['ls-files', '-z', '--cached', '--', ...TREES.map((t) => path.relative(ROOT, t))], {
      cwd: ROOT,
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'ignore'],
    })
    const files = out.split('\0').filter(Boolean).map((f) => path.join(ROOT, f))
    if (files.length) return files
  } catch {
    // no git, or not a checkout — fall through
  }
  return TREES.filter((t) => fs.existsSync(t)).flatMap((t) => walk(t))
}

function run() {
  const hits = []
  for (const file of sourceFiles()) {
    if (!fs.existsSync(file)) continue
    const rel = path.relative(ROOT, file).split(path.sep).join('/')

    // 1. the name itself
    const word = reservedWordIn(path.basename(file))
    if (word) hits.push({ file: rel, line: 0, kind: 'file name', name: path.basename(file), word })

    // 2-4. the contents, for code files only
    if (!CODE.has(path.extname(file))) continue
    hits.push(...checkFile(rel, fs.readFileSync(file, 'utf8')))
  }

  const live = hits.filter((h) => !isExempt(h))
  // A grandfathering entry that matches nothing is deleted, loudly. Reported as
  // a failure rather than a warning: the exemption list is the one part of this
  // check that can only get weaker, so it is the part that needs the noise.
  const unused = EXEMPT.filter((e) => !hits.some((h) => h.file === e.file && h.name === e.name && h.kind === e.kind))
  return { hits: live, unused }
}

module.exports = { run, checkFile, maskCode, tokenize, reservedWordIn, EXEMPT }

if (require.main === module) {
  const { hits, unused } = run()
  if (hits.length === 0 && unused.length === 0) {
    console.log('OK — core names no module identifier (MODULE_API.md §5.2).')
    process.exit(0)
  }
  for (const e of unused) {
    console.error(
      `\nUnused exemption: ${e.file} "${e.name}" (${e.kind}) matches nothing any more.\n` +
      `  ${e.why}\n` +
      '  Delete it from EXEMPT in this file. A grandfathering entry that has outlived what it ' +
      'grandfathered is permission with nothing attached to it.',
    )
  }
  if (hits.length === 0) process.exit(1)
  console.error(
    `\nCore names ${hits.length} module identifier${hits.length === 1 ? '' : 's'} ` +
    '(MODULE_API.md §5.2). Each of these belongs to an installed module:\n',
  )
  for (const h of hits) {
    console.error(`  ${h.file}:${h.line}  ${h.kind} "${h.name}" — reserved word "${h.word}"`)
  }
  console.error(
    '\nCore may TALK about a module in English; it may not name its files, import them, ' +
    'route to them, or declare its symbols. If one of these is core\'s own and the word is ' +
    'a coincidence, the fix is to rename it — the reserved list is short and deliberate.\n',
  )
  process.exit(1)
}
