// ── The loader's failure guarantees ────────────────────────────────────────
//
// docs/website/MODULE_API.md §4.4 promises that a module which fails ANYWHERE in
// its lifecycle fails alone: the site comes up, other modules are unaffected, and
// the failure is recorded rather than thrown. That is the property most worth a
// test, because the failure paths are the ones nobody exercises by hand — every
// manual check runs the happy path.
//
// Each test builds a throwaway modules directory, points MODULES_DIR at it and
// re-requires the loader with a clean cache, so the scan is genuinely redone.
// MODULES_DIR is read into a const at require time (it has to be: the scan is
// synchronous and happens during app.js's require), so busting the cache is the
// only honest way to point the loader somewhere else.
//
// Point the pool at a closed port BEFORE requiring anything: buildCtx pulls in
// the models, which build a mariadb pool at require time. No query is ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')

const express = require('express')

const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
const { startApp } = require('./_helper')

// Requiring the real admin router declares the `admin.users.detail` extension
// slot exactly the way production does (users.router.js, at require time). Doing
// it here rather than calling declareSlot by hand matters: one test below builds
// the real tier routers, and a hand-declared slot would collide with that
// require's own declaration.
require('../src/router/v1/admin')

after(() => db.close())

let tmpRoot

/** Three empty routers standing in for core's tiers — nothing owned, nothing gated. */
const emptyTiers = () => ({
  public: express.Router(),
  admin: express.Router(),
  player: express.Router(),
})

function freshLoader(dir, tiers = emptyTiers()) {
  process.env.MODULES_DIR = dir
  // The registries are process-global (there is one core), so hand the process
  // back between tests. Without this a module's staged registrations from a
  // previous test would still be committed, and every collision assertion below
  // would be asserting against the wrong history.
  registries._reset()
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  loader.load(tiers)
  return loader
}

function writeModule(id, { manifest = {}, server, schema } = {}) {
  const dir = path.join(tmpRoot, id)
  fs.mkdirSync(dir, { recursive: true })
  const full = {
    id,
    name: id,
    version: '1.0.0',
    coreApi: '^1.0.0',
    ...(server === undefined ? {} : { server: 'index.js' }),
    ...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
    ...manifest,
  }
  fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify(full))
  if (server !== undefined) fs.writeFileSync(path.join(dir, 'index.js'), server)
  if (schema !== undefined) {
    fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
    fs.writeFileSync(path.join(dir, 'purge.sql'), '')
  }
  return dir
}

/** A module that registers one router answering 200 at its prefix root. */
const oneRoute = (prefix, tier = 'public') => ({
  manifest: { mounts: { [tier]: [prefix] } },
  server: `module.exports = (ctx, api) => {
    const r = ctx.express.Router()
    r.get('/', (req, res) => res.json({ ok: true }))
    api.registerRoutes({ ${tier}: { '${prefix}': r } })
  }`,
})

const stateOf = (loader, id) => loader.list().find((m) => m.id === id)

beforeEach(() => {
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
})

// ── Discovery and the explicit trigger ─────────────────────────────────────

test('a missing modules directory is the normal case, not an error', () => {
  const loader = freshLoader(path.join(tmpRoot, 'does-not-exist'))
  assert.deepEqual(loader.list(), [])
})

test('reading the module list before load() throws instead of answering []', () => {
  // §7.6. The spike's scan was lazy and silent, so a caller that required the
  // loader and read nothing got an empty list — indistinguishable from a core
  // with no modules installed. It cost one confusing failure; it now costs an
  // error naming the missing call.
  process.env.MODULES_DIR = tmpRoot
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  assert.throws(() => loader.list(), /modules\.list\(\) before modules\.load\(\)/)
})

test('load() refuses to run without all three tier routers', () => {
  writeModule('aaa', { server: 'module.exports = () => {}' })
  process.env.MODULES_DIR = tmpRoot
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  // Not a module's failure — a wiring mistake in core, and the one thing in this
  // file that is allowed to throw past the caller.
  assert.throws(() => loader.load({ public: express.Router() }), /missing the "admin" tier router/)
})

