/**
 * Pure functions for converting SSE events into ChatMessage objects.
 * No React dependencies — just JSON in, object out.
 */

import type { ChatMessage } from "@/components/ChatPanel";
import type { AgentEvent } from "@/api/types";

/**
 * Derive a human-readable display name from a raw agent identifier.
 *
 * Examples:
 *   "competitive_intel_agent"       → "Competitive Intel Agent"
 *   "competitive_intel_agent-graph" → "Competitive Intel Agent"
 *   "inbox-management"              → "Inbox Management"
 *   "job_hunter"                    → "Job Hunter"
 */
/**
 * Extract the colony worker uuid from a parallel-worker ``streamId``.
 *
 * Worker messages tag their ``streamId`` as either ``"worker"`` (single-worker
 * legacy case) or ``"worker:{uuid}"`` (parallel fan-out). The uuid half is
 * the colony worker id — the same identifier the Colony Workers sidebar uses
 * to key its Sessions cards. Returns null for the legacy single-worker case
 * or any other stream kind.
 */
export function workerIdFromStreamId(
  streamId: string | null | undefined,
): string | null {
  if (!streamId) return null;
  const m = /^worker:(.+)$/.exec(streamId);
  return m ? m[1] : null;
}

export function formatAgentDisplayName(raw: string): string {
  // Take the last path segment (in case it's a path like "examples/templates/foo")
  const base = raw.split("/").pop() || raw;
  // Strip common suffixes like "-graph" or "_graph"
  const stripped = base.replace(/[-_]graph$/, "");
  // Replace underscores and hyphens with spaces, then title-case each word
  return stripped
    .replace(/[_-]/g, " ")
    .replace(/\b\w/g, (c) => c.toUpperCase())
    .trim();
}

/**
 * Format a message timestamp Slack-style: time-of-day for messages from today,
 * date + time for older messages.
 */
export function formatMessageTime(createdAt: number): string {
  const d = new Date(createdAt);
  const now = new Date();
  const sameDay =
    d.getFullYear() === now.getFullYear() &&
    d.getMonth() === now.getMonth() &&
    d.getDate() === now.getDate();
  const time = d.toLocaleTimeString(undefined, {
    hour: "numeric",
    minute: "2-digit",
  });
  if (sameDay) return time;
  const sameYear = d.getFullYear() === now.getFullYear();
  const date = d.toLocaleDateString(undefined, {
    month: "short",
    day: "numeric",
    ...(sameYear ? {} : { year: "numeric" }),
  });
  return `${date}, ${time}`;
}

/**
 * Format the label shown on a day-separator divider. Always absolute date + time
 * (no "Today" / "Yesterday") so the user can see exactly when activity resumed.
 */
export function formatDayDividerLabel(createdAt: number): string {
  const d = new Date(createdAt);
  const now = new Date();
  const sameYear = d.getFullYear() === now.getFullYear();
  const date = d.toLocaleDateString(undefined, {
    month: "long",
    day: "numeric",
    ...(sameYear ? {} : { year: "numeric" }),
  });
  const time = d.toLocaleTimeString(undefined, {
    hour: "numeric",
    minute: "2-digit",
  });
  return `${date}, ${time}`;
}

/**
 * Convert an SSE AgentEvent into a ChatMessage, or null if the event
 * doesn't produce a visible chat message.
 * When agentDisplayName is provided, it is used as the sender for all agent
 * messages instead of the raw node_id.
 */
