#!/usr/bin/env node
/**
 * Route manifest generator — the machine-readable freeze of the HTTP URL surface.
 *
 * Why this exists: the router files are being carved up by business capability
 * (docs/website/API_V2_PLAN.md § Phase 2) with the explicit promise that not one
 * URL moves. "Every URL is unchanged" has to be proved by a diff, not asserted in
 * review, so this walks the *live* Express stack and writes a sorted
 * `{ method, path }` list. CI regenerates it and fails on any diff; a PR that
 * really does change a URL has to commit the new manifest, which puts the change
 * in front of a reviewer instead of letting it slip through a "mechanical" PR.
 *
 * Runtime introspection, not source parsing: it is authoritative about mounts, and
 * a route's path sits on the line *after* `router.get(`, which defeats naive
 * greps. Not swagger-output.json either — that is annotation-
 * derived (only annotated routes appear) and documents intent; this records reality.
 *
 * Scope: only `/api/**` and `/.well-known/**` from the public app, plus everything
 * on the internal app. Four mounts in app.js are *filesystem* conditional — the SPA
 * catch-all `GET *`, the `/brand` static mount, installed modules' `/modules/<id>`
 * chunks and swagger-ui's `/api/docs` static assets — so including them would make
 * the output depend on whether CI had built the client, or on which modules were
 * mounted. Static mounts are not API contract.
 *
 * Usage:
 *   npm run routes:manifest            # write server/routes.manifest.json (+ guards)
 *   npm run routes:manifest -- --check # exit 1 if the committed files are stale
 */

// The apps pull in models -> utils/db, which builds a mariadb pool at require time.
// Point it at a closed port (same trick the test suite uses) so generating a
// manifest never opens a real connection or hangs on a missing database.
process.env.DB_HOST = process.env.DB_HOST || '127.0.0.1'
process.env.DB_PORT = process.env.DB_PORT || '59999'

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

const app = require('../src/app')
const internalApp = require('../src/internalApp')
const db = require('../src/utils/db')

const SERVER_ROOT = path.join(__dirname, '..')
const MANIFEST_PATH = path.join(SERVER_ROOT, 'routes.manifest.json')
const GUARDS_PATH = path.join(SERVER_ROOT, 'routes.guards.json')

const MANIFEST_COMMENT =
  'Generated route inventory - the authoritative freeze of the URL surface. ' +
  'Regenerate with `npm run routes:manifest` in website/server; a domain-split PR ' +
  'must produce a zero-line diff here.'

const GUARDS_COMMENT =
  'Generated review aid, NOT a gated contract - per route, the middleware handler ' +
  'count and the *named* middleware collected along the mount chain. Anonymous ' +
  'handlers (e.g. the arrow returned by requireRole(...)) cannot be named, so this ' +
  'is a hint for reviewers, never a security check. Regenerate with ' +
  '`npm run routes:manifest`.'

// Only these prefixes are contract. Everything else the public app serves (SPA
// shell, /uploads, /brand, /modules, swagger-ui assets) is static delivery, not
// API surface.
const PUBLIC_PREFIXES = ['/api/', '/.well-known/']

/**
 * Recover the literal path a router was mounted at from the layer's regexp.
 *
 * Express keeps no copy of the mount string, only the compiled regexp. For a
 * literal mount (`/api/v1`) that is `^\/api\/v1\/?(?=\/|$)`; a parameterised mount
 * contributes one group per entry in `layer.keys`, and the separator before the
 * parameter lives INSIDE that group — express 4.22 compiles `use('/:id', r)` to
 * `^(?:\/([^/]+?))\/?(?=\/|$)`. Unwinding both gets us back to `/api/v1` and
 * `/:id` respectively. `fast_slash` is express's marker for a router mounted at
 * the root, which contributes nothing.
 *
 * The parameterised branch went unexercised until the `admin.users.detail`
 * extension slot mounted a router at `/:id` (MODULE_SYSTEM.md §1.9), and it was
 * wrong: it expected the group as `(?:([^\/]+?))`, with the slash outside and the
 * class escaped. It threw rather than guessing, which is exactly what it is for.
 */
function mountPath(layer) {
  const re = layer.regexp
  if (!re || re.fast_slash) return ''

  let src = re.source
    .replace(/^\^/, '')
    .replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') // mount tail: \/?(?=\/|$)
    .replace(/\$$/, '')

  const keys = layer.keys || []
  let i = 0
  // `\/` optional and the `/` in the class optionally escaped, so this survives a
  // path-to-regexp that emits either shape.
  src = src.replace(/\((?:\?:)?(\\\/)?\(\[\^\\?\/\]\+\?\)\)/g, (_m, slash) => {
    const key = keys[i++]
    return `${slash ? '/' : ''}:${key ? key.name : 'param'}`
  })

  // Whatever is left should be a literal path with regexp-escaped separators.
  src = src.replace(/\\(.)/g, '$1')

  if (/[()[\]?*+|^$]/.test(src)) {
    throw new Error(
      `routeManifest: could not decode mount path from regexp ${re.source} (got "${src}"). ` +
        'A non-literal mount was added — teach mountPath() about it rather than guessing.',
    )
  }
  return src
}

