import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chatWithClaude } from './claude.js';
const ANTHROPIC_MODEL = 'claude-sonnet-4-6';
import { publishLiveEvent } from './live-events.js';
import { WORKSPACE, DATA_DIR, ensureDirs } from './paths.js';

const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');

// Parses Claude Code's `--output-format stream-json` stdout. Each line is a JSON
// envelope. We care about:
//   - assistant.message.content[].type === 'text'       → accumulate response text
//   - assistant.message.content[].type === 'tool_use'   → emit agent_tool_start
//   - user.message.content[].type      === 'tool_result' → emit agent_tool_result
// Everything else (system, init, hooks, partials, result) is ignored.
function makeStreamJsonParser({ agentId, agentName, onText, onToolStart, onToolResult, onResult }) {
  let buffer = '';
  const toolNameById = new Map();
  return function handleChunk(chunk) {
    buffer += chunk;
    let nl;
    while ((nl = buffer.indexOf('\n')) >= 0) {
      const raw = buffer.slice(0, nl).trim();
      buffer = buffer.slice(nl + 1);
      if (!raw || raw[0] !== '{') continue;
      let evt;
      try { evt = JSON.parse(raw); } catch { continue; }
      if (!evt || typeof evt !== 'object') continue;
      // Terminal result event carries the run's final text at top level
      // (`evt.result`), not inside message.content. Capture it as a last-resort
      // answer for turns that emitted only tool_use blocks and no clean text
      // turn — otherwise the run throws "no assistant text" despite succeeding.
      if (evt.type === 'result') {
        // Capture even on is_error (e.g. error_max_turns): the result string is
        // the CLI's best final text and is far more useful in chat than throwing
        // the opaque "no assistant text". Used only as a last-resort fallback
        // after real assistant text blocks.
        if (typeof evt.result === 'string' && evt.result.trim()) {
          onResult?.(evt.result.trim());
        }
        continue;
      }
      const content = evt.message?.content;
      if (!Array.isArray(content)) continue;
      if (evt.type === 'assistant') {
        // Text emitted in the same assistant message as a tool_use is
        // interstitial narration ("Launching parallel source scans now…")
        // — it must NOT end up in the saved artifact. Flag it so the caller
        // can keep it for the live chat preview but exclude it from the
        // final return value.
        const hasToolUse = content.some(b => b?.type === 'tool_use');
        const messageText = content
          .filter(b => b?.type === 'text' && typeof b.text === 'string')
          .map(b => b.text)
          .join('');
        if (messageText) onText?.(messageText, { isFinal: !hasToolUse });
        for (const block of content) {
          if (block?.type === 'tool_use') {
            if (block.id) toolNameById.set(block.id, block.name || 'tool');
            onToolStart?.({ id: block.id, name: block.name || 'tool', input: block.input });
            if (agentId) {
              publishLiveEvent({
                type: 'agent_tool_start',
                agent_id: agentId,
                agent_name: agentName || agentId,
                tool: block.name || 'tool',
                input_summary: summarizeCcInput(block.name, block.input),
              });
            }
          }
        }
      } else if (evt.type === 'user') {
        for (const block of content) {
          if (block?.type === 'tool_result') {
            const toolName = toolNameById.get(block.tool_use_id) || 'tool';
            onToolResult?.({ tool_use_id: block.tool_use_id, name: toolName, isError: Boolean(block.is_error) });
            if (agentId) {
              publishLiveEvent({
                type: 'agent_tool_result',
                agent_id: agentId,
                agent_name: agentName || agentId,
                tool: toolName,
                status: block.is_error ? 'error' : 'done',
                isError: Boolean(block.is_error),
              });
            }
            toolNameById.delete(block.tool_use_id);
          }
        }
      }
    }
  };
}

function summarizeCcInput(name, input) {
  if (!input || typeof input !== 'object') return '';
  if (name === 'Bash') return String(input.command || '').slice(0, 120);
  if (name === 'WebSearch') return String(input.query || '').slice(0, 120);
  if (name === 'WebFetch') return String(input.url || '').slice(0, 120);
  if (name === 'Read' || name === 'Glob' || name === 'Grep' || name === 'LS') return String(input.file_path || input.pattern || input.path || '').slice(0, 120);
  if (name === 'Write' || name === 'Edit' || name === 'MultiEdit') return String(input.file_path || '').slice(0, 120);
  if (name === 'TodoWrite') return Array.isArray(input.todos) ? `${input.todos.length} todos` : '';
  try { return JSON.stringify(input).slice(0, 120); } catch { return ''; }
}