export function sseEventToChatMessage(
  event: AgentEvent,
  thread: string,
  agentDisplayName?: string,
  turnId?: number,
): ChatMessage | null {
  // Combine execution_id (unique per execution) with turnId (increments per
  // loop iteration) so each iteration gets its own bubble while streaming
  // deltas within one iteration still share the same ID for upsert.
  const eid = event.execution_id ?? "";
  const tid = turnId != null ? String(turnId) : "";
  const idKey = eid && tid ? `${eid}-${tid}` : eid || tid || `t-${Date.now()}`;
  // Use the backend event timestamp for message ordering
  const createdAt = event.timestamp ? new Date(event.timestamp).getTime() : Date.now();

  switch (event.type) {
    case "client_output_delta": {
      // Prefer backend-provided iteration (reliable, embedded in event data)
      // over frontend turnCounter (can desync when SSE queue drops events).
      const iter = event.data?.iteration;
      const iterTid = iter != null ? String(iter) : tid;
      const iterIdKey = eid && iterTid ? `${eid}-${iterTid}` : eid || iterTid || `t-${Date.now()}`;

      // Distinguish multiple LLM calls within the same iteration (inner tool loop).
      // inner_turn=0 (or absent) produces no suffix for backward compat.
      const innerTurn = event.data?.inner_turn as number | undefined;
      const innerSuffix = innerTurn != null && innerTurn > 0 ? `-t${innerTurn}` : "";

      const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
      if (!snapshot.trim()) return null;
      return {
        id: `stream-${iterIdKey}${innerSuffix}-${event.node_id}`,
        agent: agentDisplayName || event.node_id || "Agent",
        agentColor: "",
        content: snapshot,
        timestamp: "",
        role: "worker",
        thread,
        createdAt,
        nodeId: event.node_id || undefined,
        executionId: event.execution_id || undefined,
        streamId: event.stream_id || undefined,
      };
    }

    case "client_input_requested": {
      // Surface the question(s) as a queen bubble in the chat history so the
      // transcript records what was asked alongside the user's answer. The
      // input widget at the bottom of the panel still drives the actual
      // answer flow — this bubble is read-only context.
      const rawQuestions = event.data?.questions;
      if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) return null;
      const prompts: string[] = [];
      for (const q of rawQuestions) {
        if (!q || typeof q !== "object") continue;
        const qo = q as Record<string, unknown>;
        const prompt =
          typeof qo.prompt === "string"
            ? qo.prompt
            : typeof qo.question === "string"
              ? (qo.question as string)
              : null;
        if (prompt) prompts.push(prompt);
      }
      if (prompts.length === 0) return null;
      const content =
        prompts.length === 1
          ? prompts[0]
          : prompts.map((p, i) => `${i + 1}. ${p}`).join("\n");
      return {
        // Stable per-request id so live + replay paths upsert the same row.
        id: `ask-user-${event.execution_id ?? ""}-${event.timestamp ?? createdAt}`,
        agent: agentDisplayName || event.node_id || "Agent",
        agentColor: "",
        content,
        timestamp: "",
        // Default to worker; the replayEvent wrapper upgrades to "queen"
        // when stream_id === "queen". Mirrors llm_text_delta's pattern.
        role: "worker",
        thread,
        createdAt,
        nodeId: event.node_id || undefined,
        executionId: event.execution_id || undefined,
        streamId: event.stream_id || undefined,
      };
    }

    case "client_input_received": {
      const userContent = (event.data?.content as string) || "";
      if (!userContent) return null;
      return {
        id: `user-input-${event.timestamp}`,
        agent: "You",
        agentColor: "",
        content: userContent,
        timestamp: "",
        type: "user",
        thread,
        createdAt,
        // Carrying execution_id here lets the optimistic-message reconciler
        // distinguish server-echoed user bubbles from still-unflushed ones.
        executionId: event.execution_id || undefined,
        streamId: event.stream_id || undefined,
      };
    }

    case "llm_text_delta": {
      const llmInnerTurn = event.data?.inner_turn as number | undefined;
      const llmInnerSuffix = llmInnerTurn != null && llmInnerTurn > 0 ? `-t${llmInnerTurn}` : "";

      const snapshot = (event.data?.snapshot as string) || (event.data?.content as string) || "";
      if (!snapshot.trim()) return null;
      return {
        id: `stream-${idKey}${llmInnerSuffix}-${event.node_id}`,
        agent: event.node_id || "Agent",
        agentColor: "",
        content: snapshot,
        timestamp: "",
        role: "worker",
        thread,
        createdAt,
        nodeId: event.node_id || undefined,
        executionId: event.execution_id || undefined,
        streamId: event.stream_id || undefined,
      };
    }

    case "execution_paused": {
      return {
        id: `paused-${event.execution_id}`,
        agent: "System",
        agentColor: "",
        content:
          (event.data?.reason as string) || "Execution paused",
        timestamp: "",
        type: "system",
        thread,
        createdAt,
        streamId: event.stream_id || undefined,
      };
    }

    case "execution_failed": {
      const error = (event.data?.error as string) || "Execution failed";
      return {
        id: `error-${event.execution_id}`,
        agent: "System",
        agentColor: "",
        content: `Error: ${error}`,
        timestamp: "",
        type: "system",
        thread,
        createdAt,
        streamId: event.stream_id || undefined,
      };
    }

    case "trigger_fired": {
      // Surface each scheduler/webhook fire as a banner in the chat, so the
      // user can see exactly when the queen was invoked by a trigger vs. by
      // a typed message. The banner sits at the start of the turn the queen
      // is about to run in response.
      const triggerId = event.data?.trigger_id as string | undefined;
      if (!triggerId) return null;
      const payload = {
        trigger_id: triggerId,
        trigger_type: event.data?.trigger_type as string | undefined,
        name: event.data?.name as string | undefined,
        task: event.data?.task as string | undefined,
        fire_count: event.data?.fire_count as number | undefined,
        last_fired_at: event.data?.last_fired_at as number | undefined,
      };
      return {
        id: `trigger-${triggerId}-${payload.last_fired_at ?? event.timestamp}`,
        agent: "Trigger",
        agentColor: "",
        content: JSON.stringify(payload),
        timestamp: "",
        type: "trigger",
        thread,
        createdAt,
        streamId: event.stream_id || undefined,
      };
    }

    default:
      return null;
  }
}

