#!/usr/bin/env node
// ACP (Agent Client Protocol) shim for CoderFlow.
//
// Spawns an ACP-mode agent over a child process (newline-delimited JSON-RPC
// over stdio), drives the prompt-turn lifecycle (initialize → session/new or
// session/load → session/prompt), auto-approves permission requests, and
// echoes every inbound JSON-RPC frame onto our own stdout so the existing
// debug-stream timestamping filter and the activity-feed parser can consume
// them with no agent-specific code below this layer.
//
// Usage:
//   acp-client.js [--resume <sessionId>] [--cwd <path>] -- <agent cmd> [args...]
//
// stdin   the user's prompt (plain text)
// stdout  every inbound JSON-RPC frame, one per line; plus a final
//         {"type":"result",...} line so agent-wrapper.sh's existing summary
//         flow keeps working
// stderr  the child's stderr (auth errors, panics) plus our own diagnostics
// exit    0 on stopReason: end_turn / max_tokens; non-zero on error or
//         cancellation

import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import process from 'node:process';

// ---------- argument parsing ----------

function parseArgs(argv) {
  const out = { resume: null, cwd: '/workspace', agentArgv: [] };
  const args = argv.slice(2);
  let i = 0;
  while (i < args.length) {
    const a = args[i];
    if (a === '--') { out.agentArgv = args.slice(i + 1); break; }
    if (a === '--resume') { out.resume = args[++i]; i++; continue; }
    if (a === '--cwd') { out.cwd = args[++i]; i++; continue; }
    i++;
  }
  if (out.agentArgv.length === 0) {
    process.stderr.write('acp-client: missing agent command after "--"\n');
    process.exit(2);
  }
  return out;
}

const opts = parseArgs(process.argv);

function diag(msg) {
  process.stderr.write(`[acp-client] ${msg}\n`);
}

// ---------- read the prompt from stdin ----------

function readAllStdin() {
  // Buffered, blocking read of all stdin.
  try {
    return readFileSync(0, 'utf8');
  } catch (e) {
    return '';
  }
}

const userPrompt = readAllStdin();

// ---------- spawn the agent ----------

diag(`spawning: ${opts.agentArgv.join(' ')}`);

const child = spawn(opts.agentArgv[0], opts.agentArgv.slice(1), {
  stdio: ['pipe', 'pipe', 'pipe'],
  cwd: opts.cwd,
  env: process.env,
});

let childExited = false;

child.on('exit', (code, signal) => {
  childExited = true;
  diag(`child exited code=${code} signal=${signal}`);
});

child.on('error', (err) => {
  diag(`child spawn error: ${err.message}`);
  emitResult(`Failed to spawn agent: ${err.message}`, 1);
  process.exit(1);
});

// Pipe child stderr to our stderr so authentication errors etc. land in the
// debug stream alongside our own diagnostics.
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk) => {
  process.stderr.write(chunk);
});

// ---------- JSON-RPC framing on child stdout (newline-delimited) ----------

const childIn = createInterface({ input: child.stdout });

let nextRpcId = 100; // ours; the agent uses lower ids for its own requests
const pending = new Map(); // rpcId -> { resolve, reject }
let sessionId = null;
let stopReason = null;
let finalAgentText = '';   // accumulated agent_message_chunk
let lastThoughtText = '';  // accumulated agent_thought_chunk (fallback if no message)

function sendToChild(obj) {
  const line = JSON.stringify(obj) + '\n';
  child.stdin.write(line);
}

function rpcCall(method, params) {
  const id = nextRpcId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    sendToChild({ jsonrpc: '2.0', id, method, params });
  });
}

function rpcNotify(method, params) {
  sendToChild({ jsonrpc: '2.0', method, params });
}

function emit(obj) {
  // Echo every inbound frame to our stdout so it ends up in debug-stream.jsonl.
  process.stdout.write(JSON.stringify(obj) + '\n');
}

