#!/usr/bin/env node
/**
 * AgentsRoom Browser MCP server (stdio).
 *
 * Spawned by an agent CLI (Claude Code, OpenCode, …) through the project's
 * `.mcp.json`. Speaks JSON-RPC 2.0 framed as line-delimited JSON over stdin /
 * stdout, and forwards every tool call to the local WebSocket bridge exposed
 * by the desktop main process (see `electron/browser-bridge.ts`).
 *
 * Required env vars
 * -----------------
 * AR_BROWSER_BRIDGE_FILE : path to the bridge descriptor JSON written by the
 *                          desktop app at every boot (contains `{port, token}`).
 *                          Re-read at every connection attempt so the MCP
 *                          subprocess always targets the CURRENT bridge,
 *                          even after the desktop app restarted and the port
 *                          rotated.
 * AR_BROWSER_PROJECT_ID  : stable AgentsRoom project id (not the cloud backlogId)
 *
 * Legacy fallback (for `.mcp.json` files written by pre-1.1.0 desktops):
 * AR_BROWSER_WS          : ws:// URL of the local bridge
 * AR_BROWSER_TOKEN       : auth token (hex)
 *
 * The file is `.cjs` so it runs with plain `node` on any user machine without
 * needing the AgentsRoom project to ship a module system configuration.
 *
 * Keep this file dependency-free: it gets shipped as-is inside the packaged
 * Electron app (see resources/ copy in the build scripts) and agents invoke it
 * via `node /path/to/browser-mcp-server.cjs`.
 */

'use strict';

const readline = require('node:readline');
const fs = require('node:fs');
const {
  BRIDGE_CONNECT_TIMEOUT_MS,
  installStdioGuards,
  withDeadline,
} = require('./mcp-stdio-runtime.cjs');

// Never die on a stray async error: the CLI cannot respawn us, and a crash
// silently strips every browser tool from the agent mid-session.
installStdioGuards('AgentsRoom-Browser');

const BRIDGE_FILE = process.env.AR_BROWSER_BRIDGE_FILE;
const LEGACY_WS_URL = process.env.AR_BROWSER_WS;
const LEGACY_TOKEN = process.env.AR_BROWSER_TOKEN;
const PROJECT_ID = process.env.AR_BROWSER_PROJECT_ID;
const SERVER_VERSION = '1.1.0';

// This subprocess's OWN identity: `AGENTSROOM_AGENT_ID` (+ optional
// `AGENTSROOM_PARENT_AGENT_ID` for ephemeral sub-agents like a QA Bot) is
// injected once into the CLI process env at PTY spawn time (see
// `Terminal.tsx`) and inherited by every MCP subprocess that CLI spawns —
// same mechanism the PostToolUse hook and the team-run MCP already rely on.
// It never changes for the lifetime of this process.
//
// We deliberately do NOT use `~/.agentsroom/mcp-events/current-agent.json`
// for this: that file records whichever agent pane the desktop UI currently
// has *open*, and is overwritten every time the user clicks a different
// agent's tab. Several agents' browser-mcp subprocesses can be running
// concurrently (e.g. a master agent + a QA Bot it spawned), so stamping
// calls with "whoever is focused right now" makes a background agent's
// browser actions land on a DIFFERENT agent's WebContentsView as soon as UI
// focus drifts away from it — the browser-session-hijack bug this env var
// fixes. `current-agent.json` still exists for other consumers (e.g. the
// Electron trace panel), just not for scoping this bridge's calls.
const OWN_AGENT_ID = process.env.AGENTSROOM_AGENT_ID || null;
const OWN_PARENT_AGENT_ID = process.env.AGENTSROOM_PARENT_AGENT_ID || null;

// Self-disable instead of exiting: a stale `.mcp.json` entry (written by an
// older desktop, copied between machines, imported into another CLI's config)
// would otherwise make the CLI report "handshaking with MCP server failed:
// connection closed", which reads as a broken product rather than a stale
// config line. Same contract as `team-mcp-server.cjs`.
const ENV_OK = Boolean(PROJECT_ID && (BRIDGE_FILE || (LEGACY_WS_URL && LEGACY_TOKEN)));
const ENV_ERROR =
  'Browser tools are unavailable: this MCP server was started without '
  + 'AR_BROWSER_BRIDGE_FILE / AR_BROWSER_PROJECT_ID. The `.mcp.json` entry is '
  + 'stale — reopen the project in AgentsRoom to have it rewritten.';