test('load() is once-only, so a second call cannot double-mount', () => {
  writeModule('aaa', oneRoute('/thing'))
  const tiers = emptyTiers()
  const loader = freshLoader(tmpRoot, tiers)
  const before = tiers.public.stack.length

  loader.load(tiers)
  assert.equal(tiers.public.stack.length, before)
  assert.equal(loader.list().length, 1)
})

test('modules load in alphabetical order, since nothing computes a precedence', () => {
  for (const id of ['ccc', 'aaa', 'bbb']) writeModule(id, { server: 'module.exports = () => {}' })
  const loader = freshLoader(tmpRoot)
  assert.deepEqual(loader.list().map((m) => m.id), ['aaa', 'bbb', 'ccc'])
})

// ── The failing module fails alone ─────────────────────────────────────────

test('a module whose entry point throws does not stop the others loading', () => {
  writeModule('aaa', { server: 'module.exports = () => {}' })
  writeModule('bbb', { server: 'throw new Error("boom")' })
  writeModule('ccc', { server: 'module.exports = () => {}' })
  const loader = freshLoader(tmpRoot)

  assert.equal(stateOf(loader, 'aaa').state, 'registered')
  assert.equal(stateOf(loader, 'ccc').state, 'registered')

  const bad = stateOf(loader, 'bbb')
  assert.equal(bad.state, 'startup_failed')
  assert.match(bad.reason, /boom/)
})

test('an entry point that exports something other than a function is rejected', () => {
  writeModule('notfn', { server: 'module.exports = { register: () => {} }' })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'notfn').reason, /does not export a function/)
})

test('a coreApi mismatch is refused before the module is required at all', () => {
  // The entry point would throw if it ran; the version gate must run first.
  writeModule('old', {
    manifest: { coreApi: '^99.0.0' },
    server: 'throw new Error("should never be required")',
  })
  const loader = freshLoader(tmpRoot)
  const mod = stateOf(loader, 'old')
  assert.equal(mod.state, 'startup_failed')
  assert.match(mod.reason, /needs core API \^99\.0\.0/)
})

test('an unknown manifest key is rejected, not ignored', () => {
  // A typo'd key must be loud: an operator who believes they configured
  // something and silently did not is worse off than one who sees a failure.
  writeModule('typo', { manifest: { mount: { public: ['/x'] } } })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'typo').reason, /unknown key "mount"/)
})

test('a module id that does not match its directory is rejected', () => {
  writeModule('onedir', { manifest: { id: 'another' } })
  const loader = freshLoader(tmpRoot)
  // Recorded under the DIRECTORY name — the id it claimed is exactly what is
  // not trusted here.
  assert.match(stateOf(loader, 'onedir').reason, /does not match directory/)
})

test('an unknown extension slot is rejected; only core may declare a slot', () => {
  writeModule('presumptuous', { manifest: { extensions: ['admin.users.detail', 'admin.invented'] } })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'presumptuous').reason, /unknown extension slot "admin\.invented"/)
})

// ── Prefix ownership ───────────────────────────────────────────────────────

test('two modules cannot claim the same prefix; the first one wins', () => {
  writeModule('aaa', oneRoute('/thing'))
  writeModule('bbb', oneRoute('/thing'))
  const loader = freshLoader(tmpRoot)

  assert.equal(stateOf(loader, 'aaa').state, 'registered')
  assert.match(stateOf(loader, 'bbb').reason, /already registered by module "aaa"/)
})