const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434';

export const AGENT_ENGINES = [
  {
    id: 'anthropic',
    label: 'Anthropic API',
    description: 'Server-native Claude Messages API brain with madarX governed tool_use.',
    default_model: ANTHROPIC_MODEL,
    mode: 'api',
  },
  {
    id: 'claude-code',
    label: 'Claude Code CLI',
    description: 'Local Claude Code runner for developer/repo agents using the authenticated CLI.',
    default_model: 'sonnet',
    mode: 'local-cli',
  },
  {
    id: 'ollama',
    label: 'Ollama Local LLM',
    description: 'Local or Ollama-hosted models through the Ollama chat API.',
    default_model: process.env.OLLAMA_MODEL || '',
    mode: 'local-llm',
  },
  {
    id: 'hermes',
    label: 'Hermes',
    description: 'Hermes as a meta-agent/router. Available through governed Hermes job tools.',
    default_model: '',
    mode: 'orchestrator',
  },
  {
    id: 'codex',
    label: 'Codex CLI',
    description: 'Local Codex CLI coding runner. Available through approval-gated terminal/tool workflows.',
    default_model: '',
    mode: 'local-cli',
  },
];

export function normalizeAgentEngine(engine) {
  const value = String(engine || '').trim().toLowerCase();
  if (!value) return 'claude-code';
  if (['anthropic', 'anthropic-api', 'claude-api', 'api'].includes(value)) return 'anthropic';
  if (['claude-code', 'claude code', 'claude_cli', 'claude-cli'].includes(value)) return 'claude-code';
  if (value.includes('hermes') && value.includes('claude code')) return 'anthropic';
  if (['ollama', 'local', 'local-llm', 'local llm'].includes(value)) return 'ollama';
  if (['hermes', 'hermes-agent'].includes(value)) return 'hermes';
  if (['codex', 'codex-cli', 'openai-codex'].includes(value)) return 'codex';
  return value;
}

export function defaultModelForEngine(engine) {
  const normalized = normalizeAgentEngine(engine);
  return AGENT_ENGINES.find(item => item.id === normalized)?.default_model || '';
}

/**
 * Per-engine availability check. Throws an Error tagged `code = 'engine_unavailable'`
 * when the selected engine cannot run, so a caller can return a clear, engine-
 * specific message instead of a generic LLM failure. Engine is a cost/scale tier:
 *   - anthropic  → an Anthropic API key must be resolvable
 *   - claude-code → the `claude` CLI must be installed (or a mock is configured)
 *   - codex       → the `codex` CLI must be installed
 *   - ollama/hermes → assumed reachable (best-effort; runtime surfaces failures)
 * Never relaxes a security gate — purely a precondition check.
 */
export async function assertEngineAvailable(engine) {
  const eng = normalizeAgentEngine(engine);
  if (eng === 'anthropic') {
    const { resolveAnthropicApiKey } = await import('./anthropic-key.js');
    if (!resolveAnthropicApiKey()) {
      const e = new Error('The Anthropic API engine is selected but no API key is configured. Add ANTHROPIC_API_KEY to the secrets vault or .env.local, or switch to the Claude Code CLI engine.');
      e.code = 'engine_unavailable';
      e.engine = eng;
      throw e;
    }
    return true;
  }
  if (eng === 'claude-code') {
    if (process.env.MADARX_MOCK_CLAUDE_CODE_RESPONSE) return true;
    if (!(await commandExists('claude'))) {
      const e = new Error('The Claude Code CLI engine is selected but the `claude` binary is not installed or not on PATH. Install Claude Code and sign in, or switch to the Anthropic API engine.');
      e.code = 'engine_unavailable';
      e.engine = eng;
      throw e;
    }
    return true;
  }
  if (eng === 'codex') {
    if (process.env.MADARX_MOCK_CODEX_RESPONSE) return true;
    if (!(await commandExists('codex'))) {
      const e = new Error('The Codex CLI engine is selected but the `codex` binary is not installed or not on PATH.');
      e.code = 'engine_unavailable';
      e.engine = eng;
      throw e;
    }
    return true;
  }
  // ollama / hermes / unknown — assumed reachable; runtime surfaces real failures.
  return true;
}

export function engineLabel(engine) {
  const normalized = normalizeAgentEngine(engine);
  return AGENT_ENGINES.find(item => item.id === normalized)?.label || engine || 'Unknown engine';
}

