#!/usr/bin/env -S npx tsx
/**
 * Chat backend standalone — HTTP server on $CHAT_PORT (default 8408).
 *
 * Why a standalone:
 *   - Exclusive writer to chat_sessions / chat_messages tables, mirroring
 *     the workspace daemon pattern (avoids multi-writer races across
 *     Next.js workers).
 *   - One canonical endpoint for every surface: web `/chat` tab (via Next
 *     proxy), `forge chat` CLI, Telegram bot, extension side panel.
 *   - LLM streams can run for tens of seconds without occupying a Next
 *     worker for the whole turn.
 *
 * Loopback-only — Next.js routes proxy requests in; CLI/Telegram talk
 * directly. Auth happens at the Next.js / Telegram layer.
 *
 * Routes:
 *   GET    /api/status
 *   GET    /api/sessions
 *   POST   /api/sessions                       { title?, model?, provider?, system_prompt?, meta? }
 *   GET    /api/sessions/:id?after_ts=&limit=
 *   PATCH  /api/sessions/:id
 *   DELETE /api/sessions/:id
 *   POST   /api/sessions/:id/messages          { text } → 202 { topic }
 *   GET    /api/sessions/:id/events            SSE stream of agent events
 *
 * Fanout:
 *   Each agent event is delivered to (a) local SSE subscribers and
 *   (b) the browser bridge as `chat:<session_id>` push. Browser surfaces
 *   keep their existing WS path; HTTP-only surfaces use SSE.
 */

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import {
  createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
  clearSessionMessages, ensureMainSession, forkSession, appendMessage,
} from './chat/session-store';
import { runTurn, type AgentEvent } from './chat/agent-loop';
import { requestAbort, addNote, isTurnRunning } from './chat/turn-control';
import { bridgePush } from './chat/bridge-client';
import { startWatchRunner } from './watch/watch-runner';

const PORT = Number(process.env.CHAT_PORT) || 8408;
const startTime = Date.now();

// ─── SSE subscriber registry ──────────────────────────────

const sseSubs = new Map<string, Set<ServerResponse>>(); // session_id → subscribers

function addSseSub(sessionId: string, res: ServerResponse): void {
  let set = sseSubs.get(sessionId);
  if (!set) { set = new Set(); sseSubs.set(sessionId, set); }
  set.add(res);
}

function removeSseSub(sessionId: string, res: ServerResponse): void {
  const set = sseSubs.get(sessionId);
  if (!set) return;
  set.delete(res);
  if (set.size === 0) sseSubs.delete(sessionId);
}

function fanoutEvent(sessionId: string, event: AgentEvent): void {
  // (a) browser bridge push — extension side panels listen on this topic.
  void bridgePush(`chat:${sessionId}`, event);
  // (b) local SSE subscribers — CLI, web tab, future surfaces.
  const set = sseSubs.get(sessionId);
  if (!set || set.size === 0) return;
  const frame = `data: ${JSON.stringify(event)}\n\n`;
  for (const res of set) {
    try { res.write(frame); } catch {}
  }
}

// ─── HTTP helpers ─────────────────────────────────────────

async function readJson(req: IncomingMessage): Promise<any> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      const raw = Buffer.concat(chunks).toString('utf-8');
      if (!raw) return resolve({});
      try { resolve(JSON.parse(raw)); } catch (e) { reject(e); }
    });
    req.on('error', reject);
  });
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  res.statusCode = status;
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify(body));
}

// ─── Route handlers ───────────────────────────────────────

async function handleSessionsList(_req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
  const limit = Math.min(200, Math.max(1, Number(url.searchParams.get('limit') || 50)));
  sendJson(res, 200, { sessions: listSessions(limit) });
}

async function handleSessionCreate(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const body = await readJson(req);
  const session = createSession({
    title: body.title,
    model: body.model,
    provider: body.provider,
    system_prompt: body.system_prompt,
    meta: body.meta,
  });
  sendJson(res, 200, { session });
}

async function handleSessionGet(_req: IncomingMessage, res: ServerResponse, id: string, url: URL): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const after_ts = Number(url.searchParams.get('after_ts') || 0);
  const limit = Math.min(2000, Math.max(1, Number(url.searchParams.get('limit') || 500)));
  sendJson(res, 200, { session, messages: listMessages(id, { after_ts, limit }) });
}

async function handleSessionPatch(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const body = await readJson(req);
  const ok = updateSession(id, {
    title: body.title,
    model: body.model,
    provider: body.provider,
    system_prompt: body.system_prompt,
    meta: body.meta,
  });
  sendJson(res, 200, { ok, session: getSession(id) });
}

