import { ref, computed, type Ref } from "vue";
import { useTeamStore } from "@/stores/team";
import { useDocumentsStore } from "@/stores/documents";
import { useCalendarStore } from "@/stores/calendar";
import { isDocumentMeta } from "@/api/documents";
import type {
  IChatMessage,
  IConversation,
  ICollaborationGraphData,
  ICollaborationContract,
  ICollaborationContractDefinedData,
  ICollaborationNotice,
  IReviewResult,
  IRiskFlag,
  IDebateStartedData,
  IDebateArgumentData,
  IDebateRoundSummaryData,
  IDebateResolvedData,
  WsServerMessage,
} from "@/api/types";
import { pickExpertIdentity, resolveExpertIdentity } from "@/components/chat/helpers/expertIdentity";

// ── Types (moved from chat.ts) ─────────────────────────────────────────

/**
 * Structured streaming progress entries. Token deltas are aggregated into
 * a single entry that is updated in place so the UI doesn't spam one
 * "步骤 3: turn.token" line per chunk.
 */
export type StreamingStepType =
  | "routing"
  | "thinking"
  | "tool_call"
  | "tool_result"
  | "streaming"
  | "final_answer"
  | "team_event"
  | "board_event"
  | "milestone";

export interface IStreamingStep {
  id: string;
  type: StreamingStepType;
  label: string;
  detail?: string;
  /** Aggregate counter for `streaming` (chars streamed) or `tool_call` (duration ms). */
  counter?: number;
  status: "running" | "success" | "error";
  /** Updated monotonically; used as a render hint for the streaming entry. */
  ts: number;
}

/** Board Meeting transient state. */
export interface BoardState {
  topic: string;
  experts: Array<{
    name: string;
    avatar: string;
    color: string;
    is_moderator: boolean;
    persona: string;
  }>;
  max_rounds: number;
  current_round: number;
  status: "discussing" | "concluding" | "completed" | "dissolved";
}

/** Debate transient state. */
export interface DebateState {
  topic: string;
  participants: string[];
  current_round: number;
  max_rounds: number;
  status: "debating" | "resolved" | "cancelled";
}

/** PLAN_EXEC phase violation record. */
export interface PhaseViolation {
  phase: string;
  tool: string;
  message: string;
  violation_kind: string;
  command_preview?: string;
  ts: number;
}

function generateId(): string {
  return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}

// ponytail: reverse-find helper avoids [...messages].reverse() allocation on per-token hot path
function findLastMessage(
  messages: IChatMessage[],
  predicate: (m: IChatMessage) => boolean,
): IChatMessage | undefined {
  for (let i = messages.length - 1; i >= 0; i--) {
    if (predicate(messages[i])) return messages[i]
  }
  return undefined
}

// ── ChatStreamState: deps bag for dispatchWsEvent ──────────────────────

/**
 * The set of reactive refs + mutation helpers that `dispatchWsEvent` needs.
 * Designed so a unit test can construct a fixture state without Pinia.
 *
 * Stream-owned state (streamingStepsByConv, currentPhase, phaseViolations,
 * boardState, debateState, collaborationState) is created by `useChatStream`
 * and exposed here. ChatStore-owned state (conversations, currentConversationId)
 * and helpers (appendMessage, updateMessage, markConversationDone,
 * resolveIncomingConvId) are passed in as deps.
 */
export interface ChatStreamState {
  // Stream-owned reactive state
  streamingStepsByConv: Ref<Map<string, IStreamingStep[]>>;
  currentPhase: Ref<string | null>;
  phaseViolations: Ref<PhaseViolation[]>;
  boardState: Ref<BoardState | null>;
  debateState: Ref<DebateState | null>;
  collaborationState: Ref<ICollaborationGraphData | null>;

  // Read access to chatStore state
  conversations: Ref<IConversation[]>;
  currentConversationId: Ref<string | null>;

  // ChatStore mutation helpers
  appendMessage: (conversationId: string, message: IChatMessage) => void;
  updateMessage: (
    conversationId: string,
    messageId: string,
    updates: Partial<IChatMessage>,
  ) => void;
  markConversationDone: (conversationId: string) => void;
  resolveIncomingConvId: () => string;

  // Cross-store accessors (lazy)
  getTeamStore: () => ReturnType<typeof useTeamStore> | null;
  getCalendarStore: () => ReturnType<typeof useCalendarStore>;
  getDocumentsStore: () => ReturnType<typeof useDocumentsStore>;
}

// ── Stream helpers (operate on stream-owned state) ─────────────────────

export function getConvSteps(
  streamingStepsByConv: Ref<Map<string, IStreamingStep[]>>,
  convId: string,
): IStreamingStep[] {
  let arr = streamingStepsByConv.value.get(convId);
  if (!arr) {
    arr = [];
    streamingStepsByConv.value.set(convId, arr);
  }
  return arr;
}

export function appendStep(
  streamingStepsByConv: Ref<Map<string, IStreamingStep[]>>,
  step: Omit<IStreamingStep, "id" | "ts">,
  convId: string,
): void {
  if (!convId) return;
  getConvSteps(streamingStepsByConv, convId).push({
    ...step,
    id: generateId(),
    ts: Date.now(),
  });
}

export function updateLastStep(
  streamingStepsByConv: Ref<Map<string, IStreamingStep[]>>,
  predicate: (s: IStreamingStep) => boolean,
  patch: Partial<IStreamingStep>,
  convId: string,
): void {
  if (!convId) return;
  const arr = streamingStepsByConv.value.get(convId);
  if (!arr) return;
  const idx = [...arr].reverse().findIndex(predicate);
  if (idx === -1) return;
  const realIdx = arr.length - 1 - idx;
  arr[realIdx] = { ...arr[realIdx], ...patch, ts: Date.now() };
}

export function clearConvSteps(
  streamingStepsByConv: Ref<Map<string, IStreamingStep[]>>,
  convId: string,
): void {
  // Delete the key entirely rather than setting an empty array —
  // otherwise the Map grows unboundedly across conversations.
  streamingStepsByConv.value.delete(convId);
}

export function resetPlanExecState(state: ChatStreamState): void {
  state.currentPhase.value = null;
  state.phaseViolations.value = [];
}