// Resolve a CLI to an ABSOLUTE path so spawning never depends on the inherited
// PATH of the (possibly GUI/daemon-launched) server process. Detection used a
// login shell (`sh -lc`, sources ~/.profile) and found `claude` in ~/.local/bin,
// but the bare `spawn('claude')` execution path used the raw inherited PATH,
// which often lacks ~/.local/bin → "spawn claude ENOENT" even though the engine
// reported available. Resolving an absolute path once closes that gap.
// Override with MADARX_<CMD>_BIN (e.g. MADARX_CLAUDE_BIN).
const _commandPathCache = new Map();

function resolveCommandPathSync(command) {
  if (_commandPathCache.has(command)) return _commandPathCache.get(command);
  const envOverride = process.env[`MADARX_${command.toUpperCase()}_BIN`];
  const candidates = [
    envOverride,
    path.join(os.homedir(), '.local', 'bin', command),
    `/opt/homebrew/bin/${command}`,
    `/usr/local/bin/${command}`,
    path.join(os.homedir(), '.npm-global', 'bin', command),
  ].filter(Boolean);
  for (const candidate of candidates) {
    try {
      fs.accessSync(candidate, fs.constants.X_OK);
      _commandPathCache.set(command, candidate);
      return candidate;
    } catch {
      // not here, keep looking
    }
  }
  return null;
}

// Login-shell lookup (sources ~/.profile etc.) — authoritative but async.
// Caches the resolved absolute path for reuse by the spawn path.
function resolveCommandPathViaShell(command) {
  return new Promise(resolve => {
    const child = spawn('sh', ['-lc', `command -v ${command}`], { shell: false });
    let out = '';
    child.stdout.on('data', chunk => { out += chunk.toString(); });
    child.on('error', () => resolve(null));
    child.on('close', code => {
      const resolved = code === 0 ? out.trim().split('\n').pop()?.trim() : '';
      if (resolved && path.isAbsolute(resolved)) {
        _commandPathCache.set(command, resolved);
        resolve(resolved);
      } else {
        resolve(resolveCommandPathSync(command));
      }
    });
  });
}

// Absolute path used to spawn the CLI. Falls back to the bare command name so
// the inherited PATH still gets a chance (preserves old behavior as last resort).
function commandSpawnTarget(command) {
  return _commandPathCache.get(command) || resolveCommandPathSync(command) || command;
}

async function commandExists(command) {
  if (resolveCommandPathSync(command)) return true;
  return new Promise(resolve => {
    const child = spawn('sh', ['-lc', `command -v ${command}`], { shell: false });
    let out = '';
    child.stdout.on('data', chunk => { out += chunk.toString(); });
    child.on('error', () => resolve(false));
    child.on('close', code => {
      const resolved = code === 0 ? out.trim().split('\n').pop()?.trim() : '';
      if (resolved && path.isAbsolute(resolved)) _commandPathCache.set(command, resolved);
      resolve(code === 0);
    });
  });
}

async function listOllamaModels() {
  try {
    const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
    if (!response.ok) return [];
    const data = await response.json();
    return (data.models || []).map(model => ({
      name: model.name || model.model,
      model: model.model || model.name,
      size: model.size || 0,
      details: model.details || {},
      local: !model.remote_host,
      remote_host: model.remote_host || null,
    })).filter(model => model.name);
  } catch {
    return [];
  }
}

export async function listAgentEngines() {
  const [ollamaModels, claudeCodeInstalled, codexInstalled] = await Promise.all([
    listOllamaModels(),
    commandExists('claude'),
    commandExists('codex'),
  ]);
  const fallbackOllamaModel = process.env.OLLAMA_MODEL || (process.env.MADARX_MOCK_OLLAMA_RESPONSE ? 'qwen3.5:latest' : '');
  const firstOllamaModel = ollamaModels[0]?.name || fallbackOllamaModel;
  return AGENT_ENGINES.map(engine => ({
    ...engine,
    default_model: engine.id === 'ollama' ? firstOllamaModel : engine.default_model,
    available: engine.id === 'claude-code'
      ? claudeCodeInstalled
      : engine.id === 'codex'
        ? codexInstalled
        : engine.id === 'ollama'
          ? Boolean(ollamaModels.length || process.env.MADARX_MOCK_OLLAMA_RESPONSE)
          : true,
    models: engine.id === 'ollama'
      ? (ollamaModels.length ? ollamaModels : fallbackOllamaModel ? [{ name: fallbackOllamaModel, model: fallbackOllamaModel, local: true, fallback: true }] : [])
      : engine.default_model ? [{ name: engine.default_model, model: engine.default_model }] : [],
  }));
}