test('a module cannot take a prefix core owns, asked of the live tier routers', () => {
  // Deliberately the REAL routers rather than a hardcoded prefix list. The spike
  // hardcoded core's ~24 prefixes and they were already stale; asking express
  // itself is what stops the check drifting the next time core adds a capability
  // router.
  /* eslint-disable global-require */
  const real = {
    public: require('../src/router/v1/public'),
    admin: require('../src/router/v1/admin'),
    player: require('../src/router/v1/player'),
  }
  /* eslint-enable global-require */

  writeModule('greedy', { manifest: { mounts: { admin: ['/users'] } } })
  writeModule('alsogreedy', { manifest: { mounts: { public: ['/wiki'] } } })
  // Free in every tier core actually mounts, so it must be allowed through.
  writeModule('polite', oneRoute('/widgets', 'player'))

  const loader = freshLoader(tmpRoot, real)
  assert.match(stateOf(loader, 'greedy').reason, /owned by core/)
  assert.match(stateOf(loader, 'alsogreedy').reason, /owned by core/)
  assert.equal(stateOf(loader, 'polite').state, 'registered')
})

test('a root-mounted core layer does not make every prefix look taken', () => {
  // public/index.js ends with `use('/', siteRouter)` and admin with the dashboard
  // router; both match every path. Counting them would report every prefix as
  // owned and no module could ever mount.
  const tiers = emptyTiers()
  tiers.public.use('/posts', express.Router())
  tiers.public.use('/', express.Router())

  writeModule('fine', oneRoute('/widgets'))
  writeModule('taken', oneRoute('/posts'))
  const loader = freshLoader(tmpRoot, tiers)

  assert.equal(stateOf(loader, 'fine').state, 'registered')
  assert.match(stateOf(loader, 'taken').reason, /owned by core/)
})

test('a prefix with a slash or a parameter in it is rejected', () => {
  // A prefix that could contain either would let a module reach outside the slot
  // it was given.
  writeModule('nested', { manifest: { mounts: { public: ['/a/b'] } } })
  writeModule('parameterised', { manifest: { mounts: { public: ['/:id'] } } })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'nested').reason, /bad prefix "\/a\/b"/)
  assert.match(stateOf(loader, 'parameterised').reason, /bad prefix "\/:id"/)
})

test('registering a prefix that was never declared is rejected', () => {
  // module.json is what the admin panel, the collision check and the reviewer
  // all read, so it has to be the truth rather than a hint.
  writeModule('sneaky', {
    manifest: { mounts: { public: ['/declared'] } },
    server: `module.exports = (ctx, api) => api.registerRoutes({
      public: { '/declared': ctx.express.Router(), '/undeclared': ctx.express.Router() },
    })`,
  })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'sneaky').reason, /registered public\/undeclared without declaring it/)
})

test('declaring a prefix and never registering it is rejected too', () => {
  writeModule('forgetful', {
    manifest: { mounts: { public: ['/a', '/b'] } },
    server: "module.exports = (ctx, api) => api.registerRoutes({ public: { '/a': ctx.express.Router() } })",
  })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'forgetful').reason, /declared public\/b but never registered it/)
})

test('registering the same thing twice is an error, not a silent overwrite', () => {
  writeModule('twice', {
    manifest: { mounts: { public: ['/x'] } },
    server: `module.exports = (ctx, api) => {
      api.registerRoutes({ public: { '/x': ctx.express.Router() } })
      api.registerRoutes({ public: { '/x': ctx.express.Router() } })
    }`,
  })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'twice').reason, /registerRoutes\(\) called twice/)
})

// ── Schema fragment validation (the replay itself is moduleSchema.test.js) ──

test('a schema fragment declaring a core table is rejected', () => {
  writeModule('thief', { schema: 'CREATE TABLE IF NOT EXISTS users (id INT);' })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'thief').reason, /declares core table "users"/)
})

test('a schema fragment table must carry the module id as a prefix', () => {
  writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS widgets (id INT);' })
  assert.match(stateOf(freshLoader(tmpRoot), 'mine').reason, /not prefixed "mine_"/)

  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
  writeModule('mine', { schema: 'CREATE TABLE IF NOT EXISTS mine_widgets (id INT);' })
  assert.equal(stateOf(freshLoader(tmpRoot), 'mine').state, 'registered')
})

