'use client';

import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal, SheetEditOp, Signal } from '@pac/types';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
// ⚠️ 「换无主患者补上」要重跑一版 —— 那是换人群，⛔ 不是局部改单
import { assignmentsApi } from '@/components/plans/assignments-api';

// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks（草稿状态机是最该有回归的一段逻辑，
//   内联在 hook 里只能端到端点一遍才能验）。这里 re-export 保持下游 import 不变。
import {
  applyIncomingSheet,
  findActiveSheet,
  findToolIdx,
  intentToPrompt,
  intentToSheetOps,
  toApiMessages,
  upsertArtifact,
  type Block,
  type ChatMessage,
  type ChatStatus,
} from './chat-blocks';

export type { Artifact, Block, ChatMessage, ChatStatus, DraftState, ToolStep } from './chat-blocks';

let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;

/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';

/**
 * 从 revoke_assignment 的返回里抠出 assignmentId。
 * ⚠️ MCP 工具的返回是 `{content:[{type:'text',text:'<json>'}]}` 这种包装,
 *   形状随实现变 —— 所以**宽容解析**:能拿到就用,拿不到就不同步(⛔ 别抛错,
 *   撤销本身已经成功了,前端同步失败不该让用户看到红字)。
 */
function extractRevokedId(result: unknown): string | null {
  try {
    const raw = typeof result === 'string' ? result : JSON.stringify(result ?? '');
    const m = raw.match(/"assignmentId"\s*:\s*"([0-9a-fA-F-]{36})"/);
    return m?.[1] ?? null;
  } catch {
    return null;
  }
}



