// The module state machine (docs/website/MODULE_SYSTEM.md §2.4, MODULE_API.md §4.4).
//
//   installed ──► enabled ──► started
//        │            │
//        │            └──► startup_failed ──┐
//        │                                   │ (retry)
//        └──────────────► disabled ◄─────────┘
//
// One row per installed module, one column holding the state. The rules that make
// the machine mean anything live here, not in the SQL:
//
//   - `installed` is transient. An install writes the row; the restart that follows
//     resolves it to `started` or `startup_failed` (§2.5).
//   - `disabled` is the only state a boot leaves alone. It is the operator's
//     decision; every other state is an outcome and is recomputed each boot by
//     beginBoot(). That is what makes a fixed module recover on restart without
//     anyone visiting the admin panel.
//   - A failure is recorded with the stage it happened at, and every non-failing
//     transition clears it — a running module can never show a stale reason.
//
// What this table does NOT decide is which routes exist. The loader scans the
// filesystem at require time, before the database is reachable (MODULE_API.md §4.1),
// so a disabled module is still mounted and simply guarded (§4.5). Keeping the URL
// surface a property of the volume is what lets routes.manifest.json be generated
// off a dead database.

const db = require('./modules.db')

const STATES = ['installed', 'enabled', 'disabled', 'started', 'startup_failed']

// The stage a failure happened at: MODULE_API.md §4.3's seven validation steps,
// plus `boot` for an onBoot hook that threw (§2.5).
const FAILURE_STAGES = [
  'manifest',
  'core_api',
  'mounts',
  'extensions',
  'schema',
  'require',
  'register',
  'boot',
]

class ModuleStateError extends Error {
  constructor(code, message) {
    super(message)
    this.name = 'ModuleStateError'
    this.code = code
  }
}

// Legal moves, keyed by target state. Anything not listed is a bug in the caller
// and throws rather than writing a row that misrepresents what happened.
const ALLOWED_FROM = {
  // Enabling is the recovery path as well as the first step: a disabled module the
  // operator switches back on, and a startup_failed one they retry, both land here.
  enabled: ['installed', 'enabled', 'disabled', 'startup_failed', 'started'],
  // The operator may disable a module in any state, including one that is running.
  disabled: STATES,
  // Reached from `enabled` on a normal boot, and from `installed` on the first boot
  // after an install (or for a directory placed on the volume by hand, whose row is
  // written moments earlier in the same boot).
  started: ['installed', 'enabled'],
  // Failure always precedes `started` in the lifecycle; `started` is accepted so a
  // late failure can still be recorded truthfully rather than dropped.
  startup_failed: ['installed', 'enabled', 'started'],
}

// row → API shape.
function serialize(row) {
  if (!row) return null
  return {
    id: row.id,
    name: row.name,
    version: row.version,
    state: row.state,
    failureStage: row.failure_stage ?? null,
    failureReason: row.failure_reason ?? null,
    source: row.source ?? null,
    sha256: row.sha256 ?? null,
    installedAt: row.installed_at ?? null,
    startedAt: row.started_at ?? null,
    updatedAt: row.updated_at ?? null,
  }
}

async function list() {
  const rows = await db.listAll()
  return rows.map(serialize)
}

async function get(id) {
  const rows = await db.getOne(id)
  return serialize(rows[0])
}

// Record an install (or a re-install / upgrade). Metadata is refreshed; the state is
// left as it is, so upgrading an enabled module does not switch it off and
// re-installing a disabled one does not switch it on. A new row lands in `installed`.
async function recordInstalled({ id, name, version, source = null, sha256 = null }) {
  if (!id || !name || !version) {
    throw new ModuleStateError('invalid_module', 'id, name and version are required')
  }
  await db.upsert({ id, name, version, source, sha256 })
  return get(id)
}

// Start of boot: clear the last boot's outcomes so what is on display after this
// boot is what this boot did. Leaves `disabled` rows alone (see the header).
// Returns the number of rows reset.
async function beginBoot() {
  const res = await db.resetForBoot()
  return res?.affectedRows ?? 0
}

// Apply one transition, after checking it is legal for the row's current state.
// A row that does not exist is not an error the caller can act on — a module can be
// present on the volume with no row at all — so it returns null and writes nothing.
async function transition(id, target, { failureStage = null, failureReason = null } = {}) {
  const current = await get(id)
  if (!current) return null

  const allowed = ALLOWED_FROM[target]
  if (!allowed.includes(current.state)) {
    throw new ModuleStateError(
      'illegal_transition',
      `module '${id}': cannot move from '${current.state}' to '${target}'`,
    )
  }

  await db.setState({
    id,
    state: target,
    failureStage,
    failureReason,
    stampStarted: target === 'started',
  })
  return get(id)
}

const enable = (id) => transition(id, 'enabled')
const disable = (id) => transition(id, 'disabled')
const markStarted = (id) => transition(id, 'started')

// Record a failure at a named stage. Two deliberate softenings, both because this is
// called from the boot path where throwing would turn one module's failure into
// everybody's (MODULE_API.md §4.4 — the failing module fails alone):
//
//   - a `disabled` row is a no-op. The operator switched it off; a broken module
//     they already disabled is not news, and overwriting their decision with an
//     outcome would silently re-enable it on the next boot.
//   - an unrecognised stage is recorded as `require` rather than rejected, so a
//     miscategorised failure still reaches the admin panel with its reason intact.
async function markStartupFailed(id, { stage, reason }) {
  const current = await get(id)
  if (!current || current.state === 'disabled') return current

  return transition(id, 'startup_failed', {
    failureStage: FAILURE_STAGES.includes(stage) ? stage : 'require',
    failureReason: String(reason ?? 'unknown error').slice(0, 4000),
  })
}

// Purge only (§2.5). A plain uninstall disables the module and keeps its row, so its
// data survives and the admin panel can still show what was there.
async function remove(id) {
  await db.remove(id)
}

module.exports = {
  STATES,
  FAILURE_STAGES,
  ModuleStateError,
  list,
  get,
  recordInstalled,
  beginBoot,
  enable,
  disable,
  markStarted,
  markStartupFailed,
  remove,
}
