// ── The module loader ──────────────────────────────────────────────────────
//
// Phase 2, PR 2 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md Part 4; where the two disagree, the contract wins.
//
// The one property this file exists to guarantee, and the reason it looks the
// way it does:
//
//   **The filesystem is the mounting source of truth, and mounting is
//   SYNCHRONOUS.** `scripts/routeManifest.js:38` and `swagger/swagger.js:29`
//   both require app.js with the pool pointed at a dead port. A loader that
//   awaited a database row before mounting would make every module route
//   invisible to the frozen-URL-surface test (§1.12). So: readdirSync at require
//   time, no database, no promises (§4.1).
//
// A module that fails ANYWHERE in this file fails alone. Nothing here may throw
// past its own try/catch — a bad module must cost the site its routes, never its
// boot (§4.4).
//
// PR 7 added the client half's server-side end: validating `client.entry` and
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
// utils/htmlShell.js can inject its script tag. The loader resolves and validates;
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
// router, and app.js is where core's own static mounts live.
//
// PR 3 added the fragment half of the schema story: this file VALIDATES a
// fragment (statement by statement, at load time, before anything is mounted)
// and publishes it through `fragments()`. Replaying it needs a database, so it
// belongs to modules/schema.js, which utils/db.js calls after core's schema.
//
// PR 5 added the lifecycle hooks a module registers here (`onBoot`/`onShutdown`)
// and the failure STAGE carried beside every reason. Dispatching those hooks and
// reconciling `installed_modules` need a database, so they live in
// modules/lifecycle.js for the same reason schema.js is a separate file: this one
// stays require-able against a dead pool.

const fs = require('fs')
const path = require('path')

const { MODULE_API_VERSION } = require('./version')
const semver = require('./semver')
const registries = require('./registries')
const { splitStatements } = require('../utils/sqlStatements')

const log = require('../utils/logger')('modules')

const REPO_ROOT = path.join(__dirname, '..', '..', '..')
// Resolved absolute, and the `path.resolve` is load-bearing rather than tidy.
// `resolveClient` compares an absolute `path.resolve(dir, entry)` against this
// directory to check containment, so a RELATIVE `MODULES_DIR` — which is what
// anyone following §7.7's smoke recipe from `server/` naturally types — makes
// that comparison fail for every module, with the thoroughly misleading
// "client.entry escapes the module directory". Found the first time the smoke
// was run against a module with a real client half.
const MODULES_DIR = path.resolve(process.env.MODULES_DIR || path.join(REPO_ROOT, 'modules'))

// One segment, lowercase, no parameters. A module prefix that could contain a
// `/` or a `:` would let a module reach outside the slot it was given.
const ID = /^[a-z][a-z0-9-]{1,31}$/
const PREFIX = /^\/[a-z0-9][a-z0-9-]*$/
const TIERS = ['public', 'admin', 'player']

const MANIFEST_KEYS = new Set([
  'id', 'name', 'version', 'coreApi', 'server', 'client',
  'schema', 'purge', 'mounts', 'extensions', 'capabilities',
])

// Extension slots are declared by core, at require time, in the router that owns
// the resource (registries.declareSlot). The loader asks the registry which exist
// rather than keeping a list, for the same reason the prefix check probes the
// live tier routers: a second copy of the answer is a copy that drifts.

// id → record. Populated by load(), read by list().
const modules = new Map()
let loaded = false

// Bumped by every state CHANGE. One consumer today: the merged OpenAPI document
// at /api/docs.json, which is built from the fragments of `started` modules and
// so has to be rebuilt when that set moves (§6.1a). A counter rather than an
// event, because the question a cache asks is "is what I have still current",
// and a number answers it without anyone having to remember to subscribe.
let stateVersion = 0

// ── ctx ────────────────────────────────────────────────────────────────────

