// ── The de-entanglement registries ─────────────────────────────────────────
//
// Phase 2, PR 4 of docs/website/MODULE_SYSTEM.md §2.7 — the three seams §1.8 and
// §1.9 identified, where core code and game-specific content are tangled in one
// file and a folder move cannot separate them. The normative contract is
// docs/website/MODULE_API.md §2.4.
//
// The three:
//
//   1. `registerExtension(slot, router)`   — §1.9. Module routes hanging off a
//      CORE resource (`/admin/users/:id`), so all six shard sub-paths keep their
//      URLs while core never learns what "shard" means.
//   2. `registerNotificationStreams(streams)` — §1.8. The push-stream catalog:
//      push INFRASTRUCTURE is core, this CATALOG is content.
//   3. `registerAnnounceLeg({ leg, label, dispatch, classify })` — §1.8. The news
//      dispatcher's delivery legs; Discord is core, town crier is content.
//
// **Core registers through these functions too, and is the only registrant until
// Phase 3.** `registerCore()` below is called explicitly from app.js before
// `modules.load()` — explicit, never lazy, the same decision the loader's trigger
// took (MODULE_API.md §7.6). Core going through the same door is the point: a
// registry only core's hardcoded base bypasses is a registry whose first real
// exercise is a module, which is the drift this PR exists to prevent.
//
// **Registering is validate-then-commit, per registrant.** `apply()` checks every
// claim in a batch before it writes any of them, so a module that registers two
// streams and then throws — or fails a later validation step in the loader — has
// left nothing behind. That is the registry-side twin of the loader's second-pass
// mount rule: nothing a module claims takes effect until the module as a whole is
// known good.
//
// Nothing here reaches the database or the network. It is a require-time-safe
// collection of what core and modules have declared, read at request time.

const express = require('express')

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

// ── State ──────────────────────────────────────────────────────────────────

// slot → { router, filledBy }. `router` is created when CORE DECLARES the slot
// and mounted immediately; registrants `use()` into it later. That indirection is
// not optional: users.router.js is required while app.js is being built, long
// before any module has been scanned, so the thing core mounts has to be a stable
// object that can still be empty.
const slots = new Map()

// Registration order, which is display order in the app's notifications screen.
const streams = []
const streamOwners = new Map() // stream id → owner id, for the collision message

// leg id → { owner, leg, label, dispatch, classify }
const legs = new Map()

// owner -> { onSaved?, onDeleted? }. Post hooks (§1.8, API 1.1.0). A Map keyed by
// owner rather than a flat list, so a registrant is a single subscription that
// can be reported and reasoned about as one thing — and so registering twice is
// a collision with a name attached rather than a silently doubled side effect.
const postHooks = new Map()

let coreRegistered = false

// Stream ids that predate the module system and may not carry their owner's
// prefix — the exact counterpart of the loader's LEGACY_TABLE_PREFIXES, for the
// exact same reason. These seven ids are stored in `notification_subs` rows and
// are read by a shipped Android client; renaming them in Phase 3 would be a data
// migration and a client break, so `uo` keeps them and the prefix rule stays real
// for every module written after it.
const LEGACY_STREAM_IDS = {
  uo: [
    'server.status', 'idoc.warning', 'champ.start', 'governor.election',
    'vendor.sale', 'house.idoc', 'account.login',
  ],
}

// Likewise for announce legs: `towncrier` is a stored value in
// announce_job_legs.leg and the body of the admin retry endpoint.
const LEGACY_LEGS = { uo: ['towncrier'] }

const STREAM_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/
const LEG_ID = /^[a-z][a-z0-9.]{1,62}$/

// A module's claim must carry its id. Core's ids are its own namespace, and the
// grandfathered names are the ones that predate all of this.
function namespaced(owner, name, legacy) {
  return owner === 'core' || name.startsWith(`${owner}.`) || (legacy[owner] || []).includes(name)
}

// ── Extension slots (§1.9) ─────────────────────────────────────────────────

/**
 * Core declares an extension slot and gets the router to mount for it.
 *
 * ONLY core may declare a slot; a module may only fill one (MODULE_API.md §2.4).
 * That asymmetry is why this is not on the `api` object handed to a module.
 *
 * `mergeParams` so the slot's router sees the parent's `:id`. Core's own routes
 * on the resource are declared before the slot is mounted, so first-match-wins
 * gives core the path conflict, as the contract requires.
 *
 * @returns {import('express').Router} mount this at the resource, once.
 */
