// ── Module lifecycle dispatch ──────────────────────────────────────────────
//
// Phase 2, PR 5 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
// docs/website/MODULE_API.md §2.5 (the hooks and when they run) and §4.4
// (failure is a state), plus MODULE_SYSTEM.md §2.4 (what a boot does to
// `installed_modules`).
//
// This is the database half of the loader, and it is a separate file for the
// same reason modules/schema.js is: `scripts/routeManifest.js` and
// `swagger/swagger.js` both require app.js with the pool pointed at a dead port,
// so loader.js may not reach the database. Everything here runs from server.js,
// after ensureSchema() has proved the database is up.
//
// The two halves meet at exactly one place — `loader.setState()` — so the
// in-memory record that the §4.5 dispatch guard reads and the row the admin
// panel reads are moved together and cannot disagree.
//
// Two properties this file exists to keep:
//
//   1. **A module's boot failure costs that module and nothing else** (§4.4).
//      Its routes stay mounted and answer 503; the site comes up; the next
//      module boots as if nothing happened.
//   2. **The row is a record of what happened, never the source of truth for
//      what is mounted** (§2.4). Nothing here mounts, unmounts or re-scans. It
//      reads the outcome of a scan that already happened and writes it down.

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

// §2.5: shutdown races the process being killed, so a module that will not let
// go is logged and skipped rather than allowed to hang the exit. `onBoot` has no
// such budget on purpose — it delays the listener binding, which is the feature.
const SHUTDOWN_BUDGET_MS = 5000

/**
 * Run one database call for one module without letting it become everyone's
 * failure. Returns null on failure, having logged it.
 *
 * The boot path is the whole reason this exists. A row that will not update is
 * bad — the admin panel shows the wrong thing — but it is strictly less bad than
 * a site that will not start, and it must not stop the modules after it from
 * booting.
 */
async function safe(what, fn) {
  try {
    return await fn()
  } catch (err) {
    log.error(`module bookkeeping failed: ${what}`, { error: err.message })
    return null
  }
}

/**
 * Reconcile `installed_modules` with what the loader found, then run every
 * surviving module's `onBoot`.
 *
 * Called once from server.js, after `ensureSchema()` (so a module's own tables
 * exist) and `seedDefaults()`, and **before the HTTP listener binds** — a module
 * that must not serve traffic until it has warmed a cache gets that for free
 * (§2.5).
 *
 * The order of the four steps is the whole design:
 *
 *   1. `beginBoot()` clears the last boot's outcomes, so what is on display
 *      afterwards is what THIS boot did. `disabled` rows are left alone: that is
 *      an operator decision, not an outcome (§2.4).
 *   2. Every module on the volume gets a row, written with null provenance if it
 *      does not have one — a directory placed on the volume by hand is a
 *      supported install (§2.4/§2.5), and without a row it could never be
 *      disabled or shown as failed.
 *   3. Rows with no directory are marked failed, because step 1 has just reset
 *      them to `enabled` and a row claiming to be enabled for a module that is
 *      not there is the one state that is simply untrue. (A plain uninstall
 *      leaves `disabled`, which step 1 never touches, so this only catches a
 *      directory deleted by hand.)
 *   4. The outcome each module already carries — disabled by the operator,
 *      failed during load or schema replay, or ready — is written down, and only
 *      then is `onBoot` dispatched.
 *
 * Never throws. @param {object} [deps] injection seam for tests.
 */
async function boot({ modules, model } = {}) {
  /* eslint-disable global-require */
  const loader = modules || require('./loader')
  const rows = model || require('../model/modules/modules.model')
  /* eslint-enable global-require */

  // Not an error, and the same guard replayFragments carries: a process that
  // never required app.js has no scan to reconcile against, and writing rows
  // from an empty list would mark every installed module as missing.
  if (!loader.isLoaded()) {
    log.info('no module scan in this process — skipping module boot')
    return
  }

  const scanned = loader.list()

  await safe('resetting last boot\'s outcomes', () => rows.beginBoot())

  for (const m of scanned) {
    await safe(`recording module "${m.id}"`, () => rows.recordInstalled({
      id: m.id,
      name: m.name,
      version: m.version,
      // Null provenance is what a hand-placed directory looks like, and it is
      // all this step can honestly say: a scan finds a directory, never where it
      // came from. An install through the admin panel writes source and sha256
      // first, and this refresh must not undo that.
      //
      // It used to. `upsert` assigned both columns unconditionally, so every
      // boot nulled them and an install's provenance survived only until the
      // restart it asked for. The statement now COALESCEs — see the note on
      // modules.db.js's upsert. Nothing could have caught it before Phase 4:
      // this was the only caller, and it has never had a value to pass.
    }))
  }

  const stored = (await safe('reading module rows', () => rows.list())) || []
  const onVolume = new Set(scanned.map((m) => m.id))

  for (const row of stored) {
    if (onVolume.has(row.id) || row.state === 'disabled') continue
    await safe(`marking module "${row.id}" missing`, () => rows.markStartupFailed(row.id, {
      stage: 'require',
      reason: 'module directory not present on the volume',
    }))
  }

  const disabled = new Set(stored.filter((r) => r.state === 'disabled').map((r) => r.id))

  for (const m of scanned) {
    // The operator's switch wins over everything, including a failure. Its
    // routes 404 from here on (§4.5 — the leg that was unreachable until this
    // PR), it is not booted, and its failure is not re-recorded: overwriting a
    // deliberate `disabled` with an outcome would silently re-enable it on the
    // next boot.
    if (disabled.has(m.id)) {
      loader.setState(m.id, 'disabled')
      continue
    }
    if (m.state === 'startup_failed') {
      // Already failed in load() or the schema replay — both of which ran before
      // the database was available to write it down. This is where it lands.
      await safe(`recording failure for "${m.id}"`, () => rows.markStartupFailed(m.id, {
        stage: m.stage,
        reason: m.reason,
      }))
    }
  }

  for (const { id, hook, ctx } of loader.bootable()) {
    try {
      // Awaited without a timeout, deliberately (§2.5): a slow onBoot delays the
      // listener, which is the contract's promise to a module that must warm up
      // before it serves. Core's own boot steps are awaited the same way.
      if (hook) await hook(ctx)
      loader.setState(id, 'started')
      await safe(`marking module "${id}" started`, () => rows.markStarted(id))
      log.info(`module "${id}" started`)
    } catch (err) {
      // §4.4's second column: the routes are already mounted, so they stay
      // mounted and answer 503. A module that failed to warm up serving
      // half-initialised data is worse than one that says it is down.
      loader.setState(id, 'startup_failed', { stage: 'boot', reason: err.message })
      await safe(`recording boot failure for "${id}"`, () => rows.markStartupFailed(id, {
        stage: 'boot',
        reason: err.message,
      }))
      log.error(`module "${id}" onBoot failed — its routes will answer 503`, {
        reason: err.message,
      })
    }
  }
}

