import type { ACPMessage, ACPMetadata, GoatChainAgent } from "#cli/core/acp";
import type {
  ApprovalRequest,
  AssistantContentPart,
  GoatChainContext,
  Message,
  RunConfig,
  TokenUsage,
  ToolApprovalsMode,
  ToolCall,
  ToolResult,
} from "#cli/core/model";
import type { FileRevert } from "./file-reverts";
import type { PausedState } from "./types";
import { debugLog } from "#cli/core/debug-log";
import { createEnvReader, readProcessEnv } from "#cli/core/env";
import { traceLog } from "#cli/core/observability/trace-log";
import { t } from "#cli/i18n";
import { applyCcMockSystemPrompt } from "#cli/compat/runtime-host";
import { runAgentStreamLoop } from "./stream-runner-events";
import { createThinkingStateManager } from "./stream-runner-thinking";
import {
  assertValidAssistantId,
  assertValidSessionId,
  createToolCallTranscriptLookup,
  normalizeSubagentType,
  normalizeTaskDescription,
} from "./stream-runner-utils";

type ToolApprovalResult = Readonly<{ approved: boolean; reason?: string }>;
type PendingApproval = Readonly<{
  request: ApprovalRequest;
  assistantId: string;
}>;

/**
 * Result type for agent round execution.
 * Includes success status to allow callers to detect and handle errors.
 */
export type AgentRoundResult = Readonly<{
  assistantId: string;
  toolCalls: ToolCall[];
  success: boolean;
  error?: Error;
}>;

export type ToolExecutionContextInput = Readonly<{
  requiresAction?: Readonly<{
    actionId?: string;
    toolCallId?: string;
    kind?: "tool_approval" | "ask_user";
  }>;
  approval?: Readonly<{
    autoApprove?: boolean;
    decisions?: Readonly<Record<string, ToolApprovalResult>>;
  }>;
  askUser?: Readonly<{
    answers: Readonly<Record<string, Readonly<Record<string, string | string[]>>>>;
    rejected?: Readonly<Record<string, string>>;
  }>;
}>;

interface StreamRunnerUiDebug {
  lastRoundStartedAtMs?: number;
  lastRoundFirstEventAtMs?: number;
  lastRoundDoneAtMs?: number;
  lastRoundHadError?: boolean;
}

interface StreamRunnerUiState {
  debug?: StreamRunnerUiDebug;
}

interface StreamRunnerState {
  context: GoatChainContext;
  sessionId: string;
  messages: Message[];
  sessionHistoryDirty?: boolean;
  fileRevertsByToolCallId: Map<string, FileRevert>;
  paused: PausedState | null;
  approval: {
    active: ApprovalRequest | null;
    pendingAutoRun: null | { assistantId: string; toolCallId: string };
    mode: ToolApprovalsMode;
    queue: PendingApproval[];
    decisions: Record<string, ToolApprovalResult>;
  };
  thinking: {
    active: boolean;
    assistantId: string | null;
    partId: string | null;
  };
  runningAgentRound: boolean;
  config?: RunConfig;
  usage?: TokenUsage;
  ui?: StreamRunnerUiState;
}

