import { CAPABILITIES, capabilityForApiPath, capabilityForWsProvider } from './registry';

// The part that actually matters.
//
// On 2026-08-06 a Member token that was 403'd on `GET /api/tasks` opened `/api/tasks/pipeline/ws` and got
// a 101 in the same minute — a shell, the agent with --dangerously-skip-permissions, arbitrary script
// execution and the owner's screen, all reachable. The cause was not a bad rule. It was that WebSocket
// upgrades never reach Hono's middleware at all: Bun's route table in server.tsx matches
// `/api/terminal/ws` before the `/api/*` catch-all, so the authorisation gate was simply not on that code
// path. Nobody had written a rule that was wrong; a door had been added and the rule had not been told.
//
// It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of
// that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and
// every user-facing WebSocket provider must map to exactly one capability, or the server does not start.
// Add a router and forget the registry and you find out during `pm2 restart`, not during an incident.
//
// The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not
// user-capability-gated, each of which has to say why. A list that grows silently is the failure mode, so
// keep it short and keep the reasons real.

/**
 * Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a
 * platform capability. Each entry is a claim that has to stay true.
 */
const EXEMPT_API_PREFIXES: Record<string, string> = {
  // Unauthenticated by necessity — this is where a caller goes to BECOME authenticated.
  '/auth': 'signin, bootstrap and token refresh; runs before any account exists to check',
  // Public marketing surfaces, served to anonymous visitors.
  '/landing-page-data': 'public landing page content, no account involved',
  '/waitlist': 'public signup form, no account involved',
  // The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401
  // them and a capability lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself.
  '/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer',
  // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
  '/sidecar': 'sidecar registration, loopback process-to-process',
  // The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so
  // userMiddleware would 401 it and a capability lookup would have no account to resolve. What it does
  // present is a per-panel token minted by the platform, which identifies exactly one agent panel and
  // authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD.
  // It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is
  // the whole justification: this is not a general API door with a second credential, it is one verb.
  '/agent-handoff': 'agent-to-agent handoff; a per-panel bearer token authorising one verb, not a platform JWT',
};

/**
 * WebSocket providers that do not go through `upgradeWs` and therefore never carry a platform account.
 */
const EXEMPT_WS_PROVIDERS: Record<string, string> = {
  sidecar: 'sidecar registration, loopback process-to-process, no user',
  vault: 'Vaultwarden notifications hub; upgraded by upgradeVaultWs with a Bitwarden token',
};

/**
 * Is this path served above the account gate?
 *
 * Read by the capability backstop, so that signin — which by definition has no account to check — is not
 * asked to prove a capability. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption
 * granted at boot and an exemption honoured at request time must be the same list, or one of them is a
 * hole.
 */
export function isExemptApiPath(path: string): boolean {
  const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
  return Object.keys(EXEMPT_API_PREFIXES).some((prefix) => rest === prefix || rest.startsWith(`${prefix}/`));
}

export type CapabilitySurface = {
  /** Every prefix mounted on protectedRouter, as written in hono.ts. */
  apiPrefixes: string[];
  /** Every key of the `handlers` map in server.tsx. */
  wsProviders: string[];
};

/**
 * Refuses to return if the registry and the real surface disagree. Called from the boot path.
 *
 * Four ways to fail, and all four are real bugs rather than pedantry:
 *   - a mounted prefix no capability claims  → reachable by a rule nobody wrote
 *   - a prefix two capabilities claim        → which grant applies is undefined
 *   - a declared prefix nothing mounts       → the registry is describing a router that no longer exists
 *   - a socket provider no capability claims → exactly the 2026-08-06 hole, structurally
 */
export function assertCapabilityTotality(surface: CapabilitySurface): void {
  const problems: string[] = [];

  // 1. Every mount is claimed, and claimed once.
  for (const prefix of surface.apiPrefixes) {
    if (prefix in EXEMPT_API_PREFIXES) continue;
    const claimants = CAPABILITIES.filter((c) => c.api.includes(prefix));
    if (claimants.length === 0) {
      problems.push(`/api${prefix} is mounted but no capability claims it — add it to the registry`);
    } else if (claimants.length > 1) {
      problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`);
    }
  }

  // 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the
  // registry kept describing it — which would leave a grant that silently means nothing.
  const mounted = new Set(surface.apiPrefixes);
  for (const capability of CAPABILITIES) {
    for (const prefix of capability.api) {
      if (!mounted.has(prefix)) {
        problems.push(`capability '${capability.key}' claims /api${prefix}, which nothing mounts`);
      }
    }
  }

  // 3. Every socket door is claimed. This is the one the incident was about.
  for (const provider of surface.wsProviders) {
    if (provider in EXEMPT_WS_PROVIDERS) continue;
    if (!capabilityForWsProvider(provider)) {
      problems.push(`websocket provider '${provider}' is served but no capability claims it`);
    }
  }

  // 4. And no capability claims a socket that does not exist.
  const providers = new Set(surface.wsProviders);
  for (const capability of CAPABILITIES) {
    for (const provider of capability.ws ?? []) {
      if (!providers.has(provider)) {
        problems.push(`capability '${capability.key}' claims websocket '${provider}', which is not served`);
      }
    }
  }

  // 5. Keys are unique — a duplicate would make grants ambiguous in the database.
  const seen = new Set<string>();
  for (const capability of CAPABILITIES) {
    if (seen.has(capability.key)) problems.push(`duplicate capability key '${capability.key}'`);
    seen.add(capability.key);
  }

  if (problems.length > 0) {
    throw new Error(
      [
        'Capability registry does not cover the served surface. The server will not start.',
        '',
        ...problems.map((p) => `  • ${p}`),
        '',
        'Fix src/servers/capabilities/registry.ts. If a surface genuinely is not capability-gated, add it',
        'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the',
        'websocket hole happened.',
      ].join('\n'),
    );
  }
}

/** Exposed for the settings UI, so an owner can see what a capability actually covers. */
export function describeCapabilitySurface(): {
  exemptApi: typeof EXEMPT_API_PREFIXES;
  exemptWs: typeof EXEMPT_WS_PROVIDERS;
} {
  return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS };
}

export { capabilityForApiPath };