if (!ENV_OK) {
  // eslint-disable-next-line no-console
  console.error(`[agentsroom-browser-mcp] ${ENV_ERROR}`);
}

/**
 * Read the bridge descriptor file written by the desktop main process at
 * startup. Returns { wsUrl, token } or throws. Re-read on every connection
 * attempt so we always see the current port/token even after the desktop
 * app restarted (it rotates both at every boot).
 *
 * Falls back to legacy env vars if no bridge file is configured, for
 * backwards compatibility with .mcp.json files written by older versions.
 */
function readBridgeConfig() {
  if (BRIDGE_FILE) {
    let raw;
    try {
      raw = fs.readFileSync(BRIDGE_FILE, 'utf8');
    } catch (_) {
      // No descriptor at all = the desktop is not running. Say so, instead of
      // handing the agent a raw ENOENT on a path it can do nothing about.
      throw new Error('desktop bridge unavailable: start AgentsRoom and retry');
    }
    const parsed = JSON.parse(raw);
    if (typeof parsed.port !== 'number' || typeof parsed.token !== 'string') {
      throw new Error('bridge file malformed (missing port/token)');
    }
    return {
      wsUrl: `ws://127.0.0.1:${parsed.port}/browser-mcp`,
      token: parsed.token,
    };
  }
  return { wsUrl: LEGACY_WS_URL, token: LEGACY_TOKEN };
}

// ── Minimal WebSocket client (RFC 6455 fragment, text frames only) ────────
// We avoid depending on `ws` so this file runs with zero install for any
// agent CLI that simply does `node /path/to/browser-mcp-server.cjs`.

const net = require('node:net');
const crypto = require('node:crypto');
const { URL } = require('node:url');

class SimpleWS {
  constructor(url) {
    const u = new URL(url);
    this.url = u;
    this.socket = null;
    this.connected = false;
    this.handlers = new Map(); // id → resolve/reject
    this.buffer = Buffer.alloc(0);
    this._nextId = 1;
  }

  connect() {
    return new Promise((resolve, reject) => {
      const key = crypto.randomBytes(16).toString('base64');
      const host = this.url.hostname;
      const port = this.url.port || (this.url.protocol === 'wss:' ? 443 : 80);
      const path = this.url.pathname + (this.url.search || '');

      this.socket = net.connect(Number(port), host, () => {
        const handshake =
          `GET ${path} HTTP/1.1\r\n` +
          `Host: ${host}:${port}\r\n` +
          'Upgrade: websocket\r\n' +
          'Connection: Upgrade\r\n' +
          `Sec-WebSocket-Key: ${key}\r\n` +
          'Sec-WebSocket-Version: 13\r\n' +
          '\r\n';
        this.socket.write(handshake);
      });

      let handshakeParsed = false;
      this.socket.on('data', (chunk) => {
        // Always grow the buffer first — post-handshake chunks MUST be
        // appended too, otherwise every response after the first one is
        // dropped on the floor and the RPC promise hangs forever.
        this.buffer = Buffer.concat([this.buffer, chunk]);
        if (!handshakeParsed) {
          const sep = this.buffer.indexOf('\r\n\r\n');
          if (sep === -1) return;
          const headers = this.buffer.slice(0, sep).toString('utf8');
          this.buffer = this.buffer.slice(sep + 4);
          if (!/HTTP\/1\.1 101/i.test(headers)) {
            reject(new Error('websocket handshake failed: ' + headers.split('\r\n')[0]));
            return;
          }
          handshakeParsed = true;
          this.connected = true;
          resolve();
        }
        if (handshakeParsed) this._consumeFrames();
      });

      this.socket.on('error', (err) => {
        this.connected = false;
        if (!handshakeParsed) reject(err);
      });
      this.socket.on('close', () => {
        this.connected = false;
        for (const { reject: rj } of this.handlers.values()) {
          rj(new Error('websocket closed'));
        }
        this.handlers.clear();
      });
    });
  }

