#!/usr/bin/env node
/**
 * AgentsRoom QA-Tester MCP server (stdio).
 *
 * Wired ONLY in the .mcp.json of an ephemeral QA Bot agent (set up by the
 * desktop when spawning the agent). Single tool: `submit_verdict`.
 * Calling it tells the desktop that the QA Bot has finished testing — its
 * result is forwarded back to the master agent that originally invoked
 * `run_qa_test`, and the QA Bot itself is killed shortly after.
 *
 * The run is identified by `AR_QA_RUN_ID` (set by the desktop when the QA
 * agent is spawned). Without it, the tool refuses to submit anything.
 *
 * Wire format:
 *   →  { op: 'qa:submit', projectId, params: { runId, status, summary, details? } }
 *   ←  { ok: true }
 *
 * Required env vars
 * -----------------
 *   AR_BROWSER_BRIDGE_FILE : path to the bridge descriptor JSON (port+token)
 *   AR_BROWSER_PROJECT_ID  : stable AgentsRoom project id
 *   AR_QA_RUN_ID           : the QA run this agent is associated with
 *
 * Dependency-free for the same reason as the other MCP servers.
 */

'use strict';

const readline = require('node:readline');
const fs = require('node:fs');
const net = require('node:net');
const crypto = require('node:crypto');
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 a QA Bot that loses
// `submit_verdict` mid-run can never report, which reads as a stalled run.
installStdioGuards('AgentsRoom-QA-Tester');

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

// Self-disable instead of exiting: a stale `.mcp.json` entry would otherwise
// surface as "handshaking with MCP server failed: connection closed".
const ENV_OK = Boolean(BRIDGE_FILE && PROJECT_ID);
const ENV_ERROR =
  'The QA verdict tool is 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) {
  console.error(`[agentsroom-qa-tester-mcp] ${ENV_ERROR}`);
}

function readBridgeConfig() {
  let raw;
  try {
    raw = fs.readFileSync(BRIDGE_FILE, 'utf8');
  } catch {
    // No descriptor = the desktop is not running.
    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');
  }
  return { wsUrl: `ws://127.0.0.1:${parsed.port}/browser-mcp`, token: parsed.token };
}

// ── Minimal WebSocket client (same as the other MCP servers) ─────────────
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 h = `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(h);
      });
      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) {
        try {
          const parsed = JSON.parse(payload.toString('utf8'));
          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: a busy desktop completes the TCP connect from the
  // listen backlog without ever reading the upgrade, and an unguarded
  // connect() then never settles.
  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) {
  const attempt = async () => {
    await ensureWs();
    const resp = await ws.send({ op, projectId: PROJECT_ID, params, token: currentToken });
    if (!resp || resp.ok === false) {
      const appError = new Error(resp && resp.error || 'bridge error');
      // The desktop ANSWERED and refused (unknown runId, run already closed).
      // Replaying would submit a second verdict for the same run.
      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();
  }
}

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) { send({ jsonrpc: '2.0', id, error: { code, message } }); }

const TOOLS = [
  {
    name: 'submit_verdict',
    description:
      "Submit the final verdict of this QA run, then stop working. " +
      "Call this EXACTLY ONCE at the end of the scenario. " +
      "runId is the QA run id you were given in your system prompt — pass it back exactly. " +
      "Status: 'pass' if all checks succeeded, 'fail' if anything went wrong, " +
      "'inconclusive' if the scenario could not be performed (missing data, broken page, etc.). " +
      "Summary: one or two sentences the calling agent will read. " +
      "Details: optional, longer markdown writeup with steps + observations + selectors involved.",
    inputSchema: {
      type: 'object',
      properties: {
        runId: { type: 'string', description: 'The QA run id from your system prompt.' },
        status: { type: 'string', enum: ['pass', 'fail', 'inconclusive'] },
        summary: { type: 'string', description: 'One- or two-sentence verdict summary.' },
        details: { type: 'string', description: 'Optional longer writeup, markdown allowed.' },
      },
      required: ['runId', 'status', 'summary'],
    },
  },
];

async function handleSubmitVerdict(args) {
  const runId = String(args && args.runId ? args.runId : '').trim();
  const status = String(args && args.status ? args.status : '');
  const summary = String(args && args.summary ? args.summary : '');
  if (!runId) throw new Error('runId is required — find it in your system prompt');
  if (!['pass', 'fail', 'inconclusive'].includes(status)) {
    throw new Error("status must be 'pass', 'fail' or 'inconclusive'");
  }
  if (!summary.trim()) throw new Error('summary is required');
  const params = { runId, status, summary, details: args && args.details ? String(args.details) : undefined };
  await bridgeCall('qa:submit', params);
  return { ok: true, message: 'Verdict submitted. You can now stop working — the master agent has received the result.' };
}

// ── 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-qa-tester', 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 !== 'submit_verdict') {
      sendError(id, -32601, `unknown tool: ${name}`);
      return;
    }
    try {
      const result = await handleSubmitVerdict(args);
      sendResult(id, {
        content: [{ type: 'text', text: result.message }],
        structuredContent: result,
      });
    } catch (err) {
      // A tool-level failure, NOT a protocol one: `sendError` makes the CLI
      // report the whole server as faulty.
      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); });
