#!/usr/bin/env node

import { once } from 'node:events';

const endpoint = process.env.WP_AGENT_ENDPOINT || '';
const authorization = process.env.WP_AGENT_AUTHORIZATION || '';
const MAX_REQUEST_BYTES = 1024 * 1024;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 60_000;
const SESSION_DELETE_TIMEOUT_MS = 5_000;
let sessionId = '';
let protocolVersion = '';
let shuttingDown = false;
let exitCode = 0;
const activeRequests = new Set();

class BridgeError extends Error {}

function validateConfiguration() {
  const url = new URL(endpoint);
  const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname);
  if (url.username || url.password || (url.protocol !== 'https:' && !localHttp)) {
    throw new Error('WP_AGENT_ENDPOINT must use HTTPS or local HTTP and must not contain credentials.');
  }
  if (!authorization.startsWith('Basic ') || authorization.length > 4096) {
    throw new Error('WP_AGENT_AUTHORIZATION is missing or invalid.');
  }
}

function isNotification(input) {
  return input !== null
    && typeof input === 'object'
    && !Array.isArray(input)
    && input.jsonrpc === '2.0'
    && typeof input.method === 'string'
    && !Object.hasOwn(input, 'id');
}

async function writeMessage(message) {
  if (!process.stdout.write(`${JSON.stringify(message)}\n`)) {
    await once(process.stdout, 'drain');
  }
}

function errorMessage(input, message) {
  return {
    jsonrpc: '2.0',
    id: input && Object.hasOwn(input, 'id') ? input.id : null,
    error: { code: -32000, message },
  };
}

function parseSse(text) {
  const messages = [];
  for (const event of text.split(/\r?\n\r?\n/)) {
    const payload = event
      .split(/\r?\n/)
      .filter((line) => line.startsWith('data:'))
      .map((line) => line.slice(5).trimStart())
      .join('\n');
    if (payload && payload !== '[DONE]') messages.push(JSON.parse(payload));
  }
  return messages;
}

function rememberProtocolVersion(input, messages) {
  if (!input || input.method !== 'initialize' || !Object.hasOwn(input, 'id')) return;
  const response = messages.find((message) => message
    && typeof message === 'object'
    && !Array.isArray(message)
    && message.id === input.id
    && message.result
    && typeof message.result === 'object');
  const version = response?.result?.protocolVersion;
  if (typeof version === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(version)) {
    protocolVersion = version;
  }
}

async function readLimitedBody(response) {
  if (!response.body) return new Uint8Array();
  const reader = response.body.getReader();
  const chunks = [];
  let length = 0;
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    length += value.byteLength;
    if (length > MAX_RESPONSE_BYTES) {
      await reader.cancel();
      throw new BridgeError('Remote response exceeded the size limit.');
    }
    chunks.push(value);
  }
  const body = new Uint8Array(length);
  let offset = 0;
  for (const chunk of chunks) {
    body.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return body;
}

async function proxy(input) {
  const controller = new AbortController();
  activeRequests.add(controller);
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const headers = {
      Accept: 'application/json, text/event-stream',
      Authorization: authorization,
      'Content-Type': 'application/json',
    };
    if (sessionId) headers['Mcp-Session-Id'] = sessionId;
    if (protocolVersion) headers['MCP-Protocol-Version'] = protocolVersion;
    const response = await fetch(endpoint, {
      method: 'POST',
      headers,
      body: JSON.stringify(input),
      redirect: 'error',
      signal: controller.signal,
    });
    if (!response.ok) {
      await response.body?.cancel().catch(() => {});
      throw new BridgeError(`Remote MCP request failed with HTTP ${response.status}.`);
    }
    const nextSession = response.headers.get('mcp-session-id');
    if (nextSession && nextSession.length <= 1024) sessionId = nextSession;
    if (nextSession && nextSession.length > 1024) {
      throw new BridgeError('Remote MCP session identifier exceeded the size limit.');
    }
    const bytes = await readLimitedBody(response);
    if (response.status === 202 || bytes.byteLength === 0) return [];
    const text = new TextDecoder().decode(bytes);
    const type = response.headers.get('content-type') || '';
    const messages = type.includes('text/event-stream') ? parseSse(text) : [JSON.parse(text)];
    rememberProtocolVersion(input, messages);
    return messages;
  } finally {
    clearTimeout(timeout);
    activeRequests.delete(controller);
  }
}

