// ── Admin: installed modules ───────────────────────────────────────────────
//
// Phase 4, slice 1 of docs/website/MODULE_SYSTEM.md §2.7.2. Admin-only, and more
// so than anything else in this directory: installing a module puts JavaScript on
// the volume that core will `require` into its own process on the next boot. That
// is the feature — it is what "an operator never builds anything" means (§1.14) —
// but it is worth being plain that this controller is remote code execution with
// an audit trail, not a settings screen.
//
// What guards it, in the order an attacker would meet them:
//
//   1. `requireRole('admin')` on every route, on top of the group's staff gate.
//   2. An `https`-only host allowlist, re-checked on every redirect hop, so a
//      pasted URL cannot be pointed at the compose network or a metadata service
//      (install.js).
//   3. The sha256 the release published, compared against the bytes that arrived.
//   4. A full inspection of the archive before a byte of it is unpacked, and an
//      unpack into a scratch directory that is only moved into place once the
//      bundle has agreed with the manifest about what it is (archive.js).
//   5. Every action here writes to core's one audit log.
//
// The one thing this file cannot do is mount anything. §1.12 makes the volume the
// mounting source of truth, read once at require time, so install and uninstall
// take effect on the next boot — which is why `restart` is a route here rather
// than a sentence in a tooltip (decision 1).

const modules = require('../../../model/modules/modules.model')
const activity = require('../../../model/activity/activity.model')
const settings = require('../../../model/settings/settings.model')
const loader = require('../../../modules/loader')
const lifecycle = require('../../../modules/lifecycle')
const install = require('../../../modules/install')
const declared = require('../../../modules/declared')
// A namespace import, like every other require in this file, and not
// `const { runPurge } = …`: destructuring at require time captures the function
// rather than the module, which makes it the one dependency here that cannot be
// substituted. That matters because the two tests worth having about purge are
// about the ORDER it runs in relative to the directory being removed.
const schema = require('../../../modules/schema')

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

// The allowlist setting. Seeded from MODULE_SOURCE_HOSTS on first boot and
// admin-managed from then on (decision 6) — db/seed.js writes it once and never
// overwrites it, so changing the variable later does not silently reach in and
// undo an operator's choice. The key itself lives in install.js, which is now
// read by boot-time declared-set resolution as well as by this controller.
const HOSTS_KEY = install.HOSTS_SETTING

// A hostname, not a URL: no scheme, no path, no port, no wildcard. Deliberately
// strict — every character allowed here is a character that can appear in the
// host of a URL this server will fetch and execute the contents of.
const HOSTNAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/

async function allowedHosts() {
  return install.parseHosts(await settings.get(HOSTS_KEY))
}

/**
 * One module, as the admin screen needs it.
 *
 * Four sources have to be reconciled, and which one answers which question is
 * the whole of §2.4:
 *
 *   - the ROW says what the operator decided and what the last boot recorded;
 *   - the LOADER says what is mounted and answering right now;
 *   - the VOLUME says whether there is still a directory there at all;
 *   - the DECLARATION (slice 3) says what this container's environment asks for,
 *     which is the only one of the four an admin cannot change from this screen.
 *
 * They can legitimately disagree, and the screen has to show that rather than
 * pick a winner. A row `enabled` with a loader state of `disabled` is a module
 * the operator has just switched back on and which is waiting for a restart —
 * exactly the case decision 3 creates, and it would be a lie to render it as
 * either "running" or "off". A declared module with no row and no directory is
 * the newest of those disagreements: MODULES asked for it and resolution could
 * not get it, so the screen carries the reason rather than showing nothing.
 */
