import fs from 'node:fs';
import path from 'node:path';
import { chatWithClaude } from './claude.js';
import { defaultModelForEngine, normalizeAgentEngine, runAgentEngine } from './agent-engines.js';
import { currentDateInstruction } from './agent-config.js';
import { renderDocumentHtml, stripEmoji } from './artifact-format.js';
import { getDb, nowIso } from './db.js';
import { AGENTS_DIR, REPORTS_DIR, ensureDirs, relToRoot } from './paths.js';
import { executeRegistryTool, getEnabledToolSchemasForAgent } from './tools/registry.js';
import { assertRunnable } from './engine-capabilities.js';
import { looksFabricatedToolTranscript } from './fabricated-tool-output.js';
import { publishLiveEvent } from './live-events.js';
import {
  addTaskComment,
  appendAgentRunEvent,
  createTaskRun,
  getAgentRun,
  getTask,
  markAgentRunFinished,
  markAgentRunRunning,
  markAgentRunWaitingApproval,
  updateTask,
  upsertAgentRuntimeState,
} from './delegation.js';

function parseJson(value, fallback) {
  if (!value) return fallback;
  try { return JSON.parse(value); } catch { return fallback; }
}

// Tool inputs from LLM tool_use blocks can contain file paths, env data, or
// arbitrary text. The live-events SSE channel is read by all dashboard tabs,
// so we never forward raw input. Only short, whitelisted, scalar fields go on
// the wire — and only the ones the UI's toolStepSublabel knows how to render.
function summarizeToolInput(name, input) {
  if (!input || typeof input !== 'object') return null;
  const summary = {};
  if (name === 'web_search' && typeof input.query === 'string') {
    summary.query = input.query.slice(0, 80);
  } else if (name === 'web_fetch' && typeof input.url === 'string') {
    try { summary.host = new URL(input.url).hostname; } catch { /* drop */ }
  } else if (name === 'create_artifact' && typeof input.title === 'string') {
    summary.title = input.title.slice(0, 60);
  } else if (name === 'create_agent' && typeof input.name === 'string') {
    summary.name = input.name.slice(0, 60);
  }
  return Object.keys(summary).length ? summary : null;
}

function slugPart(input) {
  return String(input || 'agent')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 80) || 'agent';
}

// Safety net for file-writing engines (e.g. claude-code): such agents do their
// real work via the Write tool and often end with a short "what I did" recap.
// runClaudeCodeAgent returns only that final tool-free turn, so the recap — not
// the report — would otherwise become the artifact. If the agent left a
// substantially richer markdown file for today under REPORTS_DIR, promote it as
// the deliverable. Returns `fallback` unchanged when no richer file exists.
function pickDeliverableMarkdown({ agentId, today, fallback }) {
  const fallbackText = fallback || '';
  const fallbackBytes = Buffer.byteLength(fallbackText, 'utf8');
  try {
    const slug = slugPart(agentId);
    let best = null;
    for (const name of fs.readdirSync(REPORTS_DIR)) {
      if (!name.endsWith('.md') || !name.startsWith(slug) || !name.includes(today)) continue;
      const full = path.join(REPORTS_DIR, name);
      let size = 0;
      try { size = fs.statSync(full).size; } catch { continue; }
      if (!best || size > best.size) best = { full, size };
    }
    if (best && best.size > Math.max(2048, fallbackBytes * 1.5)) {
      const md = fs.readFileSync(best.full, 'utf8');
      if (md.trim().length > fallbackText.trim().length) return md;
    }
  } catch {
    // REPORTS_DIR scan is best-effort; fall back to the agent's final message.
  }
  return fallbackText;
}

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

function toolUsesFromClaudeResponse(response) {
  return (response?.content || []).filter(part => part?.type === 'tool_use' && part.name && part.id);
}

function safeJson(value) {
  try { return JSON.stringify(value).slice(0, 4000); } catch { return String(value).slice(0, 4000); }
}