// ---------------------------------------------------------------------------
// Stateful event replay — produces tool_status pills + regular messages
// ---------------------------------------------------------------------------

/**
 * State maintained while replaying an event stream. Tracks per-stream turn
 * counters, materialized tool rows, and a pending tool_use_id → row map so
 * deferred `tool_call_completed` events can find the exact pill they belong
 * to after the turn counter moves on.
 */
/**
 * For chart_* tools we retain the args (from tool_call_started) and
 * result envelope (from tool_call_completed) so the chat panel can
 * render the live chart inline from the same spec the runtime
 * rasterized to PNG. Other tools omit these fields to keep the
 * tool_status content payload small (catalogs are pill-only).
 */
type ToolEntry = {
  name: string;
  done: boolean;
  /** opaque per-call id surfaced to the UI; used to key React rows */
  callKey?: string;
  /** present only for tools whose name matches shouldRetainDetail */
  args?: unknown;
  result?: unknown;
  isError?: boolean;
};

type ToolRowState = {
  streamId: string;
  executionId: string;
  tools: Record<string, ToolEntry>;
};

/**
 * Names whose detail (args + result envelope) we surface in the chat.
 * Other tools stay pill-only — keeping their args/results out of the
 * message content avoids ballooning the chat history with tool
 * catalogs, file blobs, etc.
 */
function shouldRetainDetail(toolName: string): boolean {
  return toolName.startsWith("chart_");
}

export interface ReplayState {
  turnCounters: Record<string, number>;
  toolRows: Record<string, ToolRowState>;
  toolUseToPill: Record<
    string,
    { msgId: string; toolKey: string; name: string }
  >;
  queenIterText: Record<string, Record<number, string>>;
}

export function newReplayState(): ReplayState {
  return {
    turnCounters: {},
    toolRows: {},
    toolUseToPill: {},
    queenIterText: {},
  };
}

/**
 * Token / cost accumulator for cold-restore.
 *
 * Folded into ``replayEventsToMessages`` so callers don't need a second
 * pass over the event array just to sum ``llm_turn_complete`` payloads.
 * The accumulator object is mutated in place — pass a fresh one in,
 * read its fields out after the call.
 */
export interface TokenAccumulator {
  input: number;
  output: number;
  cached: number;
  cacheCreated: number;
  costUsd: number;
}

export function newTokenAccumulator(): TokenAccumulator {
  return { input: 0, output: 0, cached: 0, cacheCreated: 0, costUsd: 0 };
}

function toolLookupKey(
  streamId: string,
  executionId: string | null | undefined,
  toolUseId: string,
): string {
  return `${streamId}:${executionId || "exec"}:${toolUseId}`;
}