function present(row, live, onVolume, declaration = null) {
  const id = row ? row.id : live ? live.id : declaration.id
  return {
    id,
    name: row ? row.name : live ? live.name : id,
    version: row ? row.version : live ? live.version : null,
    // What the database records.
    state: row ? row.state : null,
    failureStage: row ? row.failureStage : (live && live.stage) || null,
    failureReason: row ? row.failureReason : (live && live.reason) || null,
    source: row ? row.source : null,
    sha256: row ? row.sha256 : null,
    installedAt: row ? row.installedAt : null,
    startedAt: row ? row.startedAt : null,
    // What is actually mounted in this process, and what it is answering.
    liveState: live ? live.state : null,
    // The version RUNNING, which is not always the version installed: an upgrade
    // writes new files and a new row while the old code stays loaded until the
    // restart. Without this the screen would report the new version as
    // "Running", which is the same lie in a different place.
    liveVersion: live ? live.version : null,
    capabilities: live ? live.capabilities : [],
    // What is on the volume.
    onVolume,
    canPurge: onVolume && Boolean(install.purgeFile(id)),
    // What the environment declares. `declaredVersion` is what MODULES pins, not
    // what is installed — they differ exactly while a resolution is failing, and
    // `declaredError` says why. Uninstalling a declared module from this screen
    // removes its directory and disables its row; the next boot puts the
    // directory back and leaves the row disabled, so the screen says so rather
    // than letting the files reappear unexplained.
    declared: Boolean(declaration),
    declaredVersion: declaration ? declaration.version : null,
    declaredError: declaration && declaration.action === 'failed' ? declaration.message : null,
  }
}