function handleSessionUpdate(params) {
  const update = params?.update || {};
  switch (update.sessionUpdate) {
    case 'agent_message_chunk': {
      const text = update.content?.text;
      if (typeof text === 'string') finalAgentText += text;
      break;
    }
    case 'agent_thought_chunk': {
      const text = update.content?.text;
      if (typeof text === 'string') lastThoughtText += text;
      break;
    }
    default:
      break;
  }
}

function answerPermission(reqId, options) {
  // Pick the first allow-style option; fall back to the first listed.
  const opts = Array.isArray(options) ? options : [];
  const allow =
    opts.find((o) => o?.kind === 'allow_always') ||
    opts.find((o) => o?.kind === 'allow_once') ||
    opts.find((o) => (o?.optionId || '').toLowerCase().includes('allow')) ||
    opts[0];
  const optionId = allow?.optionId || allow?.id || 'allow';
  sendToChild({
    jsonrpc: '2.0',
    id: reqId,
    result: { outcome: { outcome: 'selected', optionId } },
  });
}

childIn.on('line', (line) => {
  if (!line.trim()) return;
  let obj;
  try {
    obj = JSON.parse(line);
  } catch {
    // Non-JSON child output — write to stderr for visibility, don't pollute
    // debug-stream with garbage that the parser would just skip.
    process.stderr.write(`[acp-client non-json] ${line}\n`);
    return;
  }

  // Always echo first so the activity-feed parser sees the raw frame.
  emit(obj);

  // Response to one of our requests
  if (obj.jsonrpc === '2.0' && Object.prototype.hasOwnProperty.call(obj, 'id') && !obj.method) {
    const waiter = pending.get(obj.id);
    if (waiter) {
      pending.delete(obj.id);
      if (obj.error) waiter.reject(obj.error);
      else waiter.resolve(obj.result);
    }
    return;
  }

  // Inbound notification or request from agent
  if (obj.method === 'session/update') {
    handleSessionUpdate(obj.params || {});
    return;
  }
  if (obj.method === 'session/request_permission') {
    if (typeof obj.id !== 'undefined') {
      answerPermission(obj.id, obj.params?.options || obj.params?.toolCall?.options);
    }
    return;
  }
  if (obj.method === 'fs/read_text_file' || obj.method === 'fs/write_text_file') {
    // We did not advertise fs capabilities; if the agent calls these anyway,
    // refuse so it falls back to its own file tools (which we already see
    // via tool_call notifications).
    if (typeof obj.id !== 'undefined') {
      sendToChild({
        jsonrpc: '2.0',
        id: obj.id,
        error: { code: -32601, message: 'fs methods not supported by this client' },
      });
    }
    return;
  }
  // Anything else (terminal/*, future methods) — respond with method-not-found
  // if it's a request, ignore if it's a notification.
  if (obj.method && typeof obj.id !== 'undefined') {
    sendToChild({
      jsonrpc: '2.0',
      id: obj.id,
      error: { code: -32601, message: `Method not supported: ${obj.method}` },
    });
  }
});

// ---------- final result emission for agent-wrapper.sh ----------

const startMs = Date.now();
let resultEmitted = false;

function emitResult(text, exitCode) {
  if (resultEmitted) return;
  resultEmitted = true;
  const duration = Date.now() - startMs;
  emit({
    type: 'result',
    result: text || (exitCode === 0 ? 'Task completed successfully' : `Task failed with exit code ${exitCode}`),
    duration_ms: duration,
    num_turns: 1,
    usage: { output_tokens: 0, cache_read_input_tokens: 0 },
    exit_code: exitCode,
    stopReason: stopReason,
    transport: 'acp',
  });
}

// ---------- main flow ----------

