// ── Admin · Modules: the delivery surface ──────────────────────────────────
//
// Phase 4, slice 1 of MODULE_SYSTEM.md §2.7.2. The controller is tested directly
// with a mock `res` and stubbed models — the same shape adminUsers.test.js uses —
// because what is interesting here is not the HTTP plumbing but the ORDER of
// operations and which of the three sources of truth answers which question.
//
// Two of these tests exist to pin decisions that are easy to "fix" back into
// being wrong:
//
//   - **enable must not touch the loader.** Disable ran the module's onShutdown;
//     there is no onBoot re-dispatch, so flipping the record back would put a
//     module with closed sockets and cleared timers back on the nav.
//   - **uninstall's four steps have exactly one valid order**: stop, purge,
//     remove the directory, remove the row. purge.sql lives inside that
//     directory, so purging after the delete silently does nothing with a 200 —
//     and purging before the stop drops the tables under a module that is still
//     running. See the test itself for why neither is visible in a response.
//
// Point the DB at a closed port BEFORE requiring anything that builds the pool.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

const ctrl = require('../src/router/v1/admin/modules.controller')
const modules = require('../src/model/modules/modules.model')
const activity = require('../src/model/activity/activity.model')
const settings = require('../src/model/settings/settings.model')
const loader = require('../src/modules/loader')
const lifecycle = require('../src/modules/lifecycle')
const install = require('../src/modules/install')
const declared = require('../src/modules/declared')
const schema = require('../src/modules/schema')
const db = require('../src/utils/db')

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

function mockRes() {
  return {
    statusCode: 200,
    body: null,
    status(c) { this.statusCode = c; return this },
    json(b) { this.body = b; return this },
  }
}

const req = (extra = {}) => ({
  user: { id: 1, username: 'admin' },
  params: {},
  query: {},
  body: {},
  ...extra,
})

// Everything the controller reaches for, replaced wholesale per test. Restored
// from these originals rather than from a snapshot taken mid-run, so one test
// leaking a stub cannot quietly become another test's fixture.
const originals = {
  modules: { ...modules },
  activity: { log: activity.log },
  settings: { get: settings.get, set: settings.set },
  loader: { isLoaded: loader.isLoaded, list: loader.list },
  lifecycle: { stop: lifecycle.stop },
  install: {
    install: install.install,
    isInstalled: install.isInstalled,
    purgeFile: install.purgeFile,
    removeDir: install.removeDir,
  },
  schema: { runPurge: schema.runPurge },
  declared: { state: declared.state },
}

let logged

beforeEach(() => {
  Object.assign(modules, originals.modules)
  Object.assign(activity, originals.activity)
  Object.assign(settings, originals.settings)
  Object.assign(loader, originals.loader)
  Object.assign(lifecycle, originals.lifecycle)
  Object.assign(install, originals.install)
  Object.assign(schema, originals.schema)
  Object.assign(declared, originals.declared)

  logged = []
  activity.log = async (entry) => { logged.push(entry) }
  settings.get = async () => 'gitea.whitlocktech.com'
  loader.isLoaded = () => true
  loader.list = () => []
})

// ── list ───────────────────────────────────────────────────────────────────

test('list reconciles the row, the loader and the volume without picking a winner', async () => {
  // The case §2.4 creates and decision 3 makes routine: the row says `enabled`
  // because the operator just switched it back on, the loader still says
  // `disabled` because its onShutdown has run and there is no way back without a
  // restart. Rendering either one alone would be a lie.
  modules.list = async () => [{
    id: 'uo', name: 'UO', version: '1.0.0', state: 'enabled',
    failureStage: null, failureReason: null, source: 'https://x/y.json', sha256: 'a'.repeat(64),
    installedAt: null, startedAt: null,
  }]
  loader.list = () => [{ id: 'uo', name: 'UO', version: '1.0.0', state: 'disabled', stage: null, reason: null, capabilities: ['shard'] }]
  install.isInstalled = () => true
  install.purgeFile = () => '/modules/uo/server/db/purge.sql'

  const res = mockRes()
  await ctrl.list(req(), res)

  const [m] = res.body.modules
  assert.equal(m.state, 'enabled', 'what the operator decided')
  assert.equal(m.liveState, 'disabled', 'what is actually answering')
  assert.equal(m.onVolume, true)
  assert.equal(m.canPurge, true)
  assert.deepEqual(m.capabilities, ['shard'])
  assert.deepEqual(res.body.sourceHosts, ['gitea.whitlocktech.com'])
})