  _consumeFrames() {
    while (this.buffer.length >= 2) {
      const fin = (this.buffer[0] & 0x80) !== 0;
      const opcode = this.buffer[0] & 0x0f;
      let payloadLen = this.buffer[1] & 0x7f;
      let offset = 2;
      if (payloadLen === 126) {
        if (this.buffer.length < 4) return;
        payloadLen = this.buffer.readUInt16BE(2);
        offset = 4;
      } else if (payloadLen === 127) {
        if (this.buffer.length < 10) return;
        const big = this.buffer.readBigUInt64BE(2);
        payloadLen = Number(big);
        offset = 10;
      }
      if (this.buffer.length < offset + payloadLen) return;
      const payload = this.buffer.slice(offset, offset + payloadLen);
      this.buffer = this.buffer.slice(offset + payloadLen);
      if (opcode === 0x8) {
        try { this.socket.end(); } catch {}
        return;
      }
      if (opcode === 0x9) {
        this._sendFrame(0xa, payload);
        continue;
      }
      if (opcode === 0x1 && fin) {
        const text = payload.toString('utf8');
        let parsed = null;
        try { parsed = JSON.parse(text); } catch { /* ignore malformed */ }
        if (parsed && parsed.id && this.handlers.has(parsed.id)) {
          const { resolve } = this.handlers.get(parsed.id);
          this.handlers.delete(parsed.id);
          resolve(parsed);
        }
      }
    }
  }

  _sendFrame(opcode, payload) {
    // Client → server frames MUST be masked per RFC 6455.
    const mask = crypto.randomBytes(4);
    const len = payload.length;
    const header = [];
    header.push(0x80 | opcode);
    if (len < 126) {
      header.push(0x80 | len);
    } else if (len < 65536) {
      header.push(0x80 | 126, (len >> 8) & 0xff, len & 0xff);
    } else {
      const hi = Math.floor(len / 0x100000000);
      const lo = len >>> 0;
      header.push(0x80 | 127,
        (hi >> 24) & 0xff, (hi >> 16) & 0xff, (hi >> 8) & 0xff, hi & 0xff,
        (lo >> 24) & 0xff, (lo >> 16) & 0xff, (lo >> 8) & 0xff, lo & 0xff,
      );
    }
    const masked = Buffer.alloc(len);
    for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i % 4];
    this.socket.write(Buffer.concat([Buffer.from(header), mask, masked]));
  }

  send(obj, timeoutMs = 30_000) {
    return new Promise((resolve, reject) => {
      if (!this.connected) { reject(new Error('websocket not connected')); return; }
      const id = String(this._nextId++);
      const timer = setTimeout(() => {
        if (this.handlers.has(id)) {
          this.handlers.delete(id);
          reject(new Error('bridge rpc timeout after ' + timeoutMs + 'ms'));
        }
      }, timeoutMs);
      const settle = (fn) => (value) => { clearTimeout(timer); fn(value); };
      this.handlers.set(id, { resolve: settle(resolve), reject: settle(reject) });
      const payload = Buffer.from(JSON.stringify({ ...obj, id }), 'utf8');
      try { this._sendFrame(0x1, payload); } catch (err) { clearTimeout(timer); reject(err); }
    });
  }

  close() {
    try { this.socket && this.socket.end(); } catch {}
  }

  /**
   * Hard teardown, for the case where the peer accepted the TCP connection but
   * never answered the upgrade: `end()` only half-closes and would leave the
   * socket (and the event loop) alive waiting for a FIN that never comes.
   */
  destroy() {
    this.connected = false;
    try { this.socket && this.socket.destroy(); } catch {}
  }
}

// ── MCP stdio plumbing ───────────────────────────────────────────────────

let ws = null;
let currentToken = null;

