#!/usr/bin/env node

/**
 * Reasonix ACP wrapper for mnote-web.
 *
 * Spawned by mnote-web's AcpRuntimeManager as:
 *   node scripts/reasonix-acp-wrapper.mjs
 *
 * Implements the ACP (Agent Client Protocol) JSON-RPC 2.0 server over stdio.
 * This is a self-contained NDJSON implementation — no dependency on AcpServer
 * (which is a Reasonix internal module not shipped in the npm package).
 *
 * Registers mnote tools as HTTP calls to mnote-web's tool endpoints.
 *
 * Reference: DeepSeek-Reasonix-main/src/cli/commands/acp.ts
 *
 * Environment:
 *   DEEPSEEK_API_KEY   — DeepSeek API key (required)
 *   MNOTE_WEB_URL      — mnote-web base URL (default: http://127.0.0.1:3000)
 */

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';

const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';

function debugLog(message) {
  if (DEBUG) process.stderr.write(`${message}\n`);
}

const toolContextStorage = new AsyncLocalStorage();

function isWriteMnoteTool(toolName) {
  return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
}

function stableIdPart(value, fallback) {
  const text = String(value || fallback || '')
    .trim()
    .replace(/[^a-zA-Z0-9_.:-]+/g, '_')
    .slice(0, 80);
  return text || String(fallback || 'unknown');
}

