import express from 'express';
import { randomUUID } from 'crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';

import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems, vwCreateLogin, vwUpdatePassword } from './vaultwarden.js';
import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, giteaWikiWrite, giteaListIssues } from './gitea.js';
import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js';
import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js';
import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js';
import { initTodoist, todoistListTasks, todoistListProjects, todoistCreateTask, todoistUpdateTask, todoistCompleteTask } from './todoist.js';
import { todoistCaptureIdea } from './capture.js';
import { initMediaWiki, wikiSearch, wikiRead, wikiEdit } from './mediawiki.js';
import { loadTokenMap, resolveCallerAgent, vaultAllowed, authHeaderToken } from './trust-gate.js';
import { listenerAuth, assertListenerAuthConfig, requireAuthEnabled, bindSseSession, authorizeSseSession } from './listener-auth.js';

const PORT = parseInt(process.env.PORT || '3100');

// --- kb#147: vault trust gate (A2A-15) ---------------------------------
// OFF by default (ENFORCE_VAULT_TRUST=false) so merging this file changes
// NOTHING about the live service's behavior until an operator deliberately
// sets AGAP_MCP_ENFORCE_VAULT_TRUST=1 AND populates AGAP_MCP_AGENT_TOKENS
// with real per-agent bearer tokens (stored in Vaultwarden, injected via
// this container's .env — never committed to git). That two-step
// activation is the kb#147 handover: it requires a docker-compose env
// change + container restart, which this task deliberately does not do
// (see DESIGN-a2a-agents.md v2.1 §5 — vault access = trusted only).
const ENFORCE_VAULT_TRUST = process.env.AGAP_MCP_ENFORCE_VAULT_TRUST === '1';
const AGENT_TOKENS = loadTokenMap();
const AGENT_TOKEN_COUNT = Object.keys(AGENT_TOKENS).length;

// kb#182: ENFORCE=1 with an empty token map is fail-closed by design (see
// requireVaultAccess below) but that means it silently denies EVERY caller,
// including Adolf itself — a self-inflicted vault brick with no signal
// unless someone is watching the logs. Make that state loud on boot.
if (ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0) {
  console.error(
    '\n*** agap-mcp WARNING: AGAP_MCP_ENFORCE_VAULT_TRUST=1 but AGAP_MCP_AGENT_TOKENS ' +
    'is empty/unset. Every caller — including Adolf — will be denied vault access. ' +
    'This is fail-closed, not a crash: the process will keep serving non-vault tools, ' +
    'but ALL vw_* calls will error until AGAP_MCP_AGENT_TOKENS is populated with real ' +
    'per-agent bearer tokens (kb#147/kb#182). Check /health for tokenMapSize. ***\n'
  );
}

function requireVaultAccess(callerAgentId) {
  if (!ENFORCE_VAULT_TRUST) return; // legacy behavior: unchanged until activated
  if (!vaultAllowed(callerAgentId)) {
    throw new Error(
      `vault access denied: caller ${callerAgentId ? `'${callerAgentId}'` : '(unauthenticated)'} ` +
      `is not trust_class >= trusted (kb#147, DESIGN-a2a-agents.md §5)`
    );
  }
}

// --- Init services ---
async function init() {
  await initVaultwarden();

  // Fetch all tokens from org to avoid "more than one result" on duplicates
  const orgItems = vwListOrgItems();
  const orgToken = (name) => {
    const item = orgItems.find(i => i.name === name);
    if (!item) throw new Error(`Token not found in org: ${name}`);
    return item.login?.password;
  };

  const gitea_token = orgToken('GITEA_TOKEN');
  const ha_token = orgToken('HA_TOKEN');
  const zabbix_token = orgToken('ZABBIX_TOKEN');
  const radicale_password = orgToken('RADICALE_PASSWORD');
  const todoist_token = orgToken('TODOIST_TOKEN');

  // family.alogins.net (РодоВики MediaWiki) — a login item, not a bare
  // token: username + password both needed for the login flow.
  const wikiItem = orgItems.find(i => i.name === 'family.alogins.net');
  if (!wikiItem) throw new Error('Token not found in org: family.alogins.net');

  initGitea(gitea_token);
  initHA(ha_token);
  initZabbix(zabbix_token);
  initRadicale(radicale_password);
  initTodoist(todoist_token);
  initMediaWiki(wikiItem.login?.username, wikiItem.login?.password);

}

