// listener-auth — kb#180: authenticate the agap-mcp :3100 listener itself,
// not just the vault tools.
//
// WHY THIS EXISTS
// agap-mcp runs `network_mode: host` and binds :3100 on every interface. The
// LAN is explicitly NOT a trust boundary here — DESIGN-a2a-agents.md v2.1 §4
// ("Auth is mandatory on every A2A surface. The LAN is not trusted — the
// xray/3x-ui VPN terminates other people's peers on it. No unauthenticated
// JSON-RPC listener, ever: shared tokens minimum, mTLS preferred.").
//
// Before this module, the ONLY gate in the process was requireVaultAccess()
// (kb#147), which covers vw_* tools alone and only at ENFORCE=1. Everything
// else — ha_call_service, gitea_wiki_write, wiki_edit, radicale_*/todoist_*
// writes, and the plain-REST POST /capture-idea — was callable by any LAN
// peer with curl. This module closes that: the transport itself now requires
// a bearer token that resolves to a known agent id.
//
// TWO DISTINCT GATES, DELIBERATELY LAYERED
//   1. listener auth (this file)  — "are you *an* agent at all?"  → any id in
//      AGAP_MCP_AGENT_TOKENS passes; unknown/absent token = 401.
//   2. vault trust gate (trust-gate.js / requireVaultAccess in server.js)
//      — "are you trust_class >= trusted?" → only then may vw_* run.
// Both read the SAME token map, so one token per agent covers both. Gate 2
// stays independently switchable (AGAP_MCP_ENFORCE_VAULT_TRUST) exactly as
// kb#147 shipped it; turning on gate 1 does not turn on gate 2.
//
// FAIL-FAST, NOT FAIL-SILENT
// Auth is ON by default (AGAP_MCP_REQUIRE_AUTH != '0'). If it is on and the
// token map is empty, assertListenerAuthConfig() throws at boot rather than
// letting the process serve 401 to literally everyone while /health says
// "ok" — a missing .env value must look like a broken restart, not like a
// quietly dead integration. See server.js boot path.
//
// SSE SESSION BINDING
// The legacy SSE transport hands out a sessionId at GET /sse and accepts JSON-RPC
// on POST /messages?sessionId=... . Previously /messages trusted ANY sessionId
// with no credential — a session-id guess/leak was full tool access (hijack).
// bindSseSession()/authorizeSseSession() below pin the caller identity captured
// at handshake to the session, and /messages must present the same agent's
// token or it is rejected.

import { resolveCallerAgent, authHeaderToken } from './trust-gate.js';

// ON unless explicitly disabled. The opposite default from kb#147's vault gate
// on purpose: an unauthenticated JSON-RPC listener is the thing §4 forbids
// outright, so "off" has to be a deliberate, visible opt-out.
export function requireAuthEnabled(env = process.env) {
  return env.AGAP_MCP_REQUIRE_AUTH !== '0';
}

// Routes that stay open even with auth on:
//   /health — the compose healthcheck calls it with no credential, it has no
//   side effects, and it returns only counts/booleans (no secrets, no tool
//   surface). Keeping it open is what lets a misconfigured token map still be
//   diagnosable from outside the container.
export const PUBLIC_PATHS = new Set(['/health']);

export class ListenerAuthConfigError extends Error {}

// Called once at boot from server.js. Throws (crash loudly) instead of
// booting an all-callers-denied service.
export function assertListenerAuthConfig(tokenMap, env = process.env) {
  if (!requireAuthEnabled(env)) {
    console.error(
      '\n*** agap-mcp WARNING: AGAP_MCP_REQUIRE_AUTH=0 — the :3100 MCP listener is ' +
      'UNAUTHENTICATED. Every tool (ha_call_service, gitea_wiki_write, wiki_edit, ' +
      'radicale/todoist writes, POST /capture-idea) is callable by any LAN peer, and ' +
      'the LAN carries VPN-terminated peers. This violates DESIGN-a2a-agents.md §4 ' +
      'and is only acceptable as a temporary, deliberate rollback (kb#180). ***\n'
    );
    return;
  }
  if (Object.keys(tokenMap || {}).length === 0) {
    throw new ListenerAuthConfigError(
      'AGAP_MCP_REQUIRE_AUTH is on (default) but AGAP_MCP_AGENT_TOKENS is empty/unset, ' +
      'so no caller could ever authenticate. Populate AGAP_MCP_AGENT_TOKENS in this ' +
      "container's .env with a JSON map {\"<bearer-token>\":\"<agent-id>\"} (agent ids " +
      'must exist in agent-registry.yaml), or set AGAP_MCP_REQUIRE_AUTH=0 to ' +
      'deliberately run the listener unauthenticated (kb#180, DESIGN-a2a-agents.md §4).'
    );
  }
}

// Express middleware factory. On success sets req.callerAgentId (string) and
// req.callerToken, which the /mcp, /sse and /messages routes consume.
// With auth disabled it sets req.callerAgentId from the token if one happens
// to be present (so the vault gate keeps working) and lets the request through.
export function listenerAuth(tokenMap, env = process.env) {
  const enabled = requireAuthEnabled(env);
  return function listenerAuthMiddleware(req, res, next) {
    const token = authHeaderToken(req);
    const agentId = resolveCallerAgent(token, tokenMap);
    req.callerToken = token;
    req.callerAgentId = agentId;

    if (!enabled) return next();
    if (PUBLIC_PATHS.has(req.path)) return next();
    if (agentId) return next();

    return denyUnauthenticated(req, res, token ? 'unknown-token' : 'no-credential');
  };
}

// 401 body shape: JSON-RPC error for MCP routes (so an MCP client surfaces a
// real protocol error rather than a parse failure), plain JSON elsewhere.
export function denyUnauthenticated(req, res, reason) {
  const message =
    `unauthenticated: this endpoint requires an Authorization: Bearer <token> header ` +
    `resolving to a known agent (kb#180, DESIGN-a2a-agents.md §4) [${reason}]`;
  res.set('WWW-Authenticate', 'Bearer realm="agap-mcp"');
  if (isJsonRpcPath(req.path)) {
    return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message }, id: null });
  }
  return res.status(401).json({ error: message });
}

export function isJsonRpcPath(path) {
  return path === '/mcp' || path === '/messages' || path === '/sse';
}

// --- SSE session binding -------------------------------------------------
// sessions: Map<sessionId, { transport, agentId, token }>

export function bindSseSession(sessions, sessionId, transport, req) {
  sessions.set(sessionId, {
    transport,
    agentId: req.callerAgentId || null,
    token: req.callerToken || null,
  });
}

// Returns { ok: true, transport } or { ok: false, status, reason }.
// A /messages POST must (a) name a live session and (b) carry the SAME
// caller identity that opened it. Comparing the agent id (not just "is
// authenticated") is what stops agent B from driving agent A's session; the
// token is compared too so two tokens mapped to the same agent id are still
// treated as distinct sessions.
export function authorizeSseSession(sessions, sessionId, req, env = process.env) {
  const entry = sessions.get(sessionId);
  if (!entry) return { ok: false, status: 400, reason: 'unknown-session' };
  if (!requireAuthEnabled(env)) return { ok: true, transport: entry.transport };
  if (!req.callerAgentId) return { ok: false, status: 401, reason: 'no-credential' };
  if (entry.agentId !== req.callerAgentId || entry.token !== req.callerToken) {
    return { ok: false, status: 403, reason: 'session-caller-mismatch' };
  }
  return { ok: true, transport: entry.transport };
}