/** 听写音频上传 → 文字(后端 /assistant/transcribe → 自部署 SenseVoice,PII 不出内网)。 */
export async function transcribeAudio(blob: Blob): Promise<string> {
  const token = useAuthStore.getState().accessToken;
  const form = new FormData();
  const ext = blob.type.includes('mp4') ? 'mp4' : blob.type.includes('ogg') ? 'ogg' : 'webm';
  form.append('file', blob, `dictation.${ext}`);
  const res = await fetch(new URL('/pac/v1/assistant/transcribe', env.apiBaseUrl), {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : undefined,
    body: form,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const json = (await res.json()) as { code?: number; msg?: string; data?: { text?: string } };
  if (json.code !== 0) throw new Error(json.msg || '识别失败');
  return json.data?.text ?? '';
}

export function useAssistantChat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  /** 当前这段对话的 id（见下方 send 里那段）—— 组件重挂即换新的。 */
  const conversationIdRef = useRef<string>('');
  const [status, setStatus] = useState<ChatStatus>('idle');
  /**
   * 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
   *
   * 🔴 必须在**这一层**（2026-08-13）：会花时间的两类动作都已经不在卡片里了 ——
   *   `pending.refill` 走 HTTP 重排（几秒），`basis.set` / `cohort.*` 走
   *   `send()` 让模型重出一版（几十秒）。⛔ 局部改单（to_owner / spread / remove /
   *   expiry.set）是同步的，⛔ 不要给它们转圈：闪一下反而像出错了。
   */
  const [busyIntent, setBusyIntent] = useState<string | null>(null);
  /**
   * 默认模型 = `deepseek`（2026-08-14 由 `qwen` 改过来）。
   *
   * 助手的排版靠模型在写字的**同一条消息里**顺手调 `show_sheet` / `show_guidance`
   * （content 与 tool_calls 并存），前端按到达顺序追加块，位置由此确定。
   *
   * 🔴 **2026-08-15 撤回原来那段"能力判断"** —— 它是**伪证据**，两处推理都断：
   *   ① 「百炼文档的示例里出 tool_calls 那轮 `"content": ""` ⇒ 那一步写不了字」——
   *      示例是一次示范、不是契约；"某个示例没写字"推不出"写不了"。
   *   ② 「一旦某步没有工具调用，多步循环就结束 ⇒ 所以它只能先把工具全调完再一口气写完」——
   *      前半句对**任何模型都成立**（deepseek 也一样），而且它恰恰不推出后半句：
   *      文字+工具、文字+工具、末步只有文字，循环照样正常结束、输出照样交错。这个「所以」是空的。
   *   ⚠️ 它当时还带着 ⛔「不是提示词能改的」这种硬结论 —— 而**今天的实测正好反证**：
   *      DeepSeek 在旧提示词下也会出一模一样的形状（`[工具×3][文本]`，见 golden 的
   *      `guidance-opens-in-place`），是补了工具描述里那句「可动手的事**一件一件**开放」
   *      才稳住的。⇒ 8-14 那次观察从没把「模型能力」和「提示词缺口」分开。
   *
   * 🔴 **而且它早就被验过了 —— 结论相反。**（产品指出，2026-08-15）
   *   `assistant-lab`（`lab.controller.ts`）就是为这件事建的裸台，里面专门开了一个
   *   `qwenThinking` provider（只塞 `enable_thinking`，⛔ 不碰 response_format），
   *   目的写在那儿：「这一轮只是要验**开了思考 qwen 会不会好起来**」。
   *   **产品实测结果：开了思考的 qwen 能边写正文边调工具。**
   *   ⇒ 「结构上做不到」这条**已被推翻**，⛔ 别再引用。
   *
   * ⚠️ 那条被推翻的结论**当时就有反证摆在旁边**：同一天的注释写着
   *   「共用 provider 无条件给每个 qwen 请求塞 `enable_thinking = false`，连 agent 这条
   *   带工具的链路也塞 ⇒ 这几天拿 qwen 跟 DeepSeek 比'要不要边写边调工具'，其实是
   *   **「便宜档 + 会思考」对「最高档 + 不许思考」**，比较本身不成立」——
   *   而"这几天"正是本条结论的产出期。两份注释直接打架，日期都是 2026-08-14。
   *
   * ⇒ 现在只保留一句能站住的：**默认是 deepseek，因为跑批基线都在它上面测的**。
   *   ⛔ 别把它写成"另一个不行"。要比就在裸台上开着思考比，或
   *   `GOLDEN_MODEL=qwen3.8-max` 跑 `guidance-opens-in-place` 比通过率。
   * ⚠️ 裸台那次的**具体数没落进代码**（只有机制和开关）—— 这正是这次出错的根：
   *   ⛔ 结论只留在人脑子里，而被它推翻的旧结论却用硬语气写在代码里，
   *   下一个人（包括 AI）读到的必然是错的那份。
   *
   * ⚠️ 下面那条**退化保护**（无根 → 挪到正文之后 + 补小标题）**照旧留着**：
   *   它防的是"按钮出现在解释之前"这个现象本身，与是哪个模型造成的无关 ——
   *   今天证明 deepseek 也会。
   * ⛔ 别为了"两个模型看起来一样"再往 `assistant-chat` 的排版里加分支 ——
   *   2026-08-14 已经为此翻过一次车（历史里留位置空壳，模型把标记当正文写了出来）。
   */
  const [model, setModel] = useState('deepseek');
  const abortRef = useRef<AbortController | null>(null);
  /**
   * 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。
   * ⛔ 别把这类读取塞进 `setMessages` 的 updater：StrictMode 会把 updater 跑两次，
   *   写在里面的副作用（如重排接口）就会**发两遍**。
   */
  const messagesRef = useRef<ChatMessage[]>([]);
  messagesRef.current = messages;

  /**
   * ⭐ **这一轮结束了，把还在跑的那几步落定**（2026-08-15 对抗审查抓到）。
   *
   * `running` 只有 `tool_result` / `tool_error` 两个事件能收尾。点「停止」或连接断掉时
   * 这两个事件都不会再来，于是那一行**永远写着「进行中…」**，而这一轮早已结束
   * （GeneratingHint 跟着 streaming 一起消失）。主管看到的是「答完了，但它好像还在查」。
   * ⚠️ 落成 `aborted` **不是** `error`：服务端那次调用可能已经跑完了，我们只是不再听 ——
   *   替它宣布失败，主管会以为那批没出，转身再发一遍（见 ToolStep 上那段）。
   * ⚠️ 在 `finally` 里调，正常收尾也走一次：那时没有 running，是个空操作。
   */
  const settleRunningSteps = useCallback(() => {
    setMessages((prev) =>
      prev.map((m) => ({
        ...m,
        blocks: m.blocks.map((b) =>
          b.kind === 'tool' && b.step.status === 'running'
            ? { ...b, step: { ...b.step, status: 'aborted' as const } }
            : b,
        ),
      })),
    );
  }, []);

  const stop = useCallback(() => {
    abortRef.current?.abort();
    abortRef.current = null;
    setStatus('idle');
  }, []);

  const send = useCallback(
    async (text: string) => {
      const trimmed = text.trim();
      if (!trimmed || status === 'streaming') return;

      const userMsg: ChatMessage = {
        id: nextId(),
        role: 'user',
        blocks: [{ kind: 'text', text: trimmed }],
      };
      const assistantId = nextId();
      const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', blocks: [] };

      /**
       * 历史(含新 user)→ apiMessages：按**结构**回传（真的 tool-call / tool-result 消息）。
       * ⛔ 不压平成文本 —— 沿革与三次事故见 `toApiMessages` 上面那段。
       */
      const apiMessages = [...messages, userMsg].flatMap(toApiMessages);

      /**
       * ⭐ 这一串对话的 id —— **messages 为空就是一段新对话的第一句**，此刻现生成；
       *   此后每轮原样带上，服务端拿它把同一次对话的各轮串成一组。
       *
       * 🔴 助手没有"新建会话"（产品定），会话跟着他此刻在做的那件事走 ——
       *   离开工作台、组件重挂，messages 归空，下一句就是新的一段。
       * ⚠️ 只用于**归组**，⛔ 不参与鉴权取数；服务端非 uuid 一律丢弃。
       */
      if (messages.length === 0) conversationIdRef.current = crypto.randomUUID();

      setMessages((prev) => [...prev, userMsg, assistantMsg]);
      setStatus('streaming');
      emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)

      // 更新 assistant 消息 blocks 的小工具
      const patch = (fn: (blocks: Block[]) => Block[]) =>
        setMessages((prev) =>
          prev.map((m) => (m.id === assistantId ? { ...m, blocks: fn(m.blocks) } : m)),
        );

      /**
       * 文本 / 思考的增量追加 —— **追加到最后一个同类块**，被别的东西打断就新起一段。
       *
       * ⚠️ 这就是"时序"的全部实现：块的先后 = 事件到达的先后，⛔ 前端不重排。
       *   所以「思考 → 调工具 → 写字 → 再调」在消息里长什么样，就是它真实发生的顺序。
       */
      const appendStream = (kind: 'text' | 'reasoning', t: string) =>
        patch((blocks) => {
          const last = blocks[blocks.length - 1];
          if (last && last.kind === kind) {
            return [...blocks.slice(0, -1), { kind, text: last.text + t }];
          }
          return [...blocks, { kind, text: t }];
        });
      const appendText = (t: string) => appendStream('text', t);

      const onEvent = (evt: Record<string, unknown>) => {
        switch (evt.type) {
          case 'text':
            appendText((evt.text as string) ?? '');
            break;
          case 'reasoning':
            appendStream('reasoning', (evt.text as string) ?? '');
            break;
          case 'assignment_sheet':
            // ⭐ 侧信道推来的确认单:upsert 它 + 把此前所有 active 的作废。
            //   逻辑在 chat-blocks.applyIncomingSheet(纯函数,有回归测试)。
            setMessages((prev) =>
              applyIncomingSheet(prev, {
                requestId: String(evt.requestId ?? ''),
                sheet: evt.sheet as AssignmentProposal,
                hostMessageId: assistantId,
              }),
            );
            break;
          case 'assignment_sheet_edit':
            // ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
            //
            // 🔴 必须**跨消息**找,⛔ 不能用 patch(那只改当前这条助手消息)——
            //   确认单几乎总是在**上一轮**的消息里(主管看着它才提的要求),
            //   只在当前消息里找永远找不到,而且不会报错、界面毫无反应。
            // ⭐ 找的是**唯一那张 `active`**,⛔ 不再是"最后一张"(2026-08-12)——
            //   重出一版之后,最后一张可能正是刚作废的那张,改上去主管一点反应都看不到。
            //   模型手里没有 requestId(服务端铸的,没进它上下文),它说"这批"指的就是当前有效那张。
            setMessages((prev) => {
              const at = findActiveSheet(prev);
              if (!at) return prev;
              const { messageIndex: mi, blockIndex: bi } = at;
              const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
              const next: Block = {
                ...b,
                edits: [
                  ...(b.edits ?? []),
                  { seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as SheetEditOp[] },
                ],
              };
              return prev.map((m, k) =>
                k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
              );
            });
            break;
          case 'assignment_sheet_anchor':
            // ⭐ 模型说「卡片摆这」—— 追加一个位置标记到当前这条消息。
            //   ⚠️ 一条消息只留一个：模型重复调用不该把卡片渲染两遍。
            patch((blocks) =>
              blocks.some((b) => b.kind === 'sheet_anchor')
                ? blocks
                : [...blocks, { kind: 'sheet_anchor' as const }],
            );
            break;
          case 'assignment_guidance': {
            /**
             * ⭐ 模型说「这条的按钮插在这」—— `patch` 追加到**当前正在写的**这条消息末尾,
             *   也就是它刚写完的那句话后面。位置由此确定，⛔ 前端不去猜。
             *
             * 🔴 2026-08-14：`signalKey` 现在由**服务端解析好**再推过来。
             *   在此之前模型传的是序号，前端要按 `filter(tier==='action')[idx-1]` 还原 ——
             *   两边各解析一次、过滤顺序必须永远一致，错了还**不报错**（按钮插到别人下面）。
             *   ⇒ 一边解析，一边用。⚠️ key 仍然不进模型上下文（侧信道不经过模型）。
             * ⚠️ 拿不到就**丢弃**：那几条会照旧显示在卡片上，⛔ 不会消失。
             */
            const key = String(evt.signalKey ?? '');
            setMessages((prev) => {
              if (!key) return prev;
              return prev.map((m) =>
                m.id === assistantId
                  ? // 同一条只插一次 —— 模型重复调用不该长出两排按钮
                    m.blocks.some((b) => b.kind === 'guidance' && b.signalKey === key)
                    ? m
                    : { ...m, blocks: [...m.blocks, { kind: 'guidance' as const, signalKey: key }] }
                  : m,
              );
            });
            break;
          }
          case 'artifact_html':
            // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
            patch((blocks) =>
              upsertArtifact(blocks, String(evt.id ?? ''), { html: String(evt.html ?? ''), streaming: true }),
            );
            break;
          case 'tool_call':
            if (evt.tool === ARTIFACT_TOOL) {
              // 渲染工具最终入参:覆盖为完整 html + title,标记 streaming=false(触发图表脚本执行)。
              const a = (evt.args ?? {}) as { title?: string; html?: string };
              patch((blocks) =>
                upsertArtifact(blocks, String(evt.id ?? nextId()), {
                  title: a.title,
                  html: a.html,
                  streaming: false,
                }),
              );
              break;
            }
            patch((blocks) => [
              ...blocks,
              {
                kind: 'tool',
                step: {
                  id: nextId(),
                  callId: evt.id ? String(evt.id) : undefined,
                  tool: String(evt.tool ?? '工具'),
                  args: evt.args,
                  status: 'running',
                },
              },
            ]);
            break;
          case 'tool_result':
            /**
             * ⭐ 助手撤销整批时,**把卡片也切到撤销态**。
             *
             * 🔴 不同步的话主管会看到两句互相矛盾的话:卡片写着「已分配」,
             *   助手说「已撤回 12 条」—— 这正是整个设计一直在防的"两处不一致听谁的"。
             * ⚠️ revoke_assignment 是 **MCP 工具**,推不了侧信道 ——
             *   但前端本来就收得到它的 tool_result,拿这个当同步信号,不必改架构。
             * ⚠️ 按 assignmentId 精确匹配,⛔ 别改"最后一张":主管可能连着分了两批,
             *   撤的是前一批。
             */
            if (evt.tool === 'revoke_assignment') {
              const rid = extractRevokedId(evt.result);
              if (rid) {
                setMessages((prev) =>
                  prev.map((m) => ({
                    ...m,
                    blocks: m.blocks.map((b) =>
                      b.kind === 'assignment_sheet' && b.assignmentId === rid
                        ? { ...b, state: 'cancelled' as const }
                        : b,
                    ),
                  })),
                );
              }
            }
            patch((blocks) => {
              const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
              if (realIdx === -1) return blocks;
              const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
              const updated: Block = {
                kind: 'tool',
                step: { ...b.step, result: evt.result, status: 'done' },
              };
              return blocks.map((x, i) => (i === realIdx ? updated : x));
            });
            break;
          case 'tool_error':
            patch((blocks) => {
              const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
              if (realIdx === -1) return blocks;
              const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
              const updated: Block = {
                kind: 'tool',
                step: { ...b.step, status: 'error', error: String(evt.error ?? '工具调用出错') },
              };
              return blocks.map((x, i) => (i === realIdx ? updated : x));
            });
            break;
          case 'step_limit':
            /**
             * 🔴 单轮工具步数到顶（服务端 stopWhen 截断）。
             *
             * ⚠️ 必须让主管看见：模型可能停在「我先查一下」之后就没了下文，
             * 而他以为查完了 —— 这跟「说了已撤销但没调工具」是同一类静默失败。
             * ⚠️ 只陈述事实 + 给一个明确的下一步，⛔ 不自动续跑（续不续是他的决定）。
             */
            appendText(
              `\n\n⚠️ **这一轮查到上限了**（${String(evt.limit ?? '')} 步），助手可能还没查完。` +
                `若上面的回答不完整，请说一句「接着查」。`,
            );
            break;
          case 'error':
            appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
            break;
          default:
            break;
        }
      };

      /**
       * 🔴🔴 **对账:说撤销了,就必须真调过工具。**
       *
       * 2026-08-03 实测(通义千问):主管说「撤销批次 #a7e1b6de」,助手回
       * 「已撤销批次:收回 9 条。」—— 而库里那批仍是 confirmed,9 条工单**一条没动**,
       * 卡片也还写着「已分配」。它**根本没调 revoke_assignment**,把成品话术**背了出来**。
       * (工具的返回值被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。)
       *
       * 这比报错更危险:报错主管会重试,而"假成功"会让他**停止补救** ——
       * 他以为 9 个人回池了,实际还挂在客服名下,而 3 天后到期才会被发现。
       *
       * ⚠️ 这里**只陈述一个观察到的事实**(本轮没调过撤销工具),⛔ 不断言助手在撒谎 ——
       *   主管问「刚才那批撤了吗」时,助手如实回顾**也**不会调工具,那句回顾是对的。
       *   两种情形界面分不出来,所以两种都说得通的措辞才是诚实的。
       * ⚠️ 因此还要求**本轮主管确实在要求撤销**,把纯回顾挡在外面。
       * ⚠️ 判据用**工具事件**而不是"卡片状态",因为卡片可能压根不在本轮消息里。
       * ⛔ 不要改成"帮他补调一次工具" —— 撤销是写操作,不能由界面替主管做决定。
       */
      const assertRevokeActuallyHappened = () => {
        // 主管这一轮是不是在**要求**撤销(而不是在问结果)
        if (!/撤销|撤回|收回/.test(trimmed)) return;
        setMessages((prev) => {
          const last = prev[prev.length - 1];
          if (!last || last.role !== 'assistant') return prev;
          const said = last.blocks
            .filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
            .map((b) => b.text)
            .join('');
          // 只认"已经做完"的说法;「要我撤销吗」这类询问不算
          if (!/已撤销|撤销成功|已收回|已经撤回|已撤回/.test(said)) return prev;
          const called = last.blocks.some(
            (b) => b.kind === 'tool' && b.step.tool === 'revoke_assignment' && b.step.status === 'done',
          );
          if (called) return prev;
          return [
            ...prev.slice(0, -1),
            {
              ...last,
              blocks: [
                ...last.blocks,
                {
                  kind: 'text' as const,
                  text:
                    '\n\n⚠️ **这一轮助手没有调用撤销工具。** 若你刚才要的是撤销,那它没有执行 ——' +
                    '这批还在客服手上,请点确认单卡片上的『撤销这批』,或再说一次「撤销刚才那批」。' +
                    '(若只是在回顾之前撤过的批次,可忽略这句。)',
                },
              ],
            },
          ];
        });
      };

      const controller = new AbortController();
      abortRef.current = controller;
      try {
        const token = useAuthStore.getState().accessToken;
        const res = await fetch(new URL('/pac/v1/assistant/chat', env.apiBaseUrl), {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Accept: 'text/event-stream',
            ...(token ? { Authorization: `Bearer ${token}` } : {}),
          },
          // ⭐ activeClinicId 走**请求体**不进模型上下文 —— 它是"主管现在看哪家"的事实,
          //   不是对话内容;让模型转译一次就多一次错的机会（见 assistant-store 的注释）。
          body: JSON.stringify({
            messages: apiMessages,
            model,
            conversationId: conversationIdRef.current,
            ...(useAssistantStore.getState().activeClinicId
              ? { activeClinicId: useAssistantStore.getState().activeClinicId }
              : {}),
            /**
             * ⭐ 眼前那张确认单**此刻**的样子 —— 同样走请求体，不进模型上下文；
             *   模型要用 `get_current_sheet` 取。
             * ⚠️ **现取现发**：主管拖一下、点个 × 它就变了，⛔ 别缓存、⛔ 别在别处存一份。
             */
            ...(useAssistantStore.getState().sheetSnapshot
              ? { sheetState: useAssistantStore.getState().sheetSnapshot }
              : {}),
            /**
             * ⭐ 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
             *
             * 🔴 他在患者详情页说「这通电话怎么开口」「这个患者为什么被召回」时，
             *   **句子里没有名字**，模型此前无从知道是谁（界面只拿姓名去预填例句文字，
             *   从没发给过它）。⇒ 上下文走旁路，⛔ 别指望模型从对话里猜。
             * ⚠️ 只发 id：姓名由工具返回，⛔ 别把界面上的显示名当事实喂给它。
             */
            ...(usePlanSyncStore.getState().current
              ? { activePatientId: usePlanSyncStore.getState().current!.patientId }
              : {}),
          }),
          signal: controller.signal,
        });
        if (!res.ok || !res.body) {
          throw new Error(`HTTP ${res.status}`);
        }

        const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
        let buffer = '';
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer += value;
          let sep: number;
          while ((sep = buffer.indexOf('\n\n')) !== -1) {
            const frame = buffer.slice(0, sep);
            buffer = buffer.slice(sep + 2);
            const line = frame.split('\n').find((l) => l.startsWith('data:'));
            if (!line) continue;
            const raw = line.slice('data:'.length).trim();
            if (!raw) continue;
            try {
              onEvent(JSON.parse(raw) as Record<string, unknown>);
            } catch {
              /* 忽略半截帧 */
            }
          }
        }
        assertRevokeActuallyHappened();
        setStatus('idle');
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          appendText(`\n\n⚠️ 连接失败:${(err as Error).message}`);
          setStatus('error');
        } else {
          setStatus('idle');
        }
      } finally {
        abortRef.current = null;
        settleRunningSteps();
        emitPetEvent({ type: 'ai_thinking_end' });
      }
    },
    [messages, model, status, settleRunningSteps],
  );

  /**
   * ⭐ 确认单点了确认之后,**必须往消息流注入一条文本块**。
   *
   * 确认是**在模型发言之外**发生的:它不是一次工具调用,所以 `toApiMessage` 的
   * 分层回传里没有它的痕迹。不注入的话,下一轮主管说「刚才那批改成 5 天」,
   * 模型手里完全没有"那批"的锚,会当成新需求重新提议一次。
   * ⚠️ P3 之后工具调用**会**回传摘要了,但**人的动作仍然要靠这里补** —— 别混为一谈。
   */
  const appendAssistantNote = useCallback(
    (text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => {
      const block: Block = { kind: 'text', text, ...(modelText ? { modelText } : {}) };
      setMessages((prev) => {
        if (prev.length === 0) return prev;
        /**
         * 🔴 **确认单落库这一句必须自成一条消息**(2026-08-06 实测)。
         *
         * 默认行为是"贴到最后一条消息的末尾" —— 对「界面回报改单结果」是对的
         * (那是模型那句话的续写)。但**主管点确认**发生在模型发言之外:
         * 贴上去会和上一条的最后一行挤成一段,渲染出来是
         * 「…确认无误就点卡片上的『确认分配』。已确认分配:修复 · 21 条 …这批还没带福利 ——」,
         * 主管扫一眼只当是上一段的尾巴,根本不觉得助手"说了话"(实测反馈:"agent 没有输出引导")。
         * ⇒ 点确认这一路走 `asNewMessage`,让它像助手新说的一句,自己一个气泡。
         */
        if (opts?.asNewMessage) {
          return [...prev, { id: nextId(), role: 'assistant', blocks: [block] }];
        }
        const last = prev[prev.length - 1]!;
        return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
      });
    },
    [],
  );

  /**
   * 「重新排一版」回来的新提案 —— **整块替换**那张确认单。
   *
   * ⚠️ 换的是整张卡:人群没变、人换了。主管在旧卡上做的删/改派对新名单没有意义,
   *   所以卡片自身的 state 也会随 key 变化重置(见下面按 requestId 换 key)。
   * ⛔ 别只替换 items:byAgent / skippedAgents / 几段话术都得跟着换,漏一个就对不上数。
   */
  const replaceSheet = useCallback((requestId: string, next: unknown) => {
    setMessages((prev) =>
      prev.map((m) => ({
        ...m,
        blocks: m.blocks.map((b) =>
          b.kind === 'assignment_sheet' && b.requestId === requestId
            ? { ...b, sheet: next as typeof b.sheet, edits: [] }
            : b,
        ),
      })),
    );
  }, []);

  /**
   * ⭐ 引导节点上的按钮被点了。
   *
   * 🔴 **和助手走同一条路**：能局部改单的翻成 `SheetEditOp[]` 压进和
   *   `edit_assignment_sheet` 同一条 edits 队列；要重跑算法的就替主管说那句话。
   *   ⛔ 别给按钮另开一条执行路径 —— 两条路各做各的必然漂，而漂了不报错。
   * ⚠️ 走「说那句话」这条时，那句话会**原样出现在对话里** —— 主管要能认出
   *   这是自己刚点的那一下，⛔ 不许做成隐藏指令（界面替他说话他就无法纠正）。
   */
  const applySignalIntent = useCallback(
    (intent: string, args?: Record<string, unknown>) => {
      /**
       * 「换无主患者补上」= 重跑一版（换人群），⛔ 不是局部改单。
       *
       * ⚠️ 2026-08-12 从确认单组件搬到这里：待分配的按钮已经不在卡片上了
       *   （它跟着模型的文案走），卡片再留一份实现就是**没人走的死代码**。
       * ⚠️ 条件必须**原样带回** —— 少带一个（口径、治疗项、时间档），重排就换了人群，
       *   而且不报错。
       */
      if (intent === 'pending.refill') {
        const at = findActiveSheet(messagesRef.current);
        if (!at) return;
        const b = messagesRef.current[at.messageIndex]!.blocks[at.blockIndex] as Extract<
          Block,
          { kind: 'assignment_sheet' }
        >;
        const s = b.sheet;
        setBusyIntent(intent);
        void assignmentsApi
          .refill({
            clinicId: s.clinicId,
            potentialTreatment: s.potentialTreatment,
            temperature: s.temperature,
            /**
             * 🔴 **他自己收窄的那几刀也要带回**（2026-08-15 补——此前整个漏了）。
             *
             * 上面那句「条件必须原样带回」写着，代码却只带了矩阵那两轴。
             * 后果：主管先「只选消费高于 ¥721 的 314 人」，再点「换无专属客服的患者补上」，
             * 服务端拿不到这一刀 → **退回整格 2,673 人重取** —— 他刚圈掉的人又回来了，
             * 而界面只显示"重新排了一版"，⛔ 不报错、⛔ 无提示。
             * ⚠️ 真源是 `narrowedBy`（提案里专门记"他追加了什么"的那个字段），
             *   ⛔ 别去 `criteria` 里翻：那里混着矩阵两轴，分不出哪些是他加的。
             */
            ...(s.narrowedBy?.personaTags ? { personaTags: s.narrowedBy.personaTags } : {}),
            ...(s.narrowedBy?.minSpendYuan ? { minSpendYuan: s.narrowedBy.minSpendYuan } : {}),
            /**
             * 🔴 **他在这一版上设的按客服精调也要带回**（2026-08-15 补）。
             *   服务端的基线读的是**上一次已确认批次**，所以不带 = 重排一下
             *   「李莉这批只给 5 条」就没了，李莉照常吃满，⛔ 而且不报错。
             * ⚠️ 发**当前这一版**的（`s.agentOverrides` 已经是基线与本轮合并后的结果），
             *   ⛔ 别只发"本轮新加的"：那样上一次沿用下来的会被当成撤销。
             */
            ...(Object.keys(s.agentOverrides ?? {}).length
              ? { agentOverrides: s.agentOverrides }
              : {}),
            targetCount: s.batchSize,
            expiresInDays: s.expiresInDays,
            // 🔴 换算尺:不带回，每位客服那列「约几天」会悄悄退回按默认 15 算
            dailyCalls: s.dailyCalls,
          })
          .then((next) => replaceSheet(b.requestId, next))
          .catch(() => appendAssistantNote('重新排版失败，这批没有改动。'))
          // ⚠️ `finally` 不能省:失败时也要把转圈停掉,否则整排按钮永久禁用
          .finally(() => setBusyIntent(null));
        return;
      }
      const ops = intentToSheetOps(intent, args);
      if (ops) {
        setMessages((prev) => {
          const at = findActiveSheet(prev);
          if (!at) return prev;
          const { messageIndex: mi, blockIndex: bi } = at;
          const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
          const next: Block = {
            ...b,
            edits: [...(b.edits ?? []), { seq: (b.edits?.length ?? 0) + 1, ops }],
          };
          return prev.map((m, k) =>
            k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
          );
        });
        return;
      }
      const text = intentToPrompt(intent, args);
      if (text) {
        // ⚠️ 这条要模型重出一版（几十秒）—— 转圈一直挂到这一轮说完
        setBusyIntent(intent);
        void send(text).finally(() => setBusyIntent(null));
      }
    },
    [send, replaceSheet, appendAssistantNote],
  );

  /**
   * 确认单落库后把卡片切到终态(按钮禁用,显示批次号)。
   *
   * ⭐ **确认之后还能做的事**(`postConfirm`)一并挂进这张单的 signals ——
   *   挂进去而不是直接塞进消息，是为了让它能**自己消失**:
   *   卡片的 `liveSignals` 会按当前状态复算(福利补上了 → 这条没主语了)，
   *   而正文里那个引导块是按 key 去查这张单的，查不到就不渲染。
   *   ⛔ 别把 signal 整个塞进引导块里图省事:那样福利补上之后，
   *   「这批还没带福利」会一直挂在那儿说一句**已经不成立的话**。
   */
  /**
   * 🔴 曾有第三个参数 `postConfirm` —— 确认成功时把「这批还没带福利」那条引导
   *   合进这张单的 signals，卡片于是长出一块黄色引导。**删于 2026-08-15**：
   *   产品定确认单里不做福利引导，确认之后要说什么归模型（提示词 ④）。
   *   ⛔ 别加回来。
   */
  const settleSheet = useCallback(
    (requestId: string, assignmentId: string) => {
      setMessages((prev) =>
        prev.map((m) => ({
          ...m,
          blocks: m.blocks.map((b) =>
            b.kind === 'assignment_sheet' && b.requestId === requestId
              ? {
                  ...b,
                  state: 'confirmed' as const,
                  assignmentId,
                }
              : b,
          ),
        })),
      );
    },
    [],
  );

  return {
    messages,
    status,
    model,
    setModel,
    send,
    stop,
    appendAssistantNote,
    settleSheet,
    replaceSheet,
    applySignalIntent,
    busyIntent,
  };
}
