#!/usr/bin/env -S npx tsx
/**
 * Browser Bridge — WebSocket control plane for Forge ↔ Extension.
 *
 * Runs as an independent process (like terminal/workspace standalones).
 * Exposes:
 *   - HTTP on $BRIDGE_PORT/api/* — push events, RPC dispatch, status.
 *   - WebSocket on $BRIDGE_PORT/ws — long-lived bidirectional channel
 *     used by the extension service worker.
 *
 * Auth model (post-Phase 4):
 *   The bridge accepts the user's existing Forge token (X-Forge-Token,
 *   issued by /api/auth/verify against the admin password). On every
 *   `hello`, the bridge validates the token via /api/auth/check on
 *   Next.js. There's no separate pairing code or long-lived bridge
 *   token — extensions reuse the credential they already have.
 *
 * Protocol (newline-delimited JSON frames):
 *
 *   Extension → Forge:
 *     { type: 'hello', forge_token, version? }
 *     { type: 'rpc_result', id, ok, value, error? }
 *     { type: 'event', name, payload }
 *     { type: 'ping' }
 *
 *   Forge → Extension:
 *     { type: 'hello_ok', ext_id }
 *     { type: 'hello_error', message }
 *     { type: 'rpc_request', id, method, params }
 *     { type: 'push', topic, payload }
 *     { type: 'pong' }
 *
 * RPC `method` examples (extension implements these):
 *   browser.execute_script   { tabId, world, scriptBody, args }
 *   browser.list_tabs        { url? }
 *   browser.navigate         { tabId, url }
 *   browser.get_tab          { tabId }
 *
 * Usage: npx tsx lib/browser-bridge-standalone.ts [--forge-port=8403]
 */

import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocket, WebSocketServer } from 'ws';
import { randomUUID, createHash } from 'node:crypto';

// ─── Config ───────────────────────────────────────────────

const PORT = Number(process.env.BRIDGE_PORT) || 8407;
const FORGE_PORT = Number(process.env.PORT) || 8403;
const RPC_TIMEOUT_MS = 60_000;
// Ceiling for per-call overrides. Kept just under undici's default 300s
// headersTimeout on the loopback fetch in bridge-client.ts — past that the
// client fetch dies first with an opaque error. Genuinely long backend work
// (multi-minute NAC upgrades) should fire-and-poll, not hold the RPC open.
const RPC_TIMEOUT_MAX_MS = 280_000;
const TOKEN_CACHE_TTL_MS = 60_000;

// ─── Forge-token validation (with short-lived cache) ──────

const tokenCache = new Map<string, number>(); // token → expires_at

async function isForgeTokenValid(token: string): Promise<boolean> {
  if (!token) return false;
  const cached = tokenCache.get(token);
  if (cached && cached > Date.now()) return true;
  try {
    const res = await fetch(`http://127.0.0.1:${FORGE_PORT}/api/auth/check`, {
      method: 'POST',
      headers: { 'x-forge-token': token, 'content-type': 'application/json' },
      body: '{}',
    });
    if (res.ok) {
      tokenCache.set(token, Date.now() + TOKEN_CACHE_TTL_MS);
      return true;
    }
    tokenCache.delete(token);
    return false;
  } catch (e) {
    console.warn('[bridge] token check failed:', (e as Error).message);
    return false;
  }
}

// Synthetic ext_id derived from the token so reconnects keep stable
// identity (and so the side panel UI can show "connected as <hash>").
function extIdForToken(token: string): string {
  const h = createHash('sha256').update(token).digest('hex');
  return 'tok-' + h.slice(0, 12);
}

// ─── Connected extensions ─────────────────────────────────

interface ExtClient {
  ext_id: string;
  ws: WebSocket;
  connected_at: number;
  name?: string;
}

const clients = new Map<string, ExtClient>(); // ext_id → client

function pickAnyClient(): ExtClient | null {
  let best: ExtClient | null = null;
  for (const c of clients.values()) {
    if (!best || c.connected_at > best.connected_at) best = c;
  }
  return best;
}

// ─── RPC tracking (Forge → Extension) ─────────────────────

interface PendingRpc {
  resolve: (value: unknown) => void;
  reject: (err: Error) => void;
  timer: NodeJS.Timeout;
}

const pendingRpcs = new Map<string, PendingRpc>(); // rpc_id → callbacks

function callExtension(method: string, params: unknown, timeoutMs?: number): Promise<unknown> {
  const client = pickAnyClient();
  if (!client) {
    return Promise.reject(new Error('No extension connected to the bridge.'));
  }
  const id = randomUUID();
  const effectiveTimeout = Math.min(
    Math.max(1000, Number(timeoutMs) || RPC_TIMEOUT_MS),
    RPC_TIMEOUT_MAX_MS,
  );
  return new Promise<unknown>((resolve, reject) => {
    const timer = setTimeout(() => {
      pendingRpcs.delete(id);
      reject(new Error(`RPC ${method} timed out after ${effectiveTimeout / 1000}s`));
    }, effectiveTimeout);
    pendingRpcs.set(id, { resolve, reject, timer });
    client.ws.send(JSON.stringify({ type: 'rpc_request', id, method, params }));
  });
}

// ─── WebSocket handlers ───────────────────────────────────