function toolRowContent(row: ToolRowState): string {
  const tools = Object.values(row.tools).map((t) => {
    const out: ToolEntry = { name: t.name, done: t.done };
    // Carry callKey + retained fields only for tools whose detail the
    // UI mounts (chart_*). Pill-only tools stay terse so the
    // tool_status payload doesn't grow with every catalog/file_ops
    // call and existing snapshot tests stay valid.
    if (shouldRetainDetail(t.name)) {
      if (t.callKey !== undefined) out.callKey = t.callKey;
      if (t.args !== undefined) out.args = t.args;
      if (t.result !== undefined) out.result = t.result;
      if (t.isError !== undefined) out.isError = t.isError;
    }
    return out;
  });
  const allDone = tools.length > 0 && tools.every((t) => t.done);
  return JSON.stringify({ tools, allDone });
}

/**
 * Process a single event and emit zero or more ChatMessage upserts.
 *
 * Why this exists: `sseEventToChatMessage` is stateless — one event in, at
 * most one message out. But the chat's tool_status pill is a SYNTHESIZED
 * message: each tool_call_started adds to an accumulating pill, and each
 * tool_call_completed flips one of its tools from running to done. Live
 * SSE handlers in colony-chat and queen-dm already do this synthesis
 * against React refs. Cold-restore from events.jsonl used to skip
 * tool_call_* events entirely, so refreshed sessions looked completely
 * different from live ones — no tool activity visible, just prose.
 *
 * This function centralizes the synthesis so cold-restore and live paths
 * can use the exact same state machine. The caller treats the returned
 * messages as upserts (by id) — a later event in the same replay may
 * emit the same pill id with updated content, which should REPLACE the
 * earlier row in the caller's message list.
 */