test('two modules cannot own the same table either', () => {
  writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);' })
  writeModule('bbb', {
    manifest: { id: 'bbb' },
    schema: 'CREATE TABLE IF NOT EXISTS aaa_shared (id INT);',
  })
  const loader = freshLoader(tmpRoot)
  assert.equal(stateOf(loader, 'aaa').state, 'registered')
  assert.match(stateOf(loader, 'bbb').reason, /already owned by module "aaa"/)
})

test('declaring a schema without a purge is rejected', () => {
  const dir = path.join(tmpRoot, 'noway')
  fs.mkdirSync(dir, { recursive: true })
  fs.writeFileSync(
    path.join(dir, 'module.json'),
    JSON.stringify({ id: 'noway', name: 'x', version: '1.0.0', coreApi: '^1.0.0', schema: 'schema.sql' }),
  )
  const loader = freshLoader(tmpRoot)
  // A module that can create tables and cannot drop them leaves an operator with
  // orphaned data and no supported way to remove it.
  assert.match(stateOf(loader, 'noway').reason, /declares schema but no purge/)
})

test('a declared purge file that is not there is rejected', () => {
  writeModule('gone', { schema: 'CREATE TABLE IF NOT EXISTS gone_x (id INT);' })
  fs.unlinkSync(path.join(tmpRoot, 'gone', 'purge.sql'))
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'gone').reason, /purge file "purge\.sql" is missing/)
})

test('a fragment may only use the four verbs core\'s own schema.sql uses', () => {
  // An allowlist rather than a DROP denylist. §2.6 bans DROP, but this file is
  // REPLAYED ON EVERY BOOT, so TRUNCATE and DELETE would empty a table at every
  // restart and RENAME would fail at the second one — a denylist only ever bans
  // what somebody thought of.
  for (const [id, sql, verb] of [
    ['dropper', 'DROP TABLE dropper_x;', 'DROP'],
    ['nuker', 'TRUNCATE TABLE nuker_x;', 'TRUNCATE'],
    ['wiper', 'DELETE FROM wiper_x;', 'DELETE'],
    ['granter', 'GRANT ALL ON *.* TO app;', 'GRANT'],
  ]) {
    writeModule(id, { schema: sql })
    const reason = stateOf(freshLoader(tmpRoot), id).reason
    assert.match(reason, new RegExp(`starts with "${verb}"`))
  }
})

test('a fragment may INSERT and UPDATE its own seed data', () => {
  // Core's schema.sql does both (INSERT IGNORE INTO settings, one UPDATE), so a
  // module that seeds a lookup table the same way must not be rejected.
  writeModule('seeder', {
    schema: [
      'CREATE TABLE IF NOT EXISTS seeder_kinds (id INT PRIMARY KEY, label VARCHAR(32));',
      "INSERT IGNORE INTO seeder_kinds (id, label) VALUES (1, 'first');",
      "UPDATE seeder_kinds SET label = 'first' WHERE id = 1;",
    ].join('\n'),
  })
  assert.equal(stateOf(freshLoader(tmpRoot), 'seeder').state, 'registered')
})

test('a CREATE TABLE without IF NOT EXISTS is rejected', () => {
  // It succeeds exactly once and fails every boot after it, which presents as a
  // module that worked until the first restart — the worst kind of bug to ship
  // to an operator, and free to catch by reading the file.
  writeModule('once', { schema: 'CREATE TABLE once_x (id INT);' })
  assert.match(stateOf(freshLoader(tmpRoot), 'once').reason, /CREATE TABLE without IF NOT EXISTS/)
})