function handleFrame(ws: WebSocket, raw: string): void {
  let frame: any;
  try { frame = JSON.parse(raw); }
  catch {
    ws.send(JSON.stringify({ type: 'error', message: 'invalid JSON' }));
    return;
  }

  switch (frame.type) {
    case 'hello':
      void handleHello(ws, frame);
      break;
    case 'rpc_result':
      handleRpcResult(frame);
      break;
    case 'event':
      handleEvent(frame);
      break;
    case 'ping':
      ws.send(JSON.stringify({ type: 'pong' }));
      break;
    default:
      ws.send(JSON.stringify({ type: 'error', message: 'unknown frame type: ' + frame.type }));
  }
}

async function handleHello(ws: WebSocket, frame: { forge_token?: string; name?: string; version?: string }): Promise<void> {
  const token = String(frame.forge_token || '').trim();
  if (!token) {
    ws.send(JSON.stringify({ type: 'hello_error', message: 'forge_token required' }));
    try { ws.close(4001, 'unauthorized'); } catch {}
    return;
  }
  const ok = await isForgeTokenValid(token);
  if (!ok) {
    ws.send(JSON.stringify({ type: 'hello_error', message: 'token invalid or expired — log in to Forge to refresh' }));
    try { ws.close(4001, 'unauthorized'); } catch {}
    return;
  }
  const ext_id = extIdForToken(token);
  attachClient(ws, ext_id, frame.name);
  ws.send(JSON.stringify({ type: 'hello_ok', ext_id }));
  console.log(`[bridge] Extension connected: ${ext_id} (${frame.name || 'unnamed'})`);
}

function attachClient(ws: WebSocket, ext_id: string, name?: string): void {
  const prev = clients.get(ext_id);
  if (prev && prev.ws !== ws) {
    try { prev.ws.close(4000, 'replaced'); } catch {}
  }
  clients.set(ext_id, { ext_id, ws, connected_at: Date.now(), name });
  ws.on('close', () => {
    const current = clients.get(ext_id);
    if (current && current.ws === ws) clients.delete(ext_id);
    console.log(`[bridge] Extension disconnected: ${ext_id}`);
  });
}

function handleRpcResult(frame: { id: string; ok: boolean; value?: unknown; error?: string }): void {
  const pending = pendingRpcs.get(frame.id);
  if (!pending) return;
  pendingRpcs.delete(frame.id);
  clearTimeout(pending.timer);
  if (frame.ok) pending.resolve(frame.value);
  else pending.reject(new Error(frame.error || 'rpc failed without message'));
}

function handleEvent(frame: { name: string; payload: unknown }): void {
  // Reserved for extension-initiated push (tab changed, side panel opened, etc.)
  console.log(`[bridge] Event from extension: ${frame.name}`, frame.payload);
}

// ─── HTTP API ─────────────────────────────────────────────

async function readBody(req: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
    req.on('error', reject);
  });
}

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

async function handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {
  if (req.method === 'OPTIONS') {
    res.setHeader('access-control-allow-origin', '*');
    res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
    res.setHeader('access-control-allow-headers', 'content-type');
    res.statusCode = 204;
    res.end();
    return;
  }

  const url = new URL(req.url || '/', 'http://localhost');

  if (req.method === 'GET' && url.pathname === '/api/status') {
    return sendJson(res, 200, {
      port: PORT,
      forge_port: FORGE_PORT,
      connected_extensions: clients.size,
      pending_rpcs: pendingRpcs.size,
      cached_tokens: tokenCache.size,
      uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
    });
  }

  // POST /api/rpc — Forge internals call this to dispatch an RPC to the extension
  if (req.method === 'POST' && url.pathname === '/api/rpc') {
    try {
      const body = JSON.parse(await readBody(req));
      const value = await callExtension(body.method, body.params, body.timeout_ms);
      return sendJson(res, 200, { ok: true, value });
    } catch (e) {
      return sendJson(res, 200, { ok: false, error: (e as Error).message });
    }
  }

  // POST /api/push — push event to all connected extensions
  if (req.method === 'POST' && url.pathname === '/api/push') {
    try {
      const body = JSON.parse(await readBody(req));
      const frame = JSON.stringify({ type: 'push', topic: body.topic, payload: body.payload });
      let count = 0;
      for (const client of clients.values()) {
        try { client.ws.send(frame); count++; } catch {}
      }
      return sendJson(res, 200, { ok: true, delivered_to: count });
    } catch (e) {
      return sendJson(res, 400, { ok: false, error: (e as Error).message });
    }
  }

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

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

const startTime = Date.now();
const httpServer = createServer((req, res) => {
  handleHttp(req, res).catch((err) => {
    console.error('[bridge] http error:', err);
    sendJson(res, 500, { error: 'internal' });
  });
});

const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws) => {
  ws.on('message', (data) => handleFrame(ws, data.toString('utf-8')));
  ws.on('error', (err) => console.warn('[bridge] ws error:', err.message));
});

httpServer.on('upgrade', (req, socket, head) => {
  const url = new URL(req.url || '/', 'http://localhost');
  if (url.pathname !== '/ws') {
    socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req);
  });
});

httpServer.listen(PORT, () => {
  console.log(`[bridge] Browser bridge listening on http://localhost:${PORT} (ws on /ws)`);
});

function shutdown(): void {
  console.log('[bridge] shutting down');
  for (const c of clients.values()) {
    try { c.ws.close(1001, 'server shutting down'); } catch {}
  }
  httpServer.close(() => process.exit(0));
  setTimeout(() => process.exit(0), 2000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
