// Point the DB pool at a dead port before it's built; every modules.db method is
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
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 pool = require('../src/utils/db')
after(() => pool.close())

// Unit-test the module state machine (docs/website/MODULE_SYSTEM.md §2.4) against an
// in-memory fake by monkeypatching modules.db (no DB). What is locked here is
// everything the boot path and the admin panel will lean on in later phases:
//   - `installed` is transient and a re-install never overwrites the operator's
//     enable/disable decision;
//   - beginBoot() recomputes outcomes and leaves `disabled` alone — the property that
//     makes a fixed module recover on a restart with no panel visit;
//   - a failure carries its stage and reason, and every non-failing transition clears
//     them, so a running module can never display a stale reason;
//   - a disabled module's failure is a no-op, because the boot path must never turn
//     one module's failure into a re-enable of a module the operator switched off;
//   - an illegal move throws instead of writing a row that misrepresents the state.
const modulesDb = require('../src/model/modules/modules.db')
const modules = require('../src/model/modules/modules.model')

let rows // id → row, snake_case exactly as modules.db returns it

const saved = { ...modulesDb }

function reset() {
  rows = new Map()
}

modulesDb.listAll = async () => [...rows.values()].sort((a, b) => a.id.localeCompare(b.id))

modulesDb.getOne = async (id) => (rows.has(id) ? [rows.get(id)] : [])

modulesDb.upsert = async ({ id, name, version, source, sha256 }) => {
  const existing = rows.get(id)
  if (existing) {
    // COALESCE, matching the real statement: a value overwrites, a NULL leaves
    // what is there. This fake used to assign unconditionally — faithfully
    // reproducing the defect it was supposed to be able to catch, which is why
    // the boot refresh nulling an install's provenance survived until a browser
    // showed it.
    Object.assign(existing, {
      name,
      version,
      source: source ?? existing.source ?? null,
      sha256: sha256 ?? existing.sha256 ?? null,
    })
    return { affectedRows: 1 }
  }
  rows.set(id, {
    id,
    name,
    version,
    state: 'installed',
    failure_stage: null,
    failure_reason: null,
    source: source ?? null,
    sha256: sha256 ?? null,
    installed_at: '2026-08-10T00:00:00Z',
    started_at: null,
    updated_at: '2026-08-10T00:00:00Z',
  })
  return { affectedRows: 1 }
}

modulesDb.setState = async ({ id, state, failureStage, failureReason, stampStarted }) => {
  const row = rows.get(id)
  if (!row) return { affectedRows: 0 }
  row.state = state
  row.failure_stage = failureStage ?? null
  row.failure_reason = failureReason ?? null
  if (stampStarted) row.started_at = '2026-08-10T12:00:00Z'
  return { affectedRows: 1 }
}

modulesDb.resetForBoot = async () => {
  let n = 0
  for (const row of rows.values()) {
    if (row.state === 'disabled') continue
    row.state = 'enabled'
    row.failure_stage = null
    row.failure_reason = null
    n += 1
  }
  return { affectedRows: n }
}

modulesDb.remove = async (id) => ({ affectedRows: rows.delete(id) ? 1 : 0 })

after(() => Object.assign(modulesDb, saved))

beforeEach(reset)

// Install one module and put it in a given state, bypassing the machine so a test
// can start from any state without asserting its way there.
async function seed(id, state = 'installed', extra = {}) {
  await modules.recordInstalled({ id, name: `Module ${id}`, version: '1.0.0', ...extra })
  rows.get(id).state = state
  return modules.get(id)
}

// ── recordInstalled ───────────────────────────────────────────────────

test('a new install lands in the transient installed state', async () => {
  const mod = await modules.recordInstalled({
    id: 'uo',
    name: 'Ultima Online',
    version: '1.2.0',
    source: 'https://gitea.example/releases/module-uo-1.2.0.tar.gz',
    sha256: 'a'.repeat(64),
  })
  assert.equal(mod.state, 'installed')
  assert.equal(mod.version, '1.2.0')
  assert.equal(mod.sha256, 'a'.repeat(64))
  assert.equal(mod.startedAt, null)
  assert.equal(mod.failureReason, null)
})

test('a hand-placed module records with no provenance', async () => {
  const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })
  assert.equal(mod.source, null)
  assert.equal(mod.sha256, null)
})