test('list includes a module on the volume that has no row yet', async () => {
  // A hand-placed directory before its first boot. §2.5 keeps that a supported
  // install, and its routes are already being served — a screen showing nothing
  // for it would be showing the wrong thing.
  modules.list = async () => []
  loader.list = () => [{ id: 'byhand', name: 'By Hand', version: '0.1.0', state: 'started', stage: null, reason: null, capabilities: [] }]
  install.isInstalled = () => true
  install.purgeFile = () => null

  const res = mockRes()
  await ctrl.list(req(), res)

  assert.equal(res.body.modules.length, 1)
  assert.equal(res.body.modules[0].id, 'byhand')
  assert.equal(res.body.modules[0].state, null, 'no row means no recorded state, not a guessed one')
  assert.equal(res.body.modules[0].liveState, 'started')
})

test('list carries what MODULES declares, including a module it could not install', async () => {
  // Slice 3's fourth source. A declared module that failed to resolve has no
  // row, no directory and nothing mounted — so it is invisible to the other
  // three, and the reason it is missing is the one thing the operator needs.
  modules.list = async () => [{
    id: 'uo', name: 'UO', version: '0.2.0', state: 'enabled',
    failureStage: null, failureReason: null, source: null, sha256: null,
    installedAt: null, startedAt: null,
  }]
  loader.list = () => [{ id: 'uo', name: 'UO', version: '0.2.0', state: 'started', stage: null, reason: null, capabilities: [] }]
  install.isInstalled = (id) => id === 'uo'
  install.purgeFile = () => null
  declared.state = () => [
    { id: 'uo', version: '0.3.0', url: 'https://x/uo.json', action: 'failed', message: 'the host is down' },
    { id: 'market', version: '1.0.0', url: 'https://x/market.json', action: 'failed', message: 'not found' },
  ]

  const res = mockRes()
  await ctrl.list(req(), res)

  const byId = Object.fromEntries(res.body.modules.map((m) => [m.id, m]))
  // Running fine at 0.2.0 while the declared upgrade to 0.3.0 is failing: both
  // facts survive, because collapsing them would have to discard one.
  assert.equal(byId.uo.liveState, 'started')
  assert.equal(byId.uo.declared, true)
  assert.equal(byId.uo.declaredVersion, '0.3.0')
  assert.equal(byId.uo.declaredError, 'the host is down')
  // Declared and nowhere: listed anyway, with no version invented for it.
  assert.equal(byId.market.declared, true)
  assert.equal(byId.market.version, null)
  assert.equal(byId.market.state, null)
  assert.equal(byId.market.declaredError, 'not found')
})

test('a successfully resolved module is marked declared, with no error', async () => {
  modules.list = async () => []
  loader.list = () => [{ id: 'uo', name: 'UO', version: '0.3.0', state: 'started', stage: null, reason: null, capabilities: [] }]
  install.isInstalled = () => true
  install.purgeFile = () => null
  declared.state = () => [{ id: 'uo', version: '0.3.0', url: 'https://x/uo.json', action: 'noop', message: null }]

  const res = mockRes()
  await ctrl.list(req(), res)

  assert.equal(res.body.modules.length, 1, 'declared and present is ONE module, not two')
  assert.equal(res.body.modules[0].declared, true)
  assert.equal(res.body.modules[0].declaredError, null)
})

test('list survives a process where the loader never scanned', async () => {
  loader.isLoaded = () => false
  loader.list = () => { throw new Error('modules.list() before modules.load()') }
  modules.list = async () => [{ id: 'uo', name: 'UO', version: '1', state: 'disabled' }]
  install.isInstalled = () => false
  install.purgeFile = () => null

  const res = mockRes()
  await ctrl.list(req(), res)

  assert.equal(res.statusCode, 200)
  assert.equal(res.body.modules[0].liveState, null)
})

// ── install ────────────────────────────────────────────────────────────────