function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
  const {
    actorId,
    sessionId,
    runId,
    toolCallId,
    traceId,
    idempotencyKey,
    dryRun,
    workspaceId,
    ...args
  } = rawArgs || {};
  const effectiveSessionId =
    sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`;
  const effectiveRunId = runId || context.runId || effectiveSessionId;
  const effectiveToolCallId =
    toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
  const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
  const writeTool = isWriteMnoteTool(toolName);
  const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
  const effectiveIdempotencyKey =
    idempotencyKey ||
    `idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`;
  return {
    toolName,
    args,
    workspaceId: workspaceId || context.workspaceId || 'default',
    documentId: args.documentId || context.documentId,
    actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
    sessionId: effectiveSessionId,
    runId: effectiveRunId,
    toolCallId: effectiveToolCallId,
    traceId: effectiveTraceId,
    dryRun: effectiveDryRun,
    idempotencyKey: effectiveIdempotencyKey,
  };
}

if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
  const payload = buildMnoteToolPayload(
    'mnote.doc.markdown_edit',
    {
      workspaceId: 'ws_demo',
      documentId: 'doc_1',
      operations: [{ search: '旧', replace: '新' }],
    },
    {
      actorId: 'user_1',
      sessionId: 'sess_1',
      runId: 'run_1',
      traceId: 'trace_1',
    },
  );
  const required = [
    'toolName',
    'workspaceId',
    'documentId',
    'actorId',
    'sessionId',
    'runId',
    'toolCallId',
    'traceId',
    'dryRun',
    'idempotencyKey',
  ];
  for (const key of required) {
    if (payload[key] === undefined || payload[key] === null || payload[key] === '') {
      throw new Error(`selftest missing ${key}`);
    }
  }
  if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
    throw new Error('selftest expected identity fields outside args');
  }
  process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
  process.exit(0);
}

// 读取 DeepSeek API key：先环境变量，再 Reasonix 官方 JSON 配置，最后兼容旧 YAML。
function loadApiKey() {
  if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
  const jsonConfigPath = join(homedir(), '.reasonix', 'config.json');
  if (existsSync(jsonConfigPath)) {
    try {
      const raw = readFileSync(jsonConfigPath, 'utf-8');
      const parsed = JSON.parse(raw);
      if (typeof parsed?.apiKey === 'string' && parsed.apiKey.trim()) {
        return parsed.apiKey.trim();
      }
    } catch {
      // 配置损坏时继续尝试旧 YAML，不让启动阶段吞掉更明确的错误。
    }
  }
  const configPath = join(homedir(), '.reasonix', 'config.yaml');
  if (existsSync(configPath)) {
    const raw = readFileSync(configPath, 'utf-8');
    // 简单提取 YAML key，避免为 wrapper 额外引入 yaml parser。
    const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m);
    if (match) return match[1].trim();
  }
  return null;
}

const DEEPSEEK_API_KEY = loadApiKey();
if (!DEEPSEEK_API_KEY) {
  process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n');
  process.stderr.write('Set env var DEEPSEEK_API_KEY, or put apiKey in ~/.reasonix/config.json\n');
  process.exit(1);
}

process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY;

// ── Dynamic import of reasonix public API ────────────

let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix;
async function importReasonix() {
  try {
    return await import('reasonix');
  } catch (error) {
    let npmRoot = '';
    try {
      npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim();
    } catch {
      throw error;
    }
    const globalEntry = join(npmRoot, 'reasonix', 'dist', 'index.js');
    if (!existsSync(globalEntry)) {
      throw error;
    }
    return import(pathToFileURL(globalEntry).href);
  }
}

try {
  const r = await importReasonix();
  CacheFirstLoop = r.CacheFirstLoop;
  DeepSeekClient = r.DeepSeekClient;
  ToolRegistry = r.ToolRegistry;
  ImmutablePrefix = r.ImmutablePrefix;
} catch (e) {
  process.stderr.write(`FATAL: reasonix not found — run "npm install reasonix"\n`);
  process.stderr.write(`Error: ${e}\n`);
  process.exit(1);
}

// ── Simple NDJSON JSON-RPC 2.0 Server ───────────────
// Reference: Reasonix src/acp/server.ts (conceptual)

let nextId = 1;
const pendingReqs = new Map(); // id → { resolve, reject }
const requestHandlers = new Map(); // method → handler
const notificationHandlers = new Map(); // method → handler

function sendMessage(msg) {
  const line = JSON.stringify(msg) + '\n';
  stdout.write(line);
}

function sendResponse(id, result) {
  sendMessage({ jsonrpc: '2.0', id, result });
}

function sendError(id, code, message) {
  sendMessage({ jsonrpc: '2.0', id, error: { code, message } });
}

function sendNotification(method, params) {
  sendMessage({ jsonrpc: '2.0', method, params });
}

function onRequest(method, handler) {
  requestHandlers.set(method, handler);
}

function onNotification(method, handler) {
  notificationHandlers.set(method, handler);
}

// Start reading NDJSON from stdin
const rl = createInterface({ input: stdin, terminal: false });
rl.on('line', async (raw) => {
  const trimmed = raw.trim();
  if (!trimmed) return;

  let msg;
  try {
    msg = JSON.parse(trimmed);
  } catch {
    sendError(null, -32700, 'Parse error');
    return;
  }

  const hasId = msg.id !== undefined && msg.id !== null;
  const hasMethod = typeof msg.method === 'string' && msg.method.length > 0;

  if (hasId && hasMethod) {
    // Incoming request from client
    const handler = requestHandlers.get(msg.method);
    if (!handler) {
      sendError(msg.id, -32601, `Method not found: ${msg.method}`);
      return;
    }
    try {
      const result = await handler(msg.params);
      sendResponse(msg.id, result);
    } catch (err) {
      sendError(msg.id, err.code || -32603, err.message || String(err));
    }
  } else if (hasId) {
    // Response to our outgoing request
    const pending = pendingReqs.get(msg.id);
    if (pending) {
      pendingReqs.delete(msg.id);
      if (msg.error) {
        pending.reject(new Error(msg.error.message));
      } else {
        pending.resolve(msg.result);
      }
    }
  } else if (hasMethod) {
    // Notification from client
    const handler = notificationHandlers.get(msg.method);
    if (handler) handler(msg.params);
  }
});

// ── Helper: send ACP session/update (camelCase per protocol spec) ──

function emitSessionUpdate(sessionId, update) {
  sendNotification('session/update', { sessionId, update });
}

function emitTextDelta(sessionId, text) {
  emitSessionUpdate(sessionId, {
    sessionUpdate: 'agent_message_chunk',
    content: { type: 'text', text },
  });
}

function emitThoughtDelta(sessionId, text) {
  emitSessionUpdate(sessionId, {
    sessionUpdate: 'agent_thought_chunk',
    content: { type: 'text', text },
  });
}

function emitToolCall(sessionId, toolCallId, title, kind, status, rawInput) {
  emitSessionUpdate(sessionId, {
    sessionUpdate: 'tool_call',
    toolCallId,
    title,
    kind,
    status,
    rawInput,
  });
}

function emitToolResult(sessionId, toolCallId, status, text) {
  emitSessionUpdate(sessionId, {
    sessionUpdate: 'tool_call_update',
    toolCallId,
    status,
    content: text ? [{ type: 'content', content: { type: 'text', text } }] : undefined,
  });
}

function emitUsage(sessionId, used, size) {
  emitSessionUpdate(sessionId, {
    sessionUpdate: 'usage_update',
    used,
    size,
  });
}

// ── MNOTE Tool Implementations ───────────────────────
// Calls mnote-web's Rust tool endpoints via HTTP.
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs

const MNOTE_TOOL_NAMES = [
  'mnote.doc.fetch',
  'mnote.doc.markdown_edit',
  'mnote.block.*',
  'mnote.page.*',
];

const REASONIX_TOOL_TO_MNOTE_TOOL = {
  mnote_doc_fetch: 'mnote.doc.fetch',
  mnote_doc_markdown_edit: 'mnote.doc.markdown_edit',
};

async function callMnoteTool(toolName, args) {
  const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
  const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-mnote-actor-id': payload.actorId,
      'x-mnote-workspace-id': payload.workspaceId,
    },
    body: JSON.stringify(payload),
  });
  if (!response.ok) {
    const text = await response.text().catch(() => '');
    throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`);
  }
  return response.json();
}