// GET /admin/modules — every module core knows about, from all three sources,
// plus the source allowlist the install form needs.
async function list(req, res) {
  try {
    const rows = await modules.list()
    // The loader throws rather than returning [] before load() has run (§7.6),
    // and this controller is reachable from a process where that is true —
    // `npm run seed` never gets here, but a test harness might.
    const live = loader.isLoaded() ? loader.list() : []
    const byId = new Map(live.map((m) => [m.id, m]))
    const declaredById = new Map(declared.state().map((d) => [d.id, d]))

    const seen = new Set()
    const out = []
    for (const row of rows) {
      seen.add(row.id)
      out.push(
        present(row, byId.get(row.id) || null, install.isInstalled(row.id), declaredById.get(row.id)),
      )
    }
    // A directory on the volume that has no row yet — a hand-placed install
    // before its first boot. It has to be listed, or the screen would show
    // nothing for a module whose routes are already being served.
    for (const m of live) {
      if (!seen.has(m.id)) {
        seen.add(m.id)
        out.push(present(null, m, true, declaredById.get(m.id)))
      }
    }
    // A module MODULES declares that has neither. Resolution failed and left
    // nothing behind — the case an operator most needs told, because from the
    // screen's other three sources it is indistinguishable from never having
    // asked for it.
    for (const d of declaredById.values()) {
      if (!seen.has(d.id)) out.push(present(null, null, install.isInstalled(d.id), d))
    }

    return res.json({ modules: out, sourceHosts: await allowedHosts() })
  } catch (err) {
    log.error('list modules', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// POST /admin/modules — install (or upgrade) from an install-manifest URL.
async function create(req, res) {
  const url = String(req.body.url || '').trim()
  try {
    const hosts = await allowedHosts()
    const result = await install.install({ url, hosts })

    // Provenance is written here and nowhere else: the boot reconcile records a
    // module with NULL source/sha256 and leaves what it is not given, precisely
    // so that a refresh cannot overwrite what an install knew (lifecycle.js).
    const row = await modules.recordInstalled({
      id: result.id,
      name: result.name,
      version: result.version,
      source: result.source,
      sha256: result.sha256,
    })

    await activity.log({
      req,
      userId: req.user.id,
      action: 'module.install',
      detail: { id: result.id, version: result.version, source: url, sha256: result.sha256, replaced: result.replaced },
    })
    log.warn('module installed — it will mount on the next restart', {
      id: result.id,
      version: result.version,
      by: req.user.username,
    })

    return res.status(201).json({ module: row, restartRequired: true, replaced: result.replaced })
  } catch (err) {
    if (err.name === 'InstallError' || err.name === 'ArchiveError') {
      // The operator pasted a URL and something about what came back was wrong.
      // The message is the useful part and is written to be read by them.
      log.warn('module install refused', { url, reason: err.message })
      return res.status(err.status || 400).json({ message: err.message })
    }
    log.error('install module', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// POST /admin/modules/:id/enable — switch a module back on, for the next boot.
//
// Deliberately does NOT touch the loader's record. Disable ran the module's
// onShutdown (decision 3), and there is no onBoot re-dispatch to undo that: a
// module whose sockets were closed and timers cleared cannot be made to serve
// again by flipping a flag, and pretending otherwise would put it back on the
// nav with a torn-down world behind it. The row moves; the restart starts it.
async function enable(req, res) {
  const { id } = req.params
  try {
    const row = await modules.enable(id)
    if (!row) return res.status(404).json({ message: 'No such module.' })

    await activity.log({ req, userId: req.user.id, action: 'module.enable', detail: { id } })
    return res.json({ module: row, restartRequired: true })
  } catch (err) {
    if (err.name === 'ModuleStateError') return res.status(409).json({ message: err.message })
    log.error('enable module', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// POST /admin/modules/:id/disable — stop it now.
//
// The one action on this screen that takes effect without a restart, and the
// reason it does is that it is the one an operator reaches for when something is
// going wrong. Its routes answer 404 from the moment this returns, and its
// onShutdown has already run.
async function disable(req, res) {
  const { id } = req.params
  try {
    const current = await modules.get(id)
    if (!current) return res.status(404).json({ message: 'No such module.' })

    const { stopped, error } = await lifecycle.stop(id)
    const row = await modules.get(id)

    await activity.log({
      req,
      userId: req.user.id,
      action: 'module.disable',
      detail: { id, hookRan: stopped, hookError: error },
    })
    log.warn('module disabled by an operator', { id, hookRan: stopped, by: req.user.username })

    // `shutdownError` is reported rather than swallowed: the module IS disabled
    // either way, and an operator whose module could not close cleanly should be
    // told so while they still have the logs to look at.
    return res.json({ module: row, stopped, shutdownError: error })
  } catch (err) {
    log.error('disable module', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// DELETE /admin/modules/:id[?purge=true] — uninstall.
//
// Non-destructive by default (§2.5): the directory goes, the row stays
// `disabled`, and the module's tables and data are left alone.
//
// The purge option is here rather than as a follow-up action because it cannot
// be a follow-up action (decision 5): `purge.sql` is a file inside the directory
// this is about to delete, so after an uninstall there is nothing left to purge
// with. Ticking the box is the last moment the file exists.
//
// The order below is the whole of it: locate the purge file, STOP, purge, then
// delete. Only the last step is destructive to the filesystem, so the file stays
// readable the whole way through — which is why stopping comes first. Dropping a
// module's tables while it is still started leaves it serving and ingesting
// against a schema that no longer exists: module-uo's uo-link WebSocket keeps
// writing shard events for as long as its onShutdown takes to run, and requests
// in flight answer 500 where a stopped module answers 404.
async function remove(req, res) {
  const { id } = req.params
  const purge = req.query.purge === 'true' || req.query.purge === '1'
  try {
    const current = await modules.get(id)
    const onVolume = install.isInstalled(id)
    if (!current && !onVolume) return res.status(404).json({ message: 'No such module.' })

    // Resolved before anything happens, because this branch is a 400: a request
    // that is going to be refused must not stop the module on its way out.
    let purgeSql = null
    if (purge) {
      purgeSql = install.purgeFile(id)
      if (!purgeSql) {
        return res.status(400).json({
          message: 'This module ships no purge.sql, so its data cannot be deleted. Uninstall without purging instead.',
        })
      }
    }

    // Stop it before its tables and then its files vanish. A module whose
    // directory is deleted out from under a running onShutdown is being asked to
    // tear down a world whose code may already be half-unreadable — and its
    // sockets would otherwise stay open until the restart, holding a connection
    // on behalf of a module that no longer exists on disk.
    await lifecycle.stop(id)

    const purged = purgeSql ? await schema.runPurge(purgeSql) : null
    const removed = await install.removeDir(id)

    // A purge leaves nothing: no directory, no tables, no data. Keeping a
    // `disabled` row for that is a tombstone with nothing to offer and a Purge
    // button that would fail. A plain uninstall keeps its row, which is what
    // makes the retained data visible and reinstallable.
    if (purge) await modules.remove(id)

    await activity.log({
      req,
      userId: req.user.id,
      action: purge ? 'module.purge' : 'module.uninstall',
      detail: { id, purged, removed },
    })
    log.warn(`module ${purge ? 'uninstalled and purged' : 'uninstalled'}`, {
      id,
      statements: purged,
      by: req.user.username,
    })

    return res.json({ id, removed, purged, restartRequired: true })
  } catch (err) {
    log.error('uninstall module', err)
    return res.status(500).json({ message: err.message || 'Internal Server Error' })
  }
}

// POST /admin/modules/:id/purge — drop a still-installed module's data.
//
// Refuses unless the module is already disabled, and that guard is the point:
// dropping the tables under a module that is still serving requests leaves it
// answering out of a world that no longer exists. Disabling first is one click
// and makes the destructive step happen against something that has stopped.
async function purge(req, res) {
  const { id } = req.params
  try {
    const current = await modules.get(id)
    if (!current) return res.status(404).json({ message: 'No such module.' })
    if (current.state !== 'disabled') {
      return res.status(409).json({
        message: 'Disable this module before purging its data, so nothing is serving out of the tables being dropped.',
      })
    }

    const file = install.purgeFile(id)
    if (!file) {
      return res.status(400).json({ message: 'This module ships no purge.sql, so its data cannot be deleted.' })
    }

    const statements = await schema.runPurge(file)
    await activity.log({ req, userId: req.user.id, action: 'module.purge', detail: { id, statements } })
    log.warn('module data purged', { id, statements, by: req.user.username })

    return res.json({ id, purged: statements })
  } catch (err) {
    log.error('purge module', err)
    return res.status(500).json({ message: err.message || 'Internal Server Error' })
  }
}

// PUT /admin/modules/sources — the host allowlist.
async function setSources(req, res) {
  const hosts = install.parseHosts(req.body.hosts)
  const bad = hosts.find((h) => !HOSTNAME.test(h))
  if (bad) return res.status(400).json({ message: `"${bad}" is not a valid hostname.` })

  try {
    const before = await allowedHosts()
    await settings.set(HOSTS_KEY, hosts.join(','), req.user.id)
    await activity.log({
      req,
      userId: req.user.id,
      action: 'module.sources',
      detail: { before, after: hosts },
    })
    log.warn('module source allowlist changed', { before, after: hosts, by: req.user.username })
    return res.json({ sourceHosts: hosts })
  } catch (err) {
    log.error('set module sources', err)
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}

// POST /admin/modules/restart — restart the server process.
//
// Decision 1. Install, uninstall and re-enable all only take effect at boot
// because §1.12 reads the volume at require time, and §2.4 promises recovery
// "with no shell access to the box" — which a banner saying "please restart your
// container" does not deliver.
//
// It reaches server.js's existing SIGTERM handler rather than doing the work
// itself: that handler stops the modules, the workers and the listeners in the
// right order and closes the pool and the log file before exiting 0, and going
// through it means there is exactly one graceful-shutdown path that this route
// cannot drift from.
//
// It gets there by EMITTING the event, not by signalling the process, and that
// is not a detail. `process.kill(process.pid, 'SIGTERM')` is what this did
// first, and it works on Linux — but **Windows has no POSIX signals, and Node
// documents SIGTERM there as unconditional termination of the target process**.
// So on a Windows host the restart killed the server outright: no module
// `onShutdown`, no pool close, no log flush. Verified by running it — the
// process was gone and the shutdown handler had logged nothing.
//
// `process.on('SIGTERM', …)` is an ordinary EventEmitter listener, so
// `process.emit('SIGTERM')` invokes exactly the same handler on every platform
// without involving the OS at all. Deployment is Linux containers and would
// never have shown this; development is not.
//
// What brings the process BACK is the supervisor, not this. The shipped
// docker-compose.yml declares `restart: unless-stopped` on `app`, which restarts
// on a clean exit as well as a crash. A bare `npm start` does not come back, and
// the screen says so before it asks.
function restart(req, res) {
  log.warn('restart requested from the admin panel', { by: req.user.username })

  // Logged and answered first. Once the signal is raised the response has no
  // listener left to flush through, so the operator would be told nothing.
  res.status(202).json({ restarting: true })

  activity
    .log({ req, userId: req.user.id, action: 'module.restart', detail: {} })
    .catch((err) => log.error('failed to record the restart in the audit log', err))
    .finally(() => {
      // A beat, so the 202 is on the wire. `unref` so this timer is not itself
      // something keeping the process alive.
      setTimeout(() => process.emit('SIGTERM'), 250).unref()
    })
}

module.exports = { list, create, enable, disable, remove, purge, setSources, restart, HOSTS_KEY }