test('install records provenance and says a restart is needed', async () => {
  const calls = []
  install.install = async ({ url, hosts }) => {
    calls.push({ url, hosts })
    return { id: 'uo', name: 'UO', version: '1.0.0', sha256: 'b'.repeat(64), source: url, replaced: false }
  }
  modules.recordInstalled = async (row) => { calls.push(row); return { ...row, state: 'installed' } }

  const res = mockRes()
  await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x/uo.json' } }), res)

  assert.equal(res.statusCode, 201)
  assert.equal(res.body.restartRequired, true)
  assert.deepEqual(calls[0].hosts, ['gitea.whitlocktech.com'], 'the allowlist comes from the setting')
  // Provenance is written HERE and nowhere else — the boot reconcile records a
  // module with null source/sha256 and leaves what it is not given.
  assert.equal(calls[1].source, 'https://gitea.whitlocktech.com/x/uo.json')
  assert.equal(calls[1].sha256, 'b'.repeat(64))
  assert.equal(logged[0].action, 'module.install')
})

test('an install refusal is reported to the operator, with its own status', async () => {
  install.install = async () => {
    const err = new Error('"evil.net" is not an allowed module source host')
    err.name = 'InstallError'
    err.status = 400
    throw err
  }

  const res = mockRes()
  await ctrl.create(req({ body: { url: 'https://evil.net/x.json' } }), res)

  assert.equal(res.statusCode, 400)
  // The message is the useful part: the operator pasted a URL and needs to know
  // what was wrong with what came back.
  assert.match(res.body.message, /not an allowed module source host/)
  assert.equal(logged.length, 0, 'a refused install is not an audit-log entry')
})

test('an unreachable host is a 502, not a 400', async () => {
  install.install = async () => {
    const err = new Error('could not reach x: timeout')
    err.name = 'InstallError'
    err.status = 502
    throw err
  }

  const res = mockRes()
  await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x.json' } }), res)
  assert.equal(res.statusCode, 502)
})

test('an unexpected failure is a 500 and does not leak its message', async () => {
  install.install = async () => { throw new Error('ENOENT /some/internal/path') }

  const res = mockRes()
  await ctrl.create(req({ body: { url: 'https://gitea.whitlocktech.com/x.json' } }), res)

  assert.equal(res.statusCode, 500)
  assert.equal(res.body.message, 'Internal Server Error')
})

// ── enable / disable ───────────────────────────────────────────────────────

test('enable moves the row and does NOT touch the loader', async () => {
  // The decision-3 invariant. Re-enabling cannot restart a module: its
  // onShutdown has run, and MODULE_API.md has never promised onBoot is safe to
  // run twice. Flipping the record would put it back on the nav with a
  // torn-down world behind it.
  let setStateCalled = false
  loader.setState = () => { setStateCalled = true }
  modules.enable = async (id) => ({ id, state: 'enabled' })

  const res = mockRes()
  await ctrl.enable(req({ params: { id: 'uo' } }), res)

  assert.equal(res.body.module.state, 'enabled')
  assert.equal(res.body.restartRequired, true)
  assert.equal(setStateCalled, false, 'enable must not move the in-memory record')
  assert.equal(logged[0].action, 'module.enable')
  loader.setState = originals.loader.setState
})

test('enabling a module with no row is a 404', async () => {
  modules.enable = async () => null
  const res = mockRes()
  await ctrl.enable(req({ params: { id: 'ghost' } }), res)
  assert.equal(res.statusCode, 404)
})

test('an illegal transition is a 409, not a 500', async () => {
  modules.enable = async () => {
    const err = new Error("module 'uo': cannot move from 'x' to 'enabled'")
    err.name = 'ModuleStateError'
    throw err
  }
  const res = mockRes()
  await ctrl.enable(req({ params: { id: 'uo' } }), res)
  assert.equal(res.statusCode, 409)
})

test('disable stops the module and reports whether the hook ran', async () => {
  const calls = []
  modules.get = async (id) => ({ id, state: 'started' })
  lifecycle.stop = async (id) => { calls.push(id); return { stopped: true, error: null } }

  const res = mockRes()
  await ctrl.disable(req({ params: { id: 'uo' } }), res)

  assert.deepEqual(calls, ['uo'])
  assert.equal(res.body.stopped, true)
  // No restart: this is the one action that takes effect immediately, and it is
  // the one an operator reaches for when something is going wrong.
  assert.equal(res.body.restartRequired, undefined)
  assert.equal(logged[0].action, 'module.disable')
})

test('a shutdown hook that failed is reported rather than swallowed', async () => {
  modules.get = async (id) => ({ id, state: 'started' })
  lifecycle.stop = async () => ({ stopped: false, error: 'socket would not close' })

  const res = mockRes()
  await ctrl.disable(req({ params: { id: 'uo' } }), res)

  // It IS disabled either way; the operator should be told it did not close
  // cleanly while they still have the logs in front of them.
  assert.equal(res.statusCode, 200)
  assert.match(res.body.shutdownError, /socket would not close/)
})

