#!/usr/bin/env node
/**
 * AgentsRoom Test-Runner MCP server (stdio).
 *
 * Exposed to every agent in a project. Lets a master agent (e.g. an Opus
 * "dev" agent) delegate a QA scenario to an ephemeral sub-agent ("QA Bot")
 * running on a cheaper model. The sub-agent has access to the browser-mcp
 * and electron-mcp tools and reports a verdict via the qa-tester-mcp.
 *
 * Single tool: `run_qa_test(scenario, timeoutMs?)`.
 *
 * Wire format (over the local browser-bridge WS):
 *   →  { op: 'qa:start',
 *        projectId, params: { scenario, parentAgentId, projectPath, timeoutMs } }
 *   ←  { ok: true, result: { runId } }
 *   →  { op: 'qa:wait', projectId, params: { runId, timeoutMs } }
 *   ←  { ok: true, result: { verdict: { status, summary, details } } }
 *
 * The bridge handles every op; this script only knows the JSON-RPC plumbing
 * and the env contract.
 *
 * Required env vars
 * -----------------
 *   AR_BROWSER_BRIDGE_FILE : path to the bridge descriptor JSON (port+token)
 *   AR_BROWSER_PROJECT_ID  : stable AgentsRoom project id
 *   AR_PROJECT_PATH        : absolute path of the user project (so the
 *                            spawned QA agent can be wired against it)
 *
 * Stays dependency-free for the same reason browser-mcp-server.cjs does.
 */

'use strict';

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

// Never die on a stray async error: the `line` handler below is async, so an
// unhandled rejection used to be fatal and stripped `run_qa_test` from the
// agent for the rest of the session.
installStdioGuards('AgentsRoom-Test-Runner');

const BRIDGE_FILE = process.env.AR_BROWSER_BRIDGE_FILE;
const PROJECT_ID = process.env.AR_BROWSER_PROJECT_ID;
const PROJECT_PATH = process.env.AR_PROJECT_PATH;
const SERVER_VERSION = '1.1.0';

// Self-disable instead of exiting: a stale `.mcp.json` entry (this server
// gained AR_PROJECT_PATH after it shipped, so entries written by older builds
// lack it) would otherwise surface as "handshaking with MCP server failed".
const ENV_OK = Boolean(BRIDGE_FILE && PROJECT_ID && PROJECT_PATH);
const ENV_ERROR =
  'QA delegation is unavailable: this MCP server was started without '
  + 'AR_BROWSER_BRIDGE_FILE / AR_BROWSER_PROJECT_ID / AR_PROJECT_PATH. The '
  + '`.mcp.json` entry is stale — reopen the project in AgentsRoom to have it rewritten.';
if (!ENV_OK) {
  console.error(`[agentsroom-test-runner-mcp] ${ENV_ERROR}`);
}

// ── current-agent.json reader ────────────────────────────────────────────
// Used to discover the master agent id at the moment the tool is called,
// so the QA Bot's events can be folded back under it in the trace panel.
const CURRENT_AGENT_FILE = path.join(os.homedir(), '.agentsroom', 'mcp-events', 'current-agent.json');
function readMasterAgentId() {
  try {
    const raw = fs.readFileSync(CURRENT_AGENT_FILE, 'utf8');
    const parsed = JSON.parse(raw);
    if (typeof parsed.agentId === 'string' && parsed.agentId.length > 0) return parsed.agentId;
  } catch { /* ignore */ }
  return null;
}