export function replayEvent(
  state: ReplayState,
  event: AgentEvent,
  thread: string,
  agentDisplayName: string | undefined,
  queenDisplayName?: string,
): ChatMessage[] {
  const streamId = event.stream_id;
  const isQueen = streamId === "queen";
  const effectiveName = isQueen ? (queenDisplayName || agentDisplayName) : agentDisplayName;
  const role: "queen" | "worker" = isQueen ? "queen" : "worker";
  const turnKey = streamId;
  const currentTurn = state.turnCounters[turnKey] ?? 0;
  const eventCreatedAt = event.timestamp
    ? new Date(event.timestamp).getTime()
    : Date.now();

  const out: ChatMessage[] = [];

  // Update state machine BEFORE the generic converter runs so regular
  // messages and synthesized tool pills use the same turn counters in
  // both live SSE handling and cold replay.
  switch (event.type) {
    case "execution_started":
      state.turnCounters[turnKey] = currentTurn + 1;
      break;
    case "llm_turn_complete":
      state.turnCounters[turnKey] = currentTurn + 1;
      break;
    case "tool_call_started": {
      if (!event.node_id) break;
      const toolName = (event.data?.tool_name as string) || "unknown";
      const toolUseId = (event.data?.tool_use_id as string) || "";
      const pillId = `tool-pill-${streamId}-${event.execution_id || "exec"}-${currentTurn}`;
      const row =
        state.toolRows[pillId] ||
        (state.toolRows[pillId] = {
          streamId,
          executionId: event.execution_id || "exec",
          tools: {},
        });
      const toolKey = toolUseId || `anonymous-${Object.keys(row.tools).length}`;
      const entry: ToolEntry = {
        name: toolName,
        done: false,
        callKey: toolKey,
      };
      // Capture args at start for retained-detail tools so the chat
      // can show what the agent rendered. Other tools' arguments are
      // intentionally dropped to keep the tool_status JSON small.
      if (shouldRetainDetail(toolName)) {
        const toolInput = event.data?.tool_input;
        if (toolInput !== undefined) entry.args = toolInput;
      }
      row.tools[toolKey] = entry;
      if (toolUseId) {
        state.toolUseToPill[toolLookupKey(streamId, event.execution_id, toolUseId)] = {
          msgId: pillId,
          toolKey,
          name: toolName,
        };
      }
      out.push({
        id: pillId,
        agent: effectiveName || event.node_id || "Agent",
        agentColor: "",
        content: toolRowContent(row),
        timestamp: "",
        type: "tool_status",
        role,
        thread,
        createdAt: eventCreatedAt,
        nodeId: event.node_id || undefined,
        executionId: event.execution_id || undefined,
        streamId: streamId || undefined,
      });
      break;
    }
    case "tool_call_completed": {
      if (!event.node_id) break;
      const toolUseId = (event.data?.tool_use_id as string) || "";
      const lookupKey = toolLookupKey(streamId, event.execution_id, toolUseId);
      const tracked = state.toolUseToPill[lookupKey];
      if (toolUseId) delete state.toolUseToPill[lookupKey];
      if (!tracked) break;
      const row = state.toolRows[tracked.msgId];
      if (!row) break;
      const prior = row.tools[tracked.toolKey];
      const completedName = prior?.name || tracked.name;
      const completed: ToolEntry = {
        name: completedName,
        done: true,
        callKey: tracked.toolKey,
      };
      // Preserve any args captured at start; capture the result
      // envelope for retained-detail tools (chart_* needs spec/file_url
      // to mount the live chart).
      if (shouldRetainDetail(completedName)) {
        if (prior?.args !== undefined) completed.args = prior.args;
        const rawResult = event.data?.result;
        if (rawResult !== undefined) {
          // The framework serializes envelopes as JSON strings. Try to
          // parse so the renderer can pick fields cheaply; fall back to
          // the raw value when parsing fails (already-an-object or
          // non-JSON string).
          if (typeof rawResult === "string") {
            try {
              completed.result = JSON.parse(rawResult);
            } catch {
              completed.result = rawResult;
            }
          } else {
            completed.result = rawResult;
          }
        }
        const isErr = event.data?.is_error;
        if (typeof isErr === "boolean") completed.isError = isErr;
      }
      row.tools[tracked.toolKey] = completed;
      out.push({
        id: tracked.msgId,
        agent: effectiveName || event.node_id || "Agent",
        agentColor: "",
        content: toolRowContent(row),
        timestamp: "",
        type: "tool_status",
        role,
        thread,
        createdAt: eventCreatedAt,
        nodeId: event.node_id || undefined,
        executionId: event.execution_id || undefined,
        streamId: streamId || undefined,
      });
      break;
    }
  }

  // Regular stateless conversion (prose, user input, system notes).
  const msg = sseEventToChatMessage(
    event,
    thread,
    effectiveName,
    state.turnCounters[turnKey] ?? 0,
  );
  if (msg) {
    if (isQueen) {
      msg.role = "queen";
      if (
        event.execution_id &&
        (event.type === "client_output_delta" || event.type === "llm_text_delta")
      ) {
        const iter = (event.data?.iteration as number | undefined) ?? 0;
        const inner = (event.data?.inner_turn as number | undefined) ?? 0;
        const iterKey = `${event.execution_id}:${iter}`;
        if (!state.queenIterText[iterKey]) {
          state.queenIterText[iterKey] = {};
        }
        state.queenIterText[iterKey][inner] = msg.content;
        const parts = state.queenIterText[iterKey];
        const sorted = Object.keys(parts)
          .map(Number)
          .sort((a, b) => a - b);
        msg.content = sorted.map((k) => parts[k]).join("\n");
        msg.id = `queen-stream-${event.execution_id}-${iter}`;
      }
    }
    out.push(msg);
  }

  return out;
}

/**
 * Replay an entire event array and return a deduplicated, chronologically
 * sorted ChatMessage list. Used by cold-restore paths so refreshed
 * sessions match the live stream exactly.
 *
 * If the events stream contains a ``colony_fork_marker`` event (emitted
 * by ``fork_session_into_colony`` after compacting the parent transcript),
 * every message produced from events PRECEDING the marker is folded into
 * a single ``inherited_block`` ChatMessage. The colony page renders that
 * block as a collapsible widget so the inherited DM history is one click
 * away without dominating the colony's own chat.
 */