function declareSlot(slot) {
  if (slots.has(slot)) throw new Error(`extension slot "${slot}" already declared`)
  const router = express.Router({ mergeParams: true })
  slots.set(slot, { router, filledBy: null })
  return router
}

/** Does this slot exist? The loader asks, to validate `extensions` in a manifest. */
const hasSlot = (slot) => slots.has(slot)

/** Who filled a slot, or null. */
const slotFilledBy = (slot) => (slots.get(slot) || {}).filledBy || null

/**
 * Every FILLED slot, for the OpenAPI build step (swagger/slotSpecs.js).
 *
 * `router` is the slot's own stable router — the object mounted on the resource —
 * so the build can find it in the live express stack and recover the prefix it
 * hangs at without a hardcoded table.
 */
const filledSlots = () =>
  [...slots.entries()]
    .filter(([, e]) => e.filledBy)
    .map(([slot, e]) => ({ slot, filledBy: e.filledBy, router: e.router, specFile: e.specFile || null }))

/**
 * A DECLARED slot's stable router, filled or not.
 *
 * `filledSlots()` answers what the build needs — a filled slot has a spec file
 * to generate a fragment from. This answers what a test needs: the slot exists
 * from the moment core declares it at require time, and its position in the
 * express stack has to stay findable whether or not a module has filled it.
 * Before Phase 3 the two questions had the same answer, because core filled the
 * only slot itself.
 */
const declaredSlotRouter = (slot) => (slots.get(slot) || {}).router || null

// ── Notification streams (§1.8) ────────────────────────────────────────────

/** The whole catalog, core's entries first, in registration order. */
const allStreams = () => streams.slice()

/** Is this a stream anyone registered? Gates a subscription write. */
const isValidStream = (id) => streamOwners.has(id)

/** Ids of the owner-keyed streams — those needing a linked game account. */
const personalStreams = () => new Set(streams.filter((s) => s.personal).map((s) => s.id))

// ── Post hooks (§1.8) ──────────────────────────────────────────────────────

// Core's CMS is the only writer of posts, and a module may need to mirror one
// somewhere core knows nothing about — module-uo keeps UO's in-game Town Cryer
// News gump in step with it. Before this existed, core's post controller
// required `utils/newsGump` directly, which is precisely the coupling the
// extraction had to remove: core's publish path naming a UO file.
//
// It is deliberately NOT folded into `registerAnnounceLeg`, which fires on the
// same transition. A leg is a one-shot DELIVERY with retry and classification;
// a post hook maintains idempotent STATE, has to run on delete as well as save,
// and refreshes silently on an edit. Overloading the leg would have meant a
// dispatch that must not be retried and a classify that means nothing.

/** Every registered hook, in registration order. */
const postHookEntries = () => [...postHooks.entries()].map(([owner, h]) => ({ owner, ...h }))

/**
 * Fire `event` at every registered hook, one at a time, never throwing.
 *
 * Best-effort by contract, and awaited rather than fired-and-forgotten: core's
 * own call site awaited `newsGump.syncPost` before this existed, so a save that
 * returns 200 still means the mirror was attempted. One subscriber's failure
 * must not cost another's, and none of them may cost the save — a sidecar
 * hiccup breaking a post edit would be a worse bug than a stale gump.
 */
async function dispatchPostHook(event, payload) {
  for (const { owner, [event]: fn } of postHookEntries()) {
    if (typeof fn !== 'function') continue
    try {
      await fn(payload)
    } catch (err) {
      log.warn('post hook failed', { owner, event, message: err.message })
    }
  }
}

// ── Announce legs (§1.8) ───────────────────────────────────────────────────

/** Every registered leg, in registration order. */
const announceLegs = () => [...legs.values()]

/** Just the ids — the enqueue order and the retry endpoint's allowlist. */
const announceLegIds = () => [...legs.keys()]

/** One leg, or null. */
const announceLeg = (leg) => legs.get(leg) || null

// ── Shape checks, run the moment a registrant calls ────────────────────────
//
// Split from the collision checks below on the same line PR 3 drew through
// schema-fragment validation: what can be decided from the argument alone is
// decided AT THE CALL, so the error carries the registrant's own stack. What
// depends on other registrants has to wait for the batch to be complete.

