// ── Replaying module schema fragments ──────────────────────────────────────
//
// Phase 2, PR 3. The contract is MODULE_API.md §2.6 (a fragment is replayed by
// the same ensureSchema() that replays core's, statement by statement, split the
// same way) and §4.4 (a failure after mounting is a state, not a crash).
//
// The property under test throughout, as in moduleLoader.test.js: **the failing
// module fails alone.** A fragment that blows up must cost its own module its
// routes and nothing else — not core's boot, not the next module's tables.
//
// No database is involved. `replayFragments` takes its `query` as an injectable
// dependency precisely so this suite can assert on the exact statements that
// would have been executed, in order, with the pool pointed at a dead port like
// every other suite here.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const fs = require('fs')
const os = require('os')
const path = require('path')

const { test, beforeEach, after } = require('node:test')
const assert = require('node:assert/strict')

const express = require('express')

const db = require('../src/utils/db')
const { replayFragments, runPurge } = require('../src/modules/schema')
const { splitStatements } = require('../src/utils/sqlStatements')
const { startApp } = require('./_helper')

after(() => db.close())

let tmpRoot

const emptyTiers = () => ({
  public: express.Router(),
  admin: express.Router(),
  player: express.Router(),
})

function freshLoader(dir, tiers = emptyTiers()) {
  process.env.MODULES_DIR = dir
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  loader.load(tiers)
  return loader
}