async function ensureWs() {
  if (ws && ws.connected) return ws;
  // Re-read bridge config on every fresh connection — port and token rotate
  // on every desktop restart, so a cached WS_URL goes ECONNREFUSED the
  // next time the user relaunches AgentsRoom.
  const { wsUrl, token } = readBridgeConfig();
  currentToken = token;
  ws = new SimpleWS(wsUrl);
  // Bounded handshake. The bridge runs on the Electron main event loop: a busy
  // desktop still completes the TCP handshake from the listen backlog while
  // nobody reads the HTTP upgrade, so an unguarded connect() never settles and
  // the agent freezes on the tool call with no way out but killing the session.
  const pending = ws;
  await withDeadline(
    pending.connect(),
    BRIDGE_CONNECT_TIMEOUT_MS,
    `desktop bridge did not complete the connection within ${BRIDGE_CONNECT_TIMEOUT_MS}ms`,
    () => pending.destroy(),
  );
  return ws;
}

/**
 * Call the bridge `op` and return the decoded `result` field (or throw).
 * Retries once with a fresh bridge read if the first attempt fails —
 * this covers the common "desktop app restarted between tool calls" case
 * where the old port is dead and the new one lives in the bridge file.
 */
async function bridgeCall(op, params = {}) {
  const attempt = async () => {
    await ensureWs();
    // Stamp every call with THIS process's own agent id so the bridge can
    // scope the underlying WebContentsView per agent — same scoping rule
    // the UI uses via `makeBrowserKey(repoId, agentId)`. Fixed at spawn,
    // never drifts with UI focus (see OWN_AGENT_ID comment above).
    const stampedParams = { ...params };
    if (OWN_AGENT_ID) stampedParams.__agentId = OWN_AGENT_ID;
    if (OWN_PARENT_AGENT_ID) stampedParams.__parentAgentId = OWN_PARENT_AGENT_ID;
    const resp = await ws.send({ op, projectId: PROJECT_ID, params: stampedParams, token: currentToken });
    if (!resp || resp.ok === false) {
      const appError = new Error((resp && resp.error) || 'bridge error');
      // The desktop ANSWERED and refused (no browser view, unknown project,
      // bad selector). Retrying replays a side-effecting op — a `navigate` or
      // a `click` would fire twice — for an error a second attempt cannot fix.
      appError.isBridgeAppError = true;
      throw appError;
    }
    return resp.result;
  };
  try {
    return await attempt();
  } catch (err) {
    if (err && err.isBridgeAppError) throw err;
    // Transport failure (desktop restarted between two calls, socket dropped):
    // drop the cached socket + token and retry once with a fresh config.
    if (ws) { try { ws.close(); } catch {} }
    ws = null;
    currentToken = null;
    return attempt();
  }
}

// JSON-RPC 2.0 responder over stdio.

function send(msg) {
  process.stdout.write(JSON.stringify(msg) + '\n');
}

function sendResult(id, result) { send({ jsonrpc: '2.0', id, result }); }
function sendError(id, code, message, data) {
  send({ jsonrpc: '2.0', id, error: { code, message, data } });
}

