// ── Module schema fragment replay ──────────────────────────────────────────
//
// Phase 2, PR 3 of docs/website/MODULE_SYSTEM.md §2.7. Normative contract:
// docs/website/MODULE_API.md §2.6 (fragments) and §4.4 (failure is a state).
//
// utils/db.js calls replayFragments() once, immediately after core's schema.sql
// is in place and before seedDefaults(), so that by the time a module's onBoot
// runs (PR 5) its tables exist.
//
// The split of responsibility with loader.js is worth stating, because it is the
// reason there are two files:
//
//   loader.js  VALIDATES a fragment — at load time, with no database, before
//              anything is mounted. Every rule §2.6 states about the SQL is
//              knowable by reading it, so a fragment that breaks one costs the
//              module its mount entirely (§4.4, first column).
//   schema.js  EXECUTES it. Only reachable failures live here: the database
//              rejecting a statement it could not have known was bad. Those are
//              post-mount, so they 503 (§4.4, second column).
//
// The property this file exists to keep: **a fragment that fails takes down its
// own module and nothing else.** Not core's boot, not another module's tables.

const fs = require('fs')

const { splitStatements } = require('../utils/sqlStatements')

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

/**
 * Replay every installed module's schema fragment, in scan order.
 *
 * Never throws. A module whose fragment fails is moved to `startup_failed` with
 * the database's own message as the reason, its routes answer 503 through the
 * dispatch guard the loader already mounted, and the next module is replayed as
 * if nothing happened.
 *
 * Partial application is accepted rather than compensated for: MariaDB commits
 * each DDL statement implicitly, so a fragment failing at statement three has
 * already created the first two tables and no wrapping transaction could undo
 * them. Since every statement is required to be idempotent (§2.6), the fix is
 * for the operator to correct the fragment and reboot — the surviving tables are
 * re-CREATE-IF-NOT-EXISTSed harmlessly and the replay carries on past them.
 *
 * @param {object} [deps] injection seam for tests — the whole point of this
 *   function taking arguments at all, since the server suite runs with the pool
 *   pointed at a dead port.
 * @param {(sql: string) => Promise<any>} [deps.query]
 * @param {object} [deps.modules] the loader
 */
async function replayFragments({ query, modules } = {}) {
  /* eslint-disable global-require */
  const run = query || require('../utils/db').query
  const loader = modules || require('./loader')
  /* eslint-enable global-require */

  // Not an error: `npm run seed` calls ensureSchema() without ever requiring
  // app.js, so no scan has happened and there is genuinely nothing to replay.
  // Logged rather than silently skipped — the one thing that must not happen is
  // a booting server quietly getting no module tables (§7.6).
  if (!loader.isLoaded()) {
    log.info('no module scan in this process — skipping schema fragment replay')
    return
  }

  for (const { id, file } of loader.fragments()) {
    try {
      const statements = splitStatements(fs.readFileSync(file, 'utf8'))
      for (const statement of statements) {
        // Serially, and awaited: a fragment's ALTER TABLE routinely depends on
        // the CREATE TABLE above it.
        await run(statement)
      }
      log.info(`schema ensured for module "${id}"`, { statements: statements.length })
    } catch (err) {
      loader.setState(id, 'startup_failed', { stage: 'schema', reason: err.message })
      log.error(`module "${id}" schema fragment failed — its routes will answer 503`, {
        reason: err.message,
      })
    }
  }
}

/**
 * Run one module's `purge.sql` — the destructive twin of the replay above
 * (MODULE_SYSTEM.md §2.5, and §2.7.2 decision 5 for when it is offered).
 *
 * It lives beside replayFragments because they are the same operation pointed in
 * opposite directions: a file of statements the module ships, split by the same
 * splitter, run serially on the same pool. Keeping them together is what makes
 * it obvious that the fragment's leading-verb allowlist does NOT apply here —
 * `purge.sql` is the one file a module may put a DROP in, precisely because it
 * is the one file that never runs on a boot.
 *
 * Unlike the replay, this **throws**. A replay failure is one module failing to
 * start, which the site survives by 503ing that module; a purge failure is an
 * operator's explicit destructive request not having happened, and reporting
 * success for that would leave them believing data is gone when it is not.
 *
 * Statements run serially and are not wrapped in a transaction, for the reason
 * the replay's header already gives: MariaDB commits DDL implicitly, so there is
 * no rollback to have. A purge that fails halfway has dropped some tables — the
 * error names the statement that stopped it, and running it again is safe
 * because a purge script is required to be idempotent in the same way a fragment
 * is (`DROP TABLE IF EXISTS`).
 *
 * @param {string} file absolute path to the module's purge.sql
 * @param {object} [deps] injection seam for tests
 * @returns {Promise<number>} how many statements ran
 */
async function runPurge(file, { query } = {}) {
  // eslint-disable-next-line global-require
  const run = query || require('../utils/db').query

  const statements = splitStatements(fs.readFileSync(file, 'utf8'))
  let ran = 0
  for (const statement of statements) {
    try {
      await run(statement)
      ran += 1
    } catch (err) {
      throw new Error(`purge failed at statement ${ran + 1} of ${statements.length}: ${err.message}`)
    }
  }
  return ran
}

module.exports = { replayFragments, runPurge }
