import { test } from 'node:test'
import assert from 'node:assert/strict'
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'

// lib/moduleAdmin.js — what the Modules screen says about a module and what it
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
//
// This is the part of the screen worth testing, and it is plain JS so this
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
// that the row, the loader and the volume are three sources of truth which are
// ALLOWED to disagree — so most of these cases are combinations that a screen
// picking one source would render as a lie.

/** A module as GET /admin/modules returns it, with the running case as default. */
const mod = (over = {}) => ({
  id: 'uo',
  name: 'Ultima Online',
  version: '1.0.0',
  state: 'started',
  failureStage: null,
  failureReason: null,
  source: 'https://gitea.example.com/x/uo.json',
  sha256: 'a'.repeat(64),
  installedAt: null,
  startedAt: null,
  liveState: 'started',
  liveVersion: '1.0.0',
  capabilities: [],
  onVolume: true,
  canPurge: true,
  declared: false,
  declaredVersion: null,
  declaredError: null,
  ...over,
})

// ── statusOf ───────────────────────────────────────────────────────────────

test('a mounted, started module is Running and needs nothing', () => {
  const s = statusOf(mod())
  assert.equal(s.label, 'Running')
  assert.equal(s.tone, 'ok')
  assert.equal(s.pending, false)
})

test('enabled in the row but disabled in the loader is "Restart to start"', () => {
  // THE case decision 3 creates on purpose: disable ran the module's onShutdown,
  // then the operator enabled it again. The row says enabled; nothing can start
  // it before a restart. Showing either "Running" or "Disabled" would be false.
  const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
  assert.equal(s.label, 'Restart to start')
  assert.equal(s.tone, 'warn')
  assert.equal(s.pending, true)
  assert.match(s.detail, /cannot be restarted in place/)
})

test('freshly installed and never booted into is also "Restart to start"', () => {
  const s = statusOf(mod({ state: 'installed', liveState: null }))
  assert.equal(s.label, 'Restart to start')
  assert.equal(s.pending, true)
  assert.match(s.detail, /mounts when the server next starts/)
})

test('a disabled module is Disabled, and that is not pending anything', () => {
  // Disable takes effect immediately — it is the one action that does — so there
  // is nothing for a restart banner to be about.
  const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
  assert.equal(s.label, 'Disabled')
  assert.equal(s.pending, false)
})

test('a fresh install over a failed row is pending, not failed', () => {
  // THE defect the §7.7 browser smoke found, and one no test here had modelled.
  // Installing over a row the previous boot left `startup_failed` rendered
  // "Failed at the require stage: module directory not present on the volume" a
  // second after the files had been written — and suppressed the restart banner
  // the install had just told the operator to use.
  //
  // `liveState === null` with the module on the volume means the loader's scan
  // never saw it, so it arrived after boot and everything the row says predates
  // it.
  const s = statusOf(mod({
    state: 'startup_failed',
    liveState: null,
    failureStage: 'require',
    failureReason: 'module directory not present on the volume',
  }))
  assert.equal(s.label, 'Restart to start')
  assert.equal(s.pending, true)
  assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
})

test('the restart banner appears for that install', () => {
  // The second half of the same defect: the banner is driven by `pending`, so a
  // row wrongly classified as failed silently removed the only way to act on it.
  assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
})

test('an upgrade that has not been restarted into says so', () => {
  // Same class as the stale-failure defect: the row is a promise about the next
  // boot, not a description of this one. Reporting "Running v2.0.0" while the
  // process is serving v1.0.0 would hide the only action that fixes it.
  const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
  assert.equal(s.label, 'Restart to finish upgrading')
  assert.equal(s.pending, true)
  assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
})

test('reinstalling the SAME version is not an upgrade in progress', () => {
  assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
})

test('a failed module reports the stage and the reason it recorded', () => {
  const s = statusOf(mod({
    state: 'startup_failed',
    liveState: 'startup_failed',
    failureStage: 'schema',
    failureReason: "Unknown column 'x' in 'field list'",
  }))
  assert.equal(s.label, 'Failed to start')
  assert.equal(s.tone, 'bad')
  assert.match(s.detail, /schema stage/)
  assert.match(s.detail, /Unknown column/)
})

test('a failure with no recorded reason says so rather than showing a blank', () => {
  const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
  assert.match(s.detail, /recorded no reason/)
})

test('a row whose directory is gone by hand is bad, not merely disabled', () => {
  // The boot reconcile marks this `startup_failed` because a row claiming to be
  // enabled for a module that is not on the volume is simply untrue.
  const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
  assert.equal(s.label, 'Missing from the volume')
  assert.equal(s.tone, 'bad')
})

test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
  // Uninstall leaves the row `disabled` and the data alone — which is the whole
  // point of keeping the row, so the screen has to say it.
  const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
  assert.equal(s.label, 'Uninstalled')
  assert.equal(s.tone, 'idle')
  assert.match(s.detail, /data was kept/i)
})