test('a fragment carrying an unreadable file fails the module, not the boot', () => {
  writeModule('missing', { schema: 'CREATE TABLE IF NOT EXISTS missing_x (id INT);' })
  fs.unlinkSync(path.join(tmpRoot, 'missing', 'schema.sql'))
  writeModule('fine', oneRoute('/ok'))

  const loader = freshLoader(tmpRoot)
  assert.equal(stateOf(loader, 'missing').state, 'startup_failed')
  assert.equal(stateOf(loader, 'fine').state, 'registered')
})

test('fragments() lists only registered modules that have one', () => {
  writeModule('withdb', { schema: 'CREATE TABLE IF NOT EXISTS withdb_x (id INT);' })
  writeModule('nodb', oneRoute('/plain'))
  writeModule('broken', { schema: 'CREATE TABLE IF NOT EXISTS not_mine (id INT);' })

  const loader = freshLoader(tmpRoot)
  const frags = loader.fragments()

  assert.deepEqual(frags.map((f) => f.id), ['withdb'])
  // An absolute path, so the replay never has to know how a module dir is laid out.
  assert.equal(frags[0].file, path.join(tmpRoot, 'withdb', 'schema.sql'))
})

test('fragments() before load() throws, like every other accessor', () => {
  process.env.MODULES_DIR = tmpRoot
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  assert.throws(() => loader.fragments(), /modules\.fragments\(\) before modules\.load\(\)/)
})

// ── Mounting and the dispatch guard ────────────────────────────────────────

test('a registered module answers on its prefix; a failed one is simply absent', async () => {
  writeModule('good', oneRoute('/widgets'))
  writeModule('bad', { ...oneRoute('/broken'), server: 'throw new Error("boom")' })

  const tiers = emptyTiers()
  freshLoader(tmpRoot, tiers)
  const app = await startApp((a) => a.use('/public', tiers.public))

  try {
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
    // Failed BEFORE mounting, so its routes are not absent-with-a-503 — they do
    // not exist at all (§4.4, left-hand column).
    assert.equal((await fetch(`${app.url}/public/broken`)).status, 404)
  } finally {
    await app.close()
  }
})

test('a module that fails AFTER mounting keeps its URLs and answers 503', async () => {
  // The right-hand column of §4.4, and the reason routes.manifest.json can be
  // generated off a dead database: the URL surface must not depend on whether a
  // boot step succeeded on the generating machine. The schema replay and onBoot
  // are the two things that trip this in real life (moduleSchema.test.js and
  // moduleLifecycle.test.js cover both); here the state is moved by hand,
  // because the loader is the thing under test.
  writeModule('later', oneRoute('/widgets'))

  const tiers = emptyTiers()
  const loader = freshLoader(tmpRoot, tiers)
  const app = await startApp((a) => a.use('/public', tiers.public))

  try {
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)

    assert.equal(stateOf(loader, 'later').state, 'registered')

    loader.setState('later', 'startup_failed', { stage: 'schema', reason: 'schema fragment blew up' })
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
    assert.equal(stateOf(loader, 'later').reason, 'schema fragment blew up')
    assert.equal(stateOf(loader, 'later').stage, 'schema')

    // The 404 leg is reached for real by the boot reconcile, when it finds a
    // `disabled` row in installed_modules. A disabled module is mounted and
    // guarded, never unmounted (§4.5) — same reason as the 503.
    loader.setState('later', 'disabled')
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 404)

    loader.setState('later', 'started')
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
    // Every non-failing move clears the failure, so a running module can never
    // show the reason it failed two boots ago (§2.4).
    assert.equal(stateOf(loader, 'later').reason, null)
    assert.equal(stateOf(loader, 'later').stage, null)
  } finally {
    await app.close()
  }
})

test('setState refuses a state that is not one, and shrugs at an unknown id', () => {
  writeModule('here', oneRoute('/widgets'))
  const loader = freshLoader(tmpRoot)

  assert.throws(() => loader.setState('here', 'enabled'), /unknown module state "enabled"/)
  // An id with no record is not the boot path's problem to escalate.
  assert.doesNotThrow(() => loader.setState('never-installed', 'started'))
})

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