/** Ensure collaborationState is initialized; return the live data object. */
export function ensureCollaborationState(
  state: ChatStreamState,
): ICollaborationGraphData {
  if (!state.collaborationState.value) {
    state.collaborationState.value = {
      contracts: [],
      notices: [],
      reviews: [],
      risks: [],
    };
  }
  return state.collaborationState.value;
}

/**
 * Ensure a collaboration_graph message exists in the conversation and update
 * it with the latest graph data. Creates the message if absent.
 */
export function upsertCollaborationGraph(
  state: ChatStreamState,
  conversationId: string,
  graphData: ICollaborationGraphData,
): void {
  const conv = state.conversations.value.find((c) => c.id === conversationId);
  if (!conv) return;
  const existing = [...conv.messages]
    .reverse()
    .find((m) => m.message_type === "collaboration_graph");
  if (existing) {
    state.updateMessage(conversationId, existing.id, {
      collaboration_graph: {
        contracts: [...graphData.contracts],
        notices: [...graphData.notices],
        reviews: [...graphData.reviews],
        risks: [...graphData.risks],
      },
    });
  } else {
    const graphMsg: IChatMessage = {
      id: generateId(),
      role: "assistant",
      content: "",
      timestamp: new Date().toISOString(),
      status: "completed",
      message_type: "collaboration_graph",
      collaboration_graph: {
        contracts: [...graphData.contracts],
        notices: [...graphData.notices],
        reviews: [...graphData.reviews],
        risks: [...graphData.risks],
      },
    };
    state.appendMessage(conversationId, graphMsg);
  }
}

// ── dispatchWsEvent: pure event dispatcher ─────────────────────────────

/**
 * Discriminated-union dispatcher for WsServerMessage. Each `case` branch
 * narrows `event` to a specific variant so typed fields can be accessed
 * directly. Mutates `state` (stream-owned refs + chatStore helpers passed
 * via the state bag) — no module-level state access, so unit tests can
 * construct a fixture state and call this directly.
 *
 * Behavior is preserved bit-for-bit from the original `handleWsMessage`
 * in chat.ts (lines 821–1946 before refactor).
 */