test('missing-from-the-volume beats every other status', () => {
  // Ordering: a module with no files is described that way whatever its row
  // still claims, because there is nothing there to be running.
  for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
    assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
  }
})

// ── actionsFor ─────────────────────────────────────────────────────────────

test('a running module offers disable, uninstall and a blocked purge', () => {
  const a = actionsFor(mod())
  assert.equal(a.disable.shown, true)
  assert.equal(a.enable.shown, false)
  assert.equal(a.uninstall.shown, true)
  assert.equal(a.purge.shown, true)
  // Shown but not clickable: the server refuses a standalone purge on anything
  // that is not disabled, so offering the click would only produce a 409.
  assert.equal(a.purge.enabled, false)
  assert.match(a.purge.reason, /Disable it first/)
})

test('a disabled module offers enable, and purge is now live', () => {
  const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
  assert.equal(a.enable.shown, true)
  assert.equal(a.disable.shown, false)
  assert.equal(a.purge.enabled, true)
})

test('a module with no purge.sql never offers purge, and says why', () => {
  const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
  assert.equal(a.purge.shown, false)
  assert.match(a.purge.reason, /ships no purge.sql/)
})

test('a module with no files offers only clearing the row', () => {
  const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
  assert.equal(a.uninstall.shown, false)
  assert.equal(a.disable.shown, false)
  assert.equal(a.enable.shown, false)
  assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
  assert.equal(a.forget.shown, true)
})

test('a directory with no row yet is actionable, and offers nothing to forget', () => {
  // A hand-placed install before its first boot: it has no row, so `state` is
  // null. Its routes are already being served, so it must be disableable.
  const a = actionsFor(mod({ state: null, liveState: 'started' }))
  assert.equal(a.disable.shown, true)
  assert.equal(a.uninstall.shown, true)
  assert.equal(a.forget.shown, false)
})

// ── needsRestart ───────────────────────────────────────────────────────────

test('the restart banner is driven by the list, not by any one module', () => {
  // A restart is a property of the SERVER. One pending module is enough, and
  // three do not mean three restarts.
  assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
  assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
  assert.equal(needsRestart([]), false)
})

test('a disabled module does not ask for a restart', () => {
  // Disable is immediate; a banner here would be asking for a restart that
  // would change nothing.
  assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
})

test('a failed module does not ask for a restart either', () => {
  // It is retried on every boot anyway, and the operator has to fix the cause
  // first — a banner would suggest restarting is the remedy.
  assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
})

// ── parseHosts ─────────────────────────────────────────────────────────────

test('parseHosts previews exactly what the server will store', () => {
  assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
  assert.deepEqual(parseHosts('  '), [])
  assert.deepEqual(parseHosts(undefined), [])
})

// ── the declaration (slice 3) ──────────────────────────────────────────────

test('a declared module that has never installed says so, with the reason', () => {
  // No row, no directory, nothing mounted — invisible to the other three
  // sources, so without this branch the screen would describe a module it has
  // never had as a row gone stale.
  const s = statusOf(mod({
    state: null,
    liveState: null,
    liveVersion: null,
    version: null,
    onVolume: false,
    declared: true,
    declaredVersion: '0.3.0',
    declaredError: 'could not reach releases.example.com',
  }))
  assert.equal(s.label, 'Declared, not installed')
  assert.equal(s.tone, 'bad')
  assert.equal(s.pending, false, 'a restart will not fix an unreachable host')
  assert.match(s.detail, /could not reach releases.example.com/)
})

test('a declared module waiting for its first resolution is not reported as failed', () => {
  const s = statusOf(mod({ state: null, liveState: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0' }))
  assert.match(s.detail, /installed when the server next starts/)
})

test('a running module whose declared upgrade is failing is still Running', () => {
  // Both facts are true at once. The status is one label, so the declaration
  // gets its own line rather than overwriting it.
  const m = mod({ declared: true, declaredVersion: '2.0.0', declaredError: 'sha256 did not match' })
  assert.equal(statusOf(m).label, 'Running')
  const note = declarationNoteFor(m)
  assert.equal(note.tone, 'warn')
  assert.match(note.text, /sha256 did not match/)
})

test('uninstalling a declared module is told that its files come back', () => {
  // The sentence that saves an afternoon: MODULES owns what is on the volume,
  // the row owns whether it runs.
  const note = declarationNoteFor(mod({ state: 'disabled', liveState: null, onVolume: false, declared: true, declaredVersion: '1.0.0' }))
  assert.match(note.text, /come back when the server next starts/)
  assert.match(note.text, /switched off/)
})

test('an ordinary declared module gets a quiet note, and an undeclared one none', () => {
  assert.equal(declarationNoteFor(mod()), null)
  const note = declarationNoteFor(mod({ declared: true, declaredVersion: '1.0.0' }))
  assert.equal(note.tone, 'idle')
  assert.match(note.text, /MODULES/)
})