test('ctx exposes exactly the documented surface, and is frozen', () => {
  const seen = path.join(tmpRoot, 'probe-out.json')
  writeModule('probe', {
    server: `const fs = require('fs')
      module.exports = (ctx) => {
        let mutable = true
        try { ctx.db.query = null; mutable = ctx.db.query === null } catch { mutable = false }
        fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({
          keys: Object.keys(ctx).sort(),
          middleware: Object.keys(ctx.middleware).sort(),
          moduleId: ctx.moduleId,
          mutable,
        }))
      }`,
  })
  assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered')

  const probe = JSON.parse(fs.readFileSync(seen, 'utf8'))
  // API 1.1.0 added activity, users and site — each because module-uo's
  // extraction needed it and none could be vendored: an admin action a module
  // performs belongs in core's one audit log, the extension slot needs the user
  // its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
  assert.deepEqual(probe.keys, [
    'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
    'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator',
  ])
  //  is core's limiter FACTORY, not a limiter: a module states its own
  // window and cap and takes the plumbing, so there is one express-rate-limit in
  // the process and one place a breach is logged.  is
  // handed over whole because it is shared policy — core's /auth/me and
  // /player/account sit behind the same counter.
  assert.deepEqual(probe.middleware, [
    'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate',
  ])
  assert.equal(probe.moduleId, 'probe')
  assert.equal(probe.mutable, false, 'ctx members must be frozen')
})

// ── Lifecycle hooks ────────────────────────────────────────────────────────

test('a lifecycle hook must be a function, and may be registered once', () => {
  // Both are register-time failures, so they cost the module its mount entirely
  // rather than surfacing at boot — the far end of a hook that was never really
  // registered is a module that silently never warms up.
  for (const [body, expected] of [
    ['api.onBoot("later")', /onBoot: expected a function/],
    ['api.onShutdown("later")', /onShutdown: expected a function/],
    ['api.onBoot(() => {}); api.onBoot(() => {})', /onBoot\(\) called twice/],
  ]) {
    tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-modules-'))
    writeModule('hooked', { server: `module.exports = (ctx, api) => { ${body} }` })
    const state = stateOf(freshLoader(tmpRoot), 'hooked')
    assert.match(state.reason, expected)
    assert.equal(state.stage, 'register')
  }
})

test('a module with no hooks is bootable, and offers nothing to shut down', () => {
  writeModule('quiet', oneRoute('/widgets'))
  const loader = freshLoader(tmpRoot)

  // Listed with a null hook rather than filtered out: it still has to reach
  // `started`, or the admin panel and the dispatch guard would disagree about
  // whether it is serving.
  assert.deepEqual(loader.bootable().map((b) => b.id), ['quiet'])
  assert.equal(loader.bootable()[0].hook, null)
  assert.deepEqual(loader.shutdownHooks(), [])
})

test('shutdown hooks come back in reverse order, and only for started modules', () => {
  const hook = 'module.exports = (ctx, api) => api.onShutdown(async () => {})'
  writeModule('aaa', { server: hook })
  writeModule('bbb', { server: hook })
  writeModule('ccc', { server: hook })
  const loader = freshLoader(tmpRoot)

  // Nothing has started yet, so there is nothing to tear down.
  assert.deepEqual(loader.shutdownHooks(), [])

  loader.setState('aaa', 'started')
  loader.setState('bbb', 'startup_failed', { stage: 'boot', reason: 'never warmed up' })
  loader.setState('ccc', 'started')

  // Reverse registration order (§2.5), and `bbb` is absent: a module whose
  // onBoot threw is mid-way through a warm-up it never finished, and handing it
  // a half-built world to tear down is worse than not closing cleanly.
  assert.deepEqual(loader.shutdownHooks().map((h) => h.id), ['ccc', 'aaa'])
})

// ── Failure stages ─────────────────────────────────────────────────────────