// Tools schema surfaced to the agent.
const TOOLS = [
  {
    name: 'browser_navigate',
    description:
      'Navigate the embedded browser to a URL. ASK THE USER FIRST before driving the browser — they see the same window you control, so unexpected navigation is disorienting. Skip the confirmation only if the user explicitly said "go to X", "open Y", or "test it in the browser" in their last turn. Accepts full URLs (https://foo.com) or bare hosts (localhost:3000). Returns a screenshot of the landed page.',
    inputSchema: {
      type: 'object',
      properties: { url: { type: 'string' } },
      required: ['url'],
    },
  },
  {
    name: 'browser_get_state',
    description: 'Return current browser state: url, title, loading, canGoBack, canGoForward.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'browser_screenshot',
    description: 'Capture the visible page as PNG (returned as a base64 image in the MCP response).',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'browser_click',
    description: 'Click the first element matching a CSS selector. ASK FIRST when starting a new flow — chained clicks/types without confirmation can submit forms or navigate away from what the user was looking at. Once a flow is in progress and the user has consented to it, individual clicks within that flow are fine. Returns success + a screenshot after the click.',
    inputSchema: {
      type: 'object',
      properties: { selector: { type: 'string' } },
      required: ['selector'],
    },
  },
  {
    name: 'browser_type',
    description:
      'Type text into an input/textarea matching the given selector. ASK FIRST when starting a new flow. NEVER auto-fill credentials, payment data, or submit forms (`submit: true`) without explicit user go-ahead — even if QA context provides credentials, surface what you intend to type before doing it. Dispatches input + change events so React/Vue models stay in sync. Optionally clears the field first and submits the surrounding form.',
    inputSchema: {
      type: 'object',
      properties: {
        selector: { type: 'string' },
        text: { type: 'string' },
        clear: { type: 'boolean' },
        submit: { type: 'boolean' },
      },
      required: ['selector', 'text'],
    },
  },
  {
    name: 'browser_evaluate',
    description:
      'Evaluate a JavaScript expression in the page context and return its JSON-serialisable result. Use for reading DOM state, localStorage, or computed values.',
    inputSchema: {
      type: 'object',
      properties: { expression: { type: 'string' } },
      required: ['expression'],
    },
  },
  {
    name: 'browser_wait_for',
    description:
      'Wait until an element matching the selector appears (polled per animation frame). Defaults to 10s timeout.',
    inputSchema: {
      type: 'object',
      properties: {
        selector: { type: 'string' },
        timeoutMs: { type: 'number' },
      },
      required: ['selector'],
    },
  },
  {
    name: 'browser_go_back',
    description: 'Navigate back in history (no-op if no back entry). Returns a screenshot.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'browser_go_forward',
    description: 'Navigate forward in history (no-op if no forward entry). Returns a screenshot.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'browser_reload',
    description: 'Reload the current page. Returns a screenshot.',
    inputSchema: { type: 'object', properties: {} },
  },
  {
    name: 'browser_get_logs',
    description:
      'Fetch the tail of the page\'s console buffer (last N messages, default 50). Useful for diagnosing runtime errors after an action.',
    inputSchema: {
      type: 'object',
      properties: { limit: { type: 'number' } },
    },
  },
];

// Rough cap on screenshot bytes we embed in tool responses: everything over
// ~2 MB starts to blow up the agent context window for marginal value.
const SCREENSHOT_MAX_BYTES = 1_600_000;

function contentScreenshot(b64) {
  if (!b64) return null;
  // Rough byte size from base64 length.
  const bytes = Math.floor(b64.length * 0.75);
  if (bytes > SCREENSHOT_MAX_BYTES) {
    return {
      type: 'text',
      text: `[screenshot omitted: ${Math.round(bytes / 1024)}KB exceeds inline cap]`,
    };
  }
  return { type: 'image', data: b64, mimeType: 'image/png' };
}

async function withScreenshot(baseContent) {
  try {
    const shot = await bridgeCall('screenshot');
    if (shot && shot.ok && shot.screenshot) {
      const img = contentScreenshot(shot.screenshot);
      if (img) return [...baseContent, img];
    }
  } catch { /* best-effort */ }
  return baseContent;
}