/** Reject if `fn`'s promise has not settled within `ms`. */
function withBudget(fn, ms) {
  let timer
  const budget = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`onShutdown exceeded its ${ms}ms budget`)), ms)
  })
  return Promise.race([Promise.resolve().then(fn), budget]).finally(() => clearTimeout(timer))
}

/**
 * Run every started module's `onShutdown`, in reverse registration order.
 *
 * Called from server.js's signal handler before anything core owns is closed, so
 * a module still has a working database pool and push dispatcher to flush
 * through. Reverse order is the mirror of boot order: a module that booted after
 * another may be holding something the earlier one handed it.
 *
 * Never throws, and never hangs: each hook gets `SHUTDOWN_BUDGET_MS`, after
 * which it is logged and abandoned. Abandoned, not cancelled — nothing can stop
 * a promise that is still running — but the process is exiting anyway, and the
 * alternative is a shard host where `systemctl stop` hangs until SIGKILL.
 */
async function shutdown({ modules, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
  // eslint-disable-next-line global-require
  const loader = modules || require('./loader')
  if (!loader.isLoaded()) return

  for (const { id, hook } of loader.shutdownHooks()) {
    try {
      await withBudget(hook, budgetMs)
      log.info(`module "${id}" shut down`)
    } catch (err) {
      log.warn(`module "${id}" onShutdown failed or timed out — continuing`, {
        error: err.message,
      })
    }
  }
}

/**
 * Stop ONE module and mark it disabled — the admin panel's Disable (§2.7.2
 * decision 3).
 *
 * Phase 2 built disable as a pure state flip: the record moved to `disabled` and
 * the dispatch guard started answering 404. That makes a module invisible, not
 * stopped. Everything a module does that is not a response to a request — the
 * sockets and timers its `onBoot` armed — carried on running, so an operator
 * disabling a module *because* it was misbehaving got nothing until the next
 * restart, which is the one thing the button was supposed to save them.
 *
 * So the hook runs first, and the state moves after it: while `onShutdown` is
 * running the module is still `started`, which is the only state in which its
 * own routes and the things it is tearing down are consistent with each other.
 * The hook gets the same budget the exit path gives it.
 *
 * A hook that throws does NOT stop the disable. The operator asked for this
 * module to stop answering; a module that could not close cleanly is a reason to
 * log loudly, not a reason to leave it serving. That is the opposite of the boot
 * path's rule, and deliberately: there, a failure means the module never became
 * safe to use.
 *
 * **Enable is not the mirror of this, and there is no `start(id)` beside it.**
 * There is no `onBoot` re-dispatch, and MODULE_API.md has never promised the
 * hooks are re-entrant — no module author has written `onBoot` to be safe to run
 * twice in one process. Re-enabling therefore moves the row and waits for a
 * restart, which the admin screen offers.
 *
 * @returns {Promise<{stopped: boolean, error: string|null}>} whether a hook ran.
 */
async function stop(id, { modules, model, budgetMs = SHUTDOWN_BUDGET_MS } = {}) {
  /* eslint-disable global-require */
  const loader = modules || require('./loader')
  const rows = model || require('../model/modules/modules.model')
  /* eslint-enable global-require */

  let error = null
  let stopped = false

  if (loader.isLoaded()) {
    const target = loader.stopHook(id)
    if (target) {
      try {
        await withBudget(target.hook, budgetMs)
        stopped = true
        log.info(`module "${id}" stopped by an operator`)
      } catch (err) {
        error = err.message
        log.warn(`module "${id}" onShutdown failed or timed out while being disabled — disabling anyway`, {
          error: err.message,
        })
      }
    }
    // Unconditional, and after the hook: this is what makes its routes and its
    // client chunk answer 404 (§4.5). A module that is not loaded in this
    // process has no record to move, and setState ignores an unknown id.
    loader.setState(id, 'disabled')
  }

  await safe(`disabling module "${id}"`, () => rows.disable(id))
  return { stopped, error }
}

module.exports = { boot, shutdown, stop, SHUTDOWN_BUDGET_MS }