// ── uninstall and purge ────────────────────────────────────────────────────

test('uninstall stops the module, THEN purges, THEN removes the directory', async () => {
  // Two orderings pinned by one assertion, because both are one line away from
  // being wrong and neither failure is visible in the response:
  //
  //   - purge BEFORE removeDir, or decision 5 stops working entirely — purge.sql
  //     is a file inside the directory being deleted, and the endpoint would
  //     still answer 200 having deleted nothing.
  //   - stop BEFORE purge, or the tables are dropped under a module that is
  //     still started. It keeps serving and ingesting against a schema that no
  //     longer exists for as long as its onShutdown takes (module-uo's uo-link
  //     WebSocket keeps writing shard events), and requests in flight answer 500
  //     where a stopped module answers 404. Nothing about the old order was
  //     required: purge.sql stays readable until removeDir, which is the only
  //     step that touches the filesystem.
  const order = []
  modules.get = async (id) => ({ id, state: 'started' })
  install.isInstalled = () => true
  install.purgeFile = () => '/modules/uo/server/db/purge.sql'
  schema.runPurge = async () => { order.push('purge'); return 12 }
  lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } }
  install.removeDir = async () => { order.push('removeDir'); return true }
  modules.remove = async () => { order.push('removeRow') }

  const res = mockRes()
  await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)

  assert.deepEqual(order, ['stop', 'purge', 'removeDir', 'removeRow'])
  assert.equal(res.body.purged, 12)
  assert.equal(res.body.restartRequired, true)
  assert.equal(logged[0].action, 'module.purge')
})

test('a refused purge does not stop the module on its way out', async () => {
  // The 400 branch below is resolved BEFORE anything is stopped. A request that
  // is going to be refused must leave the module exactly as it found it —
  // otherwise "you cannot delete this module's data" would silently take the
  // module down as a side effect of saying no.
  const order = []
  modules.get = async (id) => ({ id, state: 'started' })
  install.isInstalled = () => true
  install.purgeFile = () => null
  lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } }
  install.removeDir = async () => { order.push('removeDir'); return true }

  const res = mockRes()
  await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)

  assert.equal(res.statusCode, 400)
  assert.deepEqual(order, [])
})

test('a plain uninstall keeps the row and does not purge', async () => {
  const order = []
  modules.get = async (id) => ({ id, state: 'started' })
  install.isInstalled = () => true
  schema.runPurge = async () => { order.push('purge'); return 1 }
  lifecycle.stop = async () => { order.push('stop'); return { stopped: true, error: null } }
  install.removeDir = async () => { order.push('removeDir'); return true }
  modules.remove = async () => { order.push('removeRow') }

  const res = mockRes()
  await ctrl.remove(req({ params: { id: 'uo' } }), res)

  // §2.5's default: the directory goes, the data stays, and the disabled row is
  // what keeps the retained data visible and the module reinstallable.
  assert.deepEqual(order, ['stop', 'removeDir'])
  assert.equal(res.body.purged, null)
  assert.equal(logged[0].action, 'module.uninstall')
})

test('asking to purge a module that ships no purge.sql refuses instead of pretending', async () => {
  modules.get = async (id) => ({ id, state: 'started' })
  install.isInstalled = () => true
  install.purgeFile = () => null
  let removed = false
  install.removeDir = async () => { removed = true; return true }

  const res = mockRes()
  await ctrl.remove(req({ params: { id: 'uo' }, query: { purge: 'true' } }), res)

  assert.equal(res.statusCode, 400)
  assert.match(res.body.message, /ships no purge.sql/)
  // Nothing happened. The operator asked for the module AND its data to go; the
  // data cannot go, so doing half of it silently would be the worst answer.
  assert.equal(removed, false)
})

test('uninstalling something that is neither on the volume nor in a row is a 404', async () => {
  modules.get = async () => null
  install.isInstalled = () => false

  const res = mockRes()
  await ctrl.remove(req({ params: { id: 'ghost' } }), res)
  assert.equal(res.statusCode, 404)
})