// Everything a module may reach in core, and nothing else (§2.3). Required
// lazily inside the factory rather than at file scope: this file is required by
// app.js, and hoisting these to the top would make the DB pool, the settings
// model and the upload directory startup-time dependencies of the loader itself.
function buildCtx(id, moduleRoot) {
  /* eslint-disable global-require */
  // The shared SERVER dependencies — the exact counterpart of window.__rg's
  // react/react-dom/react-router on the client, and load-bearing for the same
  // two reasons (§7.2).
  //
  //   1. A module lives at <repo>/modules/<id>/, OUTSIDE server/, so Node's
  //      resolver walks up from there and never sees server/node_modules. A
  //      module that required 'express' itself would fail to load — which is
  //      exactly how this was discovered.
  //   2. Even if it resolved, a second copy of express in the process is a
  //      second Router prototype and a second set of instanceof checks. One
  //      express, owned by core, is the same rule as one React.
  //
  // The consequence for a module author is the same on both sides: declare these
  // external, never bundle them, take them from what core hands you.
  const express = require('express')
  const validator = require('express-validator')
  const db = require('../utils/db')
  const settings = require('../model/settings/settings.model')
  const posts = require('../model/posts/posts.model')
  const auth = require('../utils/auth')
  const pushDispatch = require('../utils/pushDispatch')
  const secretBox = require('../utils/secretBox')
  const createLogger = require('../utils/logger')
  const { requireAuth, requireRole } = require('../auth/session.middleware')
  const siteMode = require('../middleware/siteMode')
  const validate = require('../middleware/validate')
  const noindex = require('../middleware/noindex')
  const uploads = require('../router/v1/admin/imageUpload')
  const activity = require('../model/activity/activity.model')
  const users = require('../model/users/users.model')
  const { makeLimiter, accountChangeLimiter } = require('../middleware/rateLimit')
  /* eslint-enable global-require */

  // Narrowed on purpose (§2.3): utils/auth also re-exports signToken,
  // setAuthCookie and the TOTP challenge primitives, and minting a session is
  // core's job. A module that needs an identity needs to READ one.
  const ctx = {
    moduleId: id,
    paths: { moduleRoot },
    express,
    validator,
    db: { query: db.query, pool: db.pool },
    log: (namespace) => createLogger(namespace ? `${id}:${namespace}` : id),
    settings: {
      get: settings.get,
      set: settings.set,
      getInstanceName: settings.getInstanceName,
    },
    auth: { getUserFromRequest: auth.getUserFromRequest },
    push: { publish: pushDispatch.publish },
    secretBox: { encrypt: secretBox.encrypt, decrypt: secretBox.decrypt },
    middleware: {
      requireAuth,
      requireRole,
      siteMode,
      validate,
      noindex,
      // Rate limiting, added in API 1.1.0 as a factory plus one shared limiter.
      //
      // `rateLimit(options)` is core's `makeLimiter`: a module states its own
      // window and cap — it knows what its endpoints cost — and takes the
      // plumbing from core, so there is one express-rate-limit in the process,
      // one store, and one place a breach is logged.
      //
      // `accountChangeLimiter` is handed over whole because it is genuinely
      // shared policy: core's `/auth/me`, `/player/account` and
      // `/player/appeals` are behind the same counter, and a module's
      // account-change route has to land in it rather than beside it.
      rateLimit: makeLimiter,
      accountChangeLimiter,
    },
    uploads,
    posts: {
      listAll: posts.listAll,
      getById: posts.getById,
      linkAnnounceJob: posts.linkAnnounceJob,
      markAnnounced: posts.markAnnounced,
    },
    // The admin audit log — WRITE only (§2.3, added in API 1.1.0). Core's one
    // audit trail has to include the admin actions a module performs, or the
    // trail has a hole exactly where a module operates the game. A module that
    // kept its own log would be a second place to look, which in practice means
    // a place nobody looks. `list` stays core's: reading the log is the admin
    // panel's job, and it spans every actor.
    activity: { log: activity.log },
    // One function, for one caller: the `admin.users.detail` slot router needs
    // the user its prefix names. Narrowed like `ctx.posts` — the users model
    // exports creation, role changes and password handling, none of which is a
    // module's business.
    users: { getById: users.getById },
    // Where this deployment is reachable, for a module that has to build an
    // absolute link (an announcement carries one into a game window or a chat
    // message, where a relative path means nothing). §2.7 forbids a module
    // reading `process.env` for core configuration and this is core
    // configuration, so core answers it. A getter, not a captured string: the
    // value is read per call, so it cannot go stale against the env.
    site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
  }
  // A guard against accident, not against a hostile module — the boundary is
  // organisational, not a security boundary (MODULE_SYSTEM.md §2.2).
  for (const value of Object.values(ctx)) {
    if (value && typeof value === 'object') Object.freeze(value)
  }
  return Object.freeze(ctx)
}

// ── The registration api ───────────────────────────────────────────────────