function publicErrorMessage(error) {
  if (error instanceof BridgeError) return error.message;
  if (error instanceof Error && error.name === 'AbortError') return 'Remote MCP request timed out.';
  return 'Remote MCP request failed.';
}

async function terminateSession() {
  const activeSession = sessionId;
  sessionId = '';
  if (!activeSession) return;

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), SESSION_DELETE_TIMEOUT_MS);
  try {
    const headers = {
      Accept: 'application/json, text/event-stream',
      Authorization: authorization,
      'Mcp-Session-Id': activeSession,
    };
    if (protocolVersion) headers['MCP-Protocol-Version'] = protocolVersion;
    const response = await fetch(endpoint, {
      method: 'DELETE',
      headers,
      redirect: 'error',
      signal: controller.signal,
    });
    await response.body?.cancel().catch(() => {});
  } catch {
    // Session termination is best effort during local process shutdown.
  } finally {
    clearTimeout(timeout);
  }
}

async function processBufferedLine(parts, lineLength, oversized) {
  if (oversized) {
    await writeMessage(errorMessage(null, 'Local request exceeded the size limit.'));
    return;
  }
  if (lineLength === 0) return;
  let bytes = Buffer.concat(parts, lineLength);
  if (bytes.at(-1) === 0x0d) bytes = bytes.subarray(0, -1);
  const line = bytes.toString('utf8');
  if (line.trim()) await handleLine(line);
}

async function readInput() {
  let parts = [];
  let lineLength = 0;
  let oversized = false;

  for await (const value of process.stdin) {
    if (shuttingDown) break;
    const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
    let offset = 0;
    while (offset < chunk.length && !shuttingDown) {
      const newline = chunk.indexOf(0x0a, offset);
      const end = newline === -1 ? chunk.length : newline;
      const part = chunk.subarray(offset, end);
      if (!oversized && part.length > 0) {
        lineLength += part.length;
        if (lineLength > MAX_REQUEST_BYTES + 1) {
          parts = [];
          lineLength = 0;
          oversized = true;
        } else {
          parts.push(part);
        }
      }
      if (newline === -1) break;
      await processBufferedLine(parts, lineLength, oversized);
      parts = [];
      lineLength = 0;
      oversized = false;
      offset = newline + 1;
    }
  }

  if (!shuttingDown && (oversized || lineLength > 0)) {
    await processBufferedLine(parts, lineLength, oversized);
  }
}

async function handleLine(line) {
  if (Buffer.byteLength(line, 'utf8') > MAX_REQUEST_BYTES) {
    await writeMessage(errorMessage(null, 'Local request exceeded the size limit.'));
    return;
  }
  let input;
  try {
    input = JSON.parse(line);
  } catch {
    await writeMessage(errorMessage(null, 'Invalid JSON request.'));
    return;
  }
  try {
    for (const message of await proxy(input)) await writeMessage(message);
  } catch (error) {
    if (!shuttingDown && !isNotification(input)) {
      await writeMessage(errorMessage(input, publicErrorMessage(error)));
    }
  }
}

try {
  validateConfiguration();
} catch (error) {
  process.stderr.write(`wpagently MCP bridge: ${error instanceof Error ? error.message : 'invalid configuration'}\n`);
  process.exit(1);
}

function shutdown(nextExitCode) {
  if (shuttingDown) return;
  shuttingDown = true;
  exitCode = nextExitCode;
  for (const controller of activeRequests) controller.abort();
  process.stdin.destroy();
}

process.once('SIGINT', () => shutdown(130));
process.once('SIGTERM', () => shutdown(143));

try {
  await readInput();
} catch (error) {
  if (!shuttingDown) throw error;
} finally {
  shuttingDown = true;
  await terminateSession();
}
process.exitCode = exitCode;
