import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { apiClient } from "@/api/client";
import { useTeamStore } from "@/stores/team";
import { useDocumentsStore } from "@/stores/documents";
import { useCalendarStore } from "@/stores/calendar";
import { isDocumentMeta } from "@/api/documents";
import type {
  IChatMessage,
  IConversation,
  IChatRequest,
  WsClientMessage,
  WsServerMessage,
  IDebateStartedData,
  IDebateArgumentData,
  IDebateRoundSummaryData,
  IDebateResolvedData,
  ICollaborationContract,
  ICollaborationContractDefinedData,
  ICollaborationNotice,
  IReviewResult,
  IRiskFlag,
  ICollaborationGraphData,
} from "@/api/types";

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

/**
 * 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;
}

export const useChatStore = defineStore("chat", () => {
  // --- State ---
  const conversations = ref<IConversation[]>([]);
  const currentConversationId = ref<string | null>(null);
  const isWsConnected = ref(false);
  const ws = ref<WebSocket | null>(null);
  // Guard against cascading 404 recovery when multiple conversations are stale
  let _is404Recovering = false;
  /**
   * Per-conversation in-flight tracking. The UI's `isCurrentLoading` derives
   * from the current conversation being in this set, so other tabs remain
   * fully usable while one is waiting on the agent.
   */
  const pendingConversations = ref<Set<string>>(new Set());
  const pendingLastUsedAt = ref<Map<string, number>>(new Map());
  const streamingStepsByConv = ref<Map<string, IStreamingStep[]>>(new Map());

  // --- Step helpers (structured, aggregated, per-conversation) ---
  function getConvSteps(convId: string): IStreamingStep[] {
    let arr = streamingStepsByConv.value.get(convId);
    if (!arr) {
      arr = [];
      streamingStepsByConv.value.set(convId, arr);
    }
    return arr;
  }

  function appendStep(
    step: Omit<IStreamingStep, "id" | "ts">,
    convId: string = currentConversationId.value ?? "",
  ): void {
    if (!convId) return;
    getConvSteps(convId).push({ ...step, id: generateId(), ts: Date.now() });
  }

  function updateLastStep(
    predicate: (s: IStreamingStep) => boolean,
    patch: Partial<IStreamingStep>,
    convId: string = currentConversationId.value ?? "",
  ): 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() };
  }

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

  // --- Per-conversation pending tracking ---
  function markConversationPending(convId: string): void {
    pendingConversations.value = new Set(pendingConversations.value).add(
      convId,
    );
    pendingLastUsedAt.value = new Map(pendingLastUsedAt.value).set(
      convId,
      Date.now(),
    );
  }

  function markConversationDone(convId: string): void {
    const next = new Set(pendingConversations.value);
    next.delete(convId);
    pendingConversations.value = next;
    const last = new Map(pendingLastUsedAt.value);
    last.delete(convId);
    pendingLastUsedAt.value = last;
  }

  /**
   * Resolve which conversation an incoming WS message belongs to.
   *
   * The backend protocol currently does NOT tag server→client messages with
   * `conversation_id`, so we route by recency: pick the most recently used
   * pending conversation. This is a heuristic — it works as long as users
   * don't fire two requests in quick succession across conversations. We
   * bias toward the *current* view if it's still pending, which is what the
   * user is watching right now.
   */
  function resolveIncomingConvId(): string {
    const cid = currentConversationId.value;
    if (cid && pendingConversations.value.has(cid)) return cid;
    // Fall back to the most recently used pending conversation.
    let best: string | null = null;
    let bestTs = 0;
    pendingLastUsedAt.value.forEach((ts, id) => {
      if (pendingConversations.value.has(id) && ts > bestTs) {
        best = id;
        bestTs = ts;
      }
    });
    return best ?? cid ?? "";
  }

  // Board Meeting state (transient, only active during a board discussion)
  const boardState = ref<{
    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";
  } | null>(null);

  const isBoardMode = computed(
    () => boardState.value !== null && boardState.value.status === "discussing",
  );

  // Debate state (transient, only active during a debate collaboration)
  const debateState = ref<{
    topic: string;
    participants: string[];
    current_round: number;
    max_rounds: number;
    status: "debating" | "resolved" | "cancelled";
  } | null>(null);

  // PM Collaboration state (transient, only active during a PM-mode team task)
  // Tracks contracts, notices, reviews, and risks for the collaboration graph.
  const collaborationState = ref<ICollaborationGraphData | null>(null);

  // --- Getters ---
  const currentConversation = computed<IConversation | undefined>(() => {
    return conversations.value.find(
      (c) => c.id === currentConversationId.value,
    );
  });

  const currentMessages = computed<IChatMessage[]>(() => {
    return currentConversation.value?.messages ?? [];
  });

  /**
   * `true` only when the *currently viewed* conversation is waiting on the
   * agent. Other in-flight conversations do NOT block the input box.
   */
  const isCurrentLoading = computed<boolean>(() => {
    const cid = currentConversationId.value;
    return !!cid && pendingConversations.value.has(cid);
  });

  /**
   * Streaming progress for the currently viewed conversation only. Switching
   * tabs does not clear another tab's progress.
   */
  const currentStreamingSteps = computed<IStreamingStep[]>(() => {
    const cid = currentConversationId.value;
    if (!cid) return [];
    return streamingStepsByConv.value.get(cid) ?? [];
  });

  // --- Actions ---

  /** Load all conversations from the server */
  async function loadConversations(): Promise<void> {
    try {
      const data = await apiClient.getConversations();
      // Normalize server response: backend returns {id, created_at, updated_at, message_count}
      // but frontend IConversation expects {id, title, messages, created_at, updated_at}
      conversations.value = data.map((conv: IConversation) => ({
        id: conv.id,
        title: conv.title || "对话",
        messages: Array.isArray(conv.messages) ? conv.messages : [],
        created_at: conv.created_at,
        updated_at: conv.updated_at,
      }));
    } catch (error) {
      console.error("Failed to load conversations:", error);
    }
  }

  /** Select a conversation by ID and load its messages */
  async function selectConversation(id: string, force = false): Promise<void> {
    currentConversationId.value = id;
    // streamingSteps are scoped per conversation, so switching tabs does NOT
    // clear another conversation's in-flight progress.

    // P2 #10: 会话隔离 — 切换会话时重置 collaborationState，避免跨会话数据泄漏。
    // 若新会话已有 collaboration_graph 消息，则从消息中恢复状态。
    collaborationState.value = null;

    // Load full conversation with messages if not already loaded (or when forced)
    const conv = conversations.value.find((c) => c.id === id);
    // 本地临时会话尚未同步到服务端，跳过获取避免 404
    if (
      !conv?.is_local &&
      (force || !conv || !conv.messages || conv.messages.length === 0)
    ) {
      try {
        const fullConv = await apiClient.getConversation(id);
        if (conv) {
          conv.messages = fullConv.messages || [];
          // P0 #1 fix: never let the server's placeholder title ("对话")
          // overwrite a real title we already have locally. If the server
          // returns the placeholder, keep whatever the list view had.
          const serverTitle = fullConv.title || "";
          const localTitle = conv.title || "";
          const isServerPlaceholder =
            serverTitle === "对话" || serverTitle.trim() === "";
          const isLocalReal =
            localTitle && localTitle !== "新对话" && localTitle !== "对话";
          if (serverTitle && !isServerPlaceholder) {
            conv.title = serverTitle;
          } else if (!isLocalReal) {
            // Both sides have no real title — accept server's placeholder
            conv.title = serverTitle || localTitle || "对话";
          }
          conv.created_at = fullConv.created_at || conv.created_at;
          conv.updated_at = fullConv.updated_at || conv.updated_at;
        } else {
          // P1 #7 fix: If the conversation is not in the local list (e.g.
          // after a page refresh), add it from the fetched data instead
          // of silently discarding the result.
          const serverTitle = fullConv.title || "新对话";
          conversations.value.unshift({
            id: fullConv.id || id,
            title: serverTitle === "对话" ? "新对话" : serverTitle,
            messages: fullConv.messages || [],
            created_at: fullConv.created_at || new Date().toISOString(),
            updated_at: fullConv.updated_at || new Date().toISOString(),
          });
        }
      } catch (error) {
        const isNotFound = (error as { status?: number })?.status === 404;
        if (isNotFound) {
          // 该会话在服务端已不存在（本地临时 ID 或已被删除），清理本地状态
          conversations.value = conversations.value.filter((c) => c.id !== id);
          streamingStepsByConv.value.delete(id);
          pendingConversations.value.delete(id);
          pendingLastUsedAt.value.delete(id);
          if (currentConversationId.value === id) {
            currentConversationId.value = null;
            boardState.value = null;
            debateState.value = null;
            // 自动切换到下一个可用会话，没有则新建
            // Guard against cascading 404s when multiple stale conversations exist
            if (!_is404Recovering) {
              _is404Recovering = true;
              try {
                if (conversations.value.length > 0) {
                  await selectConversation(conversations.value[0].id);
                } else {
                  createConversation();
                }
              } finally {
                _is404Recovering = false;
              }
            }
          }
        } else {
          console.error("Failed to load conversation messages:", error);
        }
      }
    }

    // P2 #10: 恢复 collaborationState — 从会话消息中查找 collaboration_graph
    const restoredConv = conversations.value.find((c) => c.id === id);
    if (restoredConv?.messages) {
      const graphMsg = [...restoredConv.messages]
        .reverse()
        .find(
          (m) =>
            m.message_type === "collaboration_graph" && m.collaboration_graph,
        );
      if (graphMsg?.collaboration_graph) {
        collaborationState.value = {
          contracts: [...graphMsg.collaboration_graph.contracts],
          notices: [...graphMsg.collaboration_graph.notices],
          reviews: [...graphMsg.collaboration_graph.reviews],
          risks: [...graphMsg.collaboration_graph.risks],
        };
      }
    }
  }

  /** Create a new empty conversation */
  function createConversation(): void {
    const newConversation: IConversation = {
      id: generateId(),
      title: "新对话",
      messages: [],
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
      is_local: true,
    };
    conversations.value.unshift(newConversation);
    currentConversationId.value = newConversation.id;
    // New conversation starts with empty steps.
    clearConvSteps(newConversation.id);
  }

  /** Delete a conversation (server + local state) */
  async function deleteConversation(id: string): Promise<void> {
    const conv = conversations.value.find((c) => c.id === id);
    // Skip API call for local-only conversations (never synced to server)
    if (!conv?.is_local) {
      try {
        await apiClient.deleteConversation(id);
      } catch (error) {
        console.error("Failed to delete conversation:", error);
      }
    }
    conversations.value = conversations.value.filter((c) => c.id !== id);
    streamingStepsByConv.value.delete(id);
    // ponytail: must also clear pending-* state — selectConversation's 404
    // handler (line ~297) does this, but deleteConversation previously
    // omitted it, leaving ghost IDs that _getMostRecentPendingConversation
    // could return (causing "switch to deleted conversation" UI bugs).
    pendingConversations.value.delete(id);
    pendingLastUsedAt.value.delete(id);
    markConversationDone(id);
    if (currentConversationId.value === id) {
      currentConversationId.value = null;
      boardState.value = null;
      debateState.value = null;
      // Auto-switch to next available or create new (consistent with 404 handler)
      if (conversations.value.length > 0) {
        await selectConversation(conversations.value[0].id);
      } else {
        createConversation();
      }
    }
  }

  /** Send a message using REST API (fallback) */
  async function sendMessage(
    message: string,
    sources?: string[],
  ): Promise<void> {
    if (!currentConversationId.value) {
      createConversation();
    }

    const conversationId = currentConversationId.value as string;

    // Add user message
    const userMessage: IChatMessage = {
      id: generateId(),
      role: "user",
      content: message,
      timestamp: new Date().toISOString(),
    };
    appendMessage(conversationId, userMessage);

    // Add placeholder assistant message
    const assistantMessage: IChatMessage = {
      id: generateId(),
      role: "assistant",
      content: "",
      timestamp: new Date().toISOString(),
      status: "pending",
    };
    appendMessage(conversationId, assistantMessage);

    markConversationPending(conversationId);

    try {
      const request: IChatRequest = {
        message,
        conversation_id: conversationId,
        sources,
      };
      const response = await apiClient.chat(request);

      // Update assistant message with response
      updateMessage(conversationId, assistantMessage.id, {
        content: response.message,
        matched_skill: response.matched_skill,
        routing_method: response.routing_method,
        confidence: response.confidence,
        task_id: response.task_id,
        status: response.status,
      });

      // Update conversation title from first message
      const conv = conversations.value.find((c) => c.id === conversationId);
      if (conv) {
        conv.is_local = false;
        if (conv.messages.length <= 2) {
          conv.title =
            message.length > 20 ? `${message.substring(0, 20)}...` : message;
        }
      }
    } catch (error) {
      updateMessage(conversationId, assistantMessage.id, {
        content: `请求失败: ${error instanceof Error ? error.message : "未知错误"}`,
        status: "completed",
      });
    } finally {
      markConversationDone(conversationId);
    }
  }

  /** Send a message via WebSocket for streaming */
  async function sendWsMessage(
    message: string,
    sources?: string[],
    model?: string,
  ): Promise<void> {
    if (!currentConversationId.value) {
      createConversation();
    }

    // Check WebSocket state BEFORE creating messages to avoid duplicates
    if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
      // Fallback to REST directly — sendMessage will create its own messages
      await sendMessage(message, sources);
      return;
    }

    const conversationId = currentConversationId.value as string;

    // Add user message
    const userMessage: IChatMessage = {
      id: generateId(),
      role: "user",
      content: message,
      timestamp: new Date().toISOString(),
    };
    appendMessage(conversationId, userMessage);

    // Add placeholder assistant message
    const assistantMessage: IChatMessage = {
      id: generateId(),
      role: "assistant",
      content: "",
      timestamp: new Date().toISOString(),
      status: "pending",
    };
    appendMessage(conversationId, assistantMessage);

    markConversationPending(conversationId);
    clearConvSteps(conversationId);

    const wsMessage: WsClientMessage = {
      type: "chat",
      message,
      sources,
      conversation_id: conversationId,
      model,
    };

    // Problem 7: catch send() exceptions (e.g. connection closed mid-send)
    try {
      ws.value.send(JSON.stringify(wsMessage));
    } catch (error) {
      console.error("WebSocket send failed, falling back to REST:", error);
      // Remove the placeholder messages we just added; sendMessage will re-add them
      const conv = conversations.value.find((c) => c.id === conversationId);
      if (conv) {
        conv.messages = conv.messages.filter(
          (m) => m.id !== userMessage.id && m.id !== assistantMessage.id,
        );
      }
      markConversationDone(conversationId);
      await sendMessage(message, sources);
      return;
    }

    // Update conversation title from first user message
    const conv = conversations.value.find((c) => c.id === conversationId);
    if (conv && conv.title === "新对话") {
      conv.title =
        message.length > 20 ? `${message.substring(0, 20)}...` : message;
    }
  }

  /** Connect to WebSocket for real-time streaming */
  let _heartbeatTimer: ReturnType<typeof setInterval> | null = null;
  let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  let _intentionalDisconnect = false;

  function connectWebSocket(): void {
    // Problem 6: also skip if already CONNECTING to avoid orphan sockets
    if (
      ws.value &&
      (ws.value.readyState === WebSocket.OPEN ||
        ws.value.readyState === WebSocket.CONNECTING)
    ) {
      return;
    }

    _intentionalDisconnect = false;
    const socket = apiClient.createWebSocket();

    socket.onopen = () => {
      isWsConnected.value = true;
      console.log("WebSocket connected");
      // Start heartbeat: send ping every 30s to keep connection alive
      if (_heartbeatTimer) clearInterval(_heartbeatTimer);
      _heartbeatTimer = setInterval(() => {
        if (ws.value && ws.value.readyState === WebSocket.OPEN) {
          ws.value.send(JSON.stringify({ type: "ping" }));
        }
      }, 30000);
      // Check for running tasks to resume after reconnection
      _recoverTaskAfterReconnect();
    };

    socket.onmessage = (event: MessageEvent) => {
      try {
        const data = JSON.parse(event.data as string) as WsServerMessage;
        console.log("[Chat WS] Received:", data.type, data);
        handleWsMessage(data);
      } catch (error) {
        console.error("Failed to parse WebSocket message:", error);
      }
    };

    socket.onclose = () => {
      isWsConnected.value = false;
      // P2 #21 fix: clear per-conversation pending state to prevent stuck
      // loading state during disconnect. _recoverTaskAfterReconnect will
      // re-mark conversations pending if an active task is found.
      pendingConversations.value = new Set();
      pendingLastUsedAt.value = new Map();
      // Clear stale streaming steps — _recoverTaskAfterReconnect will
      // rebuild them from replayed events if an active task is found.
      streamingStepsByConv.value = new Map();
      console.log("WebSocket disconnected");
      if (_heartbeatTimer) {
        clearInterval(_heartbeatTimer);
        _heartbeatTimer = null;
      }
      // Problem 1: do not auto-reconnect after an intentional disconnect
      if (_intentionalDisconnect) {
        return;
      }
      // Auto reconnect after 3 seconds
      if (_reconnectTimer) clearTimeout(_reconnectTimer);
      _reconnectTimer = setTimeout(() => {
        if (!ws.value || ws.value.readyState === WebSocket.CLOSED) {
          connectWebSocket();
        }
      }, 3000);
    };

    socket.onerror = (error) => {
      console.error("WebSocket error:", error);
      isWsConnected.value = false;
    };

    ws.value = socket;
  }

  /** Disconnect WebSocket */
  function disconnectWebSocket(): void {
    _intentionalDisconnect = true;
    if (_reconnectTimer) {
      clearTimeout(_reconnectTimer);
      _reconnectTimer = null;
    }
    if (_heartbeatTimer) {
      clearInterval(_heartbeatTimer);
      _heartbeatTimer = null;
    }
    if (ws.value) {
      ws.value.close();
      ws.value = null;
      isWsConnected.value = false;
    }
  }

  /** Stop the in-flight generation by sending a `cancel` WS message.
   *
   * The backend (`/api/v1/portal/ws`) handles `{"type":"cancel"}` by
   * cancelling the active asyncio task and replying with a `result`
   * event whose `data.status === "cancelled"`. That `result` event
   * clears `isLoading` via the normal handler — so this function only
   * needs to send the cancel message and guard against send failures.
   */
  function stopGeneration(): void {
    const cid = currentConversationId.value;
    if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
      // No open socket — just clear the current conversation's pending state.
      if (cid) {
        markConversationDone(cid);
        clearConvSteps(cid);
      }
      return;
    }
    try {
      const cancelMsg: WsClientMessage = { type: "cancel" };
      ws.value.send(JSON.stringify(cancelMsg));
    } catch (error) {
      console.error("Failed to send cancel message:", error);
      if (cid) {
        markConversationDone(cid);
        clearConvSteps(cid);
      }
    }
  }

  /** After WebSocket reconnects, check for running tasks and resume them */
  async function _recoverTaskAfterReconnect(): Promise<void> {
    if (!currentConversationId.value) return;

    try {
      // Problem 2: query both 'running' and 'pending' tasks — a task may be
      // in PENDING state if the background worker hasn't picked it up yet.
      const [runningTasks, pendingTasks] = await Promise.all([
        apiClient.listTasks("running"),
        apiClient.listTasks("pending"),
      ]);
      const candidates = [...runningTasks, ...pendingTasks];
      const activeTask = candidates.find(
        (t) => t.metadata?.conversation_id === currentConversationId.value,
      );

      if (activeTask && ws.value && ws.value.readyState === WebSocket.OPEN) {
        // P1 #12 fix: Clear the last pending assistant message's accumulated
        // content before resuming. The EventQueue will replay buffered events
        // from the beginning of the task, so any partial content already
        // accumulated before disconnect would be duplicated if not cleared.
        const conv = conversations.value.find(
          (c) => c.id === currentConversationId.value,
        );
        if (conv) {
          const lastPendingAssistant = [...conv.messages]
            .reverse()
            .find((m) => m.role === "assistant" && m.status === "pending");
          if (lastPendingAssistant) {
            updateMessage(
              currentConversationId.value,
              lastPendingAssistant.id,
              {
                content: "",
                thinking: "",
                tool_calls: [],
              },
            );
          }
        }
        // Problem 5: only mark conversation pending if we can actually send
        // the resume
        const cid = currentConversationId.value;
        if (cid) markConversationPending(cid);
        try {
          ws.value.send(
            JSON.stringify({
              type: "resume",
              task_id: activeTask.task_id,
              conversation_id: currentConversationId.value,
            }),
          );
        } catch (error) {
          console.error("Failed to send resume message:", error);
          if (cid) markConversationDone(cid);
        }
      } else {
        // No active task — force reload conversation messages (may include
        // completed results persisted while disconnected).
        await selectConversation(currentConversationId.value, true);
      }
    } catch (error) {
      console.error("Failed to recover task after reconnect:", error);
    }
  }

  // --- Internal helpers ---

  /** Get team store lazily — safe to call inside actions after Pinia is installed */
  let _teamStore: ReturnType<typeof useTeamStore> | null = null;
  function _getTeamStore() {
    if (!_teamStore) {
      _teamStore = useTeamStore();
    }
    return _teamStore;
  }

  /** Get calendar store lazily — safe to call inside actions after Pinia is installed */
  let _calendarStore: ReturnType<typeof useCalendarStore> | null = null;
  function _getCalendarStore() {
    if (!_calendarStore) {
      _calendarStore = useCalendarStore();
    }
    return _calendarStore;
  }

  /** Ensure a collaboration_graph message exists in the conversation and update
   *  it with the latest graph data. Creates the message if absent. */
  function upsertCollaborationGraph(
    conversationId: string,
    graphData: ICollaborationGraphData,
  ): void {
    const conv = conversations.value.find((c) => c.id === conversationId);
    if (!conv) return;
    const existing = [...conv.messages]
      .reverse()
      .find((m) => m.message_type === "collaboration_graph");
    if (existing) {
      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],
        },
      };
      appendMessage(conversationId, graphMsg);
    }
  }

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

  function handleWsMessage(data: WsServerMessage): void {
    // Discriminated union narrowing: each `case` branch narrows `data` to a
    // specific variant of WsServerMessage, so typed fields can be accessed
    // directly from `data` (or `data.data` for variants with a nested payload).
    switch (data.type) {
      case "connected": {
        // Backend confirms conversation — update local ID if backend assigned a different one
        const serverConvId = data.conversation_id;
        const localId = currentConversationId.value;
        const conv = conversations.value.find((c) => c.id === localId);
        if (conv) {
          conv.is_local = false;
          if (serverConvId && serverConvId !== localId) {
            conv.id = serverConvId;
            currentConversationId.value = serverConvId;
          }
        }
        break;
      }

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

      case "step": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = conversations.value.find((c) => c.id === conversationId);
        if (!conv) break;
        const lastAssistantMsg = [...conv.messages]
          .reverse()
          .find((m) => m.role === "assistant");
        const stepInfo = data.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 =
            streamingStepsByConv.value.get(conversationId) ?? [];
          const hasStreaming = convSteps.some(
            (s) => s.type === "streaming" && s.status === "running",
          );
          if (!hasStreaming) {
            appendStep(
              {
                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 = 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(
            (s) => s.type === "streaming" && s.status === "running",
            { status: "success" },
            conversationId,
          );
          appendStep(
            {
              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(
            (s) => s.type === "streaming" && s.status === "running",
            { status: "success" },
            conversationId,
          );
          appendStep(
            {
              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(
            (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 = useDocumentsStore();
                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(
            {
              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(
            {
              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,
            });
            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;
              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) {
              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) {
            updateMessage(conversationId, lastAssistantMsg.id, {
              content: (lastAssistantMsg.content || "") + chunk,
            });
          }
        }
        break;
      }

      case "result": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = 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 = data.data.message || data.data.content || "";
        if (lastAssistantMsg) {
          // Only overwrite if we didn't already stream the content
          const finalContent = content || lastAssistantMsg.content || "";
          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;
        }
        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(conversationId);
        break;
      }

      case "error": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = conversations.value.find((c) => c.id === conversationId);
        if (!conv) break;
        const lastAssistantMsg = [...conv.messages]
          .reverse()
          .find((m) => m.role === "assistant");
        if (lastAssistantMsg) {
          updateMessage(conversationId, lastAssistantMsg.id, {
            message_type: "error",
            status: "error",
            error_detail: data.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: data.data.message || "未知错误",
          };
          appendMessage(conversationId, errorMsg);
        }
        markConversationDone(conversationId);
        clearConvSteps(conversationId);
        break;
      }

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

      case "expert_step": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = 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 === data.data.expert_id && m.status === "pending",
          );
        if (existingExpertMsg) {
          updateMessage(conversationId, existingExpertMsg.id, {
            content:
              (existingExpertMsg.content || "") + (data.data.content || ""),
          });
        } else {
          const expertMsg: IChatMessage = {
            id: generateId(),
            role: "assistant",
            content: data.data.content || "",
            timestamp: new Date().toISOString(),
            status: "pending",
            expert_id: data.data.expert_id,
            expert_name: data.data.expert_name,
            expert_color: data.data.expert_color,
            message_type: "chat",
          };
          appendMessage(conversationId, expertMsg);
        }
        appendStep(
          {
            type: "team_event",
            label: data.data.expert_name || "专家",
            detail: data.data.step ? `步骤 ${data.data.step}` : undefined,
            status: "running",
          },
          conversationId,
        );
        break;
      }

      case "expert_result": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = conversations.value.find((c) => c.id === conversationId);
        if (!conv) break;
        const expertMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: data.data.content || "",
          timestamp: new Date().toISOString(),
          status: "completed",
          expert_id: data.data.expert_id,
          expert_name: data.data.expert_name,
          expert_color: data.data.expert_color,
          message_type: "chat",
        };
        appendMessage(conversationId, expertMsg);
        break;
      }

      case "plan_update": {
        const teamStore = _getTeamStore();
        if (teamStore) {
          teamStore.updatePhases(data.data.plan_phases);
        }
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = conversations.value.find((c) => c.id === conversationId);
        if (!conv) break;
        const existingPlanMsg = [...conv.messages]
          .reverse()
          .find((m) => m.message_type === "plan_update");
        if (existingPlanMsg) {
          updateMessage(conversationId, existingPlanMsg.id, {
            plan_phases: data.data.plan_phases,
          });
        } else {
          const planMsg: IChatMessage = {
            id: generateId(),
            role: "assistant",
            content: "",
            timestamp: new Date().toISOString(),
            status: "completed",
            message_type: "plan_update",
            plan_phases: data.data.plan_phases,
          };
          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 data.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();
          collab.contracts = extractedContracts;
          upsertCollaborationGraph(conversationId, collab);
          appendStep(
            {
              type: "team_event",
              label: "协作契约定义",
              detail: `${extractedContracts.length} 项契约`,
              status: "success",
            },
            conversationId,
          );
        }
        break;
      }

      case "team_synthesis": {
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const conv = conversations.value.find((c) => c.id === conversationId);
        if (!conv) break;
        const synthesisMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: data.data.content || "",
          timestamp: new Date().toISOString(),
          status: "completed",
          message_type: "milestone",
        };
        appendMessage(conversationId, synthesisMsg);
        break;
      }

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

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

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

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

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

      case "board_started": {
        const boardData = data.data;
        // Initialize board state
        boardState.value = {
          topic: boardData.topic,
          experts: boardData.experts.map((e) => ({
            name: e.name,
            avatar: e.avatar,
            color: e.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 = resolveIncomingConvId();
        if (conversationId) {
          appendStep(
            {
              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,
          };
          appendMessage(conversationId, startMsg);
        }
        break;
      }

      case "expert_speech": {
        const speechData = data.data;
        // Update current round in board state
        if (
          boardState.value &&
          speechData.round > boardState.value.current_round
        ) {
          boardState.value.current_round = speechData.round;
        }
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const speechMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: speechData.content || "",
          timestamp: new Date().toISOString(),
          status: "completed",
          expert_name: speechData.expert_name,
          expert_color: speechData.expert_color,
          expert_avatar: speechData.expert_avatar,
          message_type: "board_speech",
          board_round: speechData.round,
          board_role: speechData.role,
        };
        appendMessage(conversationId, speechMsg);
        appendStep(
          {
            type: "board_event",
            label: `${speechData.expert_avatar || ""} ${speechData.expert_name}`,
            detail: `第 ${speechData.round} 轮${speechData.role === "moderator" ? " · 主持" : ""}`,
            status: "success",
          },
          conversationId,
        );
        break;
      }

      case "round_summary": {
        const summaryData = data.data;
        const conversationId = resolveIncomingConvId();
        if (!conversationId) break;
        const summaryMsg: IChatMessage = {
          id: generateId(),
          role: "assistant",
          content: summaryData.content || "",
          timestamp: new Date().toISOString(),
          status: "completed",
          expert_name: summaryData.moderator_name,
          message_type: "board_summary",
          board_round: summaryData.round,
          board_role: "summary",
        };
        appendMessage(conversationId, summaryMsg);
        appendStep(
          {
            type: "board_event",
            label: `第 ${summaryData.round} 轮小结`,
            detail: summaryData.continue ? "继续讨论" : "即将结束",
            status: "success",
          },
          conversationId,
        );
        break;
      }

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

      case "board_concluded": {
        const conclusionData = data.data;
        // Update board state to completed
        if (boardState.value) {
          boardState.value.status = "completed";
        }
        // Push a structured conclusion message so the renderer can show BoardConclusionCard
        const conversationId = resolveIncomingConvId();
        if (conversationId) {
          appendStep(
            {
              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,
          };
          appendMessage(conversationId, conclusionMsg);
        }
        // Clear board state after a short delay to allow UI to update
        setTimeout(() => {
          boardState.value = null;
        }, 1000);
        break;
      }

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

      case "debate_started": {
        const d = data.data as IDebateStartedData;
        debateState.value = {
          topic: d.topic,
          participants: d.participants,
          current_round: 0,
          max_rounds: d.max_rounds,
          status: "debating",
        };
        const sessionId = resolveIncomingConvId();
        if (!sessionId) break;
        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(
          {
            type: "team_event",
            label: "辩论开始",
            detail: d.topic.slice(0, 40),
            status: "success",
          },
          sessionId,
        );
        break;
      }

      case "expert_argument": {
        const d = data.data as IDebateArgumentData;
        debateState.value = {
          ...(debateState.value as NonNullable<typeof debateState.value>),
          current_round: d.round,
        };
        const sessionId = resolveIncomingConvId();
        if (!sessionId) break;
        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(
          {
            type: "team_event",
            label: d.expert_name,
            detail: `辩论第${d.round}轮`,
            status: "success",
          },
          sessionId,
        );
        break;
      }

      case "debate_round_summary": {
        const d = data.data as IDebateRoundSummaryData;
        const sessionId = resolveIncomingConvId();
        if (!sessionId) break;
        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(
          {
            type: "team_event",
            label: d.moderator_name,
            detail: `第${d.round}轮小结`,
            status: "success",
          },
          sessionId,
        );
        break;
      }

      case "debate_resolved": {
        const d = data.data as IDebateResolvedData;
        if (debateState.value) {
          debateState.value = { ...debateState.value, status: "resolved" };
        }
        const sessionId = resolveIncomingConvId();
        if (!sessionId) break;
        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(
          {
            type: "team_event",
            label: "辩论裁决",
            detail: d.decision,
            status: "success",
          },
          sessionId,
        );
        // Clear debate state after 1 second (same pattern as board_concluded)
        setTimeout(() => {
          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 = data.data as { content: string };
        const sessionId = resolveIncomingConvId();
        if (!sessionId) break;
        appendStep(
          {
            type: "team_event",
            label: "用户干预",
            detail: d.content.slice(0, 40),
            status: "success",
          },
          sessionId,
        );
        break;
      }

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

      case "collaboration_contract_defined": {
        const d = data.data as ICollaborationContractDefinedData;
        const collab = _ensureCollaborationState();
        // 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 = resolveIncomingConvId();
        if (sessionId) {
          upsertCollaborationGraph(sessionId, collab);
          appendStep(
            {
              type: "team_event",
              label: "协作契约定义",
              detail: `${newContracts.length} 项契约 · ${d.phase_name}`,
              status: "success",
            },
            sessionId,
          );
        }
        break;
      }

      case "collaboration_notice": {
        const d = data.data as ICollaborationNotice;
        const collab = _ensureCollaborationState();
        // 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 = resolveIncomingConvId();
        if (sessionId) {
          upsertCollaborationGraph(sessionId, collab);
          appendStep(
            {
              type: "team_event",
              label: "协作通知",
              detail: `${d.from_expert} → ${d.to_expert}`,
              status: "success",
            },
            sessionId,
          );
        }
        break;
      }

      case "review_result": {
        const d = data.data as IReviewResult;
        const collab = _ensureCollaborationState();
        // 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 = resolveIncomingConvId();
        if (sessionId) {
          // Update the collaboration graph with new review status (node colors)
          upsertCollaborationGraph(sessionId, collab);
          // Create a dedicated ReviewResultCard message
          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(
            {
              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 = data.data as IRiskFlag;
        const collab = _ensureCollaborationState();
        // 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 = resolveIncomingConvId();
        if (sessionId) {
          // Update the collaboration graph to show risk marker on the node
          upsertCollaborationGraph(sessionId, collab);
          // Create a dedicated RiskFlagCard message
          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(
            {
              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": {
        _getCalendarStore().handleWsEvent(data);
        break;
      }
    }
  }

  function appendMessage(conversationId: string, message: IChatMessage): void {
    const conv = conversations.value.find((c) => c.id === conversationId);
    if (conv) {
      if (!Array.isArray(conv.messages)) {
        conv.messages = [];
      }
      conv.messages.push(message);
      conv.updated_at = new Date().toISOString();
    }
  }

  function updateMessage(
    conversationId: string,
    messageId: string,
    updates: Partial<IChatMessage>,
  ): void {
    const conv = conversations.value.find((c) => c.id === conversationId);
    if (!conv) return;
    const msg = conv.messages.find((m) => m.id === messageId);
    if (msg) {
      Object.assign(msg, updates);
    }
  }

  /** Resend the last user message in the current conversation */
  async function resendLastUserMessage(): Promise<void> {
    const conversationId = currentConversationId.value;
    if (!conversationId) return;
    if (pendingConversations.value.has(conversationId)) return;
    const conv = conversations.value.find((c) => c.id === conversationId);
    if (!conv) return;
    const lastUserMsg = [...conv.messages]
      .reverse()
      .find((m) => m.role === "user");
    if (!lastUserMsg) return;
    const content = lastUserMsg.content.trim();
    if (!content) return;
    await sendWsMessage(content);
  }

  return {
    // State
    conversations,
    currentConversationId,
    isWsConnected,
    ws,
    pendingConversations,
    streamingStepsByConv,
    boardState,
    debateState,
    collaborationState,
    // Legacy aliases (derive from current conversation for backward compat).
    // New code should use `isCurrentLoading` / `currentStreamingSteps` instead.
    isLoading: isCurrentLoading,
    streamingSteps: currentStreamingSteps,
    // Getters
    currentConversation,
    currentMessages,
    isCurrentLoading,
    currentStreamingSteps,
    isBoardMode,
    // Actions
    loadConversations,
    selectConversation,
    createConversation,
    deleteConversation,
    sendMessage,
    sendWsMessage,
    resendLastUserMessage,
    stopGeneration,
    connectWebSocket,
    disconnectWebSocket,
  };
});