/** A module with a valid fragment, and optionally a route to watch 503 later. */
function writeModule(id, { schema, mounts } = {}) {
  const dir = path.join(tmpRoot, id)
  fs.mkdirSync(dir, { recursive: true })
  fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
    id,
    name: id,
    version: '1.0.0',
    coreApi: '^1.0.0',
    ...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
    ...(mounts === undefined ? {} : { mounts, server: 'index.js' }),
  }))
  if (schema !== undefined) {
    fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
    fs.writeFileSync(path.join(dir, 'purge.sql'), `DROP TABLE IF EXISTS ${id}_x;`)
  }
  if (mounts !== undefined) {
    const [prefix] = mounts.public
    fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = (ctx, api) => {
      const r = ctx.express.Router()
      r.get('/', (req, res) => res.json({ ok: true }))
      api.registerRoutes({ public: { '${prefix}': r } })
    }`)
  }
  return dir
}

/** A query fn that records what it was asked to run, and can be told to fail. */
function recorder(failOn = null) {
  const ran = []
  return {
    ran,
    query: async (sql) => {
      ran.push(sql)
      if (failOn && sql.includes(failOn)) throw new Error(`ER_PARSE_ERROR: near "${failOn}"`)
      return []
    },
  }
}

const stateOf = (loader, id) => loader.list().find((m) => m.id === id)

beforeEach(() => {
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-schema-'))
})

// ── The happy path ─────────────────────────────────────────────────────────

test('a fragment is replayed statement by statement, in file order', async () => {
  writeModule('alpha', {
    schema: [
      'CREATE TABLE IF NOT EXISTS alpha_a (id INT);',
      'CREATE TABLE IF NOT EXISTS alpha_b (id INT);',
      'ALTER TABLE alpha_a ADD COLUMN IF NOT EXISTS name VARCHAR(64);',
    ].join('\n'),
  })
  const loader = freshLoader(tmpRoot)
  const rec = recorder()

  await replayFragments({ query: rec.query, modules: loader })

  assert.equal(rec.ran.length, 3)
  // Order is load-bearing, not incidental: the ALTER depends on the CREATE above
  // it, which is why the replay awaits each statement rather than Promise.all.
  assert.match(rec.ran[0], /alpha_a/)
  assert.match(rec.ran[1], /alpha_b/)
  assert.match(rec.ran[2], /^ALTER TABLE alpha_a/)
  assert.equal(stateOf(loader, 'alpha').state, 'registered')
})

test('a module with no fragment is skipped, not replayed as empty', async () => {
  writeModule('nodb')
  const rec = recorder()

  await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })

  assert.deepEqual(rec.ran, [])
})

test('fragments are split exactly the way core schema.sql is', async () => {
  // §2.6's "split the same way" is a promise about shared code, so the thing
  // worth asserting is that the module path produces what the shared splitter
  // produces — including the trailing-comment case that would otherwise chop a
  // statement in half at the `;` inside it.
  const sql = [
    '-- a leading comment block',
    '-- with two lines; and a semicolon in it',
    'CREATE TABLE IF NOT EXISTS beta_a (id INT); -- trailing; comment',
    '',
    'CREATE TABLE IF NOT EXISTS beta_b (id INT);',
  ].join('\n')
  writeModule('beta', { schema: sql })
  const rec = recorder()

  await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })

  assert.deepEqual(rec.ran, splitStatements(sql))
  assert.equal(rec.ran.length, 2)
})

// ── Failure is a state ─────────────────────────────────────────────────────

test('a fragment that throws fails its own module and no one else', async () => {
  writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' })
  writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' })
  writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' })
  const loader = freshLoader(tmpRoot)
  const rec = recorder('bbb_boom')

  // Never throws — this is called on the boot path, between core's schema and
  // seedDefaults(), and one bad module must not stop the site coming up.
  await replayFragments({ query: rec.query, modules: loader })

  assert.equal(stateOf(loader, 'aaa').state, 'registered')
  assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
  assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
  // The one that matters: the module AFTER the failure still got its tables.
  assert.equal(stateOf(loader, 'ccc').state, 'registered')
  assert.equal(rec.ran.length, 3)
})

test('a fragment stops at its first failing statement', async () => {
  writeModule('part', {
    schema: [
      'CREATE TABLE IF NOT EXISTS part_a (id INT);',
      'CREATE TABLE IF NOT EXISTS part_bad (id INT);',
      'CREATE TABLE IF NOT EXISTS part_c (id INT);',
    ].join('\n'),
  })
  const loader = freshLoader(tmpRoot)
  const rec = recorder('part_bad')

  await replayFragments({ query: rec.query, modules: loader })

  // Two attempted, the third never reached. The first table survives, and is
  // accepted rather than compensated for: DDL self-commits in MariaDB, so no
  // transaction could roll it back, and §2.6's idempotence rule is what makes
  // re-running the corrected fragment safe.
  assert.equal(rec.ran.length, 2)
  assert.equal(stateOf(loader, 'part').state, 'startup_failed')
})

test('a module whose fragment failed keeps its URLs and answers 503', async () => {
  // §4.4's right-hand column, now reachable for real rather than by a hand-moved
  // state: schema replay is the first thing in the lifecycle that fails AFTER
  // the routes are already mounted.
  writeModule('svc', {
    schema: 'CREATE TABLE IF NOT EXISTS svc_boom (id INT);',
    mounts: { public: ['/widgets'] },
  })
  const tiers = emptyTiers()
  const loader = freshLoader(tmpRoot, tiers)
  const app = await startApp((a) => a.use('/public', tiers.public))

  try {
    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)

    await replayFragments({ query: recorder('svc_boom').query, modules: loader })

    assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
  } finally {
    await app.close()
  }
})

test('a module that failed validation is not replayed at all', async () => {
  // It is never going to run, so creating its tables would leave an operator
  // with rows belonging to a module that does not load.
  writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' })
  writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' })
  const loader = freshLoader(tmpRoot)
  const rec = recorder()

  await replayFragments({ query: rec.query, modules: loader })

  assert.equal(stateOf(loader, 'bad').state, 'startup_failed')
  assert.equal(rec.ran.length, 1)
  assert.match(rec.ran[0], /good_x/)
})

// ── The seed script ────────────────────────────────────────────────────────

test('replay is skipped, not thrown, when no scan happened in this process', async () => {
  // `npm run seed` (db/seed.js) calls ensureSchema() standalone without ever
  // requiring app.js, so the loader never ran. Before this was handled it was
  // fragments()'s §7.6 throw, which would have broken seeding outright.
  process.env.MODULES_DIR = tmpRoot
  delete require.cache[require.resolve('../src/modules/loader')]
  // eslint-disable-next-line global-require
  const loader = require('../src/modules/loader')
  const rec = recorder()

  await replayFragments({ query: rec.query, modules: loader })

  assert.deepEqual(rec.ran, [])
  assert.equal(loader.isLoaded(), false)
})

// ── runPurge: the destructive twin ─────────────────────────────────────────
//
// Phase 4, slice 1. Same splitter, same pool, same serial execution as the
// replay above — pointed the other way. The two differences are deliberate and
// are what these tests are for.

test('purge runs every statement in the file, in order', async () => {
  const file = path.join(tmpRoot, 'purge.sql')
  fs.writeFileSync(file, [
    '-- drop everything this module owns',
    'DROP TABLE IF EXISTS mod_b;',
    'DROP TABLE IF EXISTS mod_a;',
    "DELETE FROM settings WHERE `key` = 'mod_thing';",
  ].join('\n'))
  const rec = recorder()

  const ran = await runPurge(file, { query: rec.query })

  assert.equal(ran, 3)
  assert.match(rec.ran[0], /DROP TABLE IF EXISTS mod_b/)
  assert.match(rec.ran[2], /DELETE FROM settings/)
})

test('purge is not held to the fragment allowlist — DROP is the point of it', async () => {
  // §2.6's leading-verb allowlist exists because a fragment replays on every
  // boot. purge.sql never does, which is exactly why it is the one file a module
  // may put a DROP in.
  const file = path.join(tmpRoot, 'purge.sql')
  fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nTRUNCATE TABLE mod_b;')
  const rec = recorder()

  assert.equal(await runPurge(file, { query: rec.query }), 2)
})

test('purge THROWS on failure, unlike the replay', async () => {
  // A replay failure is one module failing to start, which the site survives by
  // 503ing it. A purge failure is an operator's explicit destructive request not
  // having happened — reporting success would leave them believing data is gone
  // when it is not.
  const file = path.join(tmpRoot, 'purge.sql')
  fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nDROP TABLE mod_missing;')
  const query = async (sql) => {
    if (sql.includes('mod_missing')) throw new Error("Unknown table 'mod_missing'")
  }

  await assert.rejects(
    () => runPurge(file, { query }),
    // The message names WHICH statement stopped it, because a purge is not
    // transactional — the ones before it have already committed and the operator
    // needs to know where it got to.
    /purge failed at statement 2 of 2: Unknown table 'mod_missing'/,
  )
})

test('an empty purge file runs nothing and does not throw', async () => {
  const file = path.join(tmpRoot, 'purge.sql')
  fs.writeFileSync(file, '-- nothing to drop yet\n')
  const rec = recorder()

  assert.equal(await runPurge(file, { query: rec.query }), 0)
  assert.deepEqual(rec.ran, [])
})