// --- MCP Server factory (one per session — McpServer can't share transports) ---
function ok(text) {
  return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
}

function err(e) {
  return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}

// Track tool registrations for /health endpoint
let registeredToolCount = 0;

function createServer(callerAgentId = null) {
  const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
  const serverToolCount = { count: 0 };

  // Wrap server.tool() to count registrations
  const originalTool = server.tool.bind(server);
  server.tool = function(name, description, params, handler) {
    serverToolCount.count++;
    return originalTool(name, description, params, handler);
  };

// --- Vaultwarden tools (kb#147: gated to trust_class >= trusted) ---
  server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() },
  async ({ name }) => {
    try { requireVaultAccess(callerAgentId); return ok(vwGetPassword(name)); } catch (e) { return err(e); }
  });

  server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() },
  async ({ name }) => {
    try {
      requireVaultAccess(callerAgentId);
      const item = vwGetItem(name);
      return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
    } catch (e) { return err(e); }
  });

  server.tool('vw_list_items', 'List Vaultwarden items. Searches personal vault by default; set org=true to search org/AI collection', {
  search: z.string().optional(),
  org: z.boolean().optional(),
}, async ({ search, org }) => {
  try {
    requireVaultAccess(callerAgentId);
    const items = org ? vwListOrgItems(search) : vwListItems(search);
    return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
  } catch (e) { return err(e); }
});

  server.tool('vw_create_login', 'Create a new login item in Vaultwarden AI collection', {
  name: z.string(),
  username: z.string().optional(),
  password: z.string(),
  url: z.string().optional(),
  notes: z.string().optional(),
}, async (args) => {
  try { requireVaultAccess(callerAgentId); return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
});

  server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', {
  name: z.string().describe('Item name or ID'),
  password: z.string(),
}, async ({ name, password }) => {
  try { requireVaultAccess(callerAgentId); return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
});

// --- Gitea tools ---
  server.tool('gitea_list_repos', 'List all Gitea repositories', {},
  async () => {
    try { return ok(await giteaListRepos()); } catch (e) { return err(e); }
  });

  server.tool('gitea_read_file', 'Read a file from a Gitea repository', {
  repo: z.string().describe('e.g. alvis/AgapHost'),
  path: z.string().describe('file path in repo'),
  ref: z.string().optional().describe('branch/tag/commit, default HEAD'),
}, async ({ repo, path, ref }) => {
  try { return ok(await giteaReadFile(repo, path, ref)); } catch (e) { return err(e); }
});

  server.tool('gitea_wiki_list', 'List wiki pages', {
  repo: z.string().optional().describe('default: alvis/AgapHost'),
}, async ({ repo }) => {
  try { return ok(await giteaWikiList(repo)); } catch (e) { return err(e); }
});

  server.tool('gitea_wiki_read', 'Read a wiki page', {
  page: z.string(),
  repo: z.string().optional().describe('default: alvis/AgapHost'),
}, async ({ page, repo }) => {
  try { return ok(await giteaWikiRead(page, repo)); } catch (e) { return err(e); }
});

  server.tool('gitea_wiki_write', 'Write/update a wiki page', {
  page: z.string(),
  content: z.string(),
  message: z.string().optional().describe('commit message'),
  repo: z.string().optional().describe('default: alvis/AgapHost'),
}, async ({ page, content, message, repo }) => {
  try { return ok(await giteaWikiWrite(page, content, message, repo)); } catch (e) { return err(e); }
});

  server.tool('gitea_list_issues', 'List issues for a Gitea repo', {
  repo: z.string().describe('e.g. alvis/AgapHost'),
  state: z.enum(['open', 'closed', 'all']).optional(),
}, async ({ repo, state }) => {
  try { return ok(await giteaListIssues(repo, state)); } catch (e) { return err(e); }
});