async function handleSessionDelete(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  // Refuse to delete the persistent main session — the UI is allowed to
  // "clear" it (drop messages, keep the session) via DELETE /messages.
  const session = getSession(id);
  if (session?.meta && (session.meta as any).kind === 'main') {
    return sendJson(res, 400, { error: 'cannot delete the main session — use DELETE /api/sessions/:id/messages to clear it' });
  }
  const ok = deleteSession(id);
  sendJson(res, 200, { ok });
}

async function handleSessionClearMessages(_req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  // Intent: "Clear chat" only drops chat_messages rows. memory_store
  // blocks (cursor / health / summary / facts) stay — once a fact has
  // been extracted into long-term memory it should survive clearing
  // the conversation it came from. Users can delete memory explicitly
  // from the memory tab if they really want to forget. See
  // forge-chat-memory-summarizer-design.md §11 decision 3.
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const removed = clearSessionMessages(id);
  sendJson(res, 200, { ok: true, removed });
}

async function handleSessionFork(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const body = await readJson(req).catch(() => ({}));
  const title = body?.title ? String(body.title) : undefined;
  const upToTs = typeof body?.up_to_ts === 'number' ? body.up_to_ts : undefined;
  const fork = forkSession(id, { title, upToTs });
  if (!fork) return sendJson(res, 404, { error: 'source session not found' });
  sendJson(res, 200, { ok: true, session: fork });
}

async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const body = await readJson(req);
  const text = String(body?.text || '').trim();
  if (!text) return sendJson(res, 400, { error: 'text is required' });

  // Merge instead of forking: if a turn is already running on this session,
  // queue the text as a note for the running turn instead of starting a
  // second concurrent runTurn. Two parallel loops on the same session
  // (e.g. extension + webchat both sending) would otherwise interleave
  // tool calls and share turn-control state — one abort would stop both,
  // and the user sees the same task twice. This makes the merge happen
  // server-side so it doesn't depend on which client is sending.
  if (isTurnRunning(id)) {
    const queued = addNote(id, text);
    if (queued) {
      return sendJson(res, 202, { accepted: true, merged: true, topic: `chat:${id}` });
    }
    // Tiny race window: isTurnRunning was true but turn ended before we
    // queued. Fall through to start a fresh turn with this text.
  }

  const startedAt = Date.now();
  void runTurn({
    sessionId: id,
    userText: text,
    callbacks: { onEvent: (e: AgentEvent) => fanoutEvent(id, e) },
  }).then((r) => {
    if (!r.ok) fanoutEvent(id, { type: 'error', data: { error: r.error || 'unknown' } });
    console.log(`[chat] turn ${id.slice(0, 8)} done in ${Date.now() - startedAt}ms ok=${r.ok}`);
  }).catch((err) => {
    fanoutEvent(id, { type: 'error', data: { error: (err as Error).message } });
    console.error('[chat] turn error', err);
  });

  sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
}

/**
 * Inject a pre-formed message into a session WITHOUT running a turn —
 * used by schedule actions (and similar server-side surfaces) to push
 * a notification into an open chat. Going via this endpoint (instead
 * of calling appendMessage in the caller's process) is what gives the
 * UI a real-time `message_saved` SSE push; otherwise the message lands
 * in SQLite but the open tab only sees it after a reload.
 */
async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const body = await readJson(req);
  const role: 'user' | 'assistant' = body?.role === 'user' ? 'user' : 'assistant';
  const blocks = Array.isArray(body?.blocks) ? body.blocks : null;
  if (!blocks || blocks.length === 0) return sendJson(res, 400, { error: 'blocks[] required' });
  const saved = appendMessage({ session_id: id, role, blocks });
  // Payload shape must match what agent-loop emits — extension's
  // messageFromServer(event.data) reads .id/.role/.blocks/.ts directly.
  fanoutEvent(id, { type: 'message_saved', message_id: saved.id, data: saved });
  sendJson(res, 200, { ok: true, message_id: saved.id });
}

// Abort the in-flight tool-call loop for a session. The loop breaks at its
// next iteration boundary and persists a "⏹ Stopped" sentinel. No-op (409)
// when no turn is running.
async function handleAbortPost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  // Loud log: every abort goes through here. If "Stop" fires without a
  // visible UI click, this print pins the culprit (user-agent / referer).
  const accepted = requestAbort(id);
  if (!accepted) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn to stop' });
  return sendJson(res, 200, { ok: true, running: true });
}

// Queue supplementary info for the running turn — the loop splices it in as
// a user message the agent sees on its next iteration. 409 when idle (the
// client should just send a normal message instead). The note appears in
// the transcript when the loop consumes it (single source of truth).
async function handleNotePost(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });
  const body = await readJson(req);
  const text = String(body?.text || '').trim();
  if (!text) return sendJson(res, 400, { error: 'text is required' });
  const queued = addNote(id, text);
  if (!queued) return sendJson(res, 409, { ok: false, running: false, error: 'no active turn — send it as a normal message instead' });
  return sendJson(res, 200, { ok: true, running: true });
}