// Collects what the module registers so validation can compare it against what
// module.json DECLARED. Declaration is the contract; a module that registers a
// prefix it did not declare is rejected, because module.json is what the admin
// panel, the collision check and the reviewer all read.
function buildApi(record) {
  const once = (name) => {
    if (record.called.has(name)) throw new Error(`${name}() called twice`)
    record.called.add(name)
  }
  const hook = (name) => (fn) => {
    once(name)
    if (typeof fn !== 'function') throw new Error(`${name}: expected a function`)
    record.hooks[name] = fn
  }
  return {
    registerRoutes(mounts) {
      once('registerRoutes')
      if (!mounts || typeof mounts !== 'object') throw new Error('registerRoutes: expected an object')
      for (const [tier, byPrefix] of Object.entries(mounts)) {
        if (!TIERS.includes(tier)) throw new Error(`registerRoutes: unknown tier "${tier}"`)
        for (const [prefix, router] of Object.entries(byPrefix)) {
          if (!PREFIX.test(prefix)) throw new Error(`registerRoutes: bad prefix "${prefix}"`)
          if (typeof router !== 'function') throw new Error(`registerRoutes: ${tier}${prefix} is not a router`)
          record.routes[tier].set(prefix, router)
        }
      }
    },
    // The three de-entanglement registries (§2.4). They live in registries.js
    // rather than here because core registers through the same staging area, and
    // core has no `api` object.
    //
    // These STAGE. Nothing a module registers is visible to core until the
    // second pass commits it, for the reason the second pass exists at all: a
    // module that throws halfway through register(), or fails checkDeclared
    // after it, must leave nothing behind. A half-registered stream catalog
    // would be worse than a missing one — it would be a subscribable stream
    // nothing will ever publish to.
    registerExtension: record.staged.registerExtension,
    registerNotificationStreams(streams) {
      once('registerNotificationStreams')
      record.staged.registerNotificationStreams(streams)
    },
    registerAnnounceLeg: record.staged.registerAnnounceLeg,
    // The two lifecycle hooks (§2.5). Registered here, dispatched from
    // lifecycle.js — this file runs with no database and the hooks run with one.
    // Both are optional: a module with no warm-up and nothing to close simply
    // never calls them.
    onBoot: hook('onBoot'),
    onShutdown: hook('onShutdown'),
  }
}

// ── Validation ─────────────────────────────────────────────────────────────

/**
 * Throw with the §4.3 step that failed attached.
 *
 * `installed_modules.failure_stage` exists so the admin panel can say *where* a
 * module broke and not only what the message was, and the model enumerates the
 * eight stages (`FAILURE_STAGES`). The steps that share one function — a
 * manifest read that also checks `coreApi`, the mounts and the slots — cannot be
 * told apart by position in load(), so they carry their own label; everything
 * else is inferred from how far load() had got. An untagged error is recorded
 * against the step that was running, never guessed at.
 */
function fail(stage, message) {
  const err = new Error(message)
  err.stage = stage
  throw err
}

// Table names a module may create despite not carrying its own id as a prefix.
//
// module-uo's twenty-seven tables predate the module system by two years, and
// renaming live tables is a data migration this workstream deliberately does not
// do (MODULE_SYSTEM.md §1.6). Grandfathering them by an explicit, per-module
// allowlist keeps the prefix rule real for every module written after this one —
// the alternative, dropping the rule, would leave the first name collision to be
// discovered by a module silently adopting someone else's table.
const LEGACY_TABLE_PREFIXES = { uo: ['shard_', 'uo_link_'] }

const CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?(\w+)[`"]?/gi

/** Table names core's own schema.sql declares — a module may not touch these. */
let coreTables = null
function coreTableNames() {
  if (coreTables) return coreTables
  coreTables = new Set()
  try {
    const sql = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8')
    // Split first, for the same reason tablesOf() does: matching the raw file
    // reads CREATE TABLE out of comments, and a phantom core table makes a
    // module fail with a collision against something that does not exist.
    for (const statement of splitStatements(sql)) {
      for (const m of statement.matchAll(CREATE_TABLE)) coreTables.add(m[1].toLowerCase())
    }
  } catch (err) {
    log.warn('could not read core schema for the table-collision check', { message: err.message })
  }
  return coreTables
}

// The only leading verbs a fragment may use — an allowlist, not a DROP denylist.
//
// §2.6 bans `DROP`, but a denylist only ever bans what somebody thought of, and
// core's own schema.sql needs exactly four verbs: CREATE, ALTER, INSERT, UPDATE.
// Anything else in a file that is REPLAYED ON EVERY BOOT is a mistake worth
// failing on — TRUNCATE and DELETE would empty a table every restart, RENAME
// would break on the second one, and GRANT/SET/USE are core's business, not a
// module's. CREATE covers CREATE INDEX as well as CREATE TABLE.
//
// This is a leading-verb check and says so: `ALTER TABLE x DROP COLUMN y` passes
// it. Catching that needs a SQL parser, which is a large dependency to take on
// for a rule whose real job is stopping the obvious foot-gun early.
const ALLOWED_VERBS = new Set(['CREATE', 'ALTER', 'INSERT', 'UPDATE'])

const CREATE_TABLE_ANY = /^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?/i
const CREATE_TABLE_GUARDED = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+/i

/**
 * Read and validate a module's schema fragment; return every table it declares.
 *
 * Validation happens HERE, at load time, and not in modules/schema.js where the
 * fragment is replayed, because every rule §2.6 states is knowable by reading
 * the file — no database required. Failing at load means a module with a bad
 * fragment never mounts at all (§4.4's first column: routes and nav simply
 * absent), rather than mounting, 503ing, and leaving whatever its fragment did
 * manage to execute behind it.
 *
 * Throws if the file is unreadable or breaks a rule.
 */
function tablesOf(dir, manifest) {
  if (!manifest.schema) return new Set()
  const file = path.join(dir, manifest.schema)
  const sql = fs.readFileSync(file, 'utf8')
  const statements = splitStatements(sql)

  for (const statement of statements) {
    const verb = (statement.match(/^\w+/) || [''])[0].toUpperCase()
    if (!ALLOWED_VERBS.has(verb)) {
      throw new Error(`schema fragment statement starts with "${verb}" (allowed: ${[...ALLOWED_VERBS].join(', ')})`)
    }
    // A bare CREATE TABLE succeeds exactly once and fails every boot after it,
    // which presents as a module that worked until the first restart.
    if (CREATE_TABLE_ANY.test(statement) && !CREATE_TABLE_GUARDED.test(statement)) {
      throw new Error('schema fragment has a CREATE TABLE without IF NOT EXISTS')
    }
  }

  // Scanned over the SPLIT STATEMENTS, never the raw file. `splitStatements`
  // strips `--` comments; the file does not, and a fragment that documents
  // itself will say "every CREATE TABLE carries IF NOT EXISTS" in its header.
  // Read raw, that yields a table called `carries`, and the module is rejected
  // for a prefix violation on a table that does not exist — a message with no
  // way back to the comment that caused it. module-uo's fragment hit exactly
  // this on its first real load.
  const tables = new Set()
  for (const statement of statements) {
    for (const m of statement.matchAll(CREATE_TABLE)) tables.add(m[1].toLowerCase())
  }
  return tables
}

function checkTableNames(id, tables) {
  const allowed = LEGACY_TABLE_PREFIXES[id] || []
  const core = coreTableNames()

  for (const table of tables) {
    if (core.has(table)) throw new Error(`schema fragment declares core table "${table}"`)
    for (const other of modules.values()) {
      if (other.tables.has(table)) {
        throw new Error(`schema fragment declares "${table}", already owned by module "${other.id}"`)
      }
    }
    const prefixed = table.startsWith(`${id}_`) || allowed.some((p) => table.startsWith(p))
    if (!prefixed) throw new Error(`schema fragment table "${table}" is not prefixed "${id}_"`)
  }
}

/**
 * Does core already own this prefix in this tier?
 *
 * Asked of the LIVE tier router rather than a hardcoded list, so the check
 * cannot drift the first time core adds a capability router — the spike's
 * hardcoded table was already one prefix stale when it was written. Modules are
 * loaded after every core mount, so the stack is complete by the time this runs,
 * and `layer.match` is express's own matcher rather than a second-guess at its
 * regexp grammar.
 *
 * Root-mounted layers are skipped: `use(noindex, requireAuth)` and the two
 * `use('/', singletonRouter)` mounts match every path, and counting them would
 * report every prefix as taken.
 */
function ownedByCore(tierRouter, prefix) {
  return (tierRouter.stack || []).some(
    (layer) => layer.regexp && !layer.regexp.fast_slash && layer.match(prefix),
  )
}

// ── The client chunk ───────────────────────────────────────────────────────

// A chunk filename, and the same character set utils/htmlShell.js will accept in
// a script src. Two copies of the rule, deliberately: this one rejects the module
// at load time, that one refuses to write the tag. A validator three files away
// staying strict is not something an HTML attribute should depend on.
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/

/**
 * Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
 * disk, and the URL it is served at (§3.1).
 *
 * The rule that matters most is the last one, and it is the one a reviewer would
 * not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
 * IN, not at the module root. One `express.static` over a module root would
 * publish its server source, its `module.json` and its schema fragment to the
 * internet. So an entry sitting directly in the module root is rejected rather
 * than quietly turning the whole module into a public directory.
 *
 * @returns {{dir: string, url: string, entryUrl: string}|null} null when the
 *   module ships no client half — a server-only module is perfectly normal.
 */
function resolveClient(dir, id, manifest) {
  // Absent `client` is a server-only module. Present but empty is not the same
  // thing: it states a client half and delivers none, which would be a module
  // whose pages never load and nothing anywhere saying why.
  if (manifest.client === undefined) return null
  const { entry } = manifest.client
  if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')

  const file = path.resolve(dir, entry)
  // Containment before anything else: `../../server/src/config` resolves to a
  // real, readable directory, and every check below it would pass.
  if (file !== dir && !file.startsWith(dir + path.sep)) {
    fail('manifest', `client.entry "${entry}" escapes the module directory`)
  }
  if (!CHUNK_FILE.test(path.basename(file))) {
    fail('manifest', `client.entry "${entry}" must name a .js file`)
  }
  const chunkDir = path.dirname(file)
  if (chunkDir === dir) {
    fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
  }
  if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)

  return {
    dir: chunkDir,
    url: `/modules/${id}`,
    entryUrl: `/modules/${id}/${path.basename(file)}`,
  }
}

function readManifest(dir, id, tierRouters) {
  const file = path.join(dir, 'module.json')
  const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))

  for (const key of Object.keys(manifest)) {
    // Rejected, not ignored: a typo'd key must be a loud failure rather than a
    // silently inert setting the operator believes they configured.
    if (!MANIFEST_KEYS.has(key)) fail('manifest', `unknown key "${key}" in module.json`)
  }
  if (!ID.test(manifest.id || '')) fail('manifest', `invalid id "${manifest.id}"`)
  if (manifest.id !== id) fail('manifest', `id "${manifest.id}" does not match directory "${id}"`)
  if (!manifest.version) fail('manifest', 'missing version')
  if (!manifest.coreApi) fail('core_api', 'missing coreApi')
  if (!semver.satisfies(MODULE_API_VERSION, manifest.coreApi)) {
    fail('core_api', `needs core API ${manifest.coreApi}, this core is ${MODULE_API_VERSION}`)
  }

  for (const [tier, prefixes] of Object.entries(manifest.mounts || {})) {
    if (!TIERS.includes(tier)) fail('mounts', `unknown tier "${tier}" in mounts`)
    for (const prefix of prefixes) {
      if (!PREFIX.test(prefix)) fail('mounts', `bad prefix "${prefix}" in mounts.${tier}`)
      if (ownedByCore(tierRouters[tier], prefix)) {
        fail('mounts', `prefix ${tier}${prefix} is owned by core`)
      }
      for (const other of modules.values()) {
        if ((other.manifest.mounts?.[tier] || []).includes(prefix)) {
          fail('mounts', `prefix ${tier}${prefix} already registered by module "${other.id}"`)
        }
      }
    }
  }

  for (const slot of manifest.extensions || []) {
    if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
  }

  if (manifest.client !== undefined) {
    if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
      fail('manifest', 'client must be an object')
    }
    for (const key of Object.keys(manifest.client)) {
      if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
    }
  }

  if (manifest.schema && !manifest.purge) {
    // A module that can create tables and cannot drop them leaves an operator
    // with orphaned data and no supported way to remove it.
    fail('schema', 'declares schema but no purge')
  }
  if (manifest.purge && !fs.existsSync(path.join(dir, manifest.purge))) {
    fail('schema', `purge file "${manifest.purge}" is missing`)
  }
  return manifest
}

// What the module registered must equal what it declared — in both directions.
function checkDeclared(record) {
  const declared = record.manifest.mounts || {}
  for (const tier of TIERS) {
    const want = new Set(declared[tier] || [])
    const got = new Set(record.routes[tier].keys())
    for (const p of got) if (!want.has(p)) throw new Error(`registered ${tier}${p} without declaring it`)
    for (const p of want) if (!got.has(p)) throw new Error(`declared ${tier}${p} but never registered it`)
  }
}

// ── Load ───────────────────────────────────────────────────────────────────

/**
 * Discover, validate, register and mount every module under MODULES_DIR.
 *
 * **Called exactly once, explicitly, from app.js**, after the three tier routers
 * are required and before the app is exported. There is no lazy self-scan: the
 * spike's was lazy and silent, so requiring the loader and reading the module
 * list gave an empty array and no error (MODULE_API.md §7.6). Everything that
 * reads the module list now throws until this has run.
 *
 * The ordering is not incidental. Core's mounts must already be on the tier
 * routers, because that is what the prefix-collision check is asked about; and
 * modules mount after them, so first-match-wins means a module could not shadow
 * a core prefix even if the check were bypassed.
 *
 * Safe to call when the modules directory does not exist — that is the normal
 * case for a bare core, and it is the state this PR ships in.
 *
 * @param {{public: Router, admin: Router, player: Router}} tierRouters
 */
function load(tierRouters) {
  if (loaded) return
  for (const tier of TIERS) {
    if (!tierRouters || typeof tierRouters[tier] !== 'function') {
      throw new Error(`modules.load: missing the "${tier}" tier router`)
    }
  }
  loaded = true

  let entries = []
  try {
    entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true })
      .filter((e) => e.isDirectory())
      .map((e) => e.name)
      .sort() // alphabetical: there is no dependency resolution, and any other
              // order would imply a precedence nothing computes (§4.2)
  } catch {
    return // no modules directory is the normal case for a bare core
  }

  for (const id of entries) {
    const dir = path.join(MODULES_DIR, id)
    if (!fs.existsSync(path.join(dir, 'module.json'))) continue

    const record = {
      id,
      dir,
      manifest: null,
      routes: { public: new Map(), admin: new Map(), player: new Map() },
      staged: registries.stage(id),
      tables: new Set(),
      called: new Set(),
      hooks: { onBoot: null, onShutdown: null },
      client: null,
      ctx: null,
      state: 'installed',
      stage: null,
      reason: null,
    }

    // How far load() has got, so an untagged throw is recorded against the step
    // that was actually running (§4.3's steps 5-7). The steps before it label
    // themselves, because readManifest covers four of them in one pass.
    let stage = 'manifest'
    try {
      record.manifest = readManifest(dir, id, tierRouters)
      record.client = resolveClient(dir, id, record.manifest)
      stage = 'schema'
      record.tables = tablesOf(dir, record.manifest)
      checkTableNames(id, record.tables)
      if (record.manifest.server) {
        const entry = path.join(dir, record.manifest.server)
        stage = 'require'
        // eslint-disable-next-line global-require, import/no-dynamic-require
        const register = require(entry)
        if (typeof register !== 'function') throw new Error(`${record.manifest.server} does not export a function`)
        stage = 'register'
        // Kept on the record, not discarded after register(): §2.5 hands the
        // same ctx to onBoot, and building a second one would be a second frozen
        // object claiming to be the same handle.
        record.ctx = buildCtx(id, dir)
        register(record.ctx, buildApi(record))
        checkDeclared(record)
      }
      record.state = 'registered'
      modules.set(id, record)
      log.info(`registered module "${id}" v${record.manifest.version}`, {
        mounts: record.manifest.mounts,
      })
    } catch (err) {
      // A failure here is BEFORE any route was mounted, so this module's routes
      // and nav are simply absent and the site comes up without it (§4.4).
      record.state = 'startup_failed'
      record.stage = err.stage || stage
      record.reason = err.message
      record.manifest = record.manifest || { id, version: 'unknown' }
      modules.set(id, record)
      log.error(`module "${id}" failed to load — continuing without it`, {
        stage: record.stage,
        reason: err.message,
      })
    }
  }

  // Mounting is a SECOND pass, after every module has been validated, and not
  // because it reads better. `ownedByCore` asks the live tier router what is
  // already on it, so mounting inside the loop would make the first module's
  // layers indistinguishable from core's — the second module claiming a taken
  // prefix would be told it collided with core, naming the wrong culprit, and
  // the module-versus-module check below it could never be reached.
  for (const record of modules.values()) {
    if (record.state !== 'registered') continue
    try {
      // Commit what this module staged. Collisions with core or with an earlier
      // module surface here, in scan order, and cost only this module.
      registries.apply(record.staged.staged)
    } catch (err) {
      record.state = 'startup_failed'
      record.stage = 'register'
      record.reason = err.message
      log.error(`module "${record.id}" failed to register — continuing without it`, {
        reason: err.message,
      })
      continue // unmounted, exactly like a validation failure in the first pass
    }
    mount(record, tierRouters)
  }
}

/**
 * Mount one module's routers onto the tier routers, behind the dispatch guard.
 *
 * The guard is the other half of §4.4. A module that fails BEFORE this point has
 * no routes at all; one that fails after — schema replay (PR 3), `onBoot`
 * (PR 5) — keeps its URLs and answers 503, so `routes.manifest.json` never
 * depends on whether a boot hook happened to succeed on the machine that
 * generated it. `disabled` is 404 and unreachable until PR 5 wires
 * `installed_modules` in; it is written here because the guard is the contract's
 * §4.5, not a later addition.
 */
function mount(record, tierRouters) {
  for (const tier of TIERS) {
    for (const [prefix, router] of record.routes[tier]) {
      tierRouters[tier].use(prefix, stateGuard(record), router)
    }
  }
}

/**
 * The dispatch guard, as a middleware over the LIVE record.
 *
 * A closure over the record rather than over its state: everything mounts once,
 * at boot, and the states that matter here are reached afterwards — the schema
 * replay fails, `onBoot` throws, an admin disables the module. A guard that read
 * the state at mount time would answer for the state a module was in before any
 * of that happened.
 *
 * Used for a module's API routes and, since PR 7, for its client chunk: a module
 * answering 503 on its API must not also be handing the browser the script that
 * calls it, and one an admin has disabled should be as absent from the page as it
 * is from the nav.
 */
function stateGuard(record) {
  return (req, res, next) => {
    if (record.state === 'startup_failed') {
      return res.status(503).json({ message: 'Module unavailable' })
    }
    if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
    return next()
  }
}

// ── State ──────────────────────────────────────────────────────────────────

// The states a loaded record may hold, deliberately a hardcoded subset rather
// than an import of model/modules/modules.model.js's STATES: that model reaches
// the database, and this file must stay require-able against a dead one.
// `installed` is not here because a record leaves load() resolved either way.
const RECORD_STATES = new Set(['registered', 'started', 'disabled', 'startup_failed'])

/**
 * Move a loaded module to a new state — the POST-mount transitions.
 *
 * Called by whoever ran the step that failed or the step that succeeded, because
 * only they can know: `ensureSchema()` replays the fragments (PR 3), and
 * lifecycle.js runs `onBoot` and reconciles `installed_modules` (whose `disabled`
 * rows are what make the guard's 404 leg reachable).
 *
 * The stage travels with the reason and is cleared by every non-failing move,
 * for the same reason the database columns are (§2.4): a running module must
 * never be able to show a stale failure.
 *
 * Unknown ids are ignored rather than thrown on: a module can be absent from the
 * volume and still have a row, and a caller on the boot path must not turn that
 * into everyone's failure.
 */
function setState(id, state, { stage = null, reason = null } = {}) {
  if (!RECORD_STATES.has(state)) throw new Error(`unknown module state "${state}"`)
  const record = modules.get(id)
  if (!record) return
  if (record.state !== state) stateVersion += 1
  record.state = state
  record.stage = state === 'startup_failed' ? stage : null
  record.reason = state === 'startup_failed' ? reason : null
}

// ── Introspection ──────────────────────────────────────────────────────────

function assertLoaded(caller) {
  if (!loaded) throw new Error(`modules.${caller}() before modules.load()`)
}

/**
 * Has load() run in this process?
 *
 * The one legitimate reason to ask instead of just calling an accessor: a
 * process that never required app.js and so has no module list to be wrong
 * about. `npm run seed` (db/seed.js) is exactly that — it calls ensureSchema()
 * standalone, and the fragment replay has to be able to tell "this is the seed
 * script" from "the server booted and something is mis-ordered", which is the
 * distinction §7.6's throw exists to preserve everywhere else.
 */
const isLoaded = () => loaded

/**
 * Every module found on the volume, loaded or failed, in scan order.
 *
 * Throws rather than returning `[]` when load() has not run — the empty list is
 * a real answer for a core with no modules installed, and a caller cannot tell
 * the two apart (§7.6).
 */
function list() {
  assertLoaded('list')
  return [...modules.values()].map((r) => ({
    id: r.id,
    name: r.manifest.name || r.id,
    version: r.manifest.version,
    state: r.state,
    stage: r.stage,
    reason: r.reason,
    capabilities: r.manifest.capabilities || [],
  }))
}

/**
 * The modules that are ready to be booted, with their hook, in scan order.
 *
 * `registered` only — the state a module holds between a clean load and its
 * `onBoot`. One that failed validation or schema replay is not going to run, and
 * one already `started` has run. A module with no `onBoot` is still listed: it
 * has nothing to warm up, but it still has to reach `started` so the admin panel
 * and `installed_modules` agree with the guard about what is serving.
 *
 * @returns {{id: string, hook: Function|null, ctx: object|null}[]}
 */
function bootable() {
  assertLoaded('bootable')
  return [...modules.values()]
    .filter((r) => r.state === 'registered')
    .map((r) => ({ id: r.id, hook: r.hooks.onBoot, ctx: r.ctx }))
}

/**
 * The shutdown hooks to run, in REVERSE registration order (§2.5).
 *
 * `started` only. A module whose `onBoot` threw is mid-way through a warm-up it
 * never finished, and calling its `onShutdown` would hand it a half-built world
 * to tear down — the one thing worse than not closing cleanly. Reverse order is
 * the same reasoning applied between modules rather than within one.
 *
 * @returns {{id: string, hook: Function}[]}
 */
function shutdownHooks() {
  assertLoaded('shutdownHooks')
  return [...modules.values()]
    .filter((r) => r.state === 'started' && r.hooks.onShutdown)
    .map((r) => ({ id: r.id, hook: r.hooks.onShutdown }))
    .reverse()
}

/**
 * One module's `onShutdown`, for stopping it on its own rather than at exit.
 *
 * Phase 4 (§2.7.2 decision 3) gave the admin panel's Disable a real meaning.
 * Until then, disabling flipped this record's state and the dispatch guard began
 * answering 404 — which made the module invisible without making it stop. A
 * module's `onBoot` is where it opens its sockets and arms its timers, and none
 * of that is reachable through a URL, so an operator disabling a misbehaving
 * module got no relief from it at all until the next restart.
 *
 * `started` only, the same rule shutdownHooks() applies and for the same reason:
 * a module whose `onBoot` threw has a half-built world its `onShutdown` was
 * never written to tear down. Returns null when there is nothing to run — which
 * covers "not started", "no hook", and "no such module", none of which is an
 * error the caller can act on differently.
 *
 * @returns {{id: string, hook: Function}|null}
 */
function stopHook(id) {
  assertLoaded('stopHook')
  const record = modules.get(id)
  if (!record || record.state !== 'started' || !record.hooks.onShutdown) return null
  return { id: record.id, hook: record.hooks.onShutdown }
}

/**
 * Every schema fragment waiting to be replayed, in scan order.
 *
 * `registered` only: a module that failed validation must not get its tables
 * created (it is not going to run), and one already `started` has had them. The
 * absolute path is resolved here rather than handed out as a manifest-relative
 * name, so the replay never has to know how a module directory is laid out.
 *
 * @returns {{id: string, file: string}[]}
 */
function fragments() {
  assertLoaded('fragments')
  return [...modules.values()]
    .filter((r) => r.state === 'registered' && r.manifest.schema)
    .map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
}

/**
 * Every module that ships a client chunk, with where to serve it from and the
 * guard to serve it behind — in scan order.
 *
 * Listed regardless of state, because mounting happens once at boot and the
 * guard is what answers for the state at request time (the same arrangement the
 * API routes have). A module that failed VALIDATION never reaches here at all:
 * `record.client` is only resolved once the manifest passed.
 *
 * `dir` is the directory the entry sits in, never the module root — see
 * resolveClient. app.js does the mounting; this file does not know about the
 * root app.
 *
 * @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
 */
function clientChunks() {
  assertLoaded('clientChunks')
  return [...modules.values()]
    .filter((r) => r.client)
    .map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
}

/**
 * The script URLs the HTML shell should inject, in scan order.
 *
 * `started` only, and that is the difference between this and clientChunks():
 * the mount is a standing offer answered by a guard, while the tag is a decision
 * taken per page render, when the state is already known. A module whose `onBoot`
 * failed keeps its URLs and answers 503 on them — loading its client half would
 * render its pages against a backend that cannot serve them.
 *
 * @returns {string[]}
 */
function clientEntryUrls() {
  assertLoaded('clientEntryUrls')
  return [...modules.values()]
    .filter((r) => r.client && r.state === 'started')
    .map((r) => r.client.entryUrl)
}

/**
 * Every started module's OpenAPI fragment, in scan order.
 *
 * `started` only, matching clientEntryUrls() rather than clientChunks(): the
 * merged document is built when it is asked for, at which point the state is
 * known, and documenting a module that is 503ing every one of those paths would
 * send a client somewhere it cannot go.
 *
 * The filename is fixed by §2.8 — `swagger-fragment.json` in the bundle root —
 * rather than declared in `module.json`, so a module cannot point core at
 * something else. A module that ships none is simply absent: registering routes
 * without documenting them is checked in the module's OWN CI (§2.8), where the
 * routes are known; core has no way to tell the difference here between a module
 * with no routes and one that forgot.
 *
 * @returns {{id: string, file: string}[]}
 */
function specFragments() {
  assertLoaded('specFragments')
  return [...modules.values()]
    .filter((r) => r.state === 'started')
    .map((r) => ({ id: r.id, file: path.join(r.dir, 'swagger-fragment.json') }))
    .filter((f) => fs.existsSync(f.file))
}

/**
 * How many times a module's state has CHANGED in this process.
 *
 * A cache key, and nothing more: hold the value you built with, compare, rebuild
 * when it differs. It says nothing about which module moved or where to.
 */
const version = () => stateVersion

/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR

module.exports = {
  load,
  list,
  setState,
  fragments,
  bootable,
  shutdownHooks,
  stopHook,
  clientChunks,
  clientEntryUrls,
  specFragments,
  version,
  isLoaded,
  dir,
}
