import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChatAttachment, ChatMessage, DesktopApi, GatewayStatus, RuntimeStatus, RuntimeTelemetryStatus, WorkspaceSummary } from "@qjclaw/shared-types"
import type { MessagesBySession } from "./useSessionMessages"
import { appendSmokeStatusLabel, buildAssistantPlaceholder, buildUserMessage, createClientMessageId, type UiChatMessage } from "../../lib/chat-utils"
import { TYPEWRITER_CHARS_PER_FRAME } from "../../lib/constants"
import { canExchangeMessages } from "../../lib/workspace-state"
import type { SmokeStreamSnapshot } from "../smoke/types"

type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface ActiveStreamState {
  requestId: string
  assistantMessageId: string
  sessionId: string
  originSessionId: string
  runId?: string
  targetText: string
  renderedText: string
  finalReply?: ChatMessage
  frameId?: number
}

interface UseChatStreamingControllerDeps {
  desktopApi: DesktopApi
  viewMode: ViewMode
  workspace: WorkspaceSummary | null
  runtimeStatus: RuntimeStatus | null
  gatewayStatus: GatewayStatus | null
  runtimeTelemetry: RuntimeTelemetryStatus | null
  isBound: boolean
  bindingRequired: boolean
  saving: boolean
  smokeEnabled: boolean
  sessionScopeProjectId?: string
  visibleSessionId?: string
  messagesBySession: MessagesBySession
  updateSessionMessages: (sessionId: string, updater: (current: UiChatMessage[]) => UiChatMessage[]) => void
  moveSessionMessages: (sourceSessionId: string, targetSessionId: string, messageIds: string[]) => void
  updateMessageById: (messageId: string, updater: (message: UiChatMessage) => UiChatMessage) => void
  upsertSession: (session: { id: string; projectId: string; title: string; updatedAt: string }) => void
  appendTrace: (messageId: string, stage: string, label: string, detail?: string, tone?: "info" | "error" | "success") => void
  renameMessageTrace: (currentMessageId: string, nextMessageId: string) => void
  initializeMessageTrace: (messageId: string, item?: { id: string; stage: string; label: string; detail?: string; tone: "info" | "error" | "success"; createdAt: string }) => void
  setMessageTraceExpanded: (messageId: string, expanded: boolean) => void
  collapseMessageTrace: (messageId: string) => void
  clearComposerAttachment: () => void
  setPrompt: (value: string) => void
  setSkillMenuOpen: (value: boolean) => void
  setErrorText: (value: string) => void
  setWorkspace: (workspace: WorkspaceSummary | null) => void
  setRuntimeStatus: (status: RuntimeStatus | null) => void
  setGatewayStatus: (status: GatewayStatus | null) => void
  setRuntimeTelemetry: (status: RuntimeTelemetryStatus | null) => void
  scrollMessageListToBottom: (options?: { force?: boolean; behavior?: ScrollBehavior }) => void
  normalizeAssistantErrorMessage: (message: string, errorCategory?: string) => string
  ui: {
    preparingReply: string
    waitingReply: string
    replyStarted: string
    thinking: string
    replyReady: string
    replyFailed: string
    fallbackReply: string
    fallbackComplete: string
    checkingChat: string
    checkingChatDetail: string
    startingRuntime: string
    startingRuntimeDetail: string
    connectingGateway: string
    connectingGatewayDetail: string
    bindFirstError: string
    chatNotReadyError: string
    generating: string
  }
}

export interface SubmitPromptOptions {
  promptText: string
  skillId?: string
  forcedSessionId?: string
  forcedProjectId?: string
  forcedAttachments?: ChatAttachment[]
}

function buildAttachmentPromptSummary(attachments: ChatAttachment[]): string {
  return attachments.length === 1 ? `[Attachment] ${attachments[0].name}` : `[Attachment] ${attachments.length} files`
}