test('the boot refresh does not wipe an install\'s provenance', async () => {
  // The defect a browser found in Phase 4, and the reason the upsert COALESCEs.
  //
  // lifecycle.boot() re-records every scanned module with NO source and NO
  // sha256, because a hand-placed directory genuinely has neither. That refresh
  // used to write both columns as NULL, so an admin-panel install's provenance
  // survived exactly until the restart the install asked for — and the screen
  // then described a module installed from a URL as "placed on the volume by
  // hand". Nothing could see it until Phase 4: no caller had ever passed a
  // non-null value.
  await modules.recordInstalled({
    id: 'uo',
    name: 'Ultima Online',
    version: '1.0.0',
    source: 'https://gitea.example/x/uo-1.0.0.json',
    sha256: 'b'.repeat(64),
  })

  // What the next boot does.
  const after = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '1.0.0' })

  assert.equal(after.source, 'https://gitea.example/x/uo-1.0.0.json')
  assert.equal(after.sha256, 'b'.repeat(64))
})

test('a re-install from a new URL does replace the provenance', async () => {
  // The other half: COALESCE must not make the columns write-once, or an upgrade
  // would for ever show where the FIRST version came from.
  await modules.recordInstalled({
    id: 'uo', name: 'Ultima Online', version: '1.0.0', source: 'https://old/x.json', sha256: 'c'.repeat(64),
  })
  const after = await modules.recordInstalled({
    id: 'uo', name: 'Ultima Online', version: '2.0.0', source: 'https://new/y.json', sha256: 'd'.repeat(64),
  })

  assert.equal(after.source, 'https://new/y.json')
  assert.equal(after.sha256, 'd'.repeat(64))
  assert.equal(after.version, '2.0.0')
})

test('recordInstalled refuses a manifest missing id, name or version', async () => {
  await assert.rejects(
    () => modules.recordInstalled({ id: 'uo', version: '1.0.0' }),
    (err) => err.code === 'invalid_module',
  )
})

test('an upgrade refreshes metadata and leaves the operator decision alone', async () => {
  await seed('uo', 'disabled')
  const mod = await modules.recordInstalled({ id: 'uo', name: 'Ultima Online', version: '2.0.0' })
  assert.equal(mod.version, '2.0.0')
  assert.equal(mod.name, 'Ultima Online')
  assert.equal(mod.state, 'disabled', 're-installing must not silently re-enable')
})

test('an upgrade of a started module does not switch it off', async () => {
  await seed('uo', 'started')
  const mod = await modules.recordInstalled({ id: 'uo', name: 'Module uo', version: '1.1.0' })
  assert.equal(mod.state, 'started')
})

// ── the machine ───────────────────────────────────────────────────────

test('installed → enabled → started, stamping the start', async () => {
  await seed('uo')
  assert.equal((await modules.enable('uo')).state, 'enabled')
  const started = await modules.markStarted('uo')
  assert.equal(started.state, 'started')
  assert.ok(started.startedAt, 'a successful start is stamped')
})

test('a first boot may start a module straight from installed', async () => {
  await seed('uo')
  assert.equal((await modules.markStarted('uo')).state, 'started')
})

test('a disabled module can be re-enabled', async () => {
  await seed('uo', 'disabled')
  assert.equal((await modules.enable('uo')).state, 'enabled')
})

test('a failed module is retried by enabling it, which clears the reason', async () => {
  await seed('uo', 'enabled')
  await modules.markStartupFailed('uo', { stage: 'boot', reason: 'atlas refresh threw' })
  const retried = await modules.enable('uo')
  assert.equal(retried.state, 'enabled')
  assert.equal(retried.failureStage, null)
  assert.equal(retried.failureReason, null)
})

test('a running module can be disabled', async () => {
  await seed('uo', 'started')
  assert.equal((await modules.disable('uo')).state, 'disabled')
})

test('a disabled module is never started — that would be a core bug, so it throws', async () => {
  await seed('uo', 'disabled')
  await assert.rejects(
    () => modules.markStarted('uo'),
    (err) => err.name === 'ModuleStateError' && err.code === 'illegal_transition',
  )
  assert.equal((await modules.get('uo')).state, 'disabled')
})