// ── Bridge descriptor (port + token) ─────────────────────────────────────
function readBridgeConfig() {
  let raw;
  try {
    raw = fs.readFileSync(BRIDGE_FILE, 'utf8');
  } catch {
    // No descriptor = the desktop is not running. Say that, rather than
    // 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 };
}

// ── Minimal WebSocket client (same shape as browser-mcp-server.cjs) ──────
class SimpleWS {
  constructor(url) {
    this.url = new URL(url);
    this.socket = null;
    this.connected = false;
    this.handlers = new Map();
    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 || 80;
      const p = this.url.pathname + (this.url.search || '');
      this.socket = net.connect(Number(port), host, () => {
        const handshake = `GET ${p} HTTP/1.1\r\nHost: ${host}:${port}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`;
        this.socket.write(handshake);
      });
      let handshakeParsed = false;
      this.socket.on('data', (chunk) => {
        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('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('ws closed'));
        this.handlers.clear();
      });
    });
  }
  _consumeFrames() {
    while (this.buffer.length >= 2) {
      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;
        payloadLen = Number(this.buffer.readBigUInt64BE(2)); 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) {
        const text = payload.toString('utf8');
        try {
          const parsed = JSON.parse(text);
          if (parsed && parsed.id && this.handlers.has(parsed.id)) {
            const { resolve } = this.handlers.get(parsed.id);
            this.handlers.delete(parsed.id);
            resolve(parsed);
          }
        } catch { /* ignore */ }
      }
    }
  }
  _sendFrame(opcode, payload) {
    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) {
    return new Promise((resolve, reject) => {
      if (!this.connected) return reject(new Error('ws not connected'));
      const id = String(this._nextId++);
      const t = timeoutMs && timeoutMs > 0 ? timeoutMs : 30_000;
      const timer = setTimeout(() => {
        if (this.handlers.has(id)) {
          this.handlers.delete(id);
          reject(new Error(`bridge rpc timeout (${t}ms)`));
        }
      }, t);
      const settle = (fn) => (v) => { clearTimeout(timer); fn(v); };
      this.handlers.set(id, { resolve: settle(resolve), reject: settle(reject) });
      try { this._sendFrame(0x1, Buffer.from(JSON.stringify({ ...obj, id }), 'utf8')); }
      catch (err) { clearTimeout(timer); reject(err); }
    });
  }
  close() { try { this.socket && this.socket.end(); } catch {} }
  /** Hard teardown: `end()` only half-closes a socket whose peer never replied. */
  destroy() {
    this.connected = false;
    try { this.socket && this.socket.destroy(); } catch {}
  }
}

let ws = null;
let currentToken = null;
async function ensureWs() {
  if (ws && ws.connected) return ws;
  const cfg = readBridgeConfig();
  currentToken = cfg.token;
  ws = new SimpleWS(cfg.wsUrl);
  // Bounded handshake: the bridge lives on the Electron main event loop, so a
  // busy desktop completes the TCP connect from the listen backlog without
  // ever reading the upgrade. Unguarded, that is an agent frozen forever.
  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;
}
async function bridgeCall(op, params, timeoutMs) {
  const attempt = async () => {
    await ensureWs();
    const resp = await ws.send({ op, projectId: PROJECT_ID, params, token: currentToken }, timeoutMs);
    if (!resp || resp.ok === false) {
      const appError = new Error(resp && resp.error || 'bridge error');
      // The desktop ANSWERED and refused. Replaying would start a SECOND QA
      // run for the same request, which the user then has to notice and stop.
      appError.isBridgeAppError = true;
      throw appError;
    }
    return resp.result;
  };
  try { return await attempt(); }
  catch (err) {
    if (err && err.isBridgeAppError) throw err;
    if (ws) { try { ws.close(); } catch {} }
    ws = null; currentToken = null;
    return await attempt();
  }
}

// ── MCP stdio plumbing ───────────────────────────────────────────────────
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 } }); }

const TOOLS = [
  {
    name: 'run_qa_test',
    description:
      "Delegate a test scenario to an ephemeral QA agent ('QA Bot') running on a cheaper model. " +
      "The QA Bot has its own embedded browser tab in the Tests panel and access to browser-mcp + electron-mcp. " +
      "It carries out the scenario you describe, then submits a structured verdict you receive back as the tool result. " +
      "Use this whenever you'd otherwise spend Opus tokens on click/screenshot/assertion loops — drafting the scenario " +
      "is cheap, the QA Bot does the expensive part. Returns { status: 'pass'|'fail'|'inconclusive'|'cancelled'|'timeout', summary, details? }. " +
      "Set timeoutMs only if the scenario genuinely needs more than the default 10 minutes.",
    inputSchema: {
      type: 'object',
      properties: {
        scenario: {
          type: 'string',
          description:
            'Plain-language description of what the QA Bot should test. Include: the URL or app surface to hit, ' +
            'the steps to perform, the conditions for pass/fail. Be specific about credentials/data to use from ' +
            'QA context if relevant.',
        },
        timeoutMs: { type: 'number', description: 'Hard cap on the run, in milliseconds. Defaults to 600000 (10 min).' },
      },
      required: ['scenario'],
    },
  },
];