function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
  // Verify the session exists before opening the stream.
  const session = getSession(id);
  if (!session) return sendJson(res, 404, { error: 'session not found' });

  res.statusCode = 200;
  res.setHeader('content-type', 'text/event-stream');
  res.setHeader('cache-control', 'no-cache, no-transform');
  res.setHeader('connection', 'keep-alive');
  res.setHeader('x-accel-buffering', 'no'); // disable proxy buffering
  res.write(`: subscribed to chat:${id}\n\n`);

  addSseSub(id, res);

  // Heartbeat every 25s keeps proxies from idling out.
  const hb = setInterval(() => {
    try { res.write(`: ping ${Date.now()}\n\n`); } catch {}
  }, 25_000);
  hb.unref?.();

  const cleanup = () => {
    clearInterval(hb);
    removeSseSub(id, res);
    try { res.end(); } catch {}
  };
  res.on('close', cleanup);
  res.on('error', cleanup);
}

// ─── Router ───────────────────────────────────────────────

async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const url = new URL(req.url || '/', 'http://localhost');
  const m = req.method || 'GET';

  if (m === 'GET' && url.pathname === '/api/status') {
    return sendJson(res, 200, {
      port: PORT,
      uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
      active_sse_sessions: sseSubs.size,
      sse_subscribers: [...sseSubs.values()].reduce((n, s) => n + s.size, 0),
    });
  }

  if (m === 'GET' && url.pathname === '/api/sessions') return handleSessionsList(req, res, url);
  if (m === 'POST' && url.pathname === '/api/sessions') return handleSessionCreate(req, res);
  if (m === 'GET' && url.pathname === '/api/sessions/main') {
    return sendJson(res, 200, { session: ensureMainSession() });
  }

  const detail = /^\/api\/sessions\/([^/]+)$/.exec(url.pathname);
  if (detail) {
    const id = detail[1]!;
    if (m === 'GET') return handleSessionGet(req, res, id, url);
    if (m === 'PATCH') return handleSessionPatch(req, res, id);
    if (m === 'DELETE') return handleSessionDelete(req, res, id);
  }

  const messages = /^\/api\/sessions\/([^/]+)\/messages$/.exec(url.pathname);
  if (messages && m === 'POST')   return handleMessagePost(req, res, messages[1]!);
  if (messages && m === 'DELETE') return handleSessionClearMessages(req, res, messages[1]!);

  const inject = /^\/api\/sessions\/([^/]+)\/inject$/.exec(url.pathname);
  if (inject && m === 'POST') return handleInjectMessage(req, res, inject[1]!);

  const abort = /^\/api\/sessions\/([^/]+)\/abort$/.exec(url.pathname);
  if (abort && m === 'POST') return handleAbortPost(req, res, abort[1]!);

  const note = /^\/api\/sessions\/([^/]+)\/note$/.exec(url.pathname);
  if (note && m === 'POST') return handleNotePost(req, res, note[1]!);

  const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
  if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);

  const events = /^\/api\/sessions\/([^/]+)\/events$/.exec(url.pathname);
  if (events && m === 'GET') return handleEventsSse(req, res, events[1]!);

  sendJson(res, 404, { error: 'not found' });
}

// ─── Boot ─────────────────────────────────────────────────

const httpServer = createServer((req, res) => {
  route(req, res).catch((err) => {
    console.error('[chat] http error:', err);
    try { sendJson(res, 500, { error: 'internal' }); } catch {}
  });
});

httpServer.listen(PORT, '127.0.0.1', () => {
  console.log(`[chat] Chat standalone listening on http://127.0.0.1:${PORT}`);
  // Make sure exactly one persistent main session exists. The UI uses
  // meta.kind === 'main' to pin it, refuse delete, and route the
  // top-level "clear" button to DELETE /messages instead.
  try {
    const main = ensureMainSession();
    console.log(`[chat] Main session: ${main.id.slice(0, 8)} "${main.title}"`);
  } catch (e) { console.warn('[chat] ensureMainSession failed:', (e as Error).message); }

  // Background long-task watches: poll to completion, then feed the
  // result back into the originating session (assistant replies) or push
  // ambient progress (status chip, not a message).
  startWatchRunner({
    onProgress: (sessionId, payload) => fanoutEvent(sessionId, { type: 'watch_status', data: payload }),
    runChat: (sessionId, text) => {
      void runTurn({ sessionId, userText: text, callbacks: { onEvent: (e) => fanoutEvent(sessionId, e) } })
        .catch((err) => console.error('[watch] runChat failed', (err as Error).message));
    },
  });
});

function shutdown(): void {
  console.log('[chat] shutting down');
  for (const set of sseSubs.values()) {
    for (const res of set) { try { res.end(); } catch {} }
  }
  sseSubs.clear();
  httpServer.close(() => process.exit(0));
  setTimeout(() => process.exit(0), 2000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