test('a failure is recorded against the §4.3 step that produced it', () => {
  // installed_modules.failure_stage exists so the admin panel can say WHERE a
  // module broke. The four steps readManifest covers in one pass have to label
  // themselves; the rest are inferred from how far load() had got.
  const cases = [
    ['a-manifest', { manifest: { nonsense: true } }, 'manifest'],
    ['b-coreapi', { manifest: { coreApi: '^99.0.0' } }, 'core_api'],
    ['c-mounts', { manifest: { mounts: { public: ['/bad prefix'] } } }, 'mounts'],
    ['d-slots', { manifest: { extensions: ['no.such.slot'] } }, 'extensions'],
    ['e-schema', { schema: 'DELETE FROM x;' }, 'schema'],
    ['f-require', { server: 'throw new Error("boom")' }, 'require'],
    ['g-register', { server: 'module.exports = (ctx, api) => { throw new Error("nope") }' }, 'register'],
  ]
  for (const [id, spec] of cases) writeModule(id, spec)
  const loader = freshLoader(tmpRoot)

  for (const [id, , stage] of cases) {
    const state = stateOf(loader, id)
    assert.equal(state.state, 'startup_failed', `${id} should have failed`)
    assert.equal(state.stage, stage, `${id} should have failed at "${stage}"`)
  }
})

// ── Staged registrations are committed only for a module that survives ─────

test('a module that fails AFTER registering leaves nothing in the registries', () => {
  // The registry-side twin of the second-pass mount rule. register() runs before
  // checkDeclared, so a module can stage a stream catalog and then be rejected —
  // and a half-registered catalog is worse than a missing one, because it is a
  // subscribable stream nothing will ever publish to.
  writeModule('halfway', {
    manifest: { mounts: { public: ['/declared'] } },
    server: `module.exports = (ctx, api) => {
      api.registerNotificationStreams([{ id: 'halfway.thing', label: 'Thing' }])
      api.registerAnnounceLeg({ leg: 'halfway.leg', label: 'L', dispatch: async () => ({}), classify: () => ({}) })
      // declared /declared and never registered it → rejected by checkDeclared
    }`,
  })
  const loader = freshLoader(tmpRoot)

  assert.match(stateOf(loader, 'halfway').reason, /declared public\/declared but never registered it/)
  assert.equal(registries.isValidStream('halfway.thing'), false)
  assert.equal(registries.announceLeg('halfway.leg'), null)
})

test('a module colliding with an already-registered name fails alone, unmounted', () => {
  const tiers = emptyTiers()
  writeModule('first', {
    manifest: { mounts: { public: ['/first'] } },
    server: `module.exports = (ctx, api) => {
        api.registerRoutes({ public: { '/first': ctx.express.Router() } })
        api.registerNotificationStreams([{ id: 'first.shared', label: 'Shared' }])
      }`,
  })
  writeModule('second', {
    manifest: { mounts: { public: ['/second'] } },
    server: `module.exports = (ctx, api) => {
      api.registerRoutes({ public: { '/second': ctx.express.Router() } })
      api.registerNotificationStreams([{ id: 'second.ok', label: 'Ok' }, { id: 'first.shared', label: 'Mine' }])
    }`,
  })
  const loader = freshLoader(tmpRoot, tiers)

  assert.equal(stateOf(loader, 'first').state, 'registered')
  assert.match(stateOf(loader, 'second').reason, /already registered by "first"/)
  // Not even the claim that did not collide.
  assert.equal(registries.isValidStream('second.ok'), false)
  // And the loser is not mounted at all. Asked of the live router the way the
  // prefix-ownership check asks it, rather than by counting layers — one mount
  // produces two (the dispatch guard, then the module's router).
  const claims = (prefix) =>
    tiers.public.stack.some((l) => l.regexp && !l.regexp.fast_slash && l.match(prefix))
  assert.equal(claims('/first'), true)
  assert.equal(claims('/second'), false)
})

// ── The client chunk (MODULE_API.md §3.1) ──────────────────────────────────

