#!/usr/bin/env node
// Vantaloom Claude SDK bridge.
//
// A thin, long-lived Node sidecar (one process per conversation) that runs the
// official @anthropic-ai/claude-agent-sdk `query()` and pipes its NATIVE
// stream-json to the Go runtime over stdio NDJSON. This replaces the lossy
// generic ACP adapter: every SDKMessage is forwarded verbatim so the Go mapper
// gets full fidelity (real token usage, tool_use{id,name,input}, tool_result,
// parent_tool_use_id subagent links, partial streaming) instead of guessing.
//
// Protocol (one JSON object per line):
//
//   Go → bridge (stdin):
//     {"t":"init", cwd, claudeExecutable?, appendSystemPrompt?, mode?, model?, resume?, settingSources?}
//     {"t":"prompt", runId, text, images?:[{mime,data}]}
//     {"t":"permission_response", id, behavior:"allow"|"deny", updatedInput?, message?, interrupt?}
//     {"t":"tool_result", id, content?, error?}   // answers a {"t":"tool_call"}
//     {"t":"interrupt"}
//     {"t":"set_model", model}
//     {"t":"set_mode", mode}
//     {"t":"shutdown"}
//
//   bridge → Go (stdout):
//     {"t":"ready", sdkVersion}
//     {"t":"sdk", msg}                  // verbatim SDKMessage
//     {"t":"permission_request", id, toolName, input, toolUseId}
//     {"t":"tool_call", id, name, args}            // in-process MCP tool → Go executes
//     {"t":"session", sessionId}        // captured from system/init
//     {"t":"result", runId, subtype, usage?, sessionId?}
//     {"t":"error", runId?, message}
//     {"t":"log", level, message}
//
// stdout carries ONLY protocol JSON; all diagnostics go to stderr (and are
// surfaced as {"t":"log"} from the SDK's stderr callback).

import { spawn } from "node:child_process";
import process from "node:process";
import readline from "node:readline";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const bridgeDir = path.dirname(fileURLToPath(import.meta.url));

let query;
let createSdkMcpServer;
let tool;
let z;
let sdkVersion = "unknown";
try {
  ({ query, createSdkMcpServer, tool } = await import("@anthropic-ai/claude-agent-sdk"));
  // zod is bundled alongside the bridge (sibling dep of the SDK). tool()'s input
  // schema is a Zod raw shape; failure to load it disables the MCP tool but must
  // NOT prevent the bridge from starting (file-based hooks still work).
  try {
    ({ z } = await import("zod"));
  } catch (zerr) {
    z = undefined;
  }
  try {
    // The SDK's package.json isn't exposed via "exports", so read it directly from
    // the node_modules tree we bundle next to this bridge (best-effort).
    const pkgPath = path.join(
      bridgeDir,
      "node_modules",
      "@anthropic-ai",
      "claude-agent-sdk",
      "package.json",
    );
    sdkVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version || "unknown";
  } catch {
    /* version is best-effort */
  }
} catch (err) {
  // The SDK is missing — emit a fatal error the Go side can surface, then exit.
  process.stdout.write(
    JSON.stringify({
      t: "error",
      message:
        "无法加载 @anthropic-ai/claude-agent-sdk：" +
        (err && err.message ? err.message : String(err)) +
        "（请随运行时安装 SDK 桥，或检查 adapters/claude-sdk-bridge/node_modules）",
    }) + "\n",
  );
  process.exit(1);
}

// ---- stdout framing -------------------------------------------------------

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

function logLine(level, message) {
  send({ t: "log", level, message: String(message) });
}

// ---- async input queue feeding query()'s prompt iterable ------------------
//
// query() consumes an AsyncIterable<SDKUserMessage>. We back it with a queue so
// each `prompt` control message pushes one user turn into the live session.