export function dispatchWsEvent(
  event: WsServerMessage,
  state: ChatStreamState,
): void {
  switch (event.type) {
    case "connected": {
      // Backend confirms conversation — update local ID if backend assigned a different one
      const serverConvId = event.conversation_id;
      const localId = state.currentConversationId.value;
      const conv = state.conversations.value.find((c) => c.id === localId);
      if (conv) {
        conv.is_local = false;
        if (serverConvId && serverConvId !== localId) {
          conv.id = serverConvId;
          state.currentConversationId.value = serverConvId;
        }
      }
      break;
    }

    case "routing": {
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const lastAssistantMsg = [...conv.messages]
        .reverse()
        .find((m) => m.role === "assistant");
      if (lastAssistantMsg) {
        state.updateMessage(conversationId, lastAssistantMsg.id, {
          matched_skill: event.skill,
          confidence: event.confidence,
          routing_method: event.method,
        });
      }
      appendStep(
        state.streamingStepsByConv,
        {
          type: "routing",
          label: "智能路由",
          detail: `${event.skill} · 置信度 ${(event.confidence * 100).toFixed(1)}%`,
          status: "success",
        },
        conversationId,
      );
      break;
    }

    case "step": {
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const lastAssistantMsg = [...conv.messages]
        .reverse()
        .find((m) => m.role === "assistant");
      const stepInfo = event.data;
      const innerData = stepInfo.data as Record<string, unknown>;
      const eventType = stepInfo.event_type as string | undefined;
      // Aggregate token deltas into a single "streaming" step. We only push
      // a new step on transitions between non-token types, otherwise the
      // list explodes with one entry per token.
      if (eventType === "token" || eventType === "turn.token") {
        const chunk = (innerData.delta ??
          innerData.output ??
          innerData.content ??
          "") as string;
        const deltaLen = chunk?.length ?? 0;
        const convSteps =
          state.streamingStepsByConv.value.get(conversationId) ?? [];
        const hasStreaming = convSteps.some(
          (s) => s.type === "streaming" && s.status === "running",
        );
        if (!hasStreaming) {
          appendStep(
            state.streamingStepsByConv,
            {
              type: "streaming",
              label: "正在生成回答",
              status: "running",
              counter: 0,
            },
            conversationId,
          );
        }
        // Accumulate chars into the running streaming step (counter is
        // cumulative, not per-chunk).  We inline the lookup instead of
        // using ``updateLastStep`` because the patch needs the previous
        // value — and a backward for-loop avoids the [...arr].reverse()
        // allocation on every token (hot path).
        const arr = state.streamingStepsByConv.value.get(conversationId);
        if (arr) {
          for (let i = arr.length - 1; i >= 0; i--) {
            const s = arr[i];
            if (s.type === "streaming" && s.status === "running") {
              arr[i] = {
                ...s,
                counter: (s.counter ?? 0) + (deltaLen > 0 ? deltaLen : 1),
                ts: Date.now(),
              };
              break;
            }
          }
        }
      } else if (eventType === "final_answer") {
        // Mark the streaming step as completed (if any) then add final.
        updateLastStep(
          state.streamingStepsByConv,
          (s) => s.type === "streaming" && s.status === "running",
          { status: "success" },
          conversationId,
        );
        appendStep(
          state.streamingStepsByConv,
          {
            type: "final_answer",
            label: "生成最终回答",
            status: "running",
          },
          conversationId,
        );
      } else if (eventType === "tool_call") {
        const toolName = (innerData.tool_name ||
          innerData.name ||
          "#") as string;
        // Mark the streaming step done if present.
        updateLastStep(
          state.streamingStepsByConv,
          (s) => s.type === "streaming" && s.status === "running",
          { status: "success" },
          conversationId,
        );
        appendStep(
          state.streamingStepsByConv,
          {
            type: "tool_call",
            label: "调用工具",
            detail: toolName,
            status: "running",
          },
          conversationId,
        );
      } else if (eventType === "tool_result") {
        const toolName = (innerData.tool_name || innerData.name) as
          | string
          | undefined;
        const ok = !innerData.error;
        updateLastStep(
          state.streamingStepsByConv,
          (s) => s.type === "tool_call" && s.status === "running",
          { status: ok ? "success" : "error", detail: toolName },
          conversationId,
        );
        // Detect document creation results and update documents store (U8)
        if (ok && toolName === "document" && innerData.document) {
          try {
            if (isDocumentMeta(innerData.document)) {
              const documentsStore = state.getDocumentsStore();
              documentsStore.addDocument(conversationId, innerData.document);
            } else {
              console.warn(
                "Malformed document payload from tool_result:",
                innerData.document,
              );
            }
          } catch (e) {
            // Store not yet initialized or malformed payload — non-fatal
            console.warn("Failed to add document to store:", e);
          }
        }
      } else if (eventType === "thinking") {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "thinking",
            label: "深度思考",
            status: "success",
          },
          conversationId,
        );
      } else if (
        eventType === "turn.started" ||
        eventType === "turn.created"
      ) {
        // Phase marker — silence; user already sees the spinner.
      } else if (
        eventType === "turn.thinking" ||
        eventType === "turn.result"
      ) {
        // Already handled by thinking/final_answer; ignore.
      } else {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "milestone",
            label: `${eventType || "progress"}`,
            detail: stepInfo.step ? `step ${stepInfo.step}` : undefined,
            status: "running",
          },
          conversationId,
        );
      }

      // Track tool calls for ToolCallCard rendering
      if (lastAssistantMsg) {
        const toolCalls = lastAssistantMsg.tool_calls || [];

        if (stepInfo.event_type === "tool_call") {
          const tcId = `tc-${stepInfo.step || toolCalls.length}`;
          const toolName = (innerData.tool_name ||
            innerData.name ||
            "unknown") as string;
          const params = innerData.arguments
            ? typeof innerData.arguments === "string"
              ? innerData.arguments
              : JSON.stringify(innerData.arguments, null, 2)
            : undefined;
          toolCalls.push({
            id: tcId,
            name: toolName,
            status: "running",
            params: params
              ? params.length > 500
                ? params.substring(0, 500) + "..."
                : params
              : undefined,
          });
          state.updateMessage(conversationId, lastAssistantMsg.id, {
            tool_calls: [...toolCalls],
          });
        } else if (stepInfo.event_type === "tool_result") {
          // Find the last running tool call and update it
          const lastRunning = [...toolCalls]
            .reverse()
            .find((tc) => tc.status === "running");
          if (lastRunning) {
            const resultStr = innerData.output
              ? typeof innerData.output === "string"
                ? innerData.output
                : JSON.stringify(innerData.output, null, 2)
              : "";
            lastRunning.status = innerData.error ? "error" : "completed";
            lastRunning.result =
              resultStr.length > 2000
                ? resultStr.substring(0, 2000) + "..."
                : resultStr;
            lastRunning.error = innerData.error as string | undefined;
            lastRunning.duration = innerData.duration as number | undefined;
            state.updateMessage(conversationId, lastAssistantMsg.id, {
              tool_calls: [...toolCalls],
            });
          }
        } else if (stepInfo.event_type === "thinking") {
          // Accumulate thinking content for ThinkingBlock rendering
          const thinkingChunk = (innerData.content ||
            innerData.thought ||
            "") as string;
          if (thinkingChunk && lastAssistantMsg) {
            state.updateMessage(conversationId, lastAssistantMsg.id, {
              thinking: (lastAssistantMsg.thinking || "") + thinkingChunk,
            });
          }
        }
      }

      // Accumulate final_answer content for streaming display
      if (stepInfo.event_type === "final_answer" && lastAssistantMsg) {
        const chunk = (innerData.output || "") as string;
        if (chunk) {
          state.updateMessage(conversationId, lastAssistantMsg.id, {
            content: (lastAssistantMsg.content || "") + chunk,
          });
        }
      }
      break;
    }

    case "result": {
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const lastAssistantMsg = [...conv.messages]
        .reverse()
        .find((m) => m.role === "assistant");
      // Backend sends: {type: "result", data: {message: "..."}} or {data: {status, content}}
      const content = event.data.message || event.data.content || "";
      if (lastAssistantMsg) {
        // Only overwrite if we didn't already stream the content
        const finalContent = content || lastAssistantMsg.content || "";
        state.updateMessage(conversationId, lastAssistantMsg.id, {
          content: finalContent,
          status: "completed",
        });
      }
      // Update conversation title from first user message
      const firstUserMsg = conv.messages.find((m) => m.role === "user");
      if (firstUserMsg && conv.title === "新对话") {
        conv.title =
          firstUserMsg.content.length > 20
            ? `${firstUserMsg.content.substring(0, 20)}...`
            : firstUserMsg.content;
      }
      state.markConversationDone(conversationId);
      // Clear steps for this conversation so they don't accumulate
      // across multiple interactions.  The UI has already transitioned
      // to showing the final assistant message.
      clearConvSteps(state.streamingStepsByConv, conversationId);
      // Reset PLAN_EXEC phase state — the conversation is done.
      resetPlanExecState(state);
      break;
    }

    case "error": {
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const lastAssistantMsg = [...conv.messages]
        .reverse()
        .find((m) => m.role === "assistant");
      if (lastAssistantMsg) {
        state.updateMessage(conversationId, lastAssistantMsg.id, {
          message_type: "error",
          status: "error",
          error_detail: event.data.message || "未知错误",
          content: lastAssistantMsg.content || "",
        });
      } else {
        const errorMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: "",
          timestamp: new Date().toISOString(),
          status: "error",
          message_type: "error",
          error_detail: event.data.message || "未知错误",
        };
        state.appendMessage(conversationId, errorMsg);
      }
      state.markConversationDone(conversationId);
      clearConvSteps(state.streamingStepsByConv, conversationId);
      break;
    }

    case "team_formed": {
      // Reset collaboration state for a fresh team
      state.collaborationState.value = null;
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const teamStore = state.getTeamStore();
      if (teamStore) {
        teamStore.setTeamState(event.data);
      }
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: "组建专家团",
          detail: event.data.experts.map((e) => e.name).join("、"),
          status: "success",
        },
        conversationId,
      );
      break;
    }

    case "expert_step": {
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      // Dedup: append to existing expert message if one exists for this expert
      const existingExpertMsg = [...conv.messages]
        .reverse()
        .find(
          (m) =>
            m.expert_id === event.data.expert_id && m.status === "pending",
        );
      if (existingExpertMsg) {
        state.updateMessage(conversationId, existingExpertMsg.id, {
          content:
            (existingExpertMsg.content || "") + (event.data.content || ""),
        });
      } else {
        const expertMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: event.data.content || "",
          timestamp: new Date().toISOString(),
          status: "pending",
          expert_id: event.data.expert_id,
          expert_name: event.data.expert_name,
          expert_color: event.data.expert_color,
          message_type: "chat",
        };
        state.appendMessage(conversationId, expertMsg);
      }
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: event.data.expert_name || "专家",
          detail: event.data.step ? `步骤 ${event.data.step}` : undefined,
          status: "running",
        },
        conversationId,
      );
      break;
    }

    case "expert_result_chunk": {
      // U4: 流式 token chunk — 首次创建 streaming 占位，后续累加 content。
      // 每个 expert_id 维护独立 streaming slot（keyed by expert_id），并发专家
      // 渲染为独立并行 streaming 消息，按 first-chunk 到达时间排序。
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.expert_id === event.data.expert_id && m.status === "streaming",
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: (existing.content || "") + (event.data.content || ""),
        });
      } else {
        // chunk 不含 name/color — 从 teamStore 解析身份标识用于 badge 渲染。
        // ponytail: 找不到则用 expert_id 作为 name 兜底，等 expert_result 终结事件
        // 到达时再补全真实 name/color。
        const teamStore = state.getTeamStore();
        const expert = teamStore?.teamState?.experts.find(
          (e) => e.id === event.data.expert_id,
        );
        const placeholderMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: event.data.content || "",
          timestamp: new Date().toISOString(),
          status: "streaming",
          expert_id: event.data.expert_id,
          expert_name: expert?.name || event.data.expert_id,
          expert_color: expert?.color || "#1890ff",
          message_type: "chat",
        };
        state.appendMessage(conversationId, placeholderMsg);
      }
      break;
    }

    case "expert_result_chunk_reset": {
      // U3 retry 合约: 重试前清空已累积 content，重置 streaming 状态。
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.expert_id === event.data.expert_id && m.status === "streaming",
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: "",
          status: "streaming",
        });
      }
      break;
    }

    case "expert_result": {
      // U4: 最终完整结果 — 覆盖累积 content，标记 completed/error。
      // 若存在 streaming 占位则 update；否则 append（向后兼容无 chunk 的场景）。
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const isError = event.data.status === "error";
      const finalStatus = isError ? "error" : "completed";
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.expert_id === event.data.expert_id && m.status === "streaming",
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: event.data.content || "",
          status: finalStatus,
          expert_name: event.data.expert_name || existing.expert_name,
          expert_color: event.data.expert_color || existing.expert_color,
          ...(isError
            ? { message_type: "error", error_detail: event.data.error }
            : {}),
        });
      } else {
        const expertMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: event.data.content || "",
          timestamp: new Date().toISOString(),
          status: finalStatus,
          expert_id: event.data.expert_id,
          expert_name: event.data.expert_name,
          expert_color: event.data.expert_color,
          message_type: isError ? "error" : "chat",
          ...(isError ? { error_detail: event.data.error } : {}),
        };
        state.appendMessage(conversationId, expertMsg);
      }
      break;
    }

    case "plan_update": {
      const teamStore = state.getTeamStore();
      if (teamStore) {
        teamStore.updatePhases(event.data.plan_phases);
      }
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const existingPlanMsg = [...conv.messages]
        .reverse()
        .find((m) => m.message_type === "plan_update");
      if (existingPlanMsg) {
        state.updateMessage(conversationId, existingPlanMsg.id, {
          plan_phases: event.data.plan_phases,
        });
      } else {
        const planMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: "",
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "plan_update",
          plan_phases: event.data.plan_phases,
        };
        state.appendMessage(conversationId, planMsg);
      }

      // U5: Extract collaboration contracts from plan_phases and populate
      // collaborationState + collaboration_graph message. The backend
      // includes contracts inside plan_update (not as a separate event).
      const extractedContracts: Array<
        ICollaborationContract & { phase_id: string; phase_name: string }
      > = [];
      for (const phase of event.data.plan_phases) {
        if (
          phase.collaboration_contracts &&
          phase.collaboration_contracts.length > 0
        ) {
          for (const c of phase.collaboration_contracts) {
            extractedContracts.push({
              from_expert: c.from_expert,
              to_expert: c.to_expert,
              content_description: c.content_description,
              status: c.status,
              phase_id: phase.id,
              phase_name: phase.name,
            });
          }
        }
      }
      if (extractedContracts.length > 0) {
        const collab = ensureCollaborationState(state);
        collab.contracts = extractedContracts;
        upsertCollaborationGraph(state, conversationId, collab);
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "协作契约定义",
            detail: `${extractedContracts.length} 项契约`,
            status: "success",
          },
          conversationId,
        );
      }
      break;
    }

    case "team_synthesis_chunk": {
      // U4: 团队综合流式 chunk — 首次创建 streaming 占位，后续累加。
      // P2 fix: 用 synthesis_id 去重，避免 chunk 附身到上一次孤儿 streaming milestone。
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const sid = event.data.synthesis_id;
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.message_type === "milestone" &&
          m.status === "streaming" &&
          (sid ? m.synthesis_id === sid || m.synthesis_id === undefined : true),
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: (existing.content || "") + (event.data.chunk || ""),
          // 首次附身到无 synthesis_id 的 milestone 时，标记上 synthesis_id
          ...(sid && !existing.synthesis_id ? { synthesis_id: sid } : {}),
        });
      } else {
        const synthMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: event.data.chunk || "",
          timestamp: new Date().toISOString(),
          status: "streaming",
          message_type: "milestone",
          ...(sid ? { synthesis_id: sid } : {}),
        };
        state.appendMessage(conversationId, synthMsg);
      }
      break;
    }

    case "team_synthesis": {
      // U4: 最终综合结果 — 覆盖累积 content，标记 completed。
      // 若存在 streaming 占位则 update；否则 append（向后兼容）。
      // P2 fix: 用 synthesis_id 精确匹配对应 streaming milestone（跨重试场景）。
      // P2 fix: 处理 status=error/cancelled 终结事件（后端 synthesis 失败时清理孤儿）。
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const sid = event.data.synthesis_id;
      const finalStatus: 'completed' | 'error' =
        event.data.status === 'error' || event.data.status === 'cancelled'
          ? 'error'
          : 'completed';
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.message_type === "milestone" &&
          m.status === "streaming" &&
          (sid ? m.synthesis_id === sid || m.synthesis_id === undefined : true),
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: event.data.content || "",
          status: finalStatus,
          ...(event.data.error ? { error_detail: event.data.error } : {}),
        });
      } else {
        const synthesisMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: event.data.content || "",
          timestamp: new Date().toISOString(),
          status: finalStatus,
          message_type: "milestone",
          ...(sid ? { synthesis_id: sid } : {}),
          ...(event.data.error ? { error_detail: event.data.error } : {}),
        };
        state.appendMessage(conversationId, synthesisMsg);
      }
      break;
    }

    case "team_dissolved": {
      const teamStore = state.getTeamStore();
      if (teamStore) {
        teamStore.clearTeam();
      }
      // Clear collaboration state — team is done
      state.collaborationState.value = null;
      const cid = state.resolveIncomingConvId();
      if (cid) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "专家团队已解散",
            status: "success",
          },
          cid,
        );
      }
      break;
    }

    case "phase_started": {
      const teamStore = state.getTeamStore();
      if (teamStore?.teamState) {
        teamStore.updatePhaseStatus(event.data.phase_id, "in_progress");
        const cid = state.resolveIncomingConvId();
        if (cid) {
          appendStep(
            state.streamingStepsByConv,
            {
              type: "team_event",
              label: "阶段开始",
              detail: `${event.data.phase_name} · ${event.data.assigned_expert}`,
              status: "running",
            },
            cid,
          );
        }
      }
      break;
    }

    case "phase_completed": {
      const teamStore = state.getTeamStore();
      if (teamStore?.teamState) {
        teamStore.updatePhaseStatus(
          event.data.phase_id,
          "completed",
          event.data.result_summary,
        );
        const cid = state.resolveIncomingConvId();
        if (cid) {
          appendStep(
            state.streamingStepsByConv,
            {
              type: "team_event",
              label: "阶段完成",
              detail: event.data.phase_name,
              status: "success",
            },
            cid,
          );
        }
      }
      break;
    }

    case "phase_failed": {
      const teamStore = state.getTeamStore();
      if (teamStore?.teamState) {
        teamStore.updatePhaseStatus(
          event.data.phase_id,
          "failed",
          event.data.error,
        );
        const cid = state.resolveIncomingConvId();
        if (cid) {
          appendStep(
            state.streamingStepsByConv,
            {
              type: "team_event",
              label: "阶段失败",
              detail: `${event.data.phase_name} · ${event.data.error || ""}`,
              status: "error",
            },
            cid,
          );
        }
      }
      break;
    }

    // ── PLAN_EXEC (U4) — phase lifecycle events from ReActEngine ────────

    case "phase_changed": {
      state.currentPhase.value = event.data.phase;
      const cid = state.resolveIncomingConvId();
      if (cid) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "milestone",
            label: "阶段切换",
            detail: `${event.data.previous || "—"} → ${event.data.phase}`,
            status: "success",
          },
          cid,
        );
      }
      break;
    }

    case "phase_violation": {
      // Track current phase from violation data.
      state.currentPhase.value = event.data.current_phase;
      const violation: PhaseViolation = {
        phase: event.data.current_phase,
        tool: event.data.tool,
        message: event.data.message,
        violation_kind: event.data.violation_kind,
        command_preview: event.data.command_preview,
        ts: Date.now(),
      };
      state.phaseViolations.value = [
        ...state.phaseViolations.value,
        violation,
      ].slice(-5);
      // Toast notification via ant-design-vue message.
      import("ant-design-vue").then(({ message }) => {
        message.warning(
          `[${event.data.current_phase}] 工具 ${event.data.tool} 被拦截: ${event.data.message}`,
          5,
        );
      });
      const cid = state.resolveIncomingConvId();
      if (cid) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "阶段违规",
            detail: `${event.data.current_phase} · ${event.data.tool}`,
            status: "error",
          },
          cid,
        );
      }
      break;
    }

    // ── Board Meeting 模式事件 ────────────────────────────────────────

    case "board_started": {
      const boardData = event.data;
      // Initialize board state with stable identity. Server-provided
      // avatar/color may be empty (or just emoji glyphs that don't render
      // in some webview fonts); the pickExpertIdentity fallback guarantees
      // every expert has a non-empty avatar and a non-blue colour, so
      // every board_speech message uses the same identity across rounds.
      state.boardState.value = {
        topic: boardData.topic,
        experts: boardData.experts.map((e) => {
          const fallback = pickExpertIdentity(e.name);
          const serverAvatar = (e.avatar || "").trim();
          return {
            name: e.name,
            avatar: serverAvatar || fallback.avatar,
            color:
              /^#[0-9a-fA-F]{3,8}$/.test(e.color || "")
                ? e.color
                : fallback.color,
            is_moderator: e.is_moderator,
            persona: e.persona,
          };
        }),
        max_rounds: boardData.max_rounds,
        current_round: 0,
        status: "discussing",
      };
      // Push a structured banner message so the renderer can show BoardBannerCard
      const conversationId = state.resolveIncomingConvId();
      if (conversationId) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "board_event",
            label: "私董会开启",
            detail: `主题「${boardData.topic}」 · ${boardData.experts.length} 位专家 · 最多 ${boardData.max_rounds} 轮`,
            status: "success",
          },
          conversationId,
        );
        const startMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: `私董会开始：${boardData.topic}`,
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "board_started",
          board_started: boardData,
          board_round: 0,
        };
        state.appendMessage(conversationId, startMsg);
      }
      break;
    }

    case "expert_speech_chunk": {
      // Streaming speech chunk (U4 #board streaming). Find the streaming
      // message for this expert by name and append the chunk. If absent
      // (e.g. server sent a chunk without a preceding expert_speech), fall
      // back to creating a fresh streaming message.
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      const chunkData = event.data;
      const chunkName = chunkData.expert_name as string | undefined;
      const existing = findLastMessage(
        conv.messages,
        (m) =>
          m.message_type === "board_speech" &&
          m.status === "streaming" &&
          (chunkName ? m.expert_name === chunkName : true),
      );
      const identity = resolveExpertIdentity(
        chunkName,
        chunkData.expert_avatar,
        chunkData.expert_color,
      );
      if (existing) {
        state.updateMessage(conversationId, existing.id, {
          content: (existing.content || "") + (chunkData.content || ""),
          // Fill in identity fields if the original placeholder lacked them.
          expert_name: existing.expert_name || chunkName,
          expert_avatar: existing.expert_avatar || identity.avatar,
          expert_color: existing.expert_color || identity.color,
          board_round: chunkData.round ?? existing.board_round,
          board_role: chunkData.role ?? existing.board_role,
        });
      } else {
        const placeholder: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: chunkData.content || "",
          timestamp: new Date().toISOString(),
          status: "streaming",
          expert_name: chunkName,
          expert_avatar: identity.avatar,
          expert_color: identity.color,
          message_type: "board_speech",
          board_round: chunkData.round,
          board_role: chunkData.role,
        };
        state.appendMessage(conversationId, placeholder);
      }
      break;
    }

    case "expert_speech": {
      const speechData = event.data;
      // Update current round in board state
      if (
        state.boardState.value &&
        speechData.round > state.boardState.value.current_round
      ) {
        state.boardState.value.current_round = speechData.round;
      }
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      // Stable identity: prefer boardState.experts (declared at board start),
      // fall back to resolveExpertIdentity (palette + name hash). The server
      // may emit expert_color='' or the blue default; the palette fallback
      // guarantees each expert has the same colour across rounds.
      const boardExpert = state.boardState.value?.experts.find(
        (e) => e.name === speechData.expert_name,
      );
      const identity = resolveExpertIdentity(
        speechData.expert_name,
        boardExpert?.avatar || speechData.expert_avatar,
        boardExpert?.color || speechData.expert_color,
      );
      // If a streaming message already exists for this expert (the
      // streaming path created it from chunks), update it; otherwise
      // append a fresh one. This is the unifying dedup point.
      const streamingExisting = findLastMessage(
        conv.messages,
        (m) =>
          m.message_type === "board_speech" &&
          m.expert_name === speechData.expert_name &&
          m.status === "streaming",
      );
      if (streamingExisting) {
        state.updateMessage(conversationId, streamingExisting.id, {
          content: speechData.content || streamingExisting.content || "",
          status: "completed",
          expert_name: speechData.expert_name,
          expert_avatar: identity.avatar,
          expert_color: identity.color,
          board_round: speechData.round,
          board_role: speechData.role,
        });
      } else {
        const speechMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: speechData.content || "",
          timestamp: new Date().toISOString(),
          status: "completed",
          expert_name: speechData.expert_name,
          expert_avatar: identity.avatar,
          expert_color: identity.color,
          message_type: "board_speech",
          board_round: speechData.round,
          board_role: speechData.role,
        };
        state.appendMessage(conversationId, speechMsg);
      }
      appendStep(
        state.streamingStepsByConv,
        {
          type: "board_event",
          label: `${identity.avatar} ${speechData.expert_name || "专家"}`,
          detail: `第 ${speechData.round} 轮${speechData.role === "moderator" ? " · 主持" : ""}`,
          status: "success",
        },
        conversationId,
      );
      break;
    }

    case "round_summary": {
      const summaryData = event.data;
      const conversationId = state.resolveIncomingConvId();
      if (!conversationId) break;
      const conv = state.conversations.value.find(
        (c) => c.id === conversationId,
      );
      if (!conv) break;
      // Stable identity for the moderator. Prefer the event's
      // moderator_avatar/moderator_color (added in 2026-07-02 so persistence
      // has the same identity) and fall back to the boardState snapshot
      // captured at board_started.
      const moderator = state.boardState.value?.experts.find(
        (e) => e.name === summaryData.moderator_name,
      );
      const identity = resolveExpertIdentity(
        summaryData.moderator_name,
        summaryData.moderator_avatar || moderator?.avatar,
        summaryData.moderator_color || moderator?.color,
      );
      const summaryMsg: IChatMessage = {
        id: generateId(),
        role: "assistant",
        content: summaryData.content || "",
        timestamp: new Date().toISOString(),
        status: "completed",
        expert_name: summaryData.moderator_name,
        expert_avatar: identity.avatar,
        expert_color: identity.color,
        message_type: "board_summary",
        board_round: summaryData.round,
        board_role: "summary",
      };
      state.appendMessage(conversationId, summaryMsg);
      appendStep(
        state.streamingStepsByConv,
        {
          type: "board_event",
          label: `第 ${summaryData.round} 轮小结`,
          detail: summaryData.continue ? "继续讨论" : "即将结束",
          status: "success",
        },
        conversationId,
      );
      break;
    }

    case "user_intervention": {
      const interventionData = event.data;
      const cid = state.resolveIncomingConvId();
      if (cid) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "board_event",
            label: "用户干预",
            detail: (interventionData.content || "").slice(0, 50),
            status: "success",
          },
          cid,
        );
      }
      break;
    }

    case "board_concluded": {
      const conclusionData = event.data;
      // Update board state to completed
      if (state.boardState.value) {
        state.boardState.value.status = "completed";
      }
      // Push a structured conclusion message so the renderer can show BoardConclusionCard
      const conversationId = state.resolveIncomingConvId();
      if (conversationId) {
        appendStep(
          state.streamingStepsByConv,
          {
            type: "board_event",
            label: "私董会结束",
            detail: `共 ${conclusionData.total_rounds} 轮${conclusionData.error ? " · 异常" : ""}`,
            status: conclusionData.error ? "error" : "success",
          },
          conversationId,
        );
        const conclusionMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: conclusionData.summary || "私董会已结束",
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "board_conclusion",
          board_conclusion: conclusionData,
          board_round: conclusionData.total_rounds,
        };
        state.appendMessage(conversationId, conclusionMsg);
      }
      // Clear the "执行中" loading indicator and step trail immediately
      // when the board concludes. The previous 1s setTimeout made the UI
      // look stuck for a full second after the conclusion appeared, which
      // contradicted the message that the meeting was over.
      if (conversationId) {
        state.markConversationDone(conversationId);
        clearConvSteps(state.streamingStepsByConv, conversationId);
      }
      state.boardState.value = null;
      break;
    }

    // ── Debate events (U5) ──────────────────────────────────────────

    case "debate_started": {
      const d = event.data as IDebateStartedData;
      state.debateState.value = {
        topic: d.topic,
        participants: d.participants,
        current_round: 0,
        max_rounds: d.max_rounds,
        status: "debating",
      };
      const sessionId = state.resolveIncomingConvId();
      if (!sessionId) break;
      state.appendMessage(sessionId, {
        id: generateId(),
        role: "assistant",
        content: d.opening,
        timestamp: new Date().toISOString(),
        status: "completed",
        message_type: "debate_started",
        debate_topic: d.topic,
        debate_participants: d.participants,
        debate_opening: d.opening,
      });
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: "辩论开始",
          detail: d.topic.slice(0, 40),
          status: "success",
        },
        sessionId,
      );
      break;
    }

    case "expert_argument": {
      const d = event.data as IDebateArgumentData;
      state.debateState.value = {
        ...(state.debateState.value as NonNullable<DebateState>),
        current_round: d.round,
      };
      const sessionId = state.resolveIncomingConvId();
      if (!sessionId) break;
      state.appendMessage(sessionId, {
        id: generateId(),
        role: "assistant",
        content: d.content,
        timestamp: new Date().toISOString(),
        status: "completed",
        message_type: "debate_argument",
        expert_name: d.expert_name,
        expert_color: d.expert_color,
        debate_topic: d.topic,
        debate_round: d.round,
      });
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: d.expert_name,
          detail: `辩论第${d.round}轮`,
          status: "success",
        },
        sessionId,
      );
      break;
    }

    case "debate_round_summary": {
      const d = event.data as IDebateRoundSummaryData;
      const sessionId = state.resolveIncomingConvId();
      if (!sessionId) break;
      state.appendMessage(sessionId, {
        id: generateId(),
        role: "assistant",
        content: d.content,
        timestamp: new Date().toISOString(),
        status: "completed",
        message_type: "debate_summary",
        expert_name: d.moderator_name,
        debate_round: d.round,
        debate_moderator: d.moderator_name,
      });
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: d.moderator_name,
          detail: `第${d.round}轮小结`,
          status: "success",
        },
        sessionId,
      );
      break;
    }

    case "debate_resolved": {
      const d = event.data as IDebateResolvedData;
      if (state.debateState.value) {
        state.debateState.value = {
          ...state.debateState.value,
          status: "resolved",
        };
      }
      const sessionId = state.resolveIncomingConvId();
      if (!sessionId) break;
      state.appendMessage(sessionId, {
        id: generateId(),
        role: "assistant",
        content: d.conclusion,
        timestamp: new Date().toISOString(),
        status: "completed",
        message_type: "debate_resolved",
        debate_decision: d.decision,
        debate_rationale: d.rationale,
      });
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: "辩论裁决",
          detail: d.decision,
          status: "success",
        },
        sessionId,
      );
      // Clear debate state after 1 second (same pattern as board_concluded)
      setTimeout(() => {
        state.debateState.value = null;
      }, 1000);
      break;
    }

    case "team_intervention_ack": {
      // User intervention was accepted by the server — no message needed,
      // just a step entry for visibility.
      const d = event.data as { content: string };
      const sessionId = state.resolveIncomingConvId();
      if (!sessionId) break;
      appendStep(
        state.streamingStepsByConv,
        {
          type: "team_event",
          label: "用户干预",
          detail: d.content.slice(0, 40),
          status: "success",
        },
        sessionId,
      );
      break;
    }

    // ── PM Collaboration (U5) 事件 ──────────────────────────────────

    case "collaboration_contract_defined": {
      const d = event.data as ICollaborationContractDefinedData;
      const collab = ensureCollaborationState(state);
      // Replace contracts for this phase, keep contracts from other phases
      const others = collab.contracts.filter((c) => c.phase_id !== d.phase_id);
      const newContracts: Array<
        ICollaborationContract & { phase_id: string; phase_name: string }
      > = d.contracts.map((c) => ({
        from_expert: c.from_expert,
        to_expert: c.to_expert,
        content_description: c.content_description,
        status: c.status,
        phase_id: d.phase_id,
        phase_name: d.phase_name,
      }));
      collab.contracts = [...others, ...newContracts];
      const sessionId = state.resolveIncomingConvId();
      if (sessionId) {
        upsertCollaborationGraph(state, sessionId, collab);
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "协作契约定义",
            detail: `${newContracts.length} 项契约 · ${d.phase_name}`,
            status: "success",
          },
          sessionId,
        );
      }
      break;
    }

    case "collaboration_notice": {
      const d = event.data as ICollaborationNotice;
      const collab = ensureCollaborationState(state);
      // Dedup by output_key to avoid duplicate notices on replay
      if (
        !collab.notices.some(
          (n) =>
            n.output_key === d.output_key && n.from_expert === d.from_expert,
        )
      ) {
        collab.notices.push(d);
      }
      // P2 #11: 更新契约状态 — 将匹配的契约从 pending/delivered 标记为 delivered
      // 使协作关系图的边状态实时反映交付进度
      for (const c of collab.contracts) {
        if (
          c.from_expert === d.from_expert &&
          c.to_expert === d.to_expert &&
          c.status === "pending"
        ) {
          c.status = "delivered";
        }
      }
      const sessionId = state.resolveIncomingConvId();
      if (sessionId) {
        upsertCollaborationGraph(state, sessionId, collab);
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "协作通知",
            detail: `${d.from_expert} → ${d.to_expert}`,
            status: "success",
          },
          sessionId,
        );
      }
      break;
    }

    case "review_result": {
      const d = event.data as IReviewResult;
      const collab = ensureCollaborationState(state);
      // Replace any existing review for the same phase to keep latest state
      collab.reviews = collab.reviews.filter((r) => r.phase_id !== d.phase_id);
      collab.reviews.push(d);
      const sessionId = state.resolveIncomingConvId();
      if (sessionId) {
        // Update the collaboration graph with new review status (node colors)
        upsertCollaborationGraph(state, sessionId, collab);
        // Create a dedicated ReviewResultCard message
        state.appendMessage(sessionId, {
          id: generateId(),
          role: "assistant",
          content: d.feedback || (d.passed ? "验收通过" : "验收未通过"),
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "review_result",
          review_result: d,
          expert_name: d.expert,
        });
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: d.passed
              ? "验收通过"
              : d.final_status === "failed"
                ? "验收失败"
                : "要求返工",
            detail: d.phase_name,
            status: d.passed ? "success" : "error",
          },
          sessionId,
        );
      }
      break;
    }

    case "risk_flagged": {
      const d = event.data as IRiskFlag;
      const collab = ensureCollaborationState(state);
      // Dedup by expert + phase + description to avoid duplicates on replay
      if (
        !collab.risks.some(
          (r) =>
            r.expert === d.expert &&
            r.phase_id === d.phase_id &&
            r.risk_description === d.risk_description,
        )
      ) {
        collab.risks.push(d);
      }
      const sessionId = state.resolveIncomingConvId();
      if (sessionId) {
        // Update the collaboration graph to show risk marker on the node
        upsertCollaborationGraph(state, sessionId, collab);
        // Create a dedicated RiskFlagCard message
        state.appendMessage(sessionId, {
          id: generateId(),
          role: "assistant",
          content: d.risk_description,
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "risk_flagged",
          risk_flag: d,
          expert_name: d.expert_name || d.expert,
        });
        appendStep(
          state.streamingStepsByConv,
          {
            type: "team_event",
            label: "风险标记",
            detail: `${d.expert_name || d.expert}: ${d.risk_description.slice(0, 30)}`,
            status: "error",
          },
          sessionId,
        );
      }
      break;
    }

    // ── Calendar events (KTD-10) ─────────────────────────────────────

    case "calendar_event_created":
    case "calendar_reminder":
    case "calendar_invitation":
    case "calendar_sync_conflict": {
      state.getCalendarStore().handleWsEvent(event);
      break;
    }
  }
}

