// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.

const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const registered = new Set()

const AREAS = ['public', 'admin', 'player']

function assertArea(area, call) {
  if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
}

/**
 * Route components, by area.
 *
 * @param {string} id  the module id — the URL segment its routes are namespaced under
 * @param {{public?: Array, admin?: Array, player?: Array}} byArea
 *   each entry `{ path, element, gate? }`. `path` is relative to the module's
 *   namespace; core prefixes it and mounts it inside the area's existing wrapper
 *   (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
 *   AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
 *   `gate` is an optional `{ roles: [...] }` that core applies as its own
 *   RoleGate — a module cannot supply an auth wrapper, because the sidebar and
 *   the route table have to agree about who may see what (§3.3).
 */
export function registerRoutes(id, byArea) {
  for (const [area, list] of Object.entries(byArea || {})) {
    assertArea(area, 'registerRoutes')
    for (const route of list || []) {
      // Prefixed HERE rather than by the module: a module cannot claim a path
      // outside its own namespace however it spells `path` — a leading `/`, a
      // trailing one, or several — because it never gets to write the segment
      // its routes hang under.
      const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
      routes[area].push({ ...route, path, moduleId: id })
    }
  }
  registered.add(id)
}

/**
 * Nav entries, interleaved into CORE groups rather than appended as a block.
 *
 * Today's UO items sit inside core's own Moderation and System groups; a "UO"
 * group at the bottom of the sidebar would be a visible regression on the day
 * the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
 * core group, `order` sorts within it, and an unknown group name appends rather
 * than dropping the item — a mis-typed group must cost a position, never a link.
 *
 * `icon` is a component core renders exactly as it renders its own rows' icons
 * (1.3.0). It exists because without it the six UO rows would have extracted as
 * the only text-only entries in a sidebar where every other row has a glyph,
 * which reads as breakage rather than as a design. Core does not supply a
 * fallback: a module that omits it gets no icon, the same as a core row that
 * omits it, and inventing one would be core making a presentation choice for
 * content it knows nothing about. Note that `icon` is already among the fields
 * an override may not touch (lib/navOverrides.js) — the concept predates a
 * module being able to supply one.
 *
 * @param {string} id
 * @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} spec
 */
export function registerNav(id, spec) {
  const { area, items } = spec || {}
  assertArea(area, 'registerNav')
  for (const item of items || []) nav[area].push({ ...item, moduleId: id })
  registered.add(id)
}

/**
 * The hook that answers "which of this module's features may this viewer see".
 *
 * Core keeps a generic flag context and owns none of the semantics
 * (MODULE_SYSTEM.md §1.5). With no module installed the nav filter is a correct
 * no-op, because no core nav item carries a `feature` — which has been literally
 * true since Phase 3 slice 3 took the nine shard-gated rows out.
 */
export function registerFeatureProvider(id, namespace, hook) {
  providers.set(namespace, { id, hook })
  registered.add(id)
}

// ── Extension slots (§3.7) ─────────────────────────────────────────────────
//
// The client twin of the server's declareSlot/registerExtension, and the same
// rule in both halves: core declares a slot, ONLY core declares one, and at most
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
// back when the slot is unfilled — so an instance with no module installed
// renders exactly what it renders today.
//
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
// position in the footer and the styling that goes with it; the label, the
// target, the data and whether anything renders at all are the module's. The
// moment core types a slot by its content it has re-acquired the game semantics
// this whole extraction removes.

/**
 * @param {string} name  the slot id. Core-only — deliberately not on the
 *   `registry` object handed to modules.
 */
export function declareSlot(name) {
  if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
  slots.set(name, { Component: null, filledBy: null })
}

/**
 * Fill a declared slot with a component.
 *
 * **This is the one place the client registry is not fail-open**, and the
 * asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
 * another way; a silently dropped extension is invisible to everyone including
 * its author. So an unknown slot, a non-component, and a second fill all throw —
 * exactly as the server's checkExtensionShape does.
 *
 * A throw here is always a programming error and never a race, because
 * declaration structurally precedes filling: core declares in main.jsx, inside
 * its own bundle, and every module chunk is a deferred script injected after it
 * (§3.1).
 */
export function registerExtension(id, slot, Component) {
  const entry = slots.get(slot)
  if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
  if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
  if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
  entry.Component = Component
  entry.filledBy = id
  registered.add(id)
}

/**
 * The filling component, or null.
 *
 * Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
 * for a core layout to branch on, because a layout that asks whether a slot is
 * filled and then renders its own decoration alongside gets the *failed* case
 * wrong: the extension is filled, so the decoration renders, and the component
 * then throws into the boundary leaving the decoration behind on its own. Core
 * decorates through `<Slot wrap>` instead, which puts the decoration inside the
 * boundary where it shares the extension's fate. (Found in a browser, with the
 * footer's separator.)
 *
 * Undeclared and unfilled both read null: reading is fail-safe, and only writing
 * is strict.
 */
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null

export const routesFor = (area) => routes[area] || []

// Sorted by the `order` a module asked for. Array#sort is stable in every engine
// this ships to, so two modules asking for the same slot keep load order —
// which is alphabetical by id, the same order the server scans in (§4.2).
export const navFor = (area) =>
  [...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))

export const featureProviderFor = (namespace) => providers.get(namespace)

/**
 * Every registered provider, for core's feature context to call.
 *
 * Exported from the module but deliberately NOT a member of the `registry`
 * object below: a module asks for a namespace it knows the name of, and has no
 * business enumerating what everyone else registered. Core needs the list
 * because it has to call each hook — unconditionally, in a fixed order, at the
 * top of a component (modules/features.jsx).
 */
export const featureProviders = () =>
  [...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))

export const registeredIds = () => [...registered]

/** Test seam. Nothing in the app calls this — there is no unregistering. */
export function _reset() {
  for (const area of AREAS) {
    routes[area].length = 0
    nav[area].length = 0
  }
  providers.clear()
  // Declarations go too, unlike the server's, where a slot is declared once at
  // require time by the router that owns it. Core declares its slots in
  // main.jsx — the one file no test loads — so on this side there is nothing
  // declared at import time for a surviving declaration to protect.
  slots.clear()
  registered.clear()
}

// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
  registerRoutes,
  registerNav,
  registerFeatureProvider,
  registerExtension,
  routesFor,
  navFor,
  featureProviderFor,
  registeredIds,
}