async function main() {
  // 1) initialize
  const initRes = await rpcCall('initialize', {
    protocolVersion: 1,
    // Intentionally NOT advertising fs/terminal capabilities: we want the
    // agent to use its own tools (Edit/Bash/...) so we observe them as
    // tool_call notifications, exactly like today's stream-json flow.
    clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
    clientInfo: { name: 'coderflow-acp', title: 'CoderFlow', version: '1.0.0' },
  }).catch((e) => {
    diag(`initialize failed: ${JSON.stringify(e)}`);
    return null;
  });

  if (!initRes) {
    emitResult('ACP initialize failed', 1);
    cleanupAndExit(1);
    return;
  }

  // 2) session/new or session/load
  let sessionRes;
  if (opts.resume) {
    diag(`resuming session ${opts.resume}`);
    sessionRes = await rpcCall('session/load', {
      sessionId: opts.resume,
      cwd: opts.cwd,
      mcpServers: [],
    }).catch((e) => {
      diag(`session/load failed: ${JSON.stringify(e)} — falling back to session/new`);
      return null;
    });
  }
  if (!sessionRes) {
    sessionRes = await rpcCall('session/new', {
      cwd: opts.cwd,
      mcpServers: [],
    }).catch((e) => {
      diag(`session/new failed: ${JSON.stringify(e)}`);
      return null;
    });
  }

  if (!sessionRes) {
    emitResult('ACP session creation failed', 1);
    cleanupAndExit(1);
    return;
  }
  sessionId = opts.resume || sessionRes.sessionId;
  if (!sessionId) {
    emitResult('ACP session created without sessionId', 1);
    cleanupAndExit(1);
    return;
  }
  diag(`session ready: ${sessionId}`);

  // 3) session/prompt
  try {
    const promptRes = await rpcCall('session/prompt', {
      sessionId,
      prompt: [{ type: 'text', text: userPrompt }],
    });
    stopReason = promptRes?.stopReason || 'unknown';
  } catch (err) {
    diag(`session/prompt error: ${JSON.stringify(err)}`);
    emitResult(err?.message || 'session/prompt failed', 1);
    cleanupAndExit(1);
    return;
  }

  // 4) Build a final result text from what we accumulated (agent_message
  // chunks preferred; fall back to last agent_thought chunk).
  const finalText = finalAgentText.trim() || lastThoughtText.trim() || '';
  const exitCode = stopReason === 'end_turn' || stopReason === 'max_tokens' ? 0 :
                   stopReason === 'cancelled' ? 130 : 1;
  emitResult(finalText, exitCode);
  cleanupAndExit(exitCode);
}

function cleanupAndExit(code) {
  // Best-effort close child stdin so the agent shuts down cleanly.
  try { child.stdin.end(); } catch {}
  // Give the child up to 3s to exit on its own.
  const grace = setTimeout(() => {
    try { child.kill('SIGTERM'); } catch {}
    setTimeout(() => { try { child.kill('SIGKILL'); } catch {} ; process.exit(code); }, 1500).unref();
  }, 3000);
  grace.unref();
  child.on('exit', () => {
    clearTimeout(grace);
    process.exit(code);
  });
  if (childExited) {
    clearTimeout(grace);
    process.exit(code);
  }
}

// ---------- signal handling for clean cancel ----------

let cancelling = false;
function onSignal(sig) {
  if (cancelling) return;
  cancelling = true;
  diag(`received ${sig}; sending session/cancel`);
  if (sessionId) {
    try { rpcNotify('session/cancel', { sessionId }); } catch {}
  }
  // Give the agent a beat to flush, then exit.
  setTimeout(() => {
    emitResult('Task interrupted', 130);
    cleanupAndExit(130);
  }, 1000).unref();
}
process.on('SIGINT', () => onSignal('SIGINT'));
process.on('SIGTERM', () => onSignal('SIGTERM'));

main().catch((e) => {
  diag(`main() threw: ${e?.stack || e}`);
  emitResult(`acp-client error: ${e?.message || e}`, 1);
  cleanupAndExit(1);
});