function textFromClaudeResponse(response) {
  return (response?.content || [])
    .filter(part => part?.type === 'text' || typeof part?.text === 'string')
    .map(part => part.text || '')
    .join('\n')
    .trim();
}

// Env for the spawned `claude` CLI. Removes Anthropic API-key billing vars so
// the subprocess authenticates with the user's Claude Code subscription
// (Keychain OAuth) rather than a (possibly empty) pay-per-token API account.
// Opt out with MADARX_CLI_USE_API_KEY=1 if API-key billing is intended.
function claudeSubprocessEnv() {
  const env = { ...process.env };
  if (process.env.MADARX_CLI_USE_API_KEY === '1') return env;
  delete env.ANTHROPIC_API_KEY;
  delete env.ANTHROPIC_AUTH_TOKEN;
  return env;
}

function cliOutput(command, args, { cwd = process.cwd(), timeoutMs = 120000, closeStdin = false, onChunk, env } = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, { shell: false, cwd, env: env ?? { ...process.env } });
    if (closeStdin) child.stdin.end();
    let stdout = '';
    let stderr = '';
    const timer = setTimeout(() => {
      child.kill('SIGTERM');
      reject(new Error(`${command} timed out after ${timeoutMs}ms`));
    }, timeoutMs);
    child.stdout.on('data', chunk => {
      const text = chunk.toString();
      stdout += text;
      onChunk?.(text);
    });
    child.stderr.on('data', chunk => { stderr += chunk.toString(); });
    child.on('error', error => {
      clearTimeout(timer);
      reject(error);
    });
    child.on('close', code => {
      clearTimeout(timer);
      if (code === 0) { resolve(stdout.trim()); return; }
      // Exit code 1 with stdout content = soft limit hit (e.g. "Reached max turns") — use the output.
      if (code === 1 && stdout.trim()) { resolve(stdout.trim()); return; }
      reject(new Error(`${command} exited ${code}: ${stderr.trim() || stdout.trim()}`));
    });
  });
}

export async function runOllamaAgent({ model, systemPrompt, userMessage, maxTokens = 2048 }) {
  if (process.env.MADARX_MOCK_OLLAMA_RESPONSE) return process.env.MADARX_MOCK_OLLAMA_RESPONSE;
  const response = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: model || process.env.OLLAMA_MODEL,
      stream: false,
      options: { num_predict: maxTokens },
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userMessage },
      ],
    }),
  });
  if (!response.ok) throw new Error(`Ollama chat failed: HTTP ${response.status}`);
  const data = await response.json();
  const content = data.message?.content || data.response || '';
  if (!content.trim()) throw new Error('Ollama returned no text');
  return content.trim();
}

export async function runClaudeCodeAgent({ model, systemPrompt, userMessage, maxTurns, allowedTools = [], timeoutMs, onChunk, onAssistantText, onToolStart, onToolResult, cwd, addDirs = [], agentId, agentName } = {}) {
  if (process.env.MADARX_MOCK_CLAUDE_CODE_RESPONSE) return process.env.MADARX_MOCK_CLAUDE_CODE_RESPONSE;
  const prompt = `${systemPrompt}\n\n${userMessage}`;
  const effectiveMaxTurns = Number(maxTurns || process.env.CLAUDE_CODE_MAX_TURNS || 30);
  const args = [
    '-p', prompt,
    '--max-turns', String(effectiveMaxTurns),
    '--output-format', 'stream-json',
    '--verbose',
    '--permission-mode', 'bypassPermissions',
    '--setting-sources', 'project,local,user',
  ];
  for (const dir of addDirs) {
    if (dir) args.push('--add-dir', dir);
  }
  if (allowedTools.length) args.push('--allowedTools', allowedTools.join(','));
  if (model && model !== 'claude-code') args.splice(2, 0, '--model', model);

  // Two buffers: `narrationParts` keeps every assistant text block (used only
  // for the live chat preview), `finalParts` keeps text from messages that had
  // NO tool_use — i.e. the actual answer, not the "let me search next…" prose.
  // `lastNarration` is a last-resort fallback if Claude Code never emits a
  // clean text-only turn (e.g. max-turns hit mid-tool-loop).
  const narrationParts = [];
  const finalParts = [];
  let lastNarration = '';
  let resultText = '';
  const parse = makeStreamJsonParser({
    agentId,
    agentName,
    onText: (t, opts = {}) => {
      narrationParts.push(t);
      if (opts.isFinal) finalParts.push(t);
      else lastNarration = t;
      // Snapshot of running assistant prose so the route can stream a clean
      // preview to the chat without exposing raw stream-json envelopes.
      onAssistantText?.(narrationParts.join('').trim());
    },
    onResult: (t) => { resultText = t; },
    onToolStart,
    onToolResult,
  });

  await cliOutput(commandSpawnTarget('claude'), args, {
    cwd,
    timeoutMs: timeoutMs ?? Number(process.env.CLAUDE_CODE_TICK_TIMEOUT_MS || 600000),
    closeStdin: true,
    // Force the spawned Claude Code CLI onto its own subscription/OAuth login
    // (macOS Keychain) instead of the pay-per-token Anthropic API. If
    // ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN leak in from .env.local /
    // process.env, the CLI silently bills that API account — surfacing
    // "Credit balance is too low" in chat even though the user picked the
    // local CLI engine. Strip them so CC uses the logged-in subscription.
    // Honor an explicit opt-out for users who genuinely want API-key billing
    // for the spawned CLI (MADARX_CLI_USE_API_KEY=1).
    env: claudeSubprocessEnv(),
    onChunk: (chunk) => {
      parse(chunk);
      onChunk?.(chunk);
    },
  });

  const text = finalParts.join('').trim()
    || lastNarration.trim()
    || narrationParts.join('').trim()
    || resultText.trim();
  if (!text) throw new Error('Claude Code returned no assistant text');
  return text;
}