function createInputQueue() {
  /** @type {any[]} */
  const buffered = [];
  /** @type {null | ((r: {value: any, done: boolean}) => void)} */
  let waiting = null;
  let closed = false;

  return {
    push(msg) {
      if (waiting) {
        const resolve = waiting;
        waiting = null;
        resolve({ value: msg, done: false });
      } else {
        buffered.push(msg);
      }
    },
    close() {
      closed = true;
      if (waiting) {
        const resolve = waiting;
        waiting = null;
        resolve({ value: undefined, done: true });
      }
    },
    iterable: {
      [Symbol.asyncIterator]() {
        return {
          next() {
            if (buffered.length > 0) {
              return Promise.resolve({ value: buffered.shift(), done: false });
            }
            if (closed) {
              return Promise.resolve({ value: undefined, done: true });
            }
            return new Promise((resolve) => {
              waiting = resolve;
            });
          },
        };
      },
    },
  };
}

// ---- Windows-aware process spawn (mirrors paseo's spawnClaudeCodeProcess) --
//
// The SDK lets us own the child spawn. On Windows the default routing through
// cmd.exe can mangle args; we spawn explicitly with windowsHide and pick shell
// only for .cmd/.bat shims (which CreateProcess cannot launch directly). We
// pass no inline-JSON args (no --mcp-config), so shell:true for .cmd is safe.

function makeSpawnClaude(stderrSink) {
  return function spawnClaudeCodeProcess(spawnOptions) {
    let command = spawnOptions.command;
    let args = [...spawnOptions.args];
    // The SDK may ask for a generic JS runtime; use the node actually running us.
    if (command === "node" || command === "bun") {
      command = process.execPath;
    }
    const lower = command.toLowerCase();
    const isWindows = process.platform === "win32";
    const isCmdShim = isWindows && (lower.endsWith(".cmd") || lower.endsWith(".bat"));
    const child = spawn(command, args, {
      cwd: spawnOptions.cwd,
      env: spawnOptions.env,
      stdio: ["pipe", "pipe", "pipe"],
      windowsHide: true,
      shell: isCmdShim,
    });
    if (child.stderr) {
      child.stderr.on("data", (chunk) => {
        try {
          stderrSink(chunk.toString());
        } catch {
          /* ignore */
        }
      });
    }
    return child;
  };
}

// ---- bridge session state -------------------------------------------------

const state = {
  /** @type {ReturnType<typeof createInputQueue> | null} */
  input: null,
  /** @type {any} */
  query: null,
  cwd: process.cwd(),
  claudeExecutable: undefined,
  appendSystemPrompt: "",
  mode: "default",
  model: undefined,
  resume: undefined,
  settingSources: ["user", "project", "local"],
  sessionId: "",
  currentRunId: "",
  started: false,
  /** @type {Map<string, (result: any) => void>} pending canUseTool resolvers */
  pendingPermissions: new Map(),
  /** @type {Map<string, (result: {content?: string, error?: string}) => void>} pending tool_call resolvers */
  pendingToolCalls: new Map(),
};

let permissionSeq = 0;
let toolCallSeq = 0;

// callVantaloomTool round-trips a vantaloom MCP tool invocation to the Go side:
// emit {"t":"tool_call"} and await the matching {"t":"tool_result"} (correlated by
// id via state.pendingToolCalls). Mirrors canUseTool's pending-promise pattern.
// Returns the MCP CallToolResult the SDK expects.
function callVantaloomTool(name, args) {
  const id = "tool-" + ++toolCallSeq;
  return new Promise((resolve) => {
    let settled = false;
    const finish = (res) => {
      if (settled) return;
      settled = true;
      state.pendingToolCalls.delete(id);
      const out = res || {};
      if (out.error) {
        resolve({
          content: [{ type: "text", text: String(out.error) }],
          isError: true,
        });
      } else {
        resolve({
          content: [{ type: "text", text: String(out.content ?? "") }],
        });
      }
    };
    state.pendingToolCalls.set(id, finish);
    send({ t: "tool_call", id, name, args: args ?? {} });
  });
}