function checkStreamShape(entry) {
  if (!entry || !STREAM_ID.test(entry.id || '')) {
    throw new Error(`registerNotificationStreams: bad stream id "${entry && entry.id}"`)
  }
  if (!entry.label) throw new Error(`registerNotificationStreams: stream "${entry.id}" has no label`)
  return {
    id: entry.id,
    label: entry.label,
    description: entry.description || '',
    personal: Boolean(entry.personal),
    requiresLinkedAccount: Boolean(entry.requiresLinkedAccount),
  }
}

function checkLegShape(entry) {
  const { leg, label, dispatch, classify } = entry || {}
  if (!LEG_ID.test(leg || '')) throw new Error(`registerAnnounceLeg: bad leg id "${leg}"`)
  if (typeof dispatch !== 'function') throw new Error(`announce leg "${leg}" has no dispatch()`)
  if (typeof classify !== 'function') throw new Error(`announce leg "${leg}" has no classify()`)
  return { leg, label: label || leg, dispatch, classify }
}

/**
 * `registerPostHook({ onSaved, onDeleted })` — both optional, at least one
 * required. A registration with neither is a subscription that can never fire,
 * which is a typo rather than an intention.
 */
function checkPostHookShape(entry) {
  const { onSaved, onDeleted } = entry || {}
  for (const [name, fn] of [['onSaved', onSaved], ['onDeleted', onDeleted]]) {
    if (fn !== undefined && typeof fn !== 'function') {
      throw new Error(`registerPostHook: ${name} must be a function`)
    }
  }
  if (!onSaved && !onDeleted) throw new Error('registerPostHook: needs onSaved or onDeleted')
  return { onSaved, onDeleted }
}

// `specFile` is CORE-ONLY and is not on the module-facing signature. A slot's
// router reaches the app through declareSlot(), which no static parse of app.js
// can follow, so swagger-autogen would silently drop every route in it — the
// spike's exact failure (MODULE_API.md §7.4). Core names the file so
// `npm run swagger` can generate a fragment from it and merge it into the
// committed spec. A MODULE has no equivalent need: it ships a prebuilt
// `swagger-fragment.json` in its bundle (§6.1a), because core never has its
// sources to analyse.
function checkExtensionShape(slot, router, specFile) {
  if (!slots.has(slot)) throw new Error(`unknown extension slot "${slot}"`)
  if (typeof router !== 'function') throw new Error(`registerExtension: ${slot} is not a router`)
  return { slot, router, specFile: specFile || null }
}

// ── Staging + commit ───────────────────────────────────────────────────────

/**
 * A registrant's staging area: shape-checked claims, not yet visible to anyone.
 *
 * The loader hands one of these to a module through `api`, and `registerCore()`
 * builds one for core. Nothing a registrant says is readable through
 * `allStreams()` / `announceLeg()` / the slot routers until `apply()`.
 */
function stage(owner) {
  const staged = { owner, streams: [], legs: [], extensions: [], postHooks: [] }
  return {
    staged,
    registerNotificationStreams(entries) {
      if (!Array.isArray(entries)) throw new Error('registerNotificationStreams: expected an array')
      for (const e of entries) staged.streams.push(checkStreamShape(e))
    },
    registerAnnounceLeg(entry) {
      staged.legs.push(checkLegShape(entry))
    },
    registerExtension(slot, router, specFile) {
      staged.extensions.push(checkExtensionShape(slot, router, specFile))
    },
    registerPostHook(entry) {
      staged.postHooks.push(checkPostHookShape(entry))
    },
  }
}

/**
 * Validate a staged batch against everything already registered, then commit it.
 *
 * Validation is TOTAL before the first write, so this either takes all of a
 * registrant's claims or none of them. Throws on the first collision, naming who
 * holds the thing already — which is the message an operator needs and the one
 * PR 2 learned to protect (mounting inside the scan loop made every collision
 * look like it was with core).
 */