// ── Register Tools ───────────────────────────────────

const tools = new ToolRegistry();

tools.register({
  name: 'mnote_doc_fetch',
  description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。',
  parameters: {
    type: 'object',
    properties: {
      documentId: { type: 'string', description: '文档 ID（可选，默认当前文档）' },
      format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
    },
  },
  readOnly: true,
  fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args),
  parallelSafe: false,
});

tools.register({
  name: 'mnote_doc_markdown_edit',
  description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
  parameters: {
    type: 'object',
    properties: {
      documentId: { type: 'string', description: '文档 ID' },
      operations: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            search: { type: 'string', description: '要搜索的文本片段' },
            replace: { type: 'string', description: '替换后的文本' },
          },
          required: ['search', 'replace'],
        },
        description: '搜索替换操作列表',
      },
    },
    required: ['documentId', 'operations'],
  },
  fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
  parallelSafe: false,
});

// ── Session Store ────────────────────────────────────

const sessions = new Map();

// ── ACP: initialize ──────────────────────────────────

onRequest('initialize', (params) => {
  return {
    protocolVersion: 1,
    agentCapabilities: {
      loadSession: false,
      promptCapabilities: { image: false, audio: false, embeddedContext: true },
      mcpCapabilities: { http: false, sse: false },
    },
    agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' },
    authMethods: [],
  };
});

// ── ACP: session/new ─────────────────────────────────
// Creates a CacheFirstLoop for the session.

onRequest('session/new', async (params) => {
  const sessionId = randomUUID();
  const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });

  const systemPrompt = [
    'You are a helpful AI assistant for editing Markdown documents.',
    'Use mnote_doc_fetch to read the current document.',
    'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
    'Always use mnote_doc_fetch first to understand the document content before editing.',
  ].join('\n');

  const loop = new CacheFirstLoop({
    client,
    tools,
    prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }),
  });

  sessions.set(sessionId, { id: sessionId, loop, client, aborter: null });
  return { sessionId };
});

// ── ACP: session/prompt ─────────────────────────────
// Runs loop.step() and emits ACP session/update events.