export function useChatStreamingController(deps: UseChatStreamingControllerDeps) {
  const {
    desktopApi,
    viewMode,
    workspace,
    runtimeStatus,
    gatewayStatus,
    runtimeTelemetry,
    isBound,
    bindingRequired,
    saving,
    smokeEnabled,
    sessionScopeProjectId,
    visibleSessionId,
    messagesBySession,
    updateSessionMessages,
    moveSessionMessages,
    updateMessageById,
    upsertSession,
    appendTrace,
    renameMessageTrace,
    initializeMessageTrace,
    setMessageTraceExpanded,
    collapseMessageTrace,
    clearComposerAttachment,
    setPrompt,
    setSkillMenuOpen,
    setErrorText,
    setWorkspace,
    setRuntimeStatus,
    setGatewayStatus,
    setRuntimeTelemetry,
    scrollMessageListToBottom,
    normalizeAssistantErrorMessage,
    ui
  } = deps

  const [sendPhase, setSendPhase] = useState<SendPhase>("idle")
  const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null)
  const activeStreamRef = useRef<ActiveStreamState | null>(null)
  const emptyCompletedTimeoutRef = useRef<{ timer: ReturnType<typeof setTimeout>; requestId: string } | null>(null)
  const workspaceRef = useRef(workspace)
  const runtimeStatusRef = useRef(runtimeStatus)
  const gatewayStatusRef = useRef(gatewayStatus)
  const runtimeTelemetryRef = useRef(runtimeTelemetry)
  const viewModeRef = useRef(viewMode)
  const sessionScopeProjectIdRef = useRef(sessionScopeProjectId)
  const visibleSessionIdRef = useRef(visibleSessionId)
  const messagesBySessionRef = useRef(messagesBySession)
  const scrollMessageListToBottomRef = useRef(scrollMessageListToBottom)
  const clearComposerAttachmentRef = useRef(clearComposerAttachment)
  const stoppedRequestIdsRef = useRef(new Set<string>())
  const cancelledSubmissionIdsRef = useRef(new Set<string>())
  const pendingSubmissionIdRef = useRef<string | undefined>(undefined)
  const pendingSubmissionAssistantMessageIdsRef = useRef(new Map<string, string>())

  useEffect(() => {
    workspaceRef.current = workspace
  }, [workspace])
  useEffect(() => {
    runtimeStatusRef.current = runtimeStatus
  }, [runtimeStatus])
  useEffect(() => {
    gatewayStatusRef.current = gatewayStatus
  }, [gatewayStatus])
  useEffect(() => {
    runtimeTelemetryRef.current = runtimeTelemetry
  }, [runtimeTelemetry])
  useEffect(() => {
    viewModeRef.current = viewMode
  }, [viewMode])
  useEffect(() => {
    sessionScopeProjectIdRef.current = sessionScopeProjectId
  }, [sessionScopeProjectId])
  useEffect(() => {
    visibleSessionIdRef.current = visibleSessionId
  }, [visibleSessionId])
  useEffect(() => {
    messagesBySessionRef.current = messagesBySession
  }, [messagesBySession])
  useEffect(() => {
    scrollMessageListToBottomRef.current = scrollMessageListToBottom
  }, [scrollMessageListToBottom])
  useEffect(() => {
    clearComposerAttachmentRef.current = clearComposerAttachment
  }, [clearComposerAttachment])

  const updateStreamSmoke = useCallback((updater: (current: SmokeStreamSnapshot | null) => SmokeStreamSnapshot | null) => {
    if (!smokeEnabled) {
      return
    }
    setStreamSmoke(updater)
  }, [smokeEnabled])

  const updateAssistantStatus = useCallback((messageId: string, statusLabel: string, statusDetail?: string) => {
    updateMessageById(messageId, (message) => ({
      ...message,
      statusLabel,
      statusDetail,
      streamState: message.streamState ?? "streaming"
    }))
  }, [updateMessageById])

  const failPendingAssistant = useCallback((messageId: string, message: string) => {
    updateMessageById(messageId, (current) => ({
      ...current,
      content: message,
      statusLabel: undefined,
      statusDetail: undefined,
      streamState: "error"
    }))
  }, [updateMessageById])

  const replaceSessionMessageId = useCallback((sessionId: string, currentMessageId: string, nextMessageId: string) => {
    if (!sessionId || currentMessageId === nextMessageId) {
      return
    }

    updateSessionMessages(sessionId, (current) => current.map((message) => message.id === currentMessageId ? { ...message, id: nextMessageId } : message))
    renameMessageTrace(currentMessageId, nextMessageId)
    updateStreamSmoke((current) => current?.assistantMessageId === currentMessageId
      ? { ...current, assistantMessageId: nextMessageId }
      : current)

    const activeStream = activeStreamRef.current
    if (activeStream?.assistantMessageId === currentMessageId) {
      activeStream.assistantMessageId = nextMessageId
    }
  }, [renameMessageTrace, updateSessionMessages])

  const cancelTypewriter = useCallback(() => {
    const activeStream = activeStreamRef.current
    if (activeStream?.frameId) {
      window.cancelAnimationFrame(activeStream.frameId)
      activeStream.frameId = undefined
    }
  }, [])

  const syncChatAfterSend = useCallback(async () => {
    const [telemetry, nextWorkspace, nextGateway] = await Promise.all([
      desktopApi.runtimeTelemetry.getStatus().catch(() => null),
      desktopApi.workspace.getSummary().catch(() => null),
      desktopApi.gateway.status().catch(() => null)
    ])
    if (telemetry) {
      setRuntimeTelemetry(telemetry)
    }
    if (nextWorkspace) {
      setWorkspace(nextWorkspace)
    }
    setGatewayStatus(nextGateway)
  }, [desktopApi.gateway, desktopApi.runtimeTelemetry, desktopApi.workspace, setGatewayStatus, setRuntimeTelemetry, setWorkspace])

  const finalizeActiveStream = useCallback(() => {
    const activeStream = activeStreamRef.current
    if (!activeStream || !activeStream.finalReply) {
      return
    }

    cancelTypewriter()
    updateMessageById(activeStream.assistantMessageId, (message) => ({
      ...message,
      content: activeStream.finalReply?.content ?? activeStream.targetText,
      createdAt: activeStream.finalReply?.createdAt ?? message.createdAt,
      streamState: undefined,
      statusLabel: undefined,
      statusDetail: undefined
    }))
    updateStreamSmoke((current) => current ? {
      ...current,
      phase: "completed",
      renderedContent: activeStream.finalReply?.content ?? activeStream.targetText,
      finalContent: activeStream.finalReply?.content ?? activeStream.targetText
    } : current)
    collapseMessageTrace(activeStream.assistantMessageId)
    activeStreamRef.current = null
    setSendPhase("idle")
    void syncChatAfterSend()
  }, [cancelTypewriter, collapseMessageTrace, syncChatAfterSend, updateMessageById])

  const scheduleTypewriter = useCallback(() => {
    const activeStream = activeStreamRef.current
    if (!activeStream || activeStream.frameId) {
      return
    }

    activeStream.frameId = window.requestAnimationFrame(() => {
      const currentStream = activeStreamRef.current
      if (!currentStream) {
        return
      }
      currentStream.frameId = undefined

      if (currentStream.renderedText.length < currentStream.targetText.length) {
        const nextChunk = currentStream.targetText.slice(
          currentStream.renderedText.length,
          currentStream.renderedText.length + TYPEWRITER_CHARS_PER_FRAME
        )
        currentStream.renderedText += nextChunk
        updateMessageById(currentStream.assistantMessageId, (message) => ({
          ...message,
          content: currentStream.renderedText,
          streamState: "streaming",
          statusLabel: undefined,
          statusDetail: undefined
        }))
        scrollMessageListToBottomRef.current({ behavior: "auto" })
      }

      if (currentStream.renderedText.length < currentStream.targetText.length) {
        scheduleTypewriter()
        return
      }

      finalizeActiveStream()
    })
  }, [finalizeActiveStream, updateMessageById])

  const failActiveStream = useCallback((message: string) => {
    const activeStream = activeStreamRef.current
    console.error("[renderer] failActiveStream called, message:", message, "hasActiveStream:", !!activeStream);
    if (activeStream) {
      cancelTypewriter()
      updateMessageById(activeStream.assistantMessageId, (current) => {
        const nextContent = activeStream.renderedText || activeStream.targetText || message || current.content;
        console.log("[renderer] failActiveStream: setting content:", nextContent, "streamed:", activeStream.renderedText, "target:", activeStream.targetText);
        return {
          ...current,
          content: nextContent,
          streamState: "error",
          statusLabel: undefined,
          statusDetail: undefined
        };
      })
      updateStreamSmoke((current) => current ? {
        ...current,
        phase: "error",
        renderedContent: activeStream.renderedText || activeStream.targetText || current.renderedContent,
        finalContent: activeStream.renderedText || activeStream.targetText || current.finalContent,
        lastError: message,
        errorEventCount: current.errorEventCount + 1
      } : current)
      setMessageTraceExpanded(activeStream.assistantMessageId, true)
      activeStreamRef.current = null
    }
    setSendPhase("idle")
    setErrorText(message)
  }, [cancelTypewriter, setErrorText, setMessageTraceExpanded, updateMessageById])

  const cancelActiveStream = useCallback(async () => {
    const activeStream = activeStreamRef.current
    if (!activeStream) {
      const pendingSubmissionId = pendingSubmissionIdRef.current
      if (pendingSubmissionId) {
        cancelledSubmissionIdsRef.current.add(pendingSubmissionId)
      }
      const pendingAssistantMessageId = pendingSubmissionId
        ? pendingSubmissionAssistantMessageIdsRef.current.get(pendingSubmissionId)
        : undefined
      if (pendingAssistantMessageId) {
        updateMessageById(pendingAssistantMessageId, (message) => ({
          ...message,
          streamState: undefined,
          statusLabel: "已停止",
          statusDetail: undefined
        }))
        appendTrace(pendingAssistantMessageId, "cancelled", "已停止", undefined, "info")
      }
      setSendPhase("idle")
      return
    }

    stoppedRequestIdsRef.current.add(activeStream.requestId)
    cancelTypewriter()
    const stoppedContent = activeStream.renderedText || activeStream.targetText
    updateMessageById(activeStream.assistantMessageId, (message) => ({
      ...message,
      content: stoppedContent || message.content,
      streamState: undefined,
      statusLabel: "已停止",
      statusDetail: undefined
    }))
    appendTrace(activeStream.assistantMessageId, "cancelled", "已停止", undefined, "info")
    updateStreamSmoke((current) => current ? {
      ...current,
      phase: "cancelled",
      renderedContent: stoppedContent || current.renderedContent,
      finalContent: stoppedContent || current.finalContent,
      latestStatusLabel: "已停止",
      statusLabels: appendSmokeStatusLabel(current.statusLabels, "已停止")
    } : current)
    activeStreamRef.current = null
    setSendPhase("idle")

    await desktopApi.chat.cancelStream(activeStream.requestId, activeStream.runId, activeStream.sessionId).catch(() => undefined)
    stoppedRequestIdsRef.current.delete(activeStream.requestId)
    void syncChatAfterSend()
  }, [appendTrace, cancelTypewriter, desktopApi.chat, syncChatAfterSend, updateMessageById])

  const completeWithFallback = useCallback(async (sessionId: string, promptText: string, skillId: string | undefined, assistantMessageId: string, attachments?: ChatAttachment[], isCancelled?: () => boolean) => {
    const result = await desktopApi.chat.sendPrompt(sessionId, promptText, skillId, attachments)
    if (isCancelled?.()) {
      setSendPhase("idle")
      return
    }
    cancelTypewriter()
    activeStreamRef.current = null
    updateMessageById(assistantMessageId, (message) => ({
      ...message,
      content: result.reply.content,
      createdAt: result.reply.createdAt,
      streamState: undefined,
      statusLabel: undefined,
      statusDetail: undefined
    }))
    updateStreamSmoke((current) => current ? {
      ...current,
      phase: "fallback",
      fallbackUsed: true,
      finalContent: result.reply.content,
      renderedContent: result.reply.content,
      executionPolicySource: result.executionPolicy?.source ?? current.executionPolicySource,
      executionPolicyModel: result.executionPolicy?.modelLabel ?? current.executionPolicyModel,
      lastError: current.lastError
    } : current)
    appendTrace(assistantMessageId, "fallback-complete", ui.fallbackComplete, undefined, "success")
    collapseMessageTrace(assistantMessageId)
    await syncChatAfterSend()
    setSendPhase("idle")
  }, [appendTrace, cancelTypewriter, collapseMessageTrace, desktopApi.chat, syncChatAfterSend, ui.fallbackComplete, updateMessageById])

  const ensureChatAvailable = useCallback(async (assistantMessageId: string) => {
    const reportStatus = (stage: string, label: string, detail?: string) => {
      updateAssistantStatus(assistantMessageId, label, detail)
      appendTrace(assistantMessageId, stage, label, detail)
      updateStreamSmoke((current) => current ? {
        ...current,
        latestStatusLabel: label,
        statusLabels: appendSmokeStatusLabel(current.statusLabels, label)
      } : current)
    }

    reportStatus("check-chat", ui.checkingChat, ui.checkingChatDetail)
    const [latestWorkspace, latestRuntime, latestGateway] = await Promise.all([
      desktopApi.workspace.getSummary(),
      desktopApi.runtime.getStatus().catch(() => null),
      desktopApi.gateway.status().catch(() => null)
    ])

    setWorkspace(latestWorkspace)
    if (latestRuntime) {
      setRuntimeStatus(latestRuntime)
    }
    setGatewayStatus(latestGateway)

    if (!latestWorkspace.apiKeyConfigured) {
      throw new Error(ui.bindFirstError)
    }
    const latestCanExchange = canExchangeMessages(latestWorkspace, latestRuntime, latestGateway)
    const latestViewMode = viewModeRef.current
    const latestHomeChatReady = latestViewMode === "chat" && latestWorkspace.chatReady && latestCanExchange
    const latestExpertChatReady = latestViewMode === "experts"
      && latestWorkspace.chatReady
      && Boolean(latestWorkspace.projectReady && latestWorkspace.currentProjectId)
      && latestCanExchange
    if (latestHomeChatReady || latestExpertChatReady) {
      return latestWorkspace
    }

    let nextRuntime = latestRuntime
    if (nextRuntime && nextRuntime.selectedMode === "bundled-runtime" && nextRuntime.processState !== "running") {
      reportStatus("start-runtime", ui.startingRuntime, ui.startingRuntimeDetail)
      const startResult = await desktopApi.runtime.start().catch(() => null)
      nextRuntime = await desktopApi.runtime.getStatus().catch(() => startResult ?? nextRuntime)
      if (nextRuntime) {
        setRuntimeStatus(nextRuntime)
      }
    }

    let nextGateway = latestGateway
    if (nextGateway?.state !== "connected") {
      reportStatus("connect-gateway", ui.connectingGateway, ui.connectingGatewayDetail)
      nextGateway = await desktopApi.gateway.reconnect().catch(() => desktopApi.gateway.connect().catch(() => null))
      setGatewayStatus(nextGateway)
    }

    const [confirmedWorkspace, confirmedRuntime, confirmedGateway] = await Promise.all([
      desktopApi.workspace.getSummary(),
      desktopApi.runtime.getStatus().catch(() => nextRuntime),
      desktopApi.gateway.status().catch(() => nextGateway)
    ])

    setWorkspace(confirmedWorkspace)
    if (confirmedRuntime) {
      setRuntimeStatus(confirmedRuntime)
    }
    setGatewayStatus(confirmedGateway)

    const confirmedCanExchange = canExchangeMessages(confirmedWorkspace, confirmedRuntime, confirmedGateway)
    const confirmedViewMode = viewModeRef.current
    const confirmedHomeChatReady = confirmedViewMode === "chat" && confirmedWorkspace.chatReady && confirmedCanExchange
    const confirmedExpertChatReady = confirmedViewMode === "experts"
      && confirmedWorkspace.chatReady
      && Boolean(confirmedWorkspace.projectReady && confirmedWorkspace.currentProjectId)
      && confirmedCanExchange
    if (confirmedHomeChatReady || confirmedExpertChatReady) {
      return confirmedWorkspace
    }

    // Build a specific error message identifying the actual failure reason,
    // rather than blindly using chatStatusMessage which may say "chat ready"
    // when the real problem is project-level readiness.
    const missingParts: string[] = []
    if (confirmedViewMode === "experts") {
      if (confirmedWorkspace.chatReady && !confirmedWorkspace.projectReady) {
        missingParts.push("项目工作区未就绪")
      }
      if (!confirmedWorkspace.currentProjectId) {
        missingParts.push("未选择项目")
      }
    }
    if (confirmedWorkspace.chatReady && !confirmedCanExchange) {
      const runtimeReady = confirmedRuntime?.activeMode === "external-gateway" || confirmedRuntime?.processState === "running"
      if (!runtimeReady) {
        missingParts.push("本地运行时未就绪")
      } else if (confirmedGateway?.state !== "connected") {
        missingParts.push("网关未连接")
      }
    }
    throw new Error(
      missingParts.length > 0
        ? `${missingParts.join("，")}，请稍后重试`
        : (confirmedWorkspace.chatStatusMessage ?? ui.chatNotReadyError)
    )
  }, [appendTrace, desktopApi.gateway, desktopApi.runtime, desktopApi.workspace, setGatewayStatus, setRuntimeStatus, setWorkspace, ui.bindFirstError, ui.chatNotReadyError, ui.checkingChat, ui.checkingChatDetail, ui.connectingGateway, ui.connectingGatewayDetail, ui.startingRuntime, ui.startingRuntimeDetail, updateAssistantStatus])

  const submitPrompt = useCallback(async ({
    promptText,
    skillId,
    forcedSessionId,
    forcedProjectId,
    forcedAttachments
  }: SubmitPromptOptions) => {
    const trimmedPrompt = promptText.trim()
    const attachmentsToSend = forcedAttachments?.length
      ? forcedAttachments
      : undefined
    if ((!trimmedPrompt && !attachmentsToSend?.length) || sendPhase !== "idle" || saving) {
      return
    }

    const renderedPrompt = trimmedPrompt || (attachmentsToSend?.length ? buildAttachmentPromptSummary(attachmentsToSend) : "")
    const userMessage = buildUserMessage(renderedPrompt, attachmentsToSend)
    const assistantMessage = buildAssistantPlaceholder(ui.preparingReply)
    const submissionId = createClientMessageId("submission")
    cancelledSubmissionIdsRef.current.delete(submissionId)
    pendingSubmissionIdRef.current = submissionId
    pendingSubmissionAssistantMessageIdsRef.current.set(submissionId, assistantMessage.id)
    const isSubmissionCancelled = () => cancelledSubmissionIdsRef.current.has(submissionId)
    const clearPendingSubmission = () => {
      if (pendingSubmissionIdRef.current === submissionId) {
        pendingSubmissionIdRef.current = undefined
      }
      pendingSubmissionAssistantMessageIdsRef.current.delete(submissionId)
      cancelledSubmissionIdsRef.current.delete(submissionId)
    }

    setSendPhase("preparing")
    setErrorText("")
    setPrompt("")
    setSkillMenuOpen(false)
    initializeMessageTrace(assistantMessage.id, {
      id: createClientMessageId("trace"),
      stage: "request",
      label: ui.preparingReply,
      tone: "info",
      createdAt: new Date().toISOString()
    })
    let sessionId = forcedSessionId ?? visibleSessionIdRef.current ?? ""
    let userMessageId = userMessage.id
    let assistantMessageId = assistantMessage.id
    const optimisticSessionId = sessionId

    if (optimisticSessionId) {
      updateSessionMessages(optimisticSessionId, (current) => [...current, userMessage, assistantMessage])
      scrollMessageListToBottomRef.current({ force: true, behavior: "smooth" })
    }

    try {
      const confirmedWorkspace = await ensureChatAvailable(assistantMessage.id)
      if (isSubmissionCancelled()) {
        clearPendingSubmission()
        return
      }
      const effectiveProjectId = forcedProjectId
        ?? (viewModeRef.current === "chat"
          ? sessionScopeProjectIdRef.current
          : confirmedWorkspace.currentProjectId ?? sessionScopeProjectIdRef.current)
      if (!effectiveProjectId) {
        throw new Error(ui.chatNotReadyError)
      }

      if (sessionId) {
        const projectSessions = await desktopApi.chat.listSessionsByProject(effectiveProjectId).catch(() => [])
        const matchedSession = projectSessions.find((session) => session.id === sessionId)
        if (!matchedSession) {
          sessionId = ""
        }
      }

      if (!sessionId) {
        const createdSession = await desktopApi.chat.createSessionForProject(effectiveProjectId)
        sessionId = createdSession.id
        upsertSession(createdSession)
      }
      if (isSubmissionCancelled()) {
        clearPendingSubmission()
        return
      }
      if (!optimisticSessionId) {
        updateSessionMessages(sessionId, (current) => [...current, userMessage, assistantMessage])
        scrollMessageListToBottomRef.current({ force: true, behavior: "smooth" })
      } else if (optimisticSessionId !== sessionId) {
        moveSessionMessages(optimisticSessionId, sessionId, [userMessageId, assistantMessageId])
        scrollMessageListToBottomRef.current({ force: true, behavior: "smooth" })
      }

      updateStreamSmoke(() => ({
        phase: "requested",
        prompt: trimmedPrompt,
        selectedSkillId: skillId,
        requestId: undefined,
        sessionId,
        runId: undefined,
        assistantMessageId: assistantMessage.id,
        startedEventCount: 0,
        statusEventCount: 0,
        deltaEventCount: 0,
        completedEventCount: 0,
        errorEventCount: 0,
        fallbackUsed: false,
        renderedContent: "",
        finalContent: "",
        latestStatusLabel: ui.preparingReply,
        statusLabels: [ui.preparingReply]
      }))

      updateAssistantStatus(assistantMessageId, ui.waitingReply)
      appendTrace(assistantMessageId, "await-model", ui.waitingReply)

      try {
        const stream = await desktopApi.chat.streamPrompt(sessionId, trimmedPrompt, skillId, attachmentsToSend)
        if (stream.userMessageId) {
          replaceSessionMessageId(sessionId, userMessageId, stream.userMessageId)
        }
        if (stream.assistantMessageId) {
          replaceSessionMessageId(sessionId, assistantMessageId, stream.assistantMessageId)
        }
        userMessageId = stream.userMessageId ?? userMessageId
        assistantMessageId = stream.assistantMessageId ?? assistantMessageId
        if (isSubmissionCancelled()) {
          stoppedRequestIdsRef.current.add(stream.requestId)
          clearPendingSubmission()
          updateMessageById(assistantMessageId, (message) => ({
            ...message,
            streamState: undefined,
            statusLabel: "已停止",
            statusDetail: undefined
          }))
          await desktopApi.chat.cancelStream(stream.requestId, stream.runId, stream.sessionId).catch(() => undefined)
          stoppedRequestIdsRef.current.delete(stream.requestId)
          setSendPhase("idle")
          return
        }
        clearPendingSubmission()
        activeStreamRef.current = {
          requestId: stream.requestId,
          assistantMessageId,
          sessionId: stream.sessionId,
          originSessionId: sessionId,
          runId: stream.runId,
          targetText: "",
          renderedText: ""
        }
        clearComposerAttachmentRef.current()
        updateStreamSmoke((current) => current ? {
          ...current,
          requestId: stream.requestId,
          sessionId: stream.sessionId,
          runId: stream.runId,
          assistantMessageId,
          executionPolicySource: stream.executionPolicy?.source ?? current.executionPolicySource,
          executionPolicyModel: stream.executionPolicy?.modelLabel ?? current.executionPolicyModel
        } : current)
      } catch {
        if (isSubmissionCancelled()) {
          clearPendingSubmission()
          setSendPhase("idle")
          return
        }
        setSendPhase("finalizing")
        appendTrace(assistantMessageId, "fallback", ui.fallbackReply)
        updateAssistantStatus(assistantMessageId, ui.generating)
        await completeWithFallback(sessionId, trimmedPrompt, skillId, assistantMessageId, attachmentsToSend, isSubmissionCancelled)
        clearPendingSubmission()
        clearComposerAttachmentRef.current()
      }
    } catch (error) {
      if (isSubmissionCancelled()) {
        clearPendingSubmission()
        setSendPhase("idle")
        return
      }
      clearPendingSubmission()
      setSendPhase("idle")
      const message = error instanceof Error ? error.message : String(error)
      setMessageTraceExpanded(assistantMessageId, true)
      failPendingAssistant(assistantMessageId, message)
      appendTrace(assistantMessageId, "error", "\u53d1\u9001\u5931\u8d25", message, "error")
      updateStreamSmoke((current) => current ? {
        ...current,
        phase: "error",
        errorEventCount: Math.max(1, current.errorEventCount),
        latestStatusLabel: "\u53d1\u9001\u5931\u8d25",
        statusLabels: current.statusLabels.length ? current.statusLabels : ["\u53d1\u9001\u5931\u8d25"],
        lastError: message
      } : {
        phase: "error",
        prompt: trimmedPrompt,
        selectedSkillId: skillId,
        requestId: undefined,
        sessionId,
        runId: undefined,
        assistantMessageId,
        startedEventCount: 0,
        statusEventCount: 0,
        deltaEventCount: 0,
        completedEventCount: 0,
        errorEventCount: 1,
        fallbackUsed: false,
        renderedContent: "",
        finalContent: "",
        latestStatusLabel: "\u53d1\u9001\u5931\u8d25",
        statusLabels: ["\u53d1\u9001\u5931\u8d25"],
        lastError: message
      })
      setErrorText(message)
    }
  }, [
    appendTrace,
    completeWithFallback,
    desktopApi.chat,
    ensureChatAvailable,
    failPendingAssistant,
    initializeMessageTrace,
    moveSessionMessages,
    replaceSessionMessageId,
    saving,
    sendPhase,
    setErrorText,
    setMessageTraceExpanded,
    setPrompt,
    setSendPhase,
    setSkillMenuOpen,
    ui.generating,
    ui.fallbackReply,
    ui.preparingReply,
    ui.waitingReply,
    updateAssistantStatus,
    updateMessageById,
    updateSessionMessages,
    upsertSession,
    visibleSessionIdRef
  ])

  useEffect(() => {
    const unsubscribe = desktopApi.chat.onStreamEvent((event) => {
      if (event.type === "cancelled") {
        stoppedRequestIdsRef.current.add(event.requestId)
        const activeStream = activeStreamRef.current
        if (activeStream && event.requestId === activeStream.requestId) {
          void cancelActiveStream()
        }
        stoppedRequestIdsRef.current.delete(event.requestId)
        return
      }

      if ((event.type === "completed" || event.type === "error") && stoppedRequestIdsRef.current.has(event.requestId)) {
        stoppedRequestIdsRef.current.delete(event.requestId)
        return
      }

      if (stoppedRequestIdsRef.current.has(event.requestId)) {
        return
      }

      const activeStream = activeStreamRef.current
      if (!activeStream || event.requestId !== activeStream.requestId) {
        return
      }

      if (event.type === "started") {
        activeStream.sessionId = event.sessionId
        activeStream.runId = event.runId ?? activeStream.runId
        setSendPhase("streaming")
        appendTrace(activeStream.assistantMessageId, "started", ui.replyStarted)
        updateAssistantStatus(activeStream.assistantMessageId, ui.thinking)
        updateStreamSmoke((current) => current ? {
          ...current,
          phase: "started",
          requestId: event.requestId,
          sessionId: event.sessionId,
          runId: event.runId,
          startedEventCount: current.startedEventCount + 1,
          executionPolicySource: event.executionPolicy?.source ?? current.executionPolicySource,
          executionPolicyModel: event.executionPolicy?.modelLabel ?? current.executionPolicyModel
        } : current)
        return
      }

      if (event.type === "status") {
        activeStream.sessionId = event.sessionId
        activeStream.runId = event.runId ?? activeStream.runId
        appendTrace(activeStream.assistantMessageId, event.stage, event.label, event.detail)
        updateAssistantStatus(activeStream.assistantMessageId, event.label, event.detail)
        updateStreamSmoke((current) => current ? {
          ...current,
          sessionId: event.sessionId,
          runId: event.runId ?? current.runId,
          statusEventCount: current.statusEventCount + 1,
          latestStatusLabel: event.label,
          statusLabels: appendSmokeStatusLabel(current.statusLabels, event.label)
        } : current)
        return
      }

      if (event.type === "delta") {
        activeStream.sessionId = event.sessionId
        activeStream.runId = event.runId
        setSendPhase("streaming")
        activeStream.targetText = event.fullText && event.fullText.length >= activeStream.targetText.length
          ? event.fullText
          : activeStream.targetText + event.textDelta
        updateStreamSmoke((current) => current ? {
          ...current,
          phase: "streaming",
          sessionId: event.sessionId,
          runId: event.runId,
          deltaEventCount: current.deltaEventCount + 1
        } : current)
        scheduleTypewriter()
        return
      }

      if (event.type === "completed") {
        stoppedRequestIdsRef.current.delete(event.requestId)
        activeStream.sessionId = event.sessionId
        activeStream.runId = event.runId
        activeStream.finalReply = event.reply
        if (event.reply.content.length >= activeStream.targetText.length) {
          activeStream.targetText = event.reply.content
        }
        // Guard: if the reply is empty and no deltas arrived, the model likely failed.
        // Keep activeStream alive so a subsequent error event can update the message.
        // Set a 45s timeout to recover if the error event never arrives.
        if (!event.reply.content.trim() && !activeStream.targetText.trim()) {
          console.warn("[renderer] completed event with empty content, no deltas — waiting for error")
          updateAssistantStatus(activeStream.assistantMessageId, "正在等待回复…")
          const timeoutRequestId = event.requestId
          emptyCompletedTimeoutRef.current = {
            requestId: timeoutRequestId,
            timer: setTimeout(() => {
              if (activeStreamRef.current?.requestId === timeoutRequestId) {
                console.warn("[renderer] empty completed timeout — forcing error")
                failActiveStream("等待回复超时，请检查 API Key 和模型配置后重试")
              }
              emptyCompletedTimeoutRef.current = null
            }, 45_000)
          }
          return
        }
        appendTrace(activeStream.assistantMessageId, "completed", ui.replyReady, undefined, "success")
        updateStreamSmoke((current) => current ? {
          ...current,
          sessionId: event.sessionId,
          runId: event.runId,
          completedEventCount: current.completedEventCount + 1,
          finalContent: event.reply.content,
          executionPolicySource: event.executionPolicy?.source ?? current.executionPolicySource,
          executionPolicyModel: event.executionPolicy?.modelLabel ?? current.executionPolicyModel
        } : current)
        if (!activeStream.targetText.trim() && event.reply.content.trim()) {
          updateMessageById(activeStream.assistantMessageId, (message) => ({
            ...message,
            content: event.reply.content,
            streamState: undefined,
            statusLabel: undefined,
            statusDetail: undefined
          }))
          finalizeActiveStream()
          return
        }
        scheduleTypewriter()
        return
      }

      if (event.type === "error") {
        console.error("[renderer] stream error event received:", event.message, "requestId:", event.requestId);
        // Clear any pending empty-completed timeout since the error arrived
        if (emptyCompletedTimeoutRef.current?.requestId === event.requestId) {
          clearTimeout(emptyCompletedTimeoutRef.current.timer)
          emptyCompletedTimeoutRef.current = null
        }
        stoppedRequestIdsRef.current.delete(event.requestId)
        const normalizedMessage = normalizeAssistantErrorMessage(event.message, event.errorCategory)
        console.error("[renderer] normalized error message:", normalizedMessage);
        appendTrace(activeStream.assistantMessageId, "error", ui.replyFailed, normalizedMessage, "error")
        updateStreamSmoke((current) => current ? {
          ...current,
          phase: "error",
          sessionId: event.sessionId,
          runId: event.runId,
          errorEventCount: current.errorEventCount + 1,
          lastError: normalizedMessage
        } : current)
        failActiveStream(normalizedMessage)
      }
    })

    return () => {
      unsubscribe()
      cancelTypewriter()
      activeStreamRef.current = null
      const pendingTimeout = emptyCompletedTimeoutRef.current
      if (pendingTimeout) {
        clearTimeout(pendingTimeout.timer)
        emptyCompletedTimeoutRef.current = null
      }
    }
  }, [cancelActiveStream, desktopApi.chat])

  useEffect(() => {
    updateStreamSmoke((current) => {
      if (!current?.assistantMessageId) {
        return current
      }

      const assistantMessage = Object.values(messagesBySession)
        .flat()
        .find((message) => message.id === current.assistantMessageId)
      if (!assistantMessage) {
        return current
      }

      const nextRenderedContent = assistantMessage.content
      const nextFinalContent = assistantMessage.streamState ? current.finalContent : assistantMessage.content
      if (nextRenderedContent === current.renderedContent && nextFinalContent === current.finalContent) {
        return current
      }

      return {
        ...current,
        renderedContent: nextRenderedContent,
        finalContent: nextFinalContent
      }
    })
  }, [messagesBySession])

  return {
    sendPhase,
    sending: sendPhase !== "idle",
    streamSmoke,
    activeStreamRef,
    submitPrompt,
    cancelActiveStream,
    ensureChatAvailable
  }
}