async function handleRunQaTest(args) {
  const scenario = String(args && args.scenario ? args.scenario : '').trim();
  if (!scenario) throw new Error('scenario is required');
  const timeoutMs = args && typeof args.timeoutMs === 'number' && args.timeoutMs > 0 ? args.timeoutMs : undefined;

  // Capture the master agent id BEFORE the QA Bot takes focus (which would
  // overwrite current-agent.json). The bridge stores it on the run record
  // so events written from the QA Bot are stamped with parentAgentId = master.
  const masterAgentId = readMasterAgentId();

  const start = await bridgeCall('qa:start', {
    scenario,
    projectPath: PROJECT_PATH,
    parentAgentId: masterAgentId,
    timeoutMs,
  }, 15_000);

  if (!start || typeof start.runId !== 'string') {
    throw new Error('bridge did not return a runId');
  }
  const runId = start.runId;

  // Park here until the verdict (or timeout). The bridge enforces the
  // run-level timeout too — we set the WS timeout slightly higher so the
  // bridge's verdict has time to flow back.
  const waitTimeoutMs = (timeoutMs ?? 10 * 60 * 1000) + 30_000;
  const wait = await bridgeCall('qa:wait', { runId }, waitTimeoutMs);
  const v = wait && wait.verdict ? wait.verdict : null;
  if (!v || typeof v.status !== 'string') throw new Error('bridge returned a malformed verdict');
  return {
    status: v.status,
    summary: String(v.summary ?? ''),
    ...(v.details ? { details: String(v.details) } : {}),
    runId,
  };
}

// ── JSON-RPC loop ────────────────────────────────────────────────────────
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', async (line) => {
  if (!line) return;
  let msg;
  try { msg = JSON.parse(line); } catch { return; }
  const id = msg && msg.id;
  if (!msg || typeof msg.method !== 'string') return;

  if (msg.method === 'initialize') {
    sendResult(id, {
      protocolVersion: '2024-11-05',
      capabilities: { tools: {} },
      serverInfo: { name: 'agentsroom-test-runner', version: SERVER_VERSION },
    });
    return;
  }
  if (msg.method === 'tools/list') {
    sendResult(id, { tools: ENV_OK ? TOOLS : [] });
    return;
  }
  if (msg.method === 'tools/call') {
    if (!ENV_OK) {
      sendResult(id, { content: [{ type: 'text', text: ENV_ERROR }], isError: true });
      return;
    }
    const name = msg.params && msg.params.name;
    const args = (msg.params && msg.params.arguments) || {};
    if (name !== 'run_qa_test') {
      sendError(id, -32601, `unknown tool: ${name}`);
      return;
    }
    try {
      const result = await handleRunQaTest(args);
      const summary = `[${result.status}] ${result.summary}${result.details ? '\n\n' + result.details : ''}`;
      sendResult(id, {
        content: [{ type: 'text', text: summary }],
        structuredContent: result,
      });
    } catch (err) {
      // A tool-level failure, NOT a protocol one: `sendError` makes the CLI
      // report the whole server as faulty, which is how a transient bridge
      // hiccup ends up looking like "the MCP server is broken".
      sendResult(id, {
        content: [{ type: 'text', text: err && err.message ? err.message : String(err) }],
        isError: true,
      });
    }
    return;
  }
  if (msg.method === 'shutdown') {
    sendResult(id, null);
    try { if (ws) ws.close(); } catch {}
    process.exit(0);
  }
});

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