onRequest('session/prompt', async (params) => {
  if (!params?.sessionId) {
    throw Object.assign(new Error('Missing sessionId'), { code: -32602 });
  }
  const session = sessions.get(params.sessionId);
  if (!session) {
    throw Object.assign(new Error(`Unknown session: ${params.sessionId}`), { code: -32602 });
  }

  const blocks = params.prompt || [];
  const text = blocks
    .map((b) => (b.type === 'text' ? b.text : b.resource?.text || ''))
    .filter(Boolean)
    .join('\n\n')
    .trim();

  if (!text) {
    throw Object.assign(new Error('Empty prompt'), { code: -32602 });
  }

  session.aborter = new AbortController();
  const toolContext = {
    acpSessionId: params.sessionId,
    sessionId: params.mnoteSessionId || params.sessionId,
    runId: params.runId || params.mnoteRunId || params.sessionId,
    actorId: params.actorId || params.mnoteActorId,
    traceId: params.traceId || params.mnoteTraceId,
    workspaceId: params.workspaceId,
    documentId: params.documentId,
  };
  let stopReason = 'end_turn';
  let hasAssistantOutput = false;
  let hasToolCall = false;
  let nextToolCallSeq = 1;
  const announcedToolKeys = new Set();
  const preparingToolCallIds = [];
  const inflightToolCallIds = [];

  function nextToolCallId() {
    return `tc_${nextToolCallSeq++}`;
  }

  function toolKeyFor(ev) {
    if (ev.toolCallIndex !== undefined && ev.toolCallIndex !== null) {
      return `${ev.turn ?? 0}:${ev.toolCallIndex}`;
    }
    return `${ev.turn ?? 0}:${ev.toolName || 'tool'}:${nextToolCallSeq}`;
  }

  function parseToolArgs(raw) {
    if (!raw) return undefined;
    try {
      return JSON.parse(raw);
    } catch {
      return raw;
    }
  }

  try {
    await toolContextStorage.run(toolContext, async () => {
      for await (const ev of session.loop.step(text)) {
      if (session.aborter?.signal.aborted) {
        stopReason = 'cancelled';
        break;
      }

      debugLog(`[reasonix-acp] loop event role=${ev.role}`);

      // Reasonix 的 LoopEvent 使用 role 字段；这里按官方 Eventizer 的核心语义映射到 ACP。
      switch (ev.role) {
        case 'assistant_delta': {
          if (ev.content) {
            hasAssistantOutput = true;
            emitTextDelta(session.id, ev.content);
          }
          if (ev.reasoningDelta) {
            emitThoughtDelta(session.id, ev.reasoningDelta);
          }
          break;
        }
        case 'assistant_final': {
          if (ev.content && !hasAssistantOutput) {
            hasAssistantOutput = true;
            emitTextDelta(session.id, ev.content);
          } else if (ev.content) {
            hasAssistantOutput = true;
          }
          break;
        }
        case 'done': {
          if (ev.content && !hasAssistantOutput) {
            hasAssistantOutput = true;
            emitTextDelta(session.id, ev.content);
          }
          break;
        }
        case 'tool_call_delta': {
          hasToolCall = true;
          const key = toolKeyFor(ev);
          if (announcedToolKeys.has(key)) break;
          announcedToolKeys.add(key);
          const toolCallId = nextToolCallId();
          preparingToolCallIds.push(toolCallId);
          emitToolCall(
            session.id,
            toolCallId,
            ev.toolName || 'tool',
            'other',
            'pending',
            undefined,
          );
          break;
        }
        case 'tool_start': {
          hasToolCall = true;
          const toolCallId = preparingToolCallIds.shift() || ev.callId || nextToolCallId();
          inflightToolCallIds.push(toolCallId);
          emitToolCall(
            session.id,
            toolCallId,
            ev.toolName || 'tool',
            'other',
            'in_progress',
            parseToolArgs(ev.toolArgs),
          );
          break;
        }
        case 'tool': {
          hasToolCall = true;
          const resultText = String(ev.content || '').slice(0, 8000);
          emitToolResult(
            session.id,
            inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
            resultText.includes('"error"') ? 'failed' : 'completed',
            resultText,
          );
          break;
        }
        case 'error': {
          const message = ev.error || ev.content || 'Reasonix loop error';
          emitTextDelta(session.id, `\n\n[error] ${message}`);
          stopReason = 'error';
          break;
        }
        case 'warning':
        case 'status': {
          if (ev.content) emitThoughtDelta(session.id, ev.content);
          break;
        }
      }
      // Usage info from stats
      if (ev.stats) {
        emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
      }
      }
    });
  } catch (err) {
    const message = err.message || String(err);
    const stack = err.stack || '';
    process.stderr.write(`[reasonix-acp] prompt error: ${message}\n${stack}\n`);
    emitTextDelta(session.id, `\n\n[error] ${message}`);
    stopReason = 'error';
  } finally {
    // If no model output or tool calls were produced, it means the LLM backend failed.
    // This can happen when the API key is invalid, base URL is wrong, or model is unavailable.
    if (!hasAssistantOutput && !hasToolCall && stopReason !== 'error') {
      stopReason = 'error';
      emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因：API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置（以 sk- 开头）。');
    }
    session.aborter = null;
  }

  return { stopReason };
});

// ── ACP: session/cancel (notification) ──────────────

onNotification('session/cancel', (params) => {
  const session = params?.sessionId ? sessions.get(params.sessionId) : undefined;
  if (session?.aborter) {
    session.aborter.abort();
  }
});

// ── Start ────────────────────────────────────────────

process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