test('a transition on a module with no row writes nothing and returns null', async () => {
  assert.equal(await modules.enable('ghost'), null)
  assert.equal(await modules.markStarted('ghost'), null)
  assert.equal(rows.size, 0)
})

// ── failures ──────────────────────────────────────────────────────────

test('a failure records its stage and reason', async () => {
  await seed('uo', 'enabled')
  const failed = await modules.markStartupFailed('uo', {
    stage: 'core_api',
    reason: "module 'uo' needs coreApi ^2.0.0, core provides 1.0.0",
  })
  assert.equal(failed.state, 'startup_failed')
  assert.equal(failed.failureStage, 'core_api')
  assert.match(failed.failureReason, /coreApi/)
})

test('an unrecognised stage is still recorded, as require', async () => {
  await seed('uo', 'enabled')
  const failed = await modules.markStartupFailed('uo', { stage: 'nonsense', reason: 'boom' })
  assert.equal(failed.failureStage, 'require')
  assert.equal(failed.failureReason, 'boom')
})

test('a missing reason still produces a displayable one', async () => {
  await seed('uo', 'enabled')
  const failed = await modules.markStartupFailed('uo', { stage: 'register' })
  assert.equal(failed.failureReason, 'unknown error')
})

test('a runaway reason is truncated rather than refused', async () => {
  await seed('uo', 'enabled')
  const failed = await modules.markStartupFailed('uo', { stage: 'boot', reason: 'x'.repeat(9000) })
  assert.equal(failed.failureReason.length, 4000)
})

test("a disabled module's failure is a no-op, not a re-enable", async () => {
  await seed('uo', 'disabled')
  const unchanged = await modules.markStartupFailed('uo', { stage: 'require', reason: 'broken' })
  assert.equal(unchanged.state, 'disabled')
  assert.equal(unchanged.failureReason, null)
})

test('starting successfully clears the previous failure', async () => {
  await seed('uo', 'enabled')
  await modules.markStartupFailed('uo', { stage: 'schema', reason: 'bad fragment' })
  await modules.enable('uo')
  const started = await modules.markStarted('uo')
  assert.equal(started.failureStage, null)
  assert.equal(started.failureReason, null)
})

// ── boot ──────────────────────────────────────────────────────────────

test('beginBoot recomputes outcomes and leaves disabled alone', async () => {
  await seed('a', 'started')
  await seed('b', 'startup_failed')
  await seed('c', 'disabled')
  await seed('d', 'installed')
  rows.get('b').failure_reason = 'last boot blew up'
  rows.get('b').failure_stage = 'boot'

  assert.equal(await modules.beginBoot(), 3)

  const byId = Object.fromEntries((await modules.list()).map((m) => [m.id, m]))
  assert.equal(byId.a.state, 'enabled')
  assert.equal(byId.b.state, 'enabled', 'a failed module is retried on the next boot')
  assert.equal(byId.b.failureReason, null, 'and last boot’s reason is cleared')
  assert.equal(byId.c.state, 'disabled', 'the operator decision survives a boot')
  assert.equal(byId.d.state, 'enabled')
})

test('beginBoot keeps the start stamp of a module that was running', async () => {
  await seed('uo', 'enabled')
  await modules.markStarted('uo')
  await modules.beginBoot()
  assert.ok((await modules.get('uo')).startedAt, 'started_at is the last successful start')
})

// ── list / purge ──────────────────────────────────────────────────────

test('list returns every module, id-ordered and serialized', async () => {
  await seed('zzz')
  await seed('aaa')
  const all = await modules.list()
  assert.deepEqual(
    all.map((m) => m.id),
    ['aaa', 'zzz'],
  )
  assert.deepEqual(Object.keys(all[0]).sort(), [
    'failureReason',
    'failureStage',
    'id',
    'installedAt',
    'name',
    'sha256',
    'source',
    'startedAt',
    'state',
    'updatedAt',
    'version',
  ])
})

test('purge drops the row; uninstall is a disable and keeps it', async () => {
  await seed('uo', 'started')
  await modules.disable('uo')
  assert.ok(await modules.get('uo'), 'uninstall keeps the row and its data')
  await modules.remove('uo')
  assert.equal(await modules.get('uo'), null)
})