/** `layer.name` is 'router' for a mounted Router, and the fn name otherwise. */
function isRouter(layer) {
  return layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack)
}

/** Named middleware only — anonymous handlers have `name === ''`. */
function namedMiddleware(handlers) {
  return handlers
    .map((h) => h && h.name)
    .filter((n) => n && n !== 'anonymous' && n !== 'bound dispatch')
}

/**
 * Walk an Express stack, collecting one entry per (method, path). `prefix` is the
 * path accumulated from enclosing mounts; `gates` the named router-level middleware
 * seen on the way down (a `router.use(noindex, isLoggedIn, …)` gate never appears in
 * an individual route's own stack, so it has to be carried down).
 *
 * `depth === 0` is the app's own stack — helmet, morgan, the JSON parser, the bot
 * guard. Those apply to literally every route, so recording them would bury the
 * per-route gates that actually matter under a dozen identical names.
 */
function walk(stack, prefix, gates, out, depth = 0) {
  const inherited = [...gates]

  for (const layer of stack) {
    if (layer.route) {
      const routePaths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]
      // The last handler is the controller, not a gate; everything before it is.
      const guards = layer.route.stack.slice(0, -1).map((s) => s.handle)
      for (const routePath of routePaths) {
        const full = normalize(prefix + routePath)
        for (const method of Object.keys(layer.route.methods)) {
          if (method === '_all') continue
          out.push({
            method: method.toUpperCase(),
            path: full,
            handlers: layer.route.stack.length,
            gates: [...inherited, ...namedMiddleware(guards)],
          })
        }
      }
    } else if (isRouter(layer)) {
      walk(layer.handle.stack, prefix + mountPath(layer), inherited, out, depth + 1)
    } else if (depth > 0 && layer.name && layer.name !== '<anonymous>') {
      // A bare `use()` on a mounted router — a gate applying to everything after it.
      inherited.push(layer.name)
    }
  }
}

/** Collapse `//` from empty mount paths and drop a trailing slash. */
function normalize(p) {
  const collapsed = p.replace(/\/{2,}/g, '/')
  return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed
}

/** Sort by path, then method — stable and diff-friendly. */
function bySurface(a, b) {
  if (a.path !== b.path) return a.path < b.path ? -1 : 1
  if (a.method !== b.method) return a.method < b.method ? -1 : 1
  return 0
}

function dedupe(entries) {
  const seen = new Map()
  for (const e of entries) {
    const key = `${e.method} ${e.path}`
    if (!seen.has(key)) seen.set(key, e)
  }
  return [...seen.values()]
}

/** Collect the full route table for both listeners. */
function collect() {
  const publicRoutes = []
  walk(app._router.stack, '', [], publicRoutes)

  const internalRoutes = []
  walk(internalApp._router.stack, '', [], internalRoutes)

  return {
    public: dedupe(
      publicRoutes.filter((r) => PUBLIC_PREFIXES.some((p) => r.path.startsWith(p))),
    ).sort(bySurface),
    internal: dedupe(internalRoutes).sort(bySurface),
  }
}

/** The gated contract: method + path only, which is exactly what must not change. */
function buildManifest(collected) {
  const strip = (rs) => rs.map((r) => ({ method: r.method, path: r.path }))
  return {
    $comment: MANIFEST_COMMENT,
    public: strip(collected.public),
    internal: strip(collected.internal),
  }
}

/** The ungated review aid: same routes, plus handler count and named gates. */
function buildGuards(collected) {
  const shape = (rs) =>
    rs.map((r) => ({
      method: r.method,
      path: r.path,
      handlers: r.handlers,
      gates: [...new Set(r.gates)],
    }))
  return {
    $comment: GUARDS_COMMENT,
    public: shape(collected.public),
    internal: shape(collected.internal),
  }
}

// Always LF + a trailing newline so the file is byte-identical on Windows and CI.
function serialize(obj) {
  return `${JSON.stringify(obj, null, 2)}\n`
}

function main() {
  const check = process.argv.includes('--check')
  const collected = collect()
  const files = [
    [MANIFEST_PATH, serialize(buildManifest(collected))],
    [GUARDS_PATH, serialize(buildGuards(collected))],
  ]

  let stale = 0
  for (const [file, contents] of files) {
    const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n') : null
    if (check) {
      if (current !== contents) {
        process.stderr.write(`stale: ${path.relative(SERVER_ROOT, file)}\n`)
        stale += 1
      }
      continue
    }
    fs.writeFileSync(file, contents)
  }

  const total = collected.public.length + collected.internal.length
  if (check) {
    if (stale) {
      process.stderr.write('Run `npm run routes:manifest` and commit the result.\n')
      process.exitCode = 1
    } else {
      process.stdout.write(`route manifest up to date (${total} routes)\n`)
    }
  } else {
    process.stdout.write(
      `wrote routes.manifest.json (${collected.public.length} public + ${collected.internal.length} internal)\n`,
    )
  }
}

if (require.main === module) {
  main()
  // The mariadb pool keeps the loop alive even pointed at a dead port.
  db.close().finally(() => process.exit(process.exitCode || 0))
}

module.exports = { collect, buildManifest, buildGuards, serialize, mountPath }