export async function runCodexAgent({ systemPrompt, userMessage, onChunk }) {
  if (process.env.MADARX_MOCK_CODEX_RESPONSE) return process.env.MADARX_MOCK_CODEX_RESPONSE;
  const prompt = systemPrompt ? `${systemPrompt}\n\n${userMessage}` : userMessage;
  return cliOutput(commandSpawnTarget('codex'), ['exec', prompt], {
    timeoutMs: Number(process.env.CODEX_TICK_TIMEOUT_MS || 180000),
    closeStdin: true,
    onChunk,
  });
}

export async function runAnthropicAgent({ model, systemPrompt, userMessage, maxTokens = 2048 }) {
  const response = await chatWithClaude(
    [{ role: 'user', content: userMessage }],
    systemPrompt,
    [],
    { max_tokens: maxTokens, model }
  );
  const text = textFromClaudeResponse(response);
  if (!text) throw new Error('Anthropic returned no text');
  return text;
}

// Resolve a working directory that actually EXISTS AT RUNTIME for the spawned
// claude CLI. PROJECT_ROOT is derived from import.meta.url, which Next.js bakes
// at BUILD time — in the packaged standalone build that is the CI builder's
// path (/home/runner/work/madarx-os/madarx-os), which is absent on the user's
// machine. Spawning with a non-existent cwd fails with the exact same
// "spawn claude ENOENT" as a missing binary. Prefer the MADARX_HOME workspace
// (created at runtime), then the data dir, then the process cwd.
function isDir(p) {
  try { return Boolean(p) && fs.statSync(p).isDirectory(); } catch { return false; }
}

function agentSubprocessCwd() {
  for (const dir of [WORKSPACE, DATA_DIR, PROJECT_ROOT, process.cwd()]) {
    if (isDir(dir)) return dir;
  }
  return process.cwd();
}

export async function runAgentEngine({ agent, systemPrompt, userMessage, maxTokens = 2048 }) {
  const engine = normalizeAgentEngine(agent.engine);
  const model = agent.model || defaultModelForEngine(engine);
  if (engine === 'ollama') return runOllamaAgent({ model, systemPrompt, userMessage, maxTokens });
  if (engine === 'claude-code') {
    try { ensureDirs(); } catch { /* best-effort; cwd resolver falls back */ }
    const addDirs = [WORKSPACE, DATA_DIR].filter(isDir);
    return runClaudeCodeAgent({
      model,
      systemPrompt,
      userMessage,
      cwd: agentSubprocessCwd(),
      addDirs,
      agentId: agent.id,
      agentName: agent.name,
    });
  }
  if (engine === 'anthropic') return runAnthropicAgent({ model, systemPrompt, userMessage, maxTokens });
  if (engine === 'codex') return runCodexAgent({ systemPrompt, userMessage });
  if (engine === 'hermes') {
    const e = new Error('The Hermes engine cannot run scheduled autonomous ticks yet — it has no governed tool loop, so a tick would only fabricate output. Switch this agent to the "Anthropic API" engine, or drive Hermes via its governed job tools (send_to_hermes / start_hermes_job) from the console.');
    e.code = 'engine_cannot_run_tools';
    e.engine = 'hermes';
    throw e;
  }
  throw new Error(`Unknown agent engine: ${agent.engine}`);
}