// buildVantaloomMcpServer builds the in-process MCP server exposing vantaloom_hook
// (and, later, more vantaloom tools). Each tool handler round-trips to Go via
// callVantaloomTool. Returns null when the SDK/zod helpers are unavailable, in which
// case the bridge runs without the MCP server (file-based hooks remain the route).
function buildVantaloomMcpServer() {
  if (!createSdkMcpServer || !tool || !z) return null;
  try {
    const triggerSchema = z
      .object({
        source: z
          .enum(["timer", "process_exit", "webhook", "file", "shell_poll", "lifecycle"])
          .describe("触发源。timer/process_exit/file/shell_poll 由引擎周期触发；webhook 经外部 POST 触发；lifecycle 由 SDK 桥处理。"),
        delaySec: z.number().int().optional().describe("timer：自登记起 N 秒后触发一次。"),
        intervalSec: z.number().int().optional().describe("timer：每 N 秒重复触发。"),
        cron: z.string().optional().describe("timer：cron 表达式（暂未解析，预留）。"),
        terminalSessionId: z.string().optional().describe("process_exit：要监听退出的终端会话 id。"),
        backgroundCmdId: z.string().optional().describe("process_exit：要监听退出的后台命令 id。"),
        path: z.string().optional().describe("file：要监听的文件/目录路径。"),
        event: z.enum(["exists", "change"]).optional().describe("file：监听 exists 还是 change。"),
        command: z.string().optional().describe("shell_poll：要周期执行并判定的命令。"),
      })
      .describe("register：触发器。source 必填，其余按 source 取用。");
    const actionSchema = z
      .object({
        type: z
          .enum(["resume_conversation", "run_command", "start_conversation"])
          .describe("resume_conversation=唤醒一个已存在会话继续；run_command=执行一条命令（无会话副作用）；start_conversation=新建并启动一个会话。"),
        conversationId: z.string().optional().describe("resume_conversation：要唤醒的会话 id。留空则默认本会话。"),
        prompt: z.string().optional().describe("resume_conversation/start_conversation：注入的用户消息。"),
        command: z.string().optional().describe("run_command：要执行的命令。"),
        engine: z.string().optional().describe("start_conversation（可选）：引擎，如 reclaude。"),
        folder: z.string().optional().describe("start_conversation/run_command（可选）：工作目录。"),
        model: z.string().optional().describe("start_conversation（可选）：模型。"),
      })
      .describe("register：触发后执行的动作。");

    const hookTool = tool(
      "vantaloom_hook",
      "钩子工具：为本会话登记「触发器 → 动作」规则，由通用 HOOK 引擎在后台周期性检查并触发。最常见用法：当某个外部事件（定时、外部进程完成、收到 webhook 等）就绪后，自动「唤醒」本会话继续工作——action.type=resume_conversation 且 conversationId 留空（默认本会话）。register=登记一个钩子；list=查看本会话已登记的钩子；remove=按 hookId 删除；fire=立即手动触发某个钩子（用于自测）。",
      {
        mode: z
          .enum(["register", "list", "remove", "fire"])
          .describe("子模式。register=登记一个新钩子；list=查看本会话钩子；remove=按 hookId 删除；fire=立即手动触发某钩子（自测）。"),
        hookId: z.string().optional().describe("remove/fire：要操作的钩子 id。"),
        name: z.string().optional().describe("register（可选）：钩子的显示名。"),
        once: z.boolean().optional().describe("register（可选）：true 表示触发一次后自动失效。默认 false。"),
        maxFires: z.number().int().optional().describe("register（可选）：最多触发次数，0=不限（除非 once）。"),
        trigger: triggerSchema.optional(),
        action: actionSchema.optional(),
      },
      async (args) => callVantaloomTool("vantaloom_hook", args ?? {}),
    );

    return createSdkMcpServer({
      name: "vantaloom",
      version: "1.0.0",
      tools: [hookTool],
    });
  } catch (err) {
    logLine("warn", "构建 vantaloom MCP 服务失败：" + (err?.message ?? String(err)));
    return null;
  }
}