// --- Home Assistant tools ---
  server.tool('ha_get_state', 'Get current state of a Home Assistant entity', {
  entity_id: z.string().describe('e.g. light.living_room'),
}, async ({ entity_id }) => {
  try { return ok(await haGetState(entity_id)); } catch (e) { return err(e); }
});

  server.tool('ha_list_entities', 'List Home Assistant entities, optionally filtered by domain', {
  domain: z.string().optional().describe('e.g. light, switch, sensor, binary_sensor'),
}, async ({ domain }) => {
  try { return ok(await haListEntities(domain)); } catch (e) { return err(e); }
});

  server.tool('ha_call_service', 'Call a Home Assistant service', {
  domain: z.string().describe('e.g. light, switch, automation'),
  service: z.string().describe('e.g. turn_on, turn_off, toggle'),
  data: z.record(z.unknown()).optional().describe('service call data, e.g. {"entity_id": "light.x"}'),
}, async ({ domain, service, data }) => {
  try { return ok(await haCallService(domain, service, data)); } catch (e) { return err(e); }
});

  server.tool('ha_get_history', 'Get state history for a Home Assistant entity', {
  entity_id: z.string(),
  hours: z.number().optional().describe('hours of history, default 24'),
}, async ({ entity_id, hours }) => {
  try { return ok(await haGetHistory(entity_id, hours)); } catch (e) { return err(e); }
});

// --- Zabbix tools ---
  server.tool('zabbix_get_problems', 'Get current active problems in Zabbix', {},
  async () => {
    try { return ok(await zabbixGetProblems()); } catch (e) { return err(e); }
  });

  server.tool('zabbix_get_hosts', 'List all monitored hosts in Zabbix with availability status', {},
  async () => {
    try { return ok(await zabbixGetHosts()); } catch (e) { return err(e); }
  });

  server.tool('zabbix_get_items', 'Get monitored items and their latest values for a Zabbix host', {
  hostid: z.string().describe('Zabbix host ID'),
}, async ({ hostid }) => {
  try { return ok(await zabbixGetItems(hostid)); } catch (e) { return err(e); }
});

  server.tool('zabbix_get_triggers', 'Get triggers for a Zabbix host or all hosts', {
  hostid: z.string().optional().describe('Zabbix host ID, omit for all hosts'),
}, async ({ hostid }) => {
  try { return ok(await zabbixGetTriggers(hostid)); } catch (e) { return err(e); }
});