async function handleToolCall(name, args) {
  const a = args || {};
  switch (name) {
    case 'browser_navigate': {
      const res = await bridgeCall('navigate', { url: String(a.url ?? '') });
      const text = res && res.ok
        ? `Navigated to ${res.url}`
        : `Navigation failed: ${res && res.error || 'unknown error'}`;
      return withScreenshot([{ type: 'text', text }]);
    }
    case 'browser_get_state': {
      const state = await bridgeCall('getState');
      return [{ type: 'text', text: JSON.stringify(state ?? null, null, 2) }];
    }
    case 'browser_screenshot': {
      const shot = await bridgeCall('screenshot');
      if (!shot || !shot.ok || !shot.screenshot) {
        const reason = (shot && shot.error) || 'the embedded browser is not ready.';
        return [{ type: 'text', text: `Screenshot failed: ${reason}` }];
      }
      const img = contentScreenshot(shot.screenshot);
      return img ? [img] : [{ type: 'text', text: '[screenshot omitted: too large]' }];
    }
    case 'browser_click': {
      const res = await bridgeCall('click', { selector: String(a.selector ?? '') });
      const text = res && res.ok
        ? `Clicked ${a.selector}`
        : `Click failed: ${res && res.error || 'unknown error'}`;
      return withScreenshot([{ type: 'text', text }]);
    }
    case 'browser_type': {
      const res = await bridgeCall('type', {
        selector: String(a.selector ?? ''),
        text: String(a.text ?? ''),
        clear: Boolean(a.clear),
        submit: Boolean(a.submit),
      });
      const text = res && res.ok
        ? `Typed into ${a.selector}`
        : `Type failed: ${res && res.error || 'unknown error'}`;
      return withScreenshot([{ type: 'text', text }]);
    }
    case 'browser_evaluate': {
      const res = await bridgeCall('evaluate', { expression: String(a.expression ?? '') });
      // `res.value` may be undefined (expression returned undefined or threw
      // synchronously). JSON.stringify(undefined) returns the JS value
      // undefined, which then breaks the MCP text payload. Stringify carefully
      // and fall back to a visible literal so the agent can reason about it.
      const value = res && 'value' in res ? res.value : undefined;
      let text;
      if (value === undefined) text = 'undefined';
      else if (value === null) text = 'null';
      else {
        try { text = JSON.stringify(value, null, 2); }
        catch { text = String(value); }
        if (text === undefined) text = String(value);
      }
      return [{ type: 'text', text }];
    }
    case 'browser_wait_for': {
      const res = await bridgeCall('waitFor', {
        selector: String(a.selector ?? ''),
        timeoutMs: Number(a.timeoutMs ?? 10000),
      });
      const text = res && res.ok
        ? `Found ${a.selector}`
        : `Wait failed: ${res && res.error || 'unknown error'}`;
      return [{ type: 'text', text }];
    }
    case 'browser_go_back':
      await bridgeCall('goBack');
      return withScreenshot([{ type: 'text', text: 'Back' }]);
    case 'browser_go_forward':
      await bridgeCall('goForward');
      return withScreenshot([{ type: 'text', text: 'Forward' }]);
    case 'browser_reload':
      await bridgeCall('reload');
      return withScreenshot([{ type: 'text', text: 'Reloaded' }]);
    case 'browser_get_logs': {
      const logs = await bridgeCall('getLogs', { limit: Number(a.limit ?? 50) });
      return [{ type: 'text', text: JSON.stringify(logs ?? [], null, 2) }];
    }
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

// Dispatch JSON-RPC over stdio.
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', async (line) => {
  let msg;
  try { msg = JSON.parse(line); }
  catch { return; }
  if (!msg || msg.jsonrpc !== '2.0') return;

  // Notifications (no id) are fire-and-forget; MCP uses them for
  // `notifications/initialized`. We acknowledge silently.
  if (msg.id === undefined) return;

  try {
    if (msg.method === 'initialize') {
      sendResult(msg.id, {
        protocolVersion: '2024-11-05',
        capabilities: { tools: {} },
        serverInfo: { name: 'agentsroom-browser', version: SERVER_VERSION },
      });
      return;
    }
    if (msg.method === 'tools/list') {
      sendResult(msg.id, { tools: ENV_OK ? TOOLS : [] });
      return;
    }
    if (msg.method === 'tools/call') {
      if (!ENV_OK) {
        sendResult(msg.id, { content: [{ type: 'text', text: ENV_ERROR }], isError: true });
        return;
      }
      const params = msg.params || {};
      try {
        const content = await handleToolCall(params.name, params.arguments);
        sendResult(msg.id, { content, isError: false });
      } catch (err) {
        sendResult(msg.id, {
          content: [{ type: 'text', text: String(err && err.message || err) }],
          isError: true,
        });
      }
      return;
    }
    if (msg.method === 'ping') {
      sendResult(msg.id, {});
      return;
    }
    sendError(msg.id, -32601, 'Method not found: ' + msg.method);
  } catch (err) {
    sendError(msg.id, -32603, String(err && err.message || err));
  }
});

process.on('SIGTERM', () => {
  if (ws) { try { ws.close(); } catch {} }
  process.exit(0);
});