// canUseTool: emit a permission_request and await the matching response. The
// run ctx (user STOP) aborts via the provided AbortSignal.
function canUseTool(toolName, input, options) {
  const id = "perm-" + ++permissionSeq;
  const toolUseId = (options && options.toolUseID) || "";
  return new Promise((resolve) => {
    let settled = false;
    const finish = (result) => {
      if (settled) return;
      settled = true;
      state.pendingPermissions.delete(id);
      resolve(result);
    };
    state.pendingPermissions.set(id, finish);
    if (options && options.signal) {
      if (options.signal.aborted) {
        finish({ behavior: "deny", message: "用户已停止", interrupt: true });
        return;
      }
      options.signal.addEventListener(
        "abort",
        () => finish({ behavior: "deny", message: "用户已停止", interrupt: true }),
        { once: true },
      );
    }
    send({ t: "permission_request", id, toolName, input: input ?? {}, toolUseId });
  });
}

function extractSessionId(msg) {
  if (!msg || typeof msg !== "object") return "";
  if (typeof msg.session_id === "string") return msg.session_id;
  if (typeof msg.sessionId === "string") return msg.sessionId;
  if (msg.session && typeof msg.session.id === "string") return msg.session.id;
  return "";
}

// Build query() and start draining its async generator. Runs once per session.
function startQuery() {
  if (state.started) return;
  state.started = true;

  state.input = createInputQueue();
  const options = {
    cwd: state.cwd,
    includePartialMessages: true,
    permissionMode: state.mode || "default",
    allowDangerouslySkipPermissions: true,
    canUseTool,
    settingSources: state.settingSources,
    enableFileCheckpointing: true,
    systemPrompt: {
      type: "preset",
      preset: "claude_code",
      append: state.appendSystemPrompt || "",
    },
    stderr: (data) => logLine("warn", data),
    spawnClaudeCodeProcess: makeSpawnClaude((d) => logLine("warn", d)),
  };

  // Expose the in-process vantaloom MCP server (vantaloom_hook + future tools) so
  // reclaude / claude-code agents can call it natively. MCP tools are named
  // `mcp__<server>__<tool>`; allowlist it so the hook (benign metadata mgmt) runs
  // without a permission prompt. When the SDK/zod helpers are unavailable the server
  // is null and the bridge runs without it (file-based hooks remain the route).
  const vantaloomMcp = buildVantaloomMcpServer();
  if (vantaloomMcp) {
    options.mcpServers = { vantaloom: vantaloomMcp };
    options.allowedTools = ["mcp__vantaloom__vantaloom_hook"];
  }

  if (state.claudeExecutable) options.pathToClaudeCodeExecutable = state.claudeExecutable;
  if (state.model) options.model = state.model;
  if (state.resume) options.resume = state.resume;

  let q;
  try {
    q = query({ prompt: state.input.iterable, options });
  } catch (err) {
    send({ t: "error", message: "query() 启动失败：" + (err?.message ?? String(err)) });
    return;
  }
  state.query = q;

  (async () => {
    try {
      for await (const msg of q) {
        // Forward verbatim — the Go mapper owns all interpretation.
        send({ t: "sdk", msg });

        // Capture the session id (for resume) the first time we see it.
        const sid = extractSessionId(msg);
        if (sid && sid !== state.sessionId) {
          state.sessionId = sid;
          send({ t: "session", sessionId: sid });
        }

        // A `result` message ends the current turn.
        if (msg && msg.type === "result") {
          send({
            t: "result",
            runId: state.currentRunId,
            subtype: msg.subtype ?? "success",
            usage: msg.usage ?? null,
            sessionId: state.sessionId,
          });
        }
      }
      // Generator completed (session closed by the SDK).
      send({ t: "log", level: "info", message: "query 生成器已结束" });
    } catch (err) {
      send({ t: "error", runId: state.currentRunId, message: err?.message ?? String(err) });
    }
  })();
}

// ---- control message handling ---------------------------------------------

function toSdkUserMessage(text, images) {
  const content = [];
  if (Array.isArray(images)) {
    for (const img of images) {
      if (img && typeof img.data === "string" && img.data.length > 0) {
        content.push({
          type: "image",
          source: {
            type: "base64",
            media_type: img.mime || "image/png",
            data: img.data,
          },
        });
      }
    }
  }
  if (typeof text === "string" && text.length > 0) {
    content.push({ type: "text", text });
  }
  if (content.length === 0) content.push({ type: "text", text: "" });
  return {
    type: "user",
    message: { role: "user", content },
    parent_tool_use_id: null,
    session_id: state.sessionId || "",
  };
}