export function replayEventsToMessages(
  events: AgentEvent[],
  thread: string,
  agentDisplayName: string | undefined,
  queenDisplayName?: string,
  state: ReplayState = newReplayState(),
  tokenAccumulator?: TokenAccumulator,
): ChatMessage[] {
  // Upsert by id — later emissions for the same pill replace earlier ones.
  const byId = new Map<string, ChatMessage>();

  // Track the marker (if any) and which message ids belong to the
  // inherited prefix. A single fork can only happen once per session so
  // we only need to remember the first marker we encounter.
  let markerEvent: AgentEvent | null = null;
  let markerCreatedAt: number | null = null;
  const inheritedIds = new Set<string>();

  for (const evt of events) {
    // Fold the token-usage sum into this same loop so cold-restore
    // doesn't need a second pass over the event array. SSE does not
    // replay llm_turn_complete (see routes_events.py _REPLAY_TYPES) so
    // there's no double-count risk against later live updates.
    if (tokenAccumulator && evt.type === "llm_turn_complete" && evt.data) {
      const d = evt.data as Record<string, unknown>;
      tokenAccumulator.input += (d.input_tokens as number) || 0;
      tokenAccumulator.output += (d.output_tokens as number) || 0;
      tokenAccumulator.cached += (d.cached_tokens as number) || 0;
      tokenAccumulator.cacheCreated += (d.cache_creation_tokens as number) || 0;
      tokenAccumulator.costUsd += (d.cost_usd as number) || 0;
    }
    if (evt.type === "colony_fork_marker") {
      if (markerEvent === null) {
        markerEvent = evt;
        markerCreatedAt = evt.timestamp
          ? new Date(evt.timestamp).getTime()
          : Date.now();
        // Snapshot every id seen so far — those are the ones to fold
        // into the inherited block.
        for (const id of byId.keys()) inheritedIds.add(id);
      }
      continue;
    }
    for (const m of replayEvent(state, evt, thread, agentDisplayName, queenDisplayName)) {
      const previous = byId.get(m.id);
      byId.set(
        m.id,
        previous ? { ...m, createdAt: previous.createdAt ?? m.createdAt } : m,
      );
    }
  }

  const all = Array.from(byId.values()).sort(
    (a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0),
  );

  if (markerEvent === null || inheritedIds.size === 0) return all;

  const inherited: ChatMessage[] = [];
  const native: ChatMessage[] = [];
  for (const msg of all) {
    if (inheritedIds.has(msg.id)) inherited.push(msg);
    else native.push(msg);
  }
  if (inherited.length === 0) return all;

  const markerData = markerEvent.data || {};
  const block: ChatMessage = {
    id: `inherited-block-${markerEvent.timestamp || "fork"}`,
    agent: "System",
    agentColor: "",
    type: "inherited_block",
    content: JSON.stringify({
      parent_session_id: markerData.parent_session_id ?? null,
      fork_time: markerData.fork_time ?? markerEvent.timestamp ?? null,
      summary_preview: markerData.summary_preview ?? "",
      inherited_message_count:
        typeof markerData.inherited_message_count === "number"
          ? markerData.inherited_message_count
          : inherited.length,
      messages: inherited,
    }),
    timestamp: markerEvent.timestamp || "",
    thread,
    // Place the block at the marker's timestamp so it sorts immediately
    // before the first native message (the marker is always written
    // AFTER the inherited content).
    createdAt: markerCreatedAt ?? inherited[inherited.length - 1].createdAt ?? 0,
  };

  return [block, ...native];
}

type QueenPhase = "independent" | "incubating" | "working" | "reviewing";
const VALID_PHASES = new Set<string>([
  "independent",
  "incubating",
  "working",
  "reviewing",
]);

/**
 * Scan an array of persisted events and return the last queen phase seen,
 * or null if no phase event exists.  Reads both `queen_phase_changed` events
 * and the per-iteration `phase` metadata on `node_loop_iteration` events.
 */
export function extractLastPhase(events: AgentEvent[]): QueenPhase | null {
  let last: QueenPhase | null = null;
  for (const evt of events) {
    const phase =
      evt.type === "queen_phase_changed" ? (evt.data?.phase as string) :
      evt.type === "node_loop_iteration" ? (evt.data?.phase as string | undefined) :
      undefined;
    if (phase && VALID_PHASES.has(phase)) {
      last = phase as QueenPhase;
    }
  }
  return last;
}