function apply({ owner, streams: newStreams, legs: newLegs, extensions: newExtensions, postHooks: newPostHooks = [] }) {
  // ── validate ──
  const seenStreams = new Set()
  for (const s of newStreams) {
    const held = streamOwners.get(s.id)
    if (held) throw new Error(`stream "${s.id}" is already registered by "${held}"`)
    if (seenStreams.has(s.id)) throw new Error(`stream "${s.id}" registered twice`)
    if (!namespaced(owner, s.id, LEGACY_STREAM_IDS)) {
      throw new Error(`stream "${s.id}" is not namespaced "${owner}."`)
    }
    seenStreams.add(s.id)
  }

  const seenLegs = new Set()
  for (const l of newLegs) {
    const held = legs.get(l.leg)
    if (held) throw new Error(`announce leg "${l.leg}" is already registered by "${held.owner}"`)
    if (seenLegs.has(l.leg)) throw new Error(`announce leg "${l.leg}" registered twice`)
    if (!namespaced(owner, l.leg, LEGACY_LEGS)) {
      throw new Error(`announce leg "${l.leg}" is not namespaced "${owner}."`)
    }
    seenLegs.add(l.leg)
  }

  const seenSlots = new Set()
  for (const x of newExtensions) {
    const entry = slots.get(x.slot)
    if (entry.filledBy) {
      throw new Error(`extension slot "${x.slot}" is already filled by "${entry.filledBy}"`)
    }
    if (seenSlots.has(x.slot)) throw new Error(`extension slot "${x.slot}" filled twice`)
    seenSlots.add(x.slot)
  }

  if (newPostHooks.length > 1) throw new Error(`"${owner}" registered more than one post hook`)
  if (newPostHooks.length && postHooks.has(owner)) {
    throw new Error(`"${owner}" already registered a post hook`)
  }

  // ── commit — nothing below can fail ──
  for (const s of newStreams) {
    streamOwners.set(s.id, owner)
    streams.push(s)
  }
  for (const l of newLegs) legs.set(l.leg, { owner, ...l })
  for (const x of newExtensions) {
    const entry = slots.get(x.slot)
    entry.filledBy = owner
    entry.specFile = x.specFile
    entry.router.use(x.router)
  }
  for (const h of newPostHooks) postHooks.set(owner, h)
}

// ── Core's own registrations ───────────────────────────────────────────────

/**
 * Register everything CORE owns, through the same staging area a module uses.
 *
 * Called once from app.js, before `modules.load()` — before, because a module's
 * collision checks are asked against what is already registered, and core's
 * claims must be the ones already there.
 *
 * What is here is what survives Phase 3. Everything after the boundary comment is
 * shard content and leaves with module-uo, registered rather than hardcoded so
 * the seam is exercised on every boot long before a module first uses it.
 */
function registerCore() {
  if (coreRegistered) return

  /* eslint-disable global-require */
  const coreStreams = require('../config/coreStreams')
  const discordLeg = require('../utils/discordAnnounce')
  /* eslint-enable global-require */

  const api = stage('core')
  api.registerNotificationStreams(coreStreams.STREAMS)
  api.registerAnnounceLeg(discordLeg.leg)

  // The three lines that used to follow — the shard stream catalog, the town
  // crier leg and the `admin.users.detail` filling — were shard CONTENT held
  // here so the seam would be exercised on every boot before a module first used
  // it. Phase 3 moved them into module-uo's `register()` verbatim, with 'core'
  // becoming 'uo', and nothing else in core changed. That was the claim PR 4
  // made, and this deletion is it being collected.
  apply(api.staged)
  coreRegistered = true

  log.info('core registrations complete', {
    streams: streams.length,
    announceLegs: legs.size,
    extensions: [...slots.keys()].filter(slotFilledBy),
  })
}

/** Has registerCore() run? Read by tests, and by the loader's ordering assertion. */
const isCoreRegistered = () => coreRegistered

// Test-only: hand the process back. Registries are process-global by design
// (there is one core), so a test that registers has to be able to undo it.
//
// Slot DECLARATIONS survive, and only their fills are cleared: a slot is declared
// at require time by the router that owns the resource, and that require has
// already happened and will not happen again in this process. Clearing the map
// would leave a slot that nothing can re-declare. The cost is that a test filling
// the same slot twice stacks two routers inside it; no test reads through a slot
// router, so that is left rather than papered over with a rebuilt router that
// would no longer be the object users.router.js mounted.
function _reset() {
  for (const entry of slots.values()) {
    entry.filledBy = null
    entry.specFile = null
  }
  streams.length = 0
  streamOwners.clear()
  legs.clear()
  postHooks.clear()
  coreRegistered = false
}

module.exports = {
  declareSlot,
  hasSlot,
  slotFilledBy,
  filledSlots,
  declaredSlotRouter,
  allStreams,
  isValidStream,
  personalStreams,
  announceLegs,
  announceLegIds,
  announceLeg,
  postHookEntries,
  dispatchPostHook,
  stage,
  apply,
  registerCore,
  isCoreRegistered,
  _reset,
}