test('standalone purge refuses while the module is still running', async () => {
  // Dropping the tables under a module that is still serving leaves it answering
  // out of a world that no longer exists. Disabling first is one click.
  modules.get = async (id) => ({ id, state: 'started' })
  let ran = false
  schema.runPurge = async () => { ran = true; return 1 }

  const res = mockRes()
  await ctrl.purge(req({ params: { id: 'uo' } }), res)

  assert.equal(res.statusCode, 409)
  assert.match(res.body.message, /Disable this module before purging/)
  assert.equal(ran, false)
})

test('standalone purge runs on a disabled module', async () => {
  modules.get = async (id) => ({ id, state: 'disabled' })
  install.purgeFile = () => '/modules/uo/server/db/purge.sql'
  schema.runPurge = async () => 7

  const res = mockRes()
  await ctrl.purge(req({ params: { id: 'uo' } }), res)

  assert.equal(res.body.purged, 7)
  assert.equal(logged[0].action, 'module.purge')
})

// ── the allowlist ──────────────────────────────────────────────────────────

test('setSources stores a normalised list and audits the change', async () => {
  let stored = null
  settings.set = async (key, value) => { stored = { key, value } }

  const res = mockRes()
  await ctrl.setSources(req({ body: { hosts: 'Gitea.Example.com, releases.example.org' } }), res)

  assert.deepEqual(res.body.sourceHosts, ['gitea.example.com', 'releases.example.org'])
  assert.equal(stored.key, ctrl.HOSTS_KEY)
  assert.equal(stored.value, 'gitea.example.com,releases.example.org')
  // Before AND after: this setting decides what code the site will execute, so
  // the audit entry has to say what it used to be.
  assert.equal(logged[0].action, 'module.sources')
  assert.deepEqual(logged[0].detail.before, ['gitea.whitlocktech.com'])
})

test('setSources refuses anything that is not a bare hostname', async () => {
  let stored = false
  settings.set = async () => { stored = true }

  for (const bad of ['https://x.com', 'x.com/path', 'x.com:8443', '*.x.com', 'x_y.com']) {
    const res = mockRes()
    // eslint-disable-next-line no-await-in-loop
    await ctrl.setSources(req({ body: { hosts: bad } }), res)
    assert.equal(res.statusCode, 400, `${bad} should be refused`)
  }
  assert.equal(stored, false)
})

test('an empty allowlist is storable, and means no installs', async () => {
  // Not a wildcard, and not an error: "nothing may be installed" is a position
  // an operator is entitled to take.
  let stored = null
  settings.set = async (key, value) => { stored = value }

  const res = mockRes()
  await ctrl.setSources(req({ body: { hosts: '' } }), res)

  assert.equal(res.statusCode, 200)
  assert.deepEqual(res.body.sourceHosts, [])
  assert.equal(stored, '')
})

// ── restart ────────────────────────────────────────────────────────────────

// These listen for the SIGTERM EVENT rather than stubbing `process.kill`, and
// that is the whole point of them now.
//
// The first version of this route called `process.kill(process.pid, 'SIGTERM')`
// and the first version of these tests stubbed `process.kill` and asserted it
// had been called with SIGTERM. Both passed. Both were wrong: Windows has no
// POSIX signals, and Node documents SIGTERM there as unconditional termination —
// so on a Windows host the route killed the server outright, with no module
// `onShutdown`, no pool close and no log flush. A stub of `process.kill` cannot
// see that, because what it asserts is precisely the call whose MEANING differs
// by platform.
//
// Asserting on the event closes the gap: it is what server.js's handler is
// actually subscribed to, so a test passing here means the handler would run.
function onceSigterm() {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      process.removeListener('SIGTERM', handler)
      reject(new Error('no SIGTERM was emitted within 1s'))
    }, 1000)
    function handler() {
      clearTimeout(timer)
      process.removeListener('SIGTERM', handler)
      resolve(true)
    }
    process.on('SIGTERM', handler)
  })
}

test('restart answers first, then triggers the one graceful-shutdown path', async () => {
  const fired = onceSigterm()
  const res = mockRes()

  ctrl.restart(req(), res)

  // Answered synchronously: once the shutdown starts there is no listener left
  // to flush a response through, so the operator would be told nothing.
  assert.equal(res.statusCode, 202)
  assert.equal(res.body.restarting, true)

  assert.equal(await fired, true)
  assert.equal(logged[0].action, 'module.restart')
})

test('a failure to write the audit entry does not cancel the restart', async () => {
  activity.log = async () => { throw new Error('database is gone') }
  const fired = onceSigterm()

  ctrl.restart(req(), mockRes())

  assert.equal(await fired, true)
})