function summarizeForMemory(markdown) {
  const firstUsefulLine = String(markdown || '')
    .split(/\r?\n/)
    .map(line => line.replace(/^#+\s*/, '').trim())
    .find(line => line && !line.startsWith('-'));
  return (firstUsefulLine || 'Completed a scheduled tick and saved a report.').slice(0, 240);
}

function getAgentNotifyChatId(agent) {
  if (!agent?.metadata_json) return null;
  let meta = agent.metadata_json;
  if (typeof meta === 'string') {
    try { meta = JSON.parse(meta); } catch { return null; }
  }
  const chatId = meta?.whatsapp_notify_chat_id;
  return typeof chatId === 'string' && chatId.trim() ? chatId.trim() : null;
}

function artifactHtmlFilename(title) {
  const slug = String(title || 'agent-report')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 60) || 'agent-report';
  return `${slug}.html`;
}

async function maybeNotifyWhatsApp({ agent, artifactTitle, renderedHtml, runId, triggeredBy, appendAudit }) {
  const chatId = getAgentNotifyChatId(agent);
  if (!chatId) return;
  if (!renderedHtml || !String(renderedHtml).trim()) return;

  // Lazy-imported so test environments and processes that don't need the
  // bridge (e.g. main Next.js path running with mocked ticks) don't pay the
  // dynamic import cost on every run. Failures here are non-fatal — the
  // artifact is already saved; WhatsApp delivery is best-effort.
  let getBridgeHealth, sendWhatsAppDocument;
  try {
    ({ getBridgeHealth, sendWhatsAppDocument } = await import('./whatsapp.js'));
  } catch (err) {
    appendAudit({
      type: 'agent.whatsapp_notify.skipped',
      summary: `WhatsApp module unavailable for ${agent.name}: ${err.message}`,
      status: 'skipped',
      meta: { agent_id: agent.id, run_id: runId, chat_id: chatId },
    });
    return;
  }

  const health = await getBridgeHealth();
  if (!health || health.ok === false || health.pairing_state !== 'paired') {
    appendAudit({
      type: 'agent.whatsapp_notify.skipped',
      summary: `WhatsApp bridge not paired — skipped artifact notify for ${agent.name}`,
      status: 'skipped',
      meta: {
        agent_id: agent.id,
        run_id: runId,
        chat_id: chatId,
        pairing_state: health?.pairing_state || null,
        bridge_error: health?.error || null,
      },
    });
    return;
  }

  try {
    const result = await sendWhatsAppDocument({
      chat_id: chatId,
      content: renderedHtml,
      filename: artifactHtmlFilename(artifactTitle),
      mimetype: 'text/html',
      caption: `${artifactTitle} (${triggeredBy || 'scheduled'})`,
    });
    appendAudit({
      type: 'agent.whatsapp_notify.sent',
      summary: `Sent "${artifactTitle}" to ${chatId} via WhatsApp`,
      status: 'sent',
      meta: {
        agent_id: agent.id,
        run_id: runId,
        chat_id: chatId,
        message_id: result?.message_id || null,
      },
    });
    publishLiveEvent({
      type: 'agent_tool_result',
      agent_id: agent.id,
      agent_name: agent.name,
      tool: 'whatsapp_notify',
      status: 'done',
    });
  } catch (err) {
    appendAudit({
      type: 'agent.whatsapp_notify.failed',
      summary: `Failed to WhatsApp artifact for ${agent.name}: ${err.message}`,
      status: 'error',
      meta: { agent_id: agent.id, run_id: runId, chat_id: chatId },
    });
  }
}

function getRawAgent(agentOrId) {
  if (typeof agentOrId === 'object' && agentOrId?.id) return agentOrId;
  const db = getDb();
  return db.prepare('SELECT * FROM agents WHERE id = ?').get(String(agentOrId || ''));
}

function buildFallbackSystemPrompt(agent) {
  return `You are ${agent.name}, a scheduled madarX OS worker.

<agent_mission>
${agent.mission || 'Support the user with useful scheduled work.'}
</agent_mission>

Approval mode: ${agent.approval || 'human-in-loop'}.

Produce a structured markdown report. Never perform external sends, posts, deployments, purchases, or destructive operations.`;
}

function readSkills(agentId) {
  const file = path.join(AGENTS_DIR, agentId, 'skills.md');
  try { return fs.readFileSync(file, 'utf8').trim(); } catch { return ''; }
}

async function generateTickOutput({ agent, systemPrompt, userMessage, onToolStart, onToolResult, onToolError, acting_on_behalf_of_user_id, heartbeat_task_id }) {
  if (process.env.MADARX_MOCK_CLAUDE_TICK) {
    return { output: process.env.MADARX_MOCK_CLAUDE_TICK, toolEvents: [] };
  }
  const maxTokens = Math.max(256, Math.min(8192, Number(process.env.MAX_TOKENS_PER_TICK || 1024) || 1024));
  const tools = getEnabledToolSchemasForAgent(agent.id);
  const messages = [{ role: 'user', content: userMessage }];
  const toolEvents = [];
  let finalText = '';

  for (let turn = 0; turn < 4; turn += 1) {
    const response = await chatWithClaude(messages, systemPrompt, tools, { max_tokens: maxTokens, model: agent.model });
    const text = textFromClaudeResponse(response);
    if (text) finalText = text;
    const toolUses = toolUsesFromClaudeResponse(response);
    if (!toolUses.length) break;

    messages.push({ role: 'assistant', content: response.content });
    const toolResults = [];
    for (const toolUse of toolUses) {
      const event = { name: toolUse.name, input: toolUse.input || {}, status: 'pending' };
      const inputSummary = summarizeToolInput(toolUse.name, toolUse.input);
      publishLiveEvent({
        type: 'agent_tool_start',
        agent_id: agent.id,
        agent_name: agent.name,
        tool: toolUse.name,
        input_summary: inputSummary,
      });
      await onToolStart?.({ toolUse, inputSummary });
      try {
        const result = await executeRegistryTool(toolUse.name, toolUse.input || {}, {
          agent_id: agent.id,
          // Gate 0: thread service-principal identity for RD-1 membership check.
          acting_on_behalf_of_user_id: acting_on_behalf_of_user_id || null,
          heartbeat_task_id: heartbeat_task_id || null,
        });
        event.status = result?.status || 'complete';
        event.result = result;
        toolResults.push({
          type: 'tool_result',
          tool_use_id: toolUse.id,
          content: safeJson(result),
          is_error: Boolean(result?.ok === false && result?.status !== 'approval_required'),
        });
        publishLiveEvent({
          type: 'agent_tool_result',
          agent_id: agent.id,
          agent_name: agent.name,
          tool: toolUse.name,
          status: event.status,
          isError: Boolean(result?.ok === false && result?.status !== 'approval_required'),
        });
        await onToolResult?.({ toolUse, result, inputSummary, event });
      } catch (error) {
        event.status = 'failed';
        event.error = error.message;
        toolResults.push({ type: 'tool_result', tool_use_id: toolUse.id, content: error.message, is_error: true });
        publishLiveEvent({
          type: 'agent_tool_result',
          agent_id: agent.id,
          agent_name: agent.name,
          tool: toolUse.name,
          status: 'failed',
          isError: true,
          message: error.message,
        });
        await onToolError?.({ toolUse, error, inputSummary, event });
      }
      toolEvents.push(event);
    }
    messages.push({ role: 'user', content: toolResults });
  }

  if (!finalText) throw new Error('Claude returned no text for scheduled agent tick');
  return {
    output: finalText,
    toolEvents,
    engine: 'anthropic',
    model: agent.model || defaultModelForEngine('anthropic'),
  };
}

const DEFAULT_EXPECTED_OUTPUT =
  'A complete, structured markdown report covering the full result — not a status summary.';

// Resolve the work contract (what to do + what "done" looks like) for one run.
// Precedence, highest first:
//   1. per-call override  — transient, this run only (run_agent instruction/expected_output)
//   2. delegated task      — tasks.description / tasks.acceptance_criteria
//   3. agent standing      — agents.mission / agents.expected_output
//   4. hardcoded default   — last-resort instruction/output shape
// Pure + exported so the precedence is unit-testable in isolation.
export function resolveRunContract({ instruction, expected_output, task, agent } = {}) {
  const trim = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
  return {
    instruction:
      trim(instruction)
      || (task && (trim(task.description) || trim(task.title)))
      || trim(agent?.mission)
      || 'Execute your mission.',
    expected_output:
      trim(expected_output)
      || (task && trim(task.acceptance_criteria))
      || trim(agent?.expected_output)
      || DEFAULT_EXPECTED_OUTPUT,
  };
}

export async function runAgentTick(agentOrId, {
  triggered_by = 'cron',
  task_id = null,
  agent_run_id = null,
  wake_reason = null,
  // Per-call work contract. When the main agent (or any caller) dispatches an
  // agent for a specific job, these override the agent's standing mission /
  // expected_output for THIS run only — never persisted onto the agent row.
  instruction = null,
  expected_output = null,
  // Service-principal propagation (Task #7 / Gate 0):
  // Identity of the user on whose behalf this tick is running.
  // Passed from heartbeat-worker.js → executeRegistryTool context so Gate 0
  // can verify membership freshness for every tool call in this tick.
  acting_on_behalf_of_user_id = null,
  heartbeat_task_id = null,
} = {}) {
  ensureDirs();
  const db = getDb();
  const agent = getRawAgent(agentOrId);
  if (!agent) return null;
  const taskRun = task_id
    ? (agent_run_id ? getAgentRun(agent_run_id) : createTaskRun({ task_id, agent_id: agent.id, trigger_source: triggered_by, trigger_detail: wake_reason || triggered_by }))
    : null;
  if (taskRun) {
    markAgentRunRunning(taskRun.id);
    updateTask(task_id, { status: 'in_progress', actor_type: 'agent', actor_id: agent.id });
  }

  publishLiveEvent({
    type: 'agent_run_start',
    agent_id: agent.id,
    agent_name: agent.name,
    triggered_by,
    run_id: taskRun?.id || null,
    task_id,
  });

  try {
  const memory = parseJson(agent.memory, []);
  const lastMemory = memory.slice(-10);
  const skills = readSkills(agent.id);
  const today = new Date().toISOString().slice(0, 10);
  const baseSystemPrompt = agent.system_prompt || buildFallbackSystemPrompt(agent);
  const normalizedEngine = normalizeAgentEngine(agent.engine);
  const agentModel = agent.model || defaultModelForEngine(normalizedEngine);
  // Layer A: stop a tool-needing agent on a non-tool-executing engine (ollama /
  // hermes) BEFORE it runs single-shot and fabricates a tool transcript. Throws
  // `engine_cannot_run_tools`, caught below and recorded as a FAILED run with
  // remediation — never a fabricated "complete" deliverable. Skipped under the
  // mock envs so tests that stub engine output stay deterministic.
  if (!process.env.MADARX_MOCK_AGENT_TICK && !process.env.MADARX_MOCK_CLAUDE_TICK) {
    assertRunnable({ ...agent, engine: normalizedEngine });
  }
  const systemPrompt = `${baseSystemPrompt.includes('CURRENT_DATE') ? baseSystemPrompt : `${currentDateInstruction()}\n\n${baseSystemPrompt}`}

${skills ? `<skills>\n${skills}\n</skills>\n` : ''}
You are running as a scheduled cron tick. Your FINAL message is saved verbatim as the deliverable the owner sees — make it the complete result in clean markdown, not a status summary, progress recap, or "what I did" note. Reproduce the full content in your final message even if a prior version already exists on disk.`;

  // Work-unit contract resolved by precedence (see resolveRunContract):
  //   per-call override  →  delegated task  →  agent standing contract  →  default.
  const task = task_id ? getTask(task_id) : null;
  const { instruction: resolvedInstruction, expected_output: resolvedExpected } =
    resolveRunContract({ instruction, expected_output, task, agent });

  const userMessage = `Scheduled tick - today is ${today}. Carry out the instruction below and deliver exactly what the expected-output contract requires.

<task_instruction>
${resolvedInstruction}
</task_instruction>

<expected_output>
${resolvedExpected}
</expected_output>

<agent>
Name: ${agent.name}
Mission: ${agent.mission || ''}
Schedule: ${agent.schedule || ''}
Tools: ${parseJson(agent.tools, []).join(', ') || 'none listed'}
</agent>

<recent_memory>
${lastMemory.length ? lastMemory.map((entry, index) => `${index + 1}. ${entry}`).join('\n') : 'No prior memory.'}
</recent_memory>`;

  const { output, toolEvents, engine, model } = (process.env.MADARX_MOCK_AGENT_TICK || process.env.MADARX_MOCK_CLAUDE_TICK)
    ? {
        output: process.env.MADARX_MOCK_AGENT_TICK || process.env.MADARX_MOCK_CLAUDE_TICK,
        toolEvents: [],
        engine: normalizedEngine,
        model: agentModel,
      }
    : normalizedEngine === 'anthropic'
      ? await generateTickOutput({
          agent: { ...agent, engine: normalizedEngine, model: agentModel },
          systemPrompt,
          userMessage,
          acting_on_behalf_of_user_id,
          heartbeat_task_id,
          onToolStart: ({ toolUse, inputSummary }) => {
            if (!taskRun) return;
            appendAgentRunEvent(taskRun.id, {
              agent_id: agent.id,
              task_id,
              event_type: 'tool_start',
              stream: 'tool',
              message: toolUse.name,
              payload: { tool: toolUse.name, input_summary: inputSummary },
            });
          },
          onToolResult: ({ toolUse, result, event }) => {
            if (!taskRun) return;
            if (result?.status === 'approval_required') {
              markAgentRunWaitingApproval(taskRun.id, {
                message: `Approval required for ${toolUse.name}`,
                payload: { tool: toolUse.name, approval_request_id: result.approval_request_id },
              });
              return;
            }
            appendAgentRunEvent(taskRun.id, {
              agent_id: agent.id,
              task_id,
              event_type: 'tool_result',
              stream: 'tool',
              level: Boolean(result?.ok === false && result?.status !== 'approval_required') ? 'error' : 'info',
              message: `${toolUse.name} ${event.status}`,
              payload: { tool: toolUse.name, status: event.status, approval_request_id: result?.approval_request_id || null },
            });
          },
          onToolError: ({ toolUse, error }) => {
            if (!taskRun) return;
            appendAgentRunEvent(taskRun.id, {
              agent_id: agent.id,
              task_id,
              event_type: 'error',
              stream: 'tool',
              level: 'error',
              message: error.message,
              payload: { tool: toolUse.name },
            });
          },
        })
      : {
          output: await runAgentEngine({ agent: { ...agent, engine: normalizedEngine, model: agentModel }, systemPrompt, userMessage }),
          toolEvents: [],
          engine: normalizedEngine,
          model: agentModel,
        };
  // Layer B (backstop): if NO real tools ran (single-shot/CLI path) and the
  // output is a fabricated tool transcript (<tool_call>/<tool_response> with
  // invented results), refuse to persist it. A run that genuinely dispatched
  // tools (toolEvents.length) is exempt — its transcript is real. Throws
  // `fabricated_tool_output`, caught below → recorded as a FAILED run.
  if (!toolEvents.length && !process.env.MADARX_MOCK_AGENT_TICK && !process.env.MADARX_MOCK_CLAUDE_TICK
      && looksFabricatedToolTranscript(output)) {
    const e = new Error(
      `${agent.name} produced a fabricated tool transcript (it role-played tool calls and invented results) instead of running real tools — nothing actually executed, so no deliverable was saved. ` +
      `Switch this agent's engine to "Anthropic API" so madarX dispatches its tools for real.`
    );
    e.code = 'fabricated_tool_output';
    throw e;
  }
  const reportOutput = toolEvents.length
    ? `${output}\n\n## Tool events\n${toolEvents.map(event => `- ${event.name}: ${event.status}`).join('\n')}`
    : output;
  // The artifact shows the deliverable, which may live in a richer file the
  // agent wrote during the run rather than its final chat turn (see helper).
  const deliverableMd = pickDeliverableMarkdown({ agentId: agent.id, today, fallback: reportOutput });
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
  const file = path.join(REPORTS_DIR, `${slugPart(agent.id)}-${today}-${stamp}.md`);
  fs.writeFileSync(file, deliverableMd, 'utf8');
  const filePath = relToRoot(file);

  const artifactTitle = `${agent.name} scheduled report`;
  const cleanMd = stripEmoji(deliverableMd);
  const renderedHtml = renderDocumentHtml({ title: artifactTitle, markdown: cleanMd });

  const { recordRun, recordArtifact, appendAudit } = await import('./runtime.js');
  const run = recordRun({
    kind: 'agent-tick',
    title: `${agent.name} scheduled tick`,
    agent: agent.name,
    agent_id: agent.id,
    status: 'complete',
    artifact: filePath,
    preview: reportOutput.slice(0, 1000),
    output: reportOutput,
    engine,
    model,
    triggered_by,
  });
  const artifactId = recordArtifact({
    run_id: run.id,
    agent_id: agent.id,
    file_path: filePath,
    kind: 'document',
    title: artifactTitle,
    content: renderedHtml,
    source_md: cleanMd,
    project_id: agent.project_id || null,
  });
  // Expose the persisted artifact id + title so callers (e.g. the run_agent
  // tool / chat orchestrator) can pin it to the canvas. `run.artifact` is a
  // file-path string and is not addressable as an artifact.
  run.artifact_id = artifactId;
  run.artifact_title = artifactTitle;

  await maybeNotifyWhatsApp({
    agent,
    artifactTitle,
    renderedHtml,
    runId: run.id,
    triggeredBy: triggered_by,
    appendAudit,
  });

  const maxMemory = Math.max(1, Math.min(500, Number(process.env.MEMORY_MAX_ENTRIES || 50) || 50));
  const nextMemory = [...memory, `${nowIso()} - ${summarizeForMemory(reportOutput)}`].slice(-maxMemory);
  const tickAt = nowIso();
  db.prepare('UPDATE agents SET memory = ?, last_run_at = ?, status = ? WHERE id = ?')
    .run(JSON.stringify(nextMemory), tickAt, 'last cron tick complete', agent.id);

  const hbFile = path.join(AGENTS_DIR, agent.id, 'heartbeat.json');
  try {
    const hb = parseJson(fs.readFileSync(hbFile, 'utf8'), {});
    hb.status = 'last cron tick complete';
    hb.last_run = tickAt;
    hb.last_artifact = filePath;
    fs.writeFileSync(hbFile, JSON.stringify(hb, null, 2), 'utf8');
  } catch {
    appendAudit({
      type: 'agent.heartbeat.missing',
      summary: `Heartbeat file missing for ${agent.name}`,
      status: 'warning',
      meta: { agent_id: agent.id },
    });
  }

  publishLiveEvent({
    type: 'agent_run_complete',
    agent_id: agent.id,
    agent_name: agent.name,
    status: 'complete',
    triggered_by,
    run_id: taskRun?.id || null,
    task_id,
  });

  if (taskRun) {
    const truncated = reportOutput.replace(/\s+/g, ' ').slice(0, 800);
    addTaskComment(task_id, {
      body: `Execution update from ${agent.name}:\n\n${truncated}`,
      author_agent_id: agent.id,
      created_by_run_id: taskRun.id,
    });
    markAgentRunFinished(taskRun.id, {
      status: 'succeeded',
      result: { report_path: filePath, legacy_run_id: run.id },
      stdout_excerpt: truncated,
    });
    updateTask(task_id, { status: 'in_review', actor_type: 'agent', actor_id: agent.id });
    upsertAgentRuntimeState(agent.id, {
      last_run_id: taskRun.id,
      last_run_status: 'succeeded',
      state: { last_task_id: task_id, last_report: filePath },
    });
  }

  return run;
  } catch (err) {
    const message = (err?.message || String(err)).slice(0, 1000);
    if (taskRun) {
      try {
        markAgentRunFinished(taskRun.id, { status: 'failed', error: message });
      } catch {}
    }
    // Always publish run_complete on the error path so the OCC entry clears,
    // regardless of whether a taskRun was attached (on-demand runs via the
    // run_agent tool have no taskRun).
    publishLiveEvent({
      type: 'agent_run_complete',
      agent_id: agent.id,
      agent_name: agent.name,
      status: 'failed',
      isError: true,
      triggered_by,
      run_id: taskRun?.id || null,
      task_id,
      error: message,
    });
    throw err;
  }
}