/** A module shipping a prebuilt chunk at the conventional client/dist/entry.js. */
function withChunk(id, { entry = 'client/dist/entry.js', write = true, body = 'export default 1' } = {}) {
  const dir = writeModule(id, { manifest: { client: { entry } } })
  if (write) {
    const file = path.join(dir, entry)
    fs.mkdirSync(path.dirname(file), { recursive: true })
    fs.writeFileSync(file, body)
  }
  return dir
}

test('a module with a chunk publishes where to serve it from and its URL', () => {
  const dir = withChunk('uo')
  const loader = freshLoader(tmpRoot)
  const [chunk] = loader.clientChunks()

  assert.equal(chunk.id, 'uo')
  assert.equal(chunk.url, '/modules/uo')
  assert.equal(chunk.entryUrl, '/modules/uo/entry.js')
  // The DIRECTORY THE ENTRY IS IN, never the module root: one express.static over
  // a module root would publish its server source, its module.json and its schema
  // fragment.
  assert.equal(chunk.dir, path.join(dir, 'client', 'dist'))
  assert.equal(typeof chunk.guard, 'function')
})

test('a server-only module contributes no chunk', () => {
  writeModule('plain', { server: 'module.exports = () => {}' })
  const loader = freshLoader(tmpRoot)
  assert.deepEqual(loader.clientChunks(), [])
  assert.deepEqual(loader.clientEntryUrls(), [])
})

test('an entry directly in the module root is refused — its directory is served', () => {
  // The rule with the largest blast radius in this file. Accepting it would root
  // the static mount at the module root and publish everything in it.
  const dir = writeModule('uo', { manifest: { client: { entry: 'entry.js' } } })
  fs.writeFileSync(path.join(dir, 'entry.js'), 'export default 1')
  const loader = freshLoader(tmpRoot)
  assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
  assert.match(stateOf(loader, 'uo').reason, /must be in a subdirectory/)
  assert.deepEqual(loader.clientChunks(), [])
})

test('an entry that escapes the module directory is refused before anything else', () => {
  // `../../server/src/config/csp.js` is a real, readable file, and every check
  // after containment would have passed.
  withChunk('uo', { entry: '../../server/src/config/csp.js', write: false })
  const loader = freshLoader(tmpRoot)
  assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
  assert.match(stateOf(loader, 'uo').reason, /escapes the module directory/)
})

test('an entry that is not a .js file, or is missing, is refused', () => {
  withChunk('aaa', { entry: 'client/dist/entry.mjs' })
  withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
  assert.match(stateOf(loader, 'bbb').reason, /is missing/)
})

test('a malformed client key is a loud failure, not an ignored setting', () => {
  writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
  writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
  writeModule('ccc', { manifest: { client: {} } })
  const loader = freshLoader(tmpRoot)
  assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
  assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
  assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
  for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
})

test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
  // The mount is a standing offer answered by a guard; the tag is a decision
  // taken per render, when the state is known. A module answering 503 on its API
  // must not also be handing the browser the script that calls it.
  withChunk('uo')
  const loader = freshLoader(tmpRoot)
  assert.deepEqual(loader.clientEntryUrls(), [], 'registered is not yet serving')

  loader.setState('uo', 'started')
  assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])

  loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'nope' })
  assert.deepEqual(loader.clientEntryUrls(), [])
  assert.equal(loader.clientChunks().length, 1, 'the mount stays; the guard answers for it')

  loader.setState('uo', 'disabled')
  assert.deepEqual(loader.clientEntryUrls(), [])
})

test('the chunk accessors throw before load(), like every other one', () => {
  process.env.MODULES_DIR = tmpRoot
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  assert.throws(() => loader.clientChunks(), /modules\.clientChunks\(\) before modules\.load\(\)/)
  assert.throws(() => loader.clientEntryUrls(), /modules\.clientEntryUrls\(\) before modules\.load\(\)/)
})
