// redirect.mjs — Phase B3 (plan-chat-spa-supersedes-control-plane).
//
// 301 redirect layer that fronts host-cp's HTTP handler. Maps legacy
// control-plane routes that get deleted in Phase B4 onto their canonical
// successors so live URLs in operator history / bookmarks / Slack do not
// 404 after the deletion lands.
//
// Redirect rules (allow-listed; closed set):
//
//   /plan/:id                  → no-op (falls through to SPA shell;
//                                plan-chat-spa-side router handles the
//                                resolver dispatch via useResolveId).
//                                Implemented as a sentinel so callers
//                                can short-circuit but the request
//                                continues to static-serve.
//   /world/:id                 → 301 /worlds?highlight=:id
//   /sandbox/:id               → 301 /worlds?highlight=:id
//   /session/:worldId/plan     → 301 /plan/:worldId
//
// EXPLICITLY NOT REDIRECTED (more-specific routes still owned by
// control-plane until Phase E):
//   /world/:id/editor          /world/:id/events
//   /sandbox/:id/editor        /sandbox/:id/events
//   /inbox/*                   /workspaces/*
//   /repos                     /runbooks                /design
//
// Security (per plan-chat-spa-supersedes-control-plane.md K1 SEC-2):
//   - Redirect targets are HARDCODED prefixes (`/plan/`, `/worlds`). No
//     caller-supplied target is ever reflected into Location.
//   - `:id` segment is validated against RESOLVE_ID_RE before any
//     reflection into the Location header; invalid shapes → 400, not
//     301. This kills open-redirect / response-splitting / header-
//     injection vectors at the door.
//   - `highlight=<id>` query param uses the SAME shape regex. We do not
//     trust the inbound URL beyond the regex match (no decoding, no
//     surrogate pair handling).
//
// Returns one of:
//   { kind: 'redirect', status: 301, location: '<target>' }
//   { kind: 'bad-request', status: 400, message: '<reason>' }
//   { kind: 'passthrough' }   — caller continues normal request flow

import { RESOLVE_ID_RE } from './resolver.mjs';

/**
 * Compute the redirect verdict for a given pathname. Pure function;
 * does not consume the request body or write the response.
 *
 * @param {string} pathname - URL.pathname (no querystring, no hash)
 * @returns {{ kind: 'redirect', status: 301, location: string }
 *          | { kind: 'bad-request', status: 400, message: string }
 *          | { kind: 'passthrough' }}
 */
export function evaluateRedirect(pathname) {
  if (typeof pathname !== 'string' || pathname.length === 0) {
    return { kind: 'passthrough' };
  }

  // /session/:worldId/plan → /plan/:worldId
  // Match BEFORE the catch-all world rules so the `/session/...` prefix
  // wins. The trailing `/plan` is fixed; only the worldId varies.
  const sessionMatch = /^\/session\/([^/]+)\/plan\/?$/.exec(pathname);
  if (sessionMatch) {
    const worldId = sessionMatch[1];
    if (!RESOLVE_ID_RE.test(worldId)) {
      return {
        kind: 'bad-request',
        status: 400,
        message: 'invalid worldId shape on /session/:worldId/plan',
      };
    }
    return {
      kind: 'redirect',
      status: 301,
      location: `/plan/${worldId}`,
    };
  }

  // /design → / (Phase E2: the DesignSurface alpha placeholder is retired.
  // Hardcoded target — no caller reflection. Exact-match only so /designfoo
  // or /design/sub do not over-match into the redirect.)
  if (pathname === '/design' || pathname === '/design/') {
    return { kind: 'redirect', status: 301, location: '/' };
  }

  // /world/:id (catch-all, EXCLUDING /editor and /events sub-routes)
  // /sandbox/:id (catch-all, EXCLUDING /editor and /events sub-routes)
  const worldMatch = /^\/(world|sandbox)\/([^/]+)(\/.*)?$/.exec(pathname);
  if (worldMatch) {
    const [, , id, rest] = worldMatch;
    // KEEP these — control-plane still owns them until Phase E.
    if (rest === '/editor' || rest === '/events' ||
        rest?.startsWith('/editor/') || rest?.startsWith('/events/')) {
      return { kind: 'passthrough' };
    }
    if (!RESOLVE_ID_RE.test(id)) {
      return {
        kind: 'bad-request',
        status: 400,
        message: 'invalid id shape on /(world|sandbox)/:id',
      };
    }
    return {
      kind: 'redirect',
      status: 301,
      location: `/worlds?highlight=${encodeURIComponent(id)}`,
    };
  }

  // /plan/:id is intentionally passthrough — the SPA shell serves it
  // and the SPA-side router (with useResolveId) decides what to mount.
  // We DO NOT emit a self-loop 301 here. Including the rule for
  // completeness / future-proofing only.
  // (No regex needed; the static-serve layer already handles /plan/*
  // via SPA_PREFIX.)

  return { kind: 'passthrough' };
}

/**
 * Apply the redirect verdict to a node:http ServerResponse. Returns
 * `true` when the response was written (caller must NOT continue);
 * returns `false` when the caller should continue the normal request
 * flow.
 *
 * @param {import('node:http').ServerResponse} res
 * @param {ReturnType<typeof evaluateRedirect>} verdict
 * @returns {boolean} true if response was sent, false to passthrough.
 */
export function applyRedirect(res, verdict) {
  if (verdict.kind === 'passthrough') return false;

  if (verdict.kind === 'redirect') {
    res.writeHead(301, {
      Location: verdict.location,
      // Short cache so bookmarks update once but operator-local mistakes
      // (typo'd URL) don't pin to a stale redirect forever.
      'Cache-Control': 'public, max-age=300',
      'Content-Type': 'text/plain; charset=utf-8',
    });
    res.end(`Moved permanently: ${verdict.location}\n`);
    return true;
  }

  if (verdict.kind === 'bad-request') {
    res.writeHead(400, {
      'Content-Type': 'application/json; charset=utf-8',
      'Cache-Control': 'no-store',
    });
    res.end(JSON.stringify({
      error: 'bad-request',
      message: verdict.message,
    }));
    return true;
  }

  // Defensive: unknown verdict shape → fall through silently.
  return false;
}