async function handleControl(raw) {
  let cmd;
  try {
    cmd = JSON.parse(raw);
  } catch {
    return; // ignore malformed lines
  }
  switch (cmd.t) {
    case "init": {
      state.cwd = cmd.cwd || process.cwd();
      state.claudeExecutable = cmd.claudeExecutable || undefined;
      state.appendSystemPrompt = cmd.appendSystemPrompt || "";
      state.mode = cmd.mode || "default";
      state.model = cmd.model || undefined;
      state.resume = cmd.resume || undefined;
      if (Array.isArray(cmd.settingSources) && cmd.settingSources.length > 0) {
        state.settingSources = cmd.settingSources;
      }
      startQuery();
      break;
    }
    case "prompt": {
      if (!state.started) startQuery();
      state.currentRunId = cmd.runId || "";
      if (state.input) {
        state.input.push(toSdkUserMessage(cmd.text, cmd.images));
      }
      break;
    }
    case "permission_response": {
      const resolve = state.pendingPermissions.get(cmd.id);
      if (resolve) {
        if (cmd.behavior === "allow") {
          const result = { behavior: "allow" };
          if (cmd.updatedInput && typeof cmd.updatedInput === "object") {
            result.updatedInput = cmd.updatedInput;
          }
          resolve(result);
        } else {
          resolve({
            behavior: "deny",
            message: cmd.message || "已拒绝",
            interrupt: cmd.interrupt === true,
          });
        }
      }
      break;
    }
    case "tool_result": {
      const resolve = state.pendingToolCalls.get(cmd.id);
      if (resolve) {
        resolve({ content: cmd.content, error: cmd.error });
      }
      break;
    }
    case "interrupt": {
      if (state.query && typeof state.query.interrupt === "function") {
        try {
          await state.query.interrupt();
        } catch (err) {
          logLine("warn", "interrupt 失败：" + (err?.message ?? String(err)));
        }
      }
      break;
    }
    case "set_model": {
      state.model = cmd.model || undefined;
      if (state.query && typeof state.query.setModel === "function") {
        try {
          await state.query.setModel(cmd.model || undefined);
        } catch (err) {
          logLine("warn", "setModel 失败：" + (err?.message ?? String(err)));
        }
      }
      break;
    }
    case "set_mode": {
      state.mode = cmd.mode || "default";
      if (state.query && typeof state.query.setPermissionMode === "function") {
        try {
          await state.query.setPermissionMode(state.mode);
        } catch (err) {
          logLine("warn", "setPermissionMode 失败：" + (err?.message ?? String(err)));
        }
      }
      break;
    }
    case "shutdown": {
      cleanupAndExit(0);
      break;
    }
    default:
      break;
  }
}

function cleanupAndExit(code) {
  try {
    // Reject any pending permissions so the SDK doesn't hang on close.
    for (const resolve of state.pendingPermissions.values()) {
      resolve({ behavior: "deny", message: "桥已关闭", interrupt: true });
    }
    state.pendingPermissions.clear();
    // Reject any pending tool calls so the MCP handler promises don't hang on close.
    for (const resolve of state.pendingToolCalls.values()) {
      resolve({ error: "桥已关闭" });
    }
    state.pendingToolCalls.clear();
    if (state.input) state.input.close();
    if (state.query && typeof state.query.return === "function") {
      try {
        state.query.return();
      } catch {
        /* ignore */
      }
    }
  } finally {
    process.exit(code);
  }
}

// ---- main loop ------------------------------------------------------------

const rl = readline.createInterface({ input: process.stdin });
rl.on("line", (line) => {
  const trimmed = line.trim();
  if (trimmed.length === 0) return;
  handleControl(trimmed).catch((err) => {
    logLine("error", "control 处理异常：" + (err?.message ?? String(err)));
  });
});
rl.on("close", () => cleanupAndExit(0));

process.on("SIGTERM", () => cleanupAndExit(0));
process.on("SIGINT", () => cleanupAndExit(0));

send({ t: "ready", sdkVersion });