// --- Radicale (CalDAV) tools ---
  server.tool('radicale_list_calendars', 'List CalDAV calendars for a Radicale user (defaults to alvis)', {
    user: z.string().optional(),
  }, async ({ user }) => {
    try { return ok(await radicaleListCalendars(user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_list_events', 'List events in a Radicale calendar (returns filename, uid, summary, dtstart, dtend)', {
    calendar_id: z.string().describe('Calendar UUID from radicale_list_calendars'),
    user: z.string().optional(),
  }, async ({ calendar_id, user }) => {
    try { return ok(await radicaleListEvents(calendar_id, user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_get_event', 'Get raw iCalendar text for a single event', {
    calendar_id: z.string(),
    filename: z.string().describe('e.g. ABCD-1234.ics'),
    user: z.string().optional(),
  }, async ({ calendar_id, filename, user }) => {
    try { return ok(await radicaleGetEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_create_calendar', 'Create a new CalDAV calendar (MKCALENDAR). Returns the new calendar id.', {
    displayname: z.string(),
    color: z.string().optional().describe('e.g. #33CC66ff'),
    components: z.array(z.string()).optional().describe('default ["VEVENT"]'),
    id: z.string().optional().describe('explicit collection id; otherwise UUID generated'),
    user: z.string().optional(),
  }, async (args) => {
    try { return ok(await radicaleCreateCalendar(args)); } catch (e) { return err(e); }
  });

  server.tool('radicale_delete_calendar', 'Delete a Radicale calendar collection', {
    calendar_id: z.string(),
    user: z.string().optional(),
  }, async ({ calendar_id, user }) => {
    try { return ok(await radicaleDeleteCalendar(calendar_id, user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_put_event', 'PUT an iCalendar event into a calendar (create or replace)', {
    calendar_id: z.string(),
    filename: z.string().describe('Event filename, with or without .ics'),
    ics: z.string().describe('Full VCALENDAR body'),
    user: z.string().optional(),
  }, async ({ calendar_id, filename, ics, user }) => {
    try { return ok(await radicalePutEvent(calendar_id, filename, ics, user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_delete_event', 'Delete an event from a calendar', {
    calendar_id: z.string(),
    filename: z.string(),
    user: z.string().optional(),
  }, async ({ calendar_id, filename, user }) => {
    try { return ok(await radicaleDeleteEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
  });

  server.tool('radicale_move_event', 'Move an event between calendars (copy then delete original)', {
    from_calendar: z.string(),
    to_calendar: z.string(),
    filename: z.string(),
    user: z.string().optional(),
  }, async ({ from_calendar, to_calendar, filename, user }) => {
    try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); }
  });

  // --- Todoist tools (Todoist API v1) ---
  server.tool('todoist_list_tasks', 'List active Todoist tasks. Use `query` for a Todoist filter (e.g. "today", "overdue", "#Work & p1"), or `project_id` to scope to one project. Returns compact tasks.', {
    query: z.string().optional().describe('Todoist filter query, e.g. "today", "overdue", "#Project & p1". Omit for all active tasks.'),
    project_id: z.string().optional().describe('Restrict to a project id (from todoist_list_projects). Ignored when query is set.'),
  }, async ({ query, project_id }) => {
    try { return ok(await todoistListTasks({ query, project_id })); } catch (e) { return err(e); }
  });

  server.tool('todoist_list_projects', 'List Todoist projects (id + name).', {}, async () => {
    try { return ok(await todoistListProjects()); } catch (e) { return err(e); }
  });

  server.tool('todoist_create_task', 'Create a Todoist task. `due_string` is natural language ("tomorrow 9am", "every monday"). `priority`: API 1=normal..4=urgent (note: Todoist UI p1 = API 4). Defaults to the Inbox project.', {
    content: z.string().describe('Task title/content (required).'),
    description: z.string().optional(),
    due_string: z.string().optional().describe('Natural-language due date, e.g. "today", "tomorrow 9am", "next monday".'),
    priority: z.number().int().min(1).max(4).optional().describe('API priority 1=normal .. 4=urgent.'),
    project_id: z.string().optional().describe('Target project id (default Inbox).'),
    labels: z.array(z.string()).optional(),
  }, async (args) => {
    try { return ok(await todoistCreateTask(args)); } catch (e) { return err(e); }
  });

  server.tool('todoist_update_task', 'Update a Todoist task (reschedule/edit). Give the task id plus the fields to change.', {
    id: z.string(),
    content: z.string().optional(),
    description: z.string().optional(),
    due_string: z.string().optional(),
    priority: z.number().int().min(1).max(4).optional(),
    labels: z.array(z.string()).optional(),
  }, async (args) => {
    try { return ok(await todoistUpdateTask(args)); } catch (e) { return err(e); }
  });

  server.tool('todoist_complete_task', 'Complete (close) a Todoist task by id.', {
    id: z.string().describe('Task id from todoist_list_tasks.'),
  }, async ({ id }) => {
    try { return ok(await todoistCompleteTask({ id })); } catch (e) { return err(e); }
  });

  // kb#170: capture an idea/quick task with lightweight (encoder-only, no
  // Kimi/gemma call) AI classification -- area/urgency/decompose-need --
  // then create it in Todoist tagged with the result. See capture.js header
  // for why this tags with labels rather than reassigning projects.
  server.tool('todoist_capture_idea', 'Capture a free-text idea: classify it (area: adolf/welfare/дом/семья/здоровье, urgency, whether it needs Kanboard decomposition) using local bge-m3 embeddings, then create it as a labelled Todoist task. No LLM call.', {
    text: z.string().describe('The idea, in free text (Russian or English).'),
    project_id: z.string().optional().describe('Force a specific Todoist project id; otherwise auto-routed only for an unambiguous семья match, else Inbox.'),
  }, async ({ text, project_id }) => {
    try { return ok(await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects })); } catch (e) { return err(e); }
  });

  // --- MediaWiki (family wiki / РодоВики) tools ---
  server.tool('wiki_search', 'Search the family wiki (РодоВики) for pages matching a query. Returns title + snippet.', {
    query: z.string().describe('Search text, e.g. a person\'s name or event.'),
    limit: z.number().int().min(1).max(50).optional().describe('Max results, default 20.'),
  }, async ({ query, limit }) => {
    try { return ok(await wikiSearch(query, limit)); } catch (e) { return err(e); }
  });

  server.tool('wiki_read', 'Read the raw wikitext content of a family wiki page by exact title.', {
    title: z.string().describe('Exact page title, e.g. "Антон Логинс".'),
  }, async ({ title }) => {
    try { return ok(await wikiRead(title)); } catch (e) { return err(e); }
  });

  server.tool('wiki_edit', 'Create or update a family wiki page. Overwrites the page with the given wikitext.', {
    title: z.string().describe('Exact page title to create/update.'),
    text: z.string().describe('Full wikitext content of the page.'),
    summary: z.string().optional().describe('Edit summary, default "Update <title>".'),
  }, async ({ title, text, summary }) => {
    try { return ok(await wikiEdit(title, text, summary)); } catch (e) { return err(e); }
  });

  // Store tool count on the server for /health endpoint to access
  server._toolCount = serverToolCount.count;
  registeredToolCount = serverToolCount.count;

  return server;
}

// --- HTTP server (Streamable HTTP + legacy SSE) ---
const app = express();
app.use(express.json());

// kb#180: authenticate the LISTENER, not just vault tools. Mounted before
// every route below, so /mcp, /sse, /messages and /capture-idea all require
// a bearer token resolving to a known agent (/health stays open — see
// listener-auth.js PUBLIC_PATHS). This middleware also populates
// req.callerAgentId, which replaces the per-route resolveCallerAgent() call
// the /mcp and /sse handlers used to do inline; the kb#147 vault gate then
// consumes that same id, so one token per agent serves both gates.
app.use(listenerAuth(AGENT_TOKENS));

// sessionId -> { transport, agentId, token } (kb#180: the identity captured at
// the /sse handshake is pinned to the session so /messages can't be hijacked
// by anyone who merely learns/guesses the sessionId).
const sseTransports = new Map();

// Streamable HTTP — stateless: fresh server per request, survives container restarts
app.all('/mcp', async (req, res) => {
  try {
    const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    res.on('close', () => transport.close());
    await createServer(callerAgentId).connect(transport);
    await transport.handleRequest(req, res, req.body);
  } catch (e) {
    console.error('MCP request error:', e.message);
    if (!res.headersSent) {
      res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
    }
  }
});

// Legacy SSE — kept for backward compatibility
app.get('/sse', async (req, res) => {
  const callerAgentId = req.callerAgentId ?? resolveCallerAgent(authHeaderToken(req), AGENT_TOKENS);
  const transport = new SSEServerTransport('/messages', res);
  bindSseSession(sseTransports, transport.sessionId, transport, req);
  res.on('close', () => sseTransports.delete(transport.sessionId));
  await createServer(callerAgentId).connect(transport);
});

app.post('/messages', async (req, res) => {
  // kb#180: a live sessionId is no longer sufficient — the POST must carry the
  // same caller identity that opened the session at /sse.
  const auth = authorizeSseSession(sseTransports, req.query.sessionId, req);
  if (!auth.ok) return res.status(auth.status).json({ error: `/messages rejected: ${auth.reason} (kb#180)` });
  await auth.transport.handlePostMessage(req, res);
});

app.get('/health', (_, res) => res.json({
  status: 'ok',
  tools: registeredToolCount,
  vaultTrustEnforced: ENFORCE_VAULT_TRUST,
  tokenMapSize: AGENT_TOKEN_COUNT,
  // kb#182: surfaces the vault-brick footgun (ENFORCE=1 + no tokens = fail-closed
  // for everyone, including Adolf) directly in /health instead of only at boot log.
  vaultBrickRisk: ENFORCE_VAULT_TRUST && AGENT_TOKEN_COUNT === 0,
  // kb#180: whether the listener itself (not just vw_*) requires a bearer
  // token. false here means an unauthenticated JSON-RPC surface on the LAN.
  listenerAuthEnabled: requireAuthEnabled(),
}));

// kb#170: plain-REST twin of the todoist_capture_idea MCP tool, added for
// the todoist-capture-plugin native `/idea` command (openai/
// todoist-capture-plugin) — a native-command handler doesn't speak MCP
// JSON-RPC, so it needs a plain JSON endpoint to reach the same
// classify+create logic (capture.js) the MCP tool already exposes to
// Adolf/Claude's model-driven path. No new trust boundary: same
// unauthenticated-on-localhost posture as every other route in this file
// today (see trust-gate.js header for the tracked gap).
app.post('/capture-idea', async (req, res) => {
  try {
    const { text, project_id } = req.body || {};
    const result = await todoistCaptureIdea({ text, project_id }, { listProjects: todoistListProjects });
    res.json(result);
  } catch (e) {
    res.status(400).json({ error: e.message });
  }
});

// kb#179: guard the real init()+listen() side effects so this module can be
// `import`-ed by tests (trust-gate-http.test.mjs) to exercise the real
// createServer()/requireVaultAccess()/app on a throwaway port WITHOUT
// touching Vaultwarden/Gitea/HA/Zabbix or the live :3100 container. Only run
// the side effects when server.js is executed directly (`node src/server.js`
// / the production container entrypoint), never on import.
const isMainModule = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
  // kb#180: refuse to boot into an "authenticated but nobody can authenticate"
  // state (auth on + empty token map). Crashing here makes a missing
  // AGAP_MCP_AGENT_TOKENS look like a broken restart instead of a silently
  // dead integration. Only reached when run directly, never on import.
  try {
    assertListenerAuthConfig(AGENT_TOKENS);
  } catch (e) {
    console.error(`agap-mcp refusing to start: ${e.message}`);
    process.exit(1);
  }
  init()
    .then(() => {
      app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`));
    })
    .catch(e => {
      console.error('Init failed:', e.message);
      process.exit(1);
    });
}

// --- kb#179 test-only exports -------------------------------------------
// Exposes the exact functions/objects the production /mcp route uses so
// integration tests can boot the real enforcement path (ENFORCE=1 + a
// synthetic token map/registry) over real HTTP, instead of re-implementing
// the gate inline. Importing these does not start the server or call init().
// kb#180 additionally exports the live sse session map so the HTTP tests can
// assert /messages session-binding without reaching into module internals.
export { app, createServer, requireVaultAccess, ENFORCE_VAULT_TRUST, AGENT_TOKENS, sseTransports };
