// host-cp request router.
//
// Replaces the long linear `if (url.pathname === ...)` dispatch chain in
// server.mjs with an ordered route table. The table is walked in
// registration order, so route PRECEDENCE is preserved exactly as it was
// in the original if-ladder: the first matching route wins, later routes
// are never consulted once a match handles the request.
//
// Why a table and not a framework:
//   - host-cp ships with no external HTTP framework (no express/fastify);
//     this matches the existing zero-dep style.
//   - The table is a plain data structure, so it is importable + unit
//     testable WITHOUT booting server.mjs (which spawns docker-events,
//     the auth poller, and the worlds.db reconciler at import time).
//   - A route is now a table entry instead of a `return` buried in a
//     1700-line ladder. That kills the silent route-shadowing class: a
//     misplaced `return` can no longer swallow a later route, and the
//     full set of routes is enumerable (see `router.routes()`).
//
// Behavior-preservation contract (load-bearing — see
// __tests__/router.test.mjs):
//   1. Walk order == registration order == original source order.
//   2. A route MATCHES when its matcher returns a truthy match value AND
//      (no method filter OR the method matches). The matcher receives
//      ({ pathname, method, url }) and returns either a boolean or, for
//      regex routes, the RegExpMatchArray (truthy) so the handler can read
//      capture groups.
//   3. The FIRST matching route is invoked and dispatch STOPS — identical
//      to `if (cond) { ...; return; }`. The handler owns the response.
//   4. A route whose path matches but whose METHOD does not is SKIPPED,
//      and the walk continues — identical to the original
//      `if (pathMatch && req.method === 'X')` blocks, where a path hit
//      with the wrong method fell through to the next `if`.
//   5. If no route matches, dispatch returns `false` so the caller runs
//      its terminal 404 — identical to the original fall-through.
//
// The router does NOT add auth, body parsing, or any middleware semantics.
// Those stay exactly where they were in server.mjs (pre-auth routes, the
// auth gate, the plan-chat bypass) — the router only models the part of
// the chain that was a flat sequence of `if` blocks.

/**
 * @typedef {object} RouteContext
 * @property {string} pathname  url.pathname
 * @property {string} method    req.method (already normalized by node to uppercase)
 * @property {URL} url          parsed request URL
 */

/**
 * A matcher decides whether a route applies to a request, ignoring method.
 * Returning a non-boolean truthy value (e.g. a RegExpMatchArray) is
 * forwarded to the handler as `ctx.match` so regex routes can read groups.
 *
 * @typedef {(ctx: RouteContext) => (boolean | RegExpMatchArray | null | undefined)} RouteMatcher
 */

/**
 * A handler receives the node req/res plus the parsed url, the matched
 * value (for regex routes), and is responsible for writing the response.
 * It mirrors the body of an original `if` block. Return value is ignored;
 * matching alone terminates dispatch (preserving the `if ... return`
 * semantics where reaching the block always handled the request).
 *
 * @typedef {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, ctx: RouteContext & { match: any }) => unknown | Promise<unknown>} RouteHandler
 */

/**
 * @typedef {object} Route
 * @property {string} name              human label for diagnostics / tests
 * @property {string[] | null} methods  allowed methods, or null for "any method"
 * @property {RouteMatcher} match
 * @property {RouteHandler} handler
 */

/**
 * Create an ordered router. Routes are matched in the order they are
 * registered — register in the SAME order the original if-ladder ran.
 */
export function createRouter() {
  /** @type {Route[]} */
  const routes = [];

  /**
   * Register a route. Returns the router for chaining.
   *
   * @param {object} spec
   * @param {string} spec.name
   * @param {string | string[] | null} [spec.method]  single method, list, or null/omitted for any
   * @param {string} [spec.path]    exact pathname match (mutually exclusive with prefix/match)
   * @param {string} [spec.prefix]  pathname.startsWith(prefix) match
   * @param {RegExp} [spec.pattern] pathname.match(pattern) — match value passed to handler
   * @param {RouteMatcher} [spec.match]  custom matcher (overrides path/prefix/pattern)
   * @param {RouteHandler} spec.handler
   */
  function register(spec) {
    const { name, method, path, prefix, pattern } = spec;
    const handler = spec.handler;
    if (typeof handler !== 'function') {
      throw new TypeError(`route "${name}" requires a handler function`);
    }

    /** @type {string[] | null} */
    let methods = null;
    if (Array.isArray(method)) methods = method.slice();
    else if (typeof method === 'string') methods = [method];
    // method omitted or null → any method

    /** @type {RouteMatcher} */
    let match;
    if (typeof spec.match === 'function') {
      match = spec.match;
    } else if (typeof path === 'string') {
      match = (ctx) => ctx.pathname === path;
    } else if (typeof prefix === 'string') {
      match = (ctx) => ctx.pathname.startsWith(prefix);
    } else if (pattern instanceof RegExp) {
      match = (ctx) => ctx.pathname.match(pattern);
    } else {
      throw new TypeError(
        `route "${name}" requires one of: path, prefix, pattern, or match`,
      );
    }

    routes.push({ name, methods, match, handler });
    return api;
  }

  /**
   * Walk the table in registration order. Invokes the first route whose
   * matcher is truthy AND whose method filter admits the request, then
   * stops. A path-match with a non-admitted method is skipped (the walk
   * continues), preserving the original `if (pathMatch && method===X)`
   * fall-through.
   *
   * @param {import('node:http').IncomingMessage} req
   * @param {import('node:http').ServerResponse} res
   * @param {URL} url
   * @returns {Promise<boolean>} true if a route handled the request, false to fall through to 404
   */
  async function dispatch(req, res, url) {
    const ctx = { pathname: url.pathname, method: req.method ?? 'GET', url };
    for (const route of routes) {
      const matched = route.match(ctx);
      if (!matched) continue;
      // Path matched. Now gate on method — a mismatch is a SKIP, not a
      // 405, exactly mirroring the original if-ladder fall-through.
      if (route.methods !== null && !route.methods.includes(ctx.method)) {
        continue;
      }
      await route.handler(req, res, { ...ctx, match: matched });
      return true;
    }
    return false;
  }

  /**
   * Enumerate registered routes (name + methods + matcher kind) for
   * diagnostics, audits, and tests. Pure read of the table.
   *
   * @returns {Array<{ name: string, methods: string[] | null }>}
   */
  function list() {
    return routes.map((r) => ({ name: r.name, methods: r.methods }));
  }

  const api = { register, dispatch, list, get size() { return routes.length; } };
  return api;
}