// ── useChatStream composable ───────────────────────────────────────────

export interface ChatStreamDeps {
  /** Read access to chatStore state. */
  conversations: Ref<IConversation[]>;
  currentConversationId: Ref<string | null>;
  /** ChatStore mutation helpers. */
  appendMessage: (conversationId: string, message: IChatMessage) => void;
  updateMessage: (
    conversationId: string,
    messageId: string,
    updates: Partial<IChatMessage>,
  ) => void;
  markConversationDone: (conversationId: string) => void;
  resolveIncomingConvId: () => string;
  /** Cross-store lazy accessors. */
  getTeamStore: () => ReturnType<typeof useTeamStore> | null;
  getCalendarStore: () => ReturnType<typeof useCalendarStore>;
  getDocumentsStore: () => ReturnType<typeof useDocumentsStore>;
}

/**
 * Stream-side composable: owns streaming progress, PLAN_EXEC phase state,
 * board/debate/collaboration transient state, and the dispatchWsEvent
 * entry point. State owned here is mutated by `dispatchWsEvent` (the pure
 * function above); chatStore wires its own state + helpers in as deps.
 */
export function useChatStream(deps: ChatStreamDeps) {
  // --- Stream-owned state ---
  const streamingStepsByConv = ref<Map<string, IStreamingStep[]>>(new Map());
  const currentPhase = ref<string | null>(null);
  const phaseViolations = ref<PhaseViolation[]>([]);
  const boardState = ref<BoardState | null>(null);
  const debateState = ref<DebateState | null>(null);
  const collaborationState = ref<ICollaborationGraphData | null>(null);

  // --- Getters ---
  const isBoardMode = computed(
    () => boardState.value !== null && boardState.value.status === "discussing",
  );
  const isPlanExec = computed(() => currentPhase.value !== null);
  const currentStreamingSteps = computed<IStreamingStep[]>(() => {
    const cid = deps.currentConversationId.value;
    if (!cid) return [];
    return streamingStepsByConv.value.get(cid) ?? [];
  });

  // --- Stream-bound step helpers (close over local streamingStepsByConv) ---
  function getConvStepsLocal(convId: string): IStreamingStep[] {
    return getConvSteps(streamingStepsByConv, convId);
  }
  function appendStepLocal(
    step: Omit<IStreamingStep, "id" | "ts">,
    convId: string = deps.currentConversationId.value ?? "",
  ): void {
    appendStep(streamingStepsByConv, step, convId);
  }
  function updateLastStepLocal(
    predicate: (s: IStreamingStep) => boolean,
    patch: Partial<IStreamingStep>,
    convId: string = deps.currentConversationId.value ?? "",
  ): void {
    updateLastStep(streamingStepsByConv, predicate, patch, convId);
  }
  function clearConvStepsLocal(convId: string): void {
    clearConvSteps(streamingStepsByConv, convId);
  }
  function resetPlanExecStateLocal(): void {
    currentPhase.value = null;
    phaseViolations.value = [];
  }
  /** Clear all stream state — called when the socket closes. */
  function clearAllStreamState(): void {
    streamingStepsByConv.value = new Map();
  }

  // --- Build the ChatStreamState used by dispatchWsEvent ---
  const state: ChatStreamState = {
    streamingStepsByConv,
    currentPhase,
    phaseViolations,
    boardState,
    debateState,
    collaborationState,
    conversations: deps.conversations,
    currentConversationId: deps.currentConversationId,
    appendMessage: deps.appendMessage,
    updateMessage: deps.updateMessage,
    markConversationDone: deps.markConversationDone,
    resolveIncomingConvId: deps.resolveIncomingConvId,
    getTeamStore: deps.getTeamStore,
    getCalendarStore: deps.getCalendarStore,
    getDocumentsStore: deps.getDocumentsStore,
  };

  /** Dispatch a WsServerMessage through the stream. */
  function dispatch(event: WsServerMessage): void {
    dispatchWsEvent(event, state);
  }

  return {
    // Stream-owned state
    streamingStepsByConv,
    currentPhase,
    phaseViolations,
    boardState,
    debateState,
    collaborationState,
    // Getters
    isBoardMode,
    isPlanExec,
    currentStreamingSteps,
    // Step helpers
    getConvSteps: getConvStepsLocal,
    appendStep: appendStepLocal,
    updateLastStep: updateLastStepLocal,
    clearConvSteps: clearConvStepsLocal,
    resetPlanExecState: resetPlanExecStateLocal,
    clearAllStreamState,
    // Dispatcher
    dispatch,
    // Raw state bag (for tests / advanced consumers)
    state,
  };
}