export function createStreamRunner(
  opts: Readonly<{
    state: StreamRunnerState;
    agent: GoatChainAgent;
    id: (prefix: string) => string;
    buildAcpConversation: (systemPrompt: string, messages: readonly Message[]) => ACPMessage[];
    resolveConversationImages?: (conversation: ACPMessage[]) => Promise<void>;
    streamIdleTimeoutMs: number;
    streamTotalTimeoutMs: number;
    pushMessage: (m: Message) => void;
    updateAssistant: (
      messageId: string,
      fn: (m: Extract<Message, { role: "assistant" }>) => Extract<Message, { role: "assistant" }>,
    ) => void;
    setAssistantStatus: (messageId: string, text: string) => void;
    setAssistantThinking: (assistantId: string, content: string, partId: string) => void;
    startNewThinkingBlock: (assistantId: string, partId: string) => void;
    endThinkingBlock: (assistantId: string, partId: string, content?: string) => void;
    appendAssistantMarkdown: (assistantId: string, delta: string) => void;
    appendInfoPart: (assistantId: string, text: string) => void;
    appendAssistantLlmMeta: (
      assistantId: string,
      info: Readonly<{
        durationMs: number;
        modelId?: string;
        toolCalls?: number;
        tokensUsed?: number;
        changedFiles?: readonly string[];
        changedFileDetails?: readonly import("#cli/core/model").LlmMetaChangedFile[];
        isFinalRound?: boolean;
      }>,
    ) => void;
    updateAssistantLlmMeta: (
      assistantId: string,
      info: Readonly<{
        durationMs?: number;
        modelId?: string;
        toolCalls?: number;
        tokensUsed?: number;
        changedFiles?: readonly string[];
        changedFileDetails?: readonly import("#cli/core/model").LlmMetaChangedFile[];
      }>,
    ) => void;
    upsertToolCallDelta: (
      assistantId: string,
      callId: string,
      toolName: string | undefined,
      argsTextDelta: string | undefined,
      linesWritten?: number,
    ) => void;
    upsertToolCallEnd: (
      assistantId: string,
      callId: string,
      toolName: string,
      args: Record<string, unknown>,
      thoughtSignature?: string,
    ) => void;
    upsertToolOutputStart: (assistantId: string, toolCallId: string, toolName: string) => void;
    appendToolOutputDelta: (
      assistantId: string,
      toolCallId: string,
      toolName: string,
      delta: string,
    ) => void;
    flushPendingAssistantOutput?: (assistantId?: string) => void;
    insertApprovalPart: (assistantId: string, request: ApprovalRequest) => void;
    attachToolResultPart: (assistantId: string, result: ToolResult) => void;
    scheduleToolResultHighlight: (assistantId: string, result: ToolResult) => void;
    upsertChecklistPartFromGoatChain: (
      assistantId: string,
      kind: "todo" | "plan",
      toolCallId: string,
      structured: unknown,
    ) => void;
    applyLastRequestBudget: (budget: unknown) => void;
    applyUsageFromSession: (usage: unknown) => void;
    ensureErrorVisibleInTranscript: (assistantId: string, errorMeta: unknown) => void;
  }>,
): Readonly<{
  runAgentRound: (opts?: {
    toolContext?: ToolExecutionContextInput;
    baseMessages?: Message[];
    steer?: boolean;
  }) => Promise<AgentRoundResult>;
  cancelActiveAgentStream: () => void;
  cancelActiveAgentStreamAndWaitWithTimeout: (timeoutMs?: number) => Promise<void>;
  abortActiveAgentRound: () => void;
}> {
  const env = createEnvReader(readProcessEnv());
  const UI_FLUSH_CHARS = (() => {
    const raw = env.prefixedStr("STREAM_UI_FLUSH_CHARS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(1_000_000, Math.max(256, Math.floor(n)));
    return 4096;
  })();
  const UI_FLUSH_EVENTS = (() => {
    const raw = env.prefixedStr("STREAM_UI_FLUSH_EVENTS");
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return Math.min(10_000, Math.max(1, Math.floor(n)));
    // Default: flush every event so streaming feels truly "live".
    // Can be increased via env for performance on slow terminals.
    return 1;
  })();
  // Base interval for UI yielding during streaming
  // 25ms (40Hz) provides smooth streaming feel with good performance
  // Reduced from 50ms to make streaming feel more responsive
  const BASE_UI_YIELD_INTERVAL_MS = 25;
  const currentUiYieldIntervalMs = BASE_UI_YIELD_INTERVAL_MS;
  let activeAgentStream: AsyncGenerator<ACPMessage, void, void> | null = null;
  let activeAgentAbort: { promise: Promise<void>; resolve: () => void } | null = null;
  let activeAgentAbortController: AbortController | null = null;

  // Concurrency guard to prevent simultaneous runAgentRound calls
  let currentRoundId: string | null = null;
  const toolCallLookup = createToolCallTranscriptLookup(() => opts.state.messages);

  function findMostRecentExploreToolCallId(
    match: Readonly<{ description: string; subagentType: string }>,
  ): string | null {
    const description = normalizeTaskDescription(match.description);
    const subagentType = normalizeSubagentType(match.subagentType);
    if (!description && !subagentType) return null;

    const msgs = opts.state.messages;
    for (let i = msgs.length - 1; i >= 0; i--) {
      const m = msgs[i];
      if (!m || m.role !== "assistant") continue;
      for (const p of m.parts) {
        if (p.type !== "tool_call") continue;
        const toolNameLower = String(p.call.name ?? "")
          .trim()
          .toLowerCase();
        if (toolNameLower !== "task") continue;
        const args = p.call.arguments;
        const argDesc = normalizeTaskDescription(args?.description);
        const argType = normalizeSubagentType(args?.subagent_type ?? args?.subagentType);
        if (description && argDesc !== description) continue;
        if (subagentType && argType !== subagentType) continue;
        return p.call.id;
      }
    }
    return null;
  }

  function yieldToMacrotask(): Promise<void> {
    return new Promise((resolve) => {
      const si = (globalThis as { setImmediate?: (fn: () => void) => void }).setImmediate;
      if (typeof si === "function") si(resolve);
      else setTimeout(resolve, 0);
    });
  }

  function trace(tag: string, message: string, payload?: Record<string, unknown>): void {
    traceLog({
      tag,
      message,
      payload: {
        sessionId: String(opts.state.sessionId ?? ""),
        ...payload,
      },
    });
  }

  function abortActiveAgentSignal(): void {
    const controller = activeAgentAbortController;
    activeAgentAbortController = null;
    try {
      controller?.abort();
    } catch {
      // ignore
    }
  }

  function cancelActiveAgentStream(): void {
    void cancelActiveAgentStreamAndWait();
  }

  async function cancelActiveAgentStreamAndWait(): Promise<void> {
    await cancelActiveAgentStreamAndWaitWithTimeout();
  }

  async function cancelActiveAgentStreamAndWaitWithTimeout(timeoutMs?: number): Promise<void> {
    const stream = activeAgentStream;
    activeAgentStream = null;
    abortActiveAgentSignal();
    if (!stream) return;
    try {
      // Important: await return() so underlying generators can run their `finally`
      // blocks before we allow a new round (prevents "Cannot send while receiving messages").
      const p = stream.return?.();
      if (!p) return;
      const waitMs = timeoutMs == null ? null : Math.max(0, Math.floor(timeoutMs));
      if (waitMs == null) {
        await p;
        return;
      }

      // Some agent streams (or tests) may hang forever while awaiting external approval.
      // Never let cleanup block the UI indefinitely in those cases.
      p.catch(() => {
        // ignore
      });
      await Promise.race([
        p,
        new Promise<void>((resolve) => {
          const timeout = setTimeout(resolve, waitMs);
          const nodeTimeout = timeout as { unref?: () => void };
          nodeTimeout.unref?.();
        }),
      ]);
    } catch {
      // ignore
    }
  }

  function createActiveAgentAbort(): {
    promise: Promise<void>;
    resolve: () => void;
  } {
    let resolve: (() => void) | null = null;
    const promise = new Promise<void>((r) => {
      resolve = r;
    });
    return { promise, resolve: () => resolve?.() };
  }

  function abortActiveAgentRound(): void {
    const abort = activeAgentAbort;
    activeAgentAbort = null;
    abortActiveAgentSignal();
    abort?.resolve();
  }

  async function runAgentRound(runOpts?: {
    toolContext?: ToolExecutionContextInput;
    baseMessages?: Message[];
    steer?: boolean;
  }): Promise<AgentRoundResult> {
    // Generate unique round ID for this execution
    const roundId = opts.id("round");

    // Concurrency guard: prevent simultaneous rounds
    if (currentRoundId !== null) {
      debugLog("agent_round_concurrent", {
        session_id: String(opts.state.sessionId ?? ""),
        existing_round: currentRoundId,
        new_round: roundId,
      });
      // Return error result instead of throwing to avoid crashes
      return {
        assistantId: opts.id("m"),
        toolCalls: [],
        success: false,
        error: new Error("Cannot run agent round while another round is already running"),
      };
    }

    currentRoundId = roundId;

    try {
      const base = (runOpts?.baseMessages ?? opts.state.messages).slice();
      const systemPrompt = applyCcMockSystemPrompt(
        String(opts.state.context?.system ?? ""),
        (opts.state.context?.env ?? {}) as Record<string, string | undefined>,
      );
      const conversation = opts.buildAcpConversation(systemPrompt, base);
      const assistantId = opts.id("m");
      const toolCallIds = new Set<string>();

      // Validate critical IDs at start of round
      assertValidAssistantId(assistantId);
      assertValidSessionId(opts.state.sessionId);

      opts.state.thinking.active = false;
      opts.state.thinking.assistantId = null;
      opts.state.thinking.partId = null;
      const modelId = opts.state.config?.model?.trim() || undefined;
      opts.pushMessage({
        id: assistantId,
        role: "assistant",
        parts: [{ type: "status", text: "" }],
        createdAtMs: Date.now(),
        ...(modelId ? { modelId } : {}),
      });

      const sessionId = String(opts.state.sessionId ?? "").trim();
      const metadata: ACPMetadata & {
        signal?: AbortSignal;
        toolContext?: ToolExecutionContextInput;
        resume?: boolean;
        steer?: boolean;
        sync_history_from_acp?: boolean;
      } = {
        ...(sessionId ? { session_id: sessionId } : {}),
        cwd: opts.state.context.cwd,
        env: { ...opts.state.context.env },
      };
      // Let embedded agents (e.g. goatchain-node Session.receive) cancel their underlying work
      // when the UI interrupts the stream.
      activeAgentAbortController = new AbortController();
      metadata.signal = activeAgentAbortController.signal;
      const baseToolContext = runOpts?.toolContext;
      const toolContext: ToolExecutionContextInput | undefined =
        opts.state.approval.mode === "auto"
          ? {
              ...baseToolContext,
              approval: {
                ...baseToolContext?.approval,
                autoApprove: true,
              },
            }
          : baseToolContext;
      if (toolContext) {
        metadata.toolContext = toolContext;
      }
      if (runOpts?.steer) {
        metadata.steer = true;
      }
      if (runOpts?.toolContext) {
        metadata.resume = true;
      } else if (opts.state.sessionHistoryDirty === true) {
        metadata.sync_history_from_acp = true;
        opts.state.sessionHistoryDirty = false;
      }

      // Initialize thinking state manager before try block so it's accessible in catch/finally
      const readAssistantStatus = (): string => {
        const m = opts.state.messages.find((m) => m.id === assistantId);
        if (!m || m.role !== "assistant") return "";
        const statusPart = m.parts.find(
          (p): p is Extract<AssistantContentPart, { type: "status" }> => p.type === "status",
        );
        return String(statusPart?.text ?? "");
      };

      /**
       * Thinking state manager with consistency guarantees.
       * Ensures thinking state is always properly cleaned up.
       * Timing is now tracked per-block (startedAt/endedAt on each thinking part).
       */
      const thinkingState = createThinkingStateManager({
        assistantId,
        state: opts.state,
        readAssistantStatus,
        setAssistantStatus: opts.setAssistantStatus,
      });

      const cleanupState: { timeoutMsOverride?: number } = {};

      try {
        opts.state.runningAgentRound = true;
        debugLog("agent_round_start", {
          session_id: String(opts.state.sessionId ?? ""),
          resume: Boolean(runOpts?.toolContext),
        });
        if (
          opts.resolveConversationImages &&
          conversation.some(
            (m) =>
              m.role === "user" &&
              typeof m.content === "string" &&
              /\[[^\]]*\]\(\/[^)]+\.(?:png|jpe?g|gif|webp|bmp|ico|svg)\)/i.test(m.content),
          )
        ) {
          try {
            await opts.resolveConversationImages(conversation);
          } catch {
            // best-effort
          }
        }
        const stream = opts.agent.receiveMessage(conversation, metadata);
        activeAgentStream = stream;
        const abort = createActiveAgentAbort();
        activeAgentAbort = abort;
        const startedAt = Date.now();
        try {
          const dbg = opts.state.ui?.debug;
          if (dbg && typeof dbg === "object") {
            dbg.lastRoundStartedAtMs = startedAt;
            dbg.lastRoundFirstEventAtMs = 0;
            dbg.lastRoundDoneAtMs = 0;
            dbg.lastRoundHadError = false;
          }
        } catch {
          // ignore
        }
        const roundResult = await runAgentStreamLoop({
          deps: opts,
          assistantId,
          stream,
          abort,
          abortActiveAgentSignal,
          toolCallIds,
          toolCallLookup,
          findMostRecentExploreToolCallId,
          thinkingState,
          yieldToMacrotask,
          trace,
          ui: {
            flushChars: UI_FLUSH_CHARS,
            flushEvents: UI_FLUSH_EVENTS,
            yieldIntervalMs: currentUiYieldIntervalMs,
          },
          cleanupState,
          startedAt,
        });
        opts.flushPendingAssistantOutput?.(assistantId);
        return roundResult;
      } catch (e) {
        // Clear thinking state on any error
        thinkingState.forceEnd();

        const error = e instanceof Error ? e : new Error(String(e));
        opts.ensureErrorVisibleInTranscript(assistantId, error);
        opts.setAssistantStatus(assistantId, t("chatStatus.error"));

        // Log the error for debugging
        debugLog("agent_round_error", {
          session_id: String(opts.state.sessionId ?? ""),
          error_message: error.message,
          error_name: error.name,
          assistant_id: assistantId,
        });

        // Return error result instead of throwing
        const toolCalls = (() => {
          const m = opts.state.messages.find((m) => m.id === assistantId);
          if (!m || m.role !== "assistant") return [];
          return m.parts.flatMap((p) => (p.type === "tool_call" ? [p.call] : []));
        })();
        opts.flushPendingAssistantOutput?.(assistantId);

        return {
          assistantId,
          toolCalls,
          success: false,
          error,
        };
      } finally {
        // Only cleanup if we're still the active round
        if (currentRoundId === roundId) {
          if (activeAgentStream) {
            // If the round ended in a paused state, the agent stream may be waiting on
            // out-of-band input (approval / ask_user), or we may be aborting a stuck stream.
            // Don't let `return()` hang forever.
            const cleanupTimeoutMs = opts.state.paused ? 200 : cleanupState.timeoutMsOverride;
            await cancelActiveAgentStreamAndWaitWithTimeout(cleanupTimeoutMs);
          }
          if (activeAgentAbort) abortActiveAgentRound();
        }
        // Always ensure thinking state is cleared
        thinkingState.forceEnd();
        opts.state.runningAgentRound = false;
        // Clear round ID to allow next round
        currentRoundId = null;
      }
    } finally {
      // End of try block for concurrency guard
      // Always clear round ID, even if concurrent check failed
      currentRoundId = null;
    }
  } // End of runAgentRound function

  return {
    runAgentRound,
    cancelActiveAgentStream,
    cancelActiveAgentStreamAndWaitWithTimeout,
    abortActiveAgentRound,
  };
}
