'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
  Bot,
  Check,
  ChevronDown,
  LayoutTemplate,
  Loader2,
  Maximize2,
  Minimize2,
  Mic,
  Send,
  Sparkles,
  Square,
  X,
  ExternalLink,
} from 'lucide-react';
import { io, type Socket } from 'socket.io-client';
import { cn } from '@/lib/utils';
import { env } from '@/lib/env';
import { useAuthStore } from '@/stores/auth-store';
import {
  bytesToBase64,
  downsampleTo16k,
  floatToPcm16,
} from '@/components/realtime-coach/audio-utils';
import { Permission, type AssignmentProposal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import {
  useAssistantChat,
  type Artifact,
  type Block,
  type ChatMessage,
} from './use-assistant-chat';

const MODELS = [
  { value: 'deepseek', label: 'DeepSeek' },
  { value: 'qwen', label: '通义千问' },
];

// 语音输入(听写)暂时隐藏 —— 置 true 即恢复麦克风按钮(听写链路代码保留)。
const VOICE_INPUT_ENABLED = false;

/**
 * 空态开场建议 —— 调用方不传 `examples` 时的兜底(/assistant 整页就是这条路)。
 *
 * ⚠️ 按角色分,判据同占位符(PLAN_DISPATCH)。原来只有 EXAMPLES_STAFF 一份,
 *   主管打开整页助手看到的三条全是**客服**的活,分配和批次跟踪一条不提 ——
 *   而整页恰恰是他做批次活最可能待的地方(表格宽、能铺开看)。
 * ⛔ 主管那三条里点名的指标必须是工具真返回的,别许愿;口径与措辞见 assistant-widget
 *   里 leaderExamples 的注释(那份是"详情页开着某患者"时的版本)。
 */
const EXAMPLES_STAFF = [
  '今日推荐:挑几个该优先跟进的患者,并说明理由',
  '查一下患者孙柯的信息、就诊事实和画像',
  '现在有哪些潜在治疗、值得召回的患者?',
];

const EXAMPLES_LEADER = [
  '帮我给「种植 · 窗口内」这批患者出一份分配方案',
  '帮我跟踪下我分配的任务有没什么问题',
  '最近一批分配出份报表:分配数、未动过、已处理、成功、超期,退回和不成功要带原因',
  '今日推荐:挑几个该优先跟进的患者,并说明理由',
];

// ── 工具中文元信息:助手用的是内部英文工具名,界面不展示工具卡,只把"正在做什么"
//    喂给下方 loading 指示(见 MessageView)。未知工具兜底用原名。
const TOOL_META: Record<string, { label: string; running: string }> = {
  find_patient: { label: '查找患者', running: '正在查找患者' },
  get_patient_overview: { label: '读取患者概览', running: '正在读取患者概览' },
  get_persona: { label: '读取患者画像', running: '正在读取患者画像' },
  get_facts: { label: '读取关键事实', running: '正在读取关键事实' },
  get_recall_plan: { label: '读取召回计划', running: '正在读取召回计划' },
  list_recall_queue: { label: '检索召回队列', running: '正在检索召回队列' },
  recall_queue_stats: { label: '统计召回池', running: '正在统计召回池' },
  get_current_user: { label: '确认身份', running: '正在确认你的身份与权限' },
  get_agents: { label: '查在岗客服', running: '正在查在岗客服与在手负载' },
  propose_assignment: { label: '出分配方案', running: '正在挑人、排客服,出分配方案' },
  list_assignment_batches: { label: '查历史批次', running: '正在查你分过的批次' },
  get_assignment_detail: { label: '查批次详情', running: '正在查这批的执行情况' },
  // ⚠️ 这四个此前漏了 —— 兜底文案是「正在调用 get_cohort_attributes」,
  //   把内部英文工具名摆给主管看。凡是注册进 MCP 的工具都要在这里有中文说法。
  get_cohort_attributes: { label: '看这批人构成', running: '正在看这批人里各类有多少' },
  edit_assignment_sheet: { label: '改确认单', running: '正在按你说的改确认单' },
  revoke_assignment: { label: '撤销批次', running: '正在撤销这批' },
  render_artifact: { label: '出图表', running: '正在画图' },
};
function toolMeta(tool: string) {
  return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
}

/**
 * LLM 文本 → markdown 渲染(用户要求:不复用设计的结构卡,改 markdown 解析)。
 *
 * ═══ 2026-08-11 排版走查改的四件事(产品:「输出抓不住重点」)═══════════════
 * ① **段落要成块**:原来 `p` 是 `my-1.5`(6px),而行距 22px —— 段距只有行距的 1/4,
 *    段与段比行与行还紧,三段话在眼里是一坨。排版通则:段距 ≥ 1.5 倍行距。
 * ② **加粗不再身兼三职**:原来 `strong` 是 `semibold + slate-900`(字重与对比度同时跳),
 *    模型又拿它当标题使 —— 一屏 7 处黑块,等于没有重点。
 *    现在 strong 降成 `font-medium`,**标题交给 h3**(见下)。
 * ③ **标题就要比正文更重、更大、更黑**(2026-08-11 第二轮返工)。
 *    第一版把 h3 做成了 12px/slate-500 的"小节标签",结果**标题比正文还弱** ——
 *    句中那个 `font-medium` 的加粗反而比标题更抢眼,层级是反的,产品一眼就指出来了。
 *    ⇒ h3 = 14px semibold slate-900(比正文 13.5 大半号)。
 *    ⛔ 别再改回"小灰字标签":那是**卡片小节**的写法,不是**对话正文标题**的写法 ——
 *      这段文字里标题是扫视的落脚点,它必须是这一屏最重的东西。
 * ④ **数字等宽**:`tabular-nums` 让数字竖着对齐,扫视时自己跳出来。
 *    ⚠️ 曾经加过 `max-w-[34em]` 限行长(中文舒适区 25–35 字),**已撤**:
 *      助手窗口放大后文字在半路断行、右边空一大片,看起来像渲染坏了。
 *      窄窗本来就只有 ~28 字/行,行长问题只在放大时出现,而放大正是主管要"看得多"的时候。
 *      ⛔ 要限行长就得连用户气泡一起收进一个居中的对话栏(整块布局的事),
 *      别只勒住助手这一半。
 * ⛔ 别把这些值改回去而不改提示词:样式与「模型输出什么结构」是一对
 *    (见 assistant-prompts 的「怎么写」一节)。
 */
function Markdown({ text }: { text: string }) {
  /**
   * ⚠️ 容器上那三条 `[&_hN+*]:mt-1.5` 不是装饰,是**外边距折叠**的必然结果:
   *   标题的 `mb`(6px)与紧随其后那一段的 `mt`(14px)会**折叠成较大的那个** ——
   *   于是"标题↔正文"的缝跟"段↔段"一样宽,标题看着像浮在半空(产品指出来的那处)。
   * ⇒ 只把**紧跟标题**的那个块的上边距压到 6px;段与段之间仍是 14px。
   * ⛔ 别改 `p` 的 my 去凑:那会把所有段落的间距一起缩掉,段落又不成块了。
   */
  return (
    <div className="text-[13.5px] leading-relaxed text-slate-700 [font-variant-numeric:tabular-nums] [&_h1+*]:mt-1.5 [&_h2+*]:mt-1.5 [&_h3+*]:mt-1.5">
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        components={{
          h1: ({ children }) => <h1 className="mb-1.5 mt-5 text-[15px] font-semibold text-slate-900 first:mt-0">{children}</h1>,
          h2: ({ children }) => <h2 className="mb-1.5 mt-5 text-[14.5px] font-semibold text-slate-900 first:mt-0">{children}</h2>,
          /**
           * ⭐ h3 是**模型实际会用的那一档**(提示词让它用 `###` 分块)。
           * 比正文大半号 + semibold + 最深的字色 —— 它是这段文字里**最重**的东西。
           * ⚠️ `mt-5` 是段落成块的另一半:标题上方的空白才是"新的一块开始了"的信号;
           *   `first:mt-0` 防止第一块顶部多出一截空白。
           * ⛔ 别做成小灰字标签(第一版就是那样,结果标题比正文还弱,句中加粗比标题更抢眼)。
           */
          h3: ({ children }) => (
            <h3 className="mb-1.5 mt-5 text-[14px] font-semibold text-slate-900 first:mt-0">
              {children}
            </h3>
          ),
          p: ({ children }) => <p className="my-3.5">{children}</p>,
          ul: ({ children }) => <ul className="my-2.5 ml-4 list-disc space-y-1.5">{children}</ul>,
          ol: ({ children }) => <ol className="my-2.5 ml-4 list-decimal space-y-1.5">{children}</ol>,
          li: ({ children }) => <li className="leading-relaxed">{children}</li>,
          /// ⚠️ `font-medium` 不是 semibold:句中强调而已,⛔ 别再同时跳字重和颜色
          strong: ({ children }) => <strong className="font-medium text-slate-900">{children}</strong>,
          a: ({ children, href }) => (
            <a href={href} className="text-brand-700 underline" target="_blank" rel="noreferrer">
              {children}
            </a>
          ),
          hr: () => <hr className="my-3" />,
          blockquote: ({ children }) => (
            <blockquote className="my-2 border-l-2 border-brand-300 bg-brand-50/40 py-1 pl-3 text-slate-600">
              {children}
            </blockquote>
          ),
          code: ({ children, className }) =>
            className ? (
              <code className={className}>{children}</code>
            ) : (
              <code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-[12px] text-slate-700">
                {children}
              </code>
            ),
          pre: ({ children }) => (
            <pre className="my-2 overflow-x-auto rounded-md border bg-slate-50 p-3 text-[12px]">
              {children}
            </pre>
          ),
          table: ({ children }) => (
            <div className="my-2 overflow-x-auto">
              <table className="w-full border-collapse text-[12.5px]">{children}</table>
            </div>
          ),
          thead: ({ children }) => <thead className="bg-slate-50">{children}</thead>,
          th: ({ children }) => (
            <th className="border px-2 py-1 text-left font-medium text-slate-600">{children}</th>
          ),
          td: ({ children }) => <td className="border px-2 py-1 text-slate-700">{children}</td>,
        }}
      >
        {text}
      </ReactMarkdown>
    </div>
  );
}

function BlockView({
  block,
  onSheetConfirmed,
  onSheetEdited,
  onSignalIntent,
  onSheetReplaced,
}: {
  block: Block;
  onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
  /** 助手改确认单后,界面回报的那一句(注入消息流) */
  onSheetEdited?: (summary: string) => void;
  /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
  onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
  /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent（见 chat-blocks.intentToSheetOps） */
  onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
}) {
  // 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
  if (block.kind === 'tool') return null;
  if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
  if (block.kind === 'assignment_sheet') {
    return (
      <AssignmentConfirmSheet
        requestId={block.requestId}
        sheet={block.sheet}
        state={block.state}
        assignmentId={block.assignmentId}
        edits={block.edits}
        // 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
        // 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
        onEditApplied={onSheetEdited}
        onIntent={onSignalIntent}
        onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)}
        onConfirmed={(id, summary, modelSummary) =>
          onSheetConfirmed?.(block.requestId, id, summary, modelSummary)
        }
      />
    );
  }
  return <Markdown text={block.text} />;
}

// ── Artifact:模型产出的 HTML 在隔离沙箱 iframe 渲染(即用即焚)──────────────
// 安全:sandbox="allow-scripts" 不含 allow-same-origin → 脚本在 null origin,
//   碰不到父页 cookie/localStorage、无法带凭证调 PAC;CSP 只放行 Tailwind/Chart.js CDN
//   且 connect-src 'none' 堵死任何外传(患者数据不会被偷渡)。
const ARTIFACT_CSP = [
  "default-src 'none'",
  "script-src 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net",
  "style-src 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com",
  'font-src https://fonts.gstatic.com',
  'img-src data:',
  "connect-src 'none'",
  "base-uri 'none'",
  "form-action 'none'",
].join('; ');

const ARTIFACT_HEAD = `<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>body{margin:0;font-family:"PingFang SC","Noto Sans CJK SC",system-ui,sans-serif;background:#f8fafc;color:#0f172a}
/* 切断 Chart.js 响应式 ↔ 自适应 iframe 高度的正反馈环:画布层高写死上限,
   无论模型用 maintainAspectRatio:false 还是百分比容器,canvas 都不会随 body 增高而无限拉长。*/
canvas{max-height:360px!important}</style>`;

// 持久壳:iframe 只加载一次(Tailwind/Chart.js 不重载),内容经 postMessage 注入 #root。
// 流式中只 set innerHTML(Tailwind 的 MutationObserver 自动上样式);final 时重建 <script> 让 Chart.js 执行。
function artifactShell(): string {
  return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4"><div id="root"></div>
<script>
(function(){
  var root=document.getElementById('root');
  function h(){parent.postMessage({__artifactHeight:document.documentElement.scrollHeight},'*');}
  new ResizeObserver(h).observe(document.body);
  window.addEventListener('message',function(e){
    var d=e.data||{}; if(!d.__artifact)return;
    root.innerHTML=d.html||'';
    if(d.final){root.querySelectorAll('script').forEach(function(o){var s=document.createElement('script');for(var i=0;i<o.attributes.length;i++){s.setAttribute(o.attributes[i].name,o.attributes[i].value);}s.textContent=o.textContent;o.parentNode.replaceChild(s,o);});}
    setTimeout(h, d.final?150:0);
  });
  parent.postMessage({__ready:true},'*');
})();
</script>
</body></html>`;
}

// openFull / 兜底:自包含整页(内容内联,脚本随页面加载执行)。
function buildStandaloneDoc(inner: string): string {
  return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4">${inner}</body></html>`;
}

/// 单个 artifact 的沙箱 iframe 渲染器(持久壳 + postMessage 增量注入;可多实例:
/// 内联卡 + 居中放大窗同时挂,各自吃同一份流式 html → 放大窗也实时"长")。
/// autoHeight:内联卡随内容增高;放大窗 false(锁满容器,内部滚动)。
function ArtifactFrame({
  artifact,
  autoHeight,
  className,
}: {
  artifact: Artifact;
  autoHeight: boolean;
  className?: string;
}) {
  const ref = useRef<HTMLIFrameElement>(null);
  const [height, setHeight] = useState(120);
  const shell = useMemo(() => artifactShell(), []);
  const readyRef = useRef(false);
  const htmlRef = useRef(artifact.html);
  const finalRef = useRef(!artifact.streaming);
  const lastPostRef = useRef(0);

  const post = () => {
    const win = ref.current?.contentWindow;
    if (!win || !readyRef.current) return;
    win.postMessage({ __artifact: true, html: htmlRef.current, final: finalRef.current }, '*');
    lastPostRef.current = Date.now();
  };

  useEffect(() => {
    htmlRef.current = artifact.html;
    finalRef.current = !artifact.streaming;
    if (!readyRef.current) return;
    if (finalRef.current) {
      post();
      return;
    }
    const since = Date.now() - lastPostRef.current;
    if (since >= 500) {
      post();
      return;
    }
    const t = setTimeout(post, 500 - since);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [artifact.html, artifact.streaming]);

  useEffect(() => {
    const onMsg = (e: MessageEvent) => {
      if (e.source !== ref.current?.contentWindow) return;
      const d = e.data as { __ready?: boolean; __artifactHeight?: number };
      if (d?.__ready) {
        readyRef.current = true;
        post();
      }
      if (autoHeight && typeof d?.__artifactHeight === 'number') {
        setHeight(Math.min(Math.max(d.__artifactHeight, 60), 1600));
      }
    };
    window.addEventListener('message', onMsg);
    return () => window.removeEventListener('message', onMsg);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [autoHeight]);

  return (
    <iframe
      ref={ref}
      title={artifact.title || 'artifact'}
      sandbox="allow-scripts"
      srcDoc={shell}
      className={cn('w-full', className)}
      style={autoHeight ? { height, border: 0 } : { border: 0 }}
    />
  );
}

function ArtifactView({ artifact }: { artifact: Artifact }) {
  const [expanded, setExpanded] = useState(false);

  const openTab = () => {
    const w = window.open('', '_blank');
    w?.document.write(buildStandaloneDoc(artifact.html));
    w?.document.close();
  };

  return (
    <div className="overflow-hidden rounded-lg border bg-white shadow-sm">
      <div className="flex items-center gap-1.5 border-b bg-slate-50/60 px-3 py-1.5">
        <LayoutTemplate className="h-3.5 w-3.5 text-brand-600" />
        <span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span>
        <button
          type="button"
          onClick={() => setExpanded(true)}
          title="放大查看"
          className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] text-slate-400 hover:bg-slate-100 hover:text-slate-600"
        >
          <Maximize2 className="h-3 w-3" />
        </button>
      </div>
      <ArtifactFrame artifact={artifact} autoHeight />

      {/* 居中放大窗:与内联卡同源同流(streaming 时这里同步在"长")。
          ⚠️ portal 到 body —— 助手小窗容器带 transform(滑出动画),CSS 规定 transform 祖先
          会把后代 fixed 圈进自己坐标系 → 不 portal 大窗就被困在小窗里(2026-06 实测)。 */}
      {expanded &&
        typeof document !== 'undefined' &&
        createPortal(
        <div className="fixed inset-0 z-[70] flex items-center justify-center p-4">
          <div className="absolute inset-0 bg-black/40" onClick={() => setExpanded(false)} />
          <div className="relative z-10 flex h-[min(86vh,920px)] w-[min(980px,94vw)] flex-col overflow-hidden rounded-xl border bg-white shadow-2xl">
            <div className="flex flex-none items-center gap-1.5 border-b bg-slate-50/60 px-3 py-2">
              <LayoutTemplate className="h-4 w-4 text-brand-600" />
              <span className="text-[12.5px] font-medium text-slate-600">{artifact.title || '可视化卡片'}</span>
              {artifact.streaming && (
                <span className="ml-1 inline-flex items-center gap-1 text-[10.5px] text-brand-600">
                  <Loader2 className="h-3 w-3 animate-spin" />
                  生成中
                </span>
              )}
              <button
                type="button"
                onClick={openTab}
                title="新窗口打开"
                className="ml-auto inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600"
              >
                <ExternalLink className="h-3.5 w-3.5" />
              </button>
              <button
                type="button"
                onClick={() => setExpanded(false)}
                title="关闭"
                className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-600"
              >
                <X className="h-4 w-4" />
              </button>
            </div>
            <ArtifactFrame artifact={artifact} autoHeight={false} className="min-h-0 flex-1" />
          </div>
        </div>,
        document.body,
      )}
    </div>
  );
}

/**
 * 状态文案的**最短停留**。
 *
 * 🔴 2026-08-04 走查:文案一闪而过,根本看不清它在做什么。
 *   `get_current_user`、`get_agents` 这类工具 100ms 就返回,
 *   于是「正在确认你的身份与权限」这句在屏幕上活不过两帧 —— 主管只看到一片闪烁,
 *   反而以为界面卡了。慢工具(propose_assignment)的文案倒是看得见,
 *   所以他会以为"只有那一句文案"。
 * ⚠️ 用「最新覆盖待显示」而不是排队:排队会让文案在活干完之后还继续播,
 *   5 个快工具能拖出 3 秒假忙碌 —— 那是另一种撒谎。上限就是一个停留周期。
 */
const HINT_MIN_MS = 700;

function useStickyLabel(target: string): string {
  const [shown, setShown] = useState(target);
  const freeAt = useRef(0);
  useEffect(() => {
    if (target === shown) return;
    const wait = freeAt.current - Date.now();
    if (wait <= 0) {
      setShown(target);
      freeAt.current = Date.now() + HINT_MIN_MS;
      return;
    }
    const t = setTimeout(() => {
      setShown(target);
      freeAt.current = Date.now() + HINT_MIN_MS;
    }, wait);
    return () => clearTimeout(t);
  }, [target, shown]);
  return shown;
}

// 生成中指示:覆盖"工具运行 / 工具已返回但下一段还在生成"的空档。
// 有工具在跑 → 显示"正在xxx…"(动作);否则默认"生成中…"。
function GeneratingHint({ label = '生成中' }: { label?: string }) {
  // ⭐ 组件在整个"调工具"阶段是常驻的(只有 label 变),所以粘滞状态放这里就够,
  //   ⛔ 不必提到 MessageView —— 那里有 user 分支的提前 return,加 hook 会条件调用。
  const shown = useStickyLabel(label);
  return (
    <div className="flex items-center gap-1.5 text-[12px] text-slate-400">
      <Loader2 className="h-3.5 w-3.5 animate-spin text-brand-500" />
      <span className="animate-in fade-in duration-200">{shown}…</span>
    </div>
  );
}

function MessageView({
  message,
  streaming,
  onSheetConfirmed,
  onSheetEdited,
  onSignalIntent,
  onSheetReplaced,
}: {
  message: ChatMessage;
  streaming?: boolean;
  onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
  /** 助手改确认单后,界面回报的那一句(注入消息流) */
  onSheetEdited?: (summary: string) => void;
  /** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
  onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
  /** ⭐ 引导节点按钮 —— 与助手共用同一组 intent（见 chat-blocks.intentToSheetOps） */
  onSignalIntent?: (intent: string, args?: Record<string, unknown>) => void;
}) {
  if (message.role === 'user') {
    return (
      <div className="flex justify-end gap-2.5">
        {/* ⚠️ 主管说的话用**品牌主色**(PANTONE 286 C),⛔ 别用中性深灰:
              助手那边是白底,这一侧是唯一的实色块 —— 它就是这一屏的主色出场位。 */}
        <div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-brand-600 px-3.5 py-2 text-[13.5px] leading-relaxed text-white">
          {message.blocks.map((b) => (b.kind === 'text' ? b.text : '')).join('')}
        </div>
      </div>
    );
  }
  // 流式中:若最后一块不是正在增长的文字(即在调工具/生成 artifact 的空档),补一个 loading 指示。
  // 若最后一块是正在跑的工具 → 显示"正在xxx…"(动作);否则默认"生成中…"。
  const last = message.blocks[message.blocks.length - 1];
  const showHint = streaming && (!last || last.kind !== 'text');
  // ⚠️ 找**还在跑的**工具,⛔ 不能只看最后一块:模型并行调三个工具时,
  //   最后那块可能已经 done 而前两个还在跑 —— 只看最后一块就退回「生成中」,
  //   前面两句文案一次都不会出现。
  const runningTool = [...message.blocks]
    .reverse()
    .find((b): b is Extract<Block, { kind: 'tool' }> => b.kind === 'tool' && b.step.status === 'running');
  const hintLabel = runningTool ? toolMeta(runningTool.step.tool).running : '生成中';
  // ⛔ 助手消息**不带左侧头像**(2026-08-03 走查):助手窗只有 480px,
  //   每条都挂一个 28px 的圆头像 + 间距 = 白白吃掉一成宽度,而"这段是助手说的"
  //   靠气泡形态(用户消息右对齐深底、助手左对齐无底)已经分得清了。
  /**
   * ⭐ **确认单:钉在消息末尾,且要等这段话说完才出现**(2026-08-03 走查)。
   *
   * 为什么不能靠"让模型先说话"解决:工具调用**必然发生在文字之前**
   * (模型要先拿到结果才知道说什么),而卡片是工具执行时经侧信道推来的 ——
   * 到达顺序天然是"卡片 → 文字",改不了。所以两件事都在渲染层做:
   *   ① 排序:把 assignment_sheet 挪到末尾。⛔ 别整体 sort ——
   *      文字块之间的先后是流式拼出来的,打乱就成乱码。
   *   ② **流式期间先不渲染** —— 否则主管看到的是"卡片先蹦出来,文字再在它上面一行行长",
   *      比卡片在前更怪。说完了再出现,顺序才成立。
   */
  const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet');
  const ordered = [
    ...message.blocks.filter((b) => b.kind !== 'assignment_sheet'),
    ...(streaming ? [] : sheets),
  ];
  return (
    <div className="min-w-0 space-y-2.5">
      {ordered.map((b, i) => (
        <BlockView
          // 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
          //   下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本,
          //   sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**:
          //   主管删的条、拖的改派、逐条时效、以及"能不能撤销"的确认时刻,
          //   都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
          //   ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
          key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
          block={b}
          onSheetConfirmed={onSheetConfirmed}
          onSheetEdited={onSheetEdited}
          onSignalIntent={onSignalIntent}
          onSheetReplaced={onSheetReplaced}
        />
      ))}
      {showHint && <GeneratingHint label={hintLabel} />}
    </div>
  );
}

function ModelSelect({
  model,
  setModel,
  disabled,
}: {
  model: string;
  setModel: (m: string) => void;
  disabled?: boolean;
}) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const h = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, []);
  const label = MODELS.find((m) => m.value === model)?.label ?? model;
  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        disabled={disabled}
        onClick={() => setOpen((o) => !o)}
        className="inline-flex items-center gap-1.5 rounded-md border bg-white px-2.5 py-1.5 text-[12.5px] text-slate-700 transition-colors hover:bg-slate-50 disabled:opacity-50"
      >
        <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
        {label}
        <ChevronDown className={cn('h-3.5 w-3.5 text-slate-400 transition-transform', open && 'rotate-180')} />
      </button>
      {open && (
        <div className="absolute right-0 z-50 mt-1 w-40 rounded-lg border bg-white py-1 shadow-lg">
          {MODELS.map((m) => (
            <button
              key={m.value}
              type="button"
              onClick={() => {
                setModel(m.value);
                setOpen(false);
              }}
              className={cn(
                'flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] hover:bg-slate-50',
                m.value === model ? 'font-medium text-brand-700' : 'text-slate-700',
              )}
            >
              {m.value === model ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
              {m.label}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

export function AssistantChat({
  variant = 'page',
  onClose,
  onToggleMaximize,
  maximized = false,
  examples,
}: {
  /** page = /assistant 整页;widget = 详情页右下角吸附小窗(紧凑头部 + 可收起) */
  variant?: 'page' | 'widget';
  onClose?: () => void;
  /** widget:最大化/还原切换(不传则不显示最大化按钮) */
  onToggleMaximize?: () => void;
  /** widget:当前是否最大化(控制图标 Maximize2↔Minimize2) */
  maximized?: boolean;
  /** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */
  examples?: string[];
} = {}) {
  const {
    messages,
    status,
    model,
    setModel,
    send,
    stop,
    appendAssistantNote,
    settleSheet,
    replaceSheet,
    applySignalIntent,
  } = useAssistantChat();

  /**
   * 占位符按**角色**分。
   *
   * ⚠️ 原来只有一句「问关于患者画像 / 事实 / 召回计划的问题」—— 那是**客服**的活。
   *   主管打开助手是来分配和看批次跑得怎么样的,占位符从不提这两件事,
   *   等于把他能用的一半能力藏起来了(T16:主管本质也是客服,所以两件事都要留)。
   * ⚠️ 判据用 `PLAN_DISPATCH` —— 与召回池 / 确认单同一个闸,⛔ 别另立标准。
   */
  const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
  /**
   * ⚠️ 两句都**去掉了「(Enter 发送,Shift+Enter 换行)」** —— 助手窗的输入框只有 300px,
   *   而原来那句连提示带正文 450px,**那半句从来就没显示出来过**(实测量的)。
   *   占着长度却看不见,还挤掉了真正该说的"你能让我干什么"。
   *   换行键这种约定靠试一次就知道,⛔ 不值得用唯一一行提示位去讲。
   */
  const placeholder = canDispatch
    ? '让我出一批分配方案,或问某批分得怎么样…'
    : '问患者画像 / 事实 / 召回计划…';

  /**
   * 确认单落库成功。两件事一个都不能少:
   *   ① 卡片切终态(按钮禁用 + 显示批次号)
   *   ② ⭐ **往消息流注入一条文本块** —— `toApiMessage` 只回传文本块,
   *      不注入的话下一轮主管说「刚才那批改成 5 天」,模型手里没有"那批"的任何痕迹,
   *      会当成新需求重新提议一次。改动极小但极易漏,漏了会被当成模型能力问题。
   */
  const onSheetConfirmed = (
    requestId: string,
    assignmentId: string,
    summary: string,
    modelSummary?: string,
  ) => {
    settleSheet(requestId, assignmentId);
    // ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
    // ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写,
    //   他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。
    appendAssistantNote(summary, modelSummary, { asNewMessage: true });
  };
  /**
   * 助手改完确认单,界面把**实际结果**回一句进消息流。
   *
   * ⭐ 理由同上:模型看不到卡片。它只知道"指令已交给界面",成没成、有没有人没找到,
   *   全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
   */
  const onSheetEdited = (summary: string) => appendAssistantNote(summary);
  /** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
  const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
    replaceSheet(requestId, next);
  const [input, setInput] = useState('');
  const scrollRef = useRef<HTMLDivElement>(null);
  const taRef = useRef<HTMLTextAreaElement>(null);
  // 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。
  const stickRef = useRef(true);
  /// ⭐ 同一件事的**可渲染**副本 —— ref 改了不触发重渲染,而"回到底部"按钮要跟着显隐。
  ///   ⚠️ 两者必须在同一处更新(见 onScroll),分开写必然漂。
  const [atBottom, setAtBottom] = useState(true);

  // 流式/新消息时,仅在贴底状态才滚到底(否则尊重用户当前阅读位置)。
  useEffect(() => {
    const el = scrollRef.current;
    if (el && stickRef.current) el.scrollTop = el.scrollHeight;
  }, [messages]);

  // 用户滚动 → 据是否接近底部(<80px)更新贴底状态。
  const onScroll = () => {
    const el = scrollRef.current;
    if (!el) return;
    const near = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
    stickRef.current = near;
    setAtBottom(near);
  };
  const toBottom = () => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
    stickRef.current = true;
    setAtBottom(true);
  };

  // 发起新提问 → 重新贴底(让用户看到自己的消息 + 回答)。
  const fire = (text: string) => {
    stickRef.current = true;
    void send(text);
  };

  // ⭐ 外部投递的提问(「移交助手」等业务入口)—— 见 assistant-store。
  //   ⚠️ 只在 widget 变体里消费:widget 常驻挂载,而 /assistant 整页可能同时开着,
  //   两处都消费会把同一句话发两遍。
  //   ⚠️ 依赖只写 pending?.seq:写整个 pending 对象会因引用变化在 StrictMode 下重复触发。
  const pending = useAssistantStore((st) => st.pending);
  const consumePending = useAssistantStore((st) => st.consume);
  const pendingSeq = pending?.seq;
  useEffect(() => {
    if (variant !== 'widget' || !pending) return;
    if (status === 'streaming') return; // 上一轮还在跑,等它完了再发,别打断
    fire(pending.text);
    consumePending(pending.seq);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pendingSeq, status, variant]);

  const submit = () => {
    if (!input.trim() || status === 'streaming') return;
    fire(input);
    setInput('');
    if (taRef.current) taRef.current.style.height = 'auto';
  };

  // ── 实时听写:点击开启 → 边说边出字到输入框;再点 = 纯退出(已上屏文字保留)──
  // 链路:麦克风 PCM16 16k(RMS 静音门控)→ socket.io → 网关按停顿断句调 SenseVoice
  //   dictation:partial = 当前句滚动识别(覆盖式);dictation:final = 本句定稿(累加)
  const [voice, setVoice] = useState<'idle' | 'live'>('idle');
  const [voiceErr, setVoiceErr] = useState<string | null>(null);
  const dictRef = useRef<{
    socket: Socket;
    stream: MediaStream;
    ctx: AudioContext;
    base: string;
    finalized: string;
  } | null>(null);

  const stopVoice = () => {
    const d = dictRef.current;
    dictRef.current = null;
    if (d) {
      d.socket.disconnect();
      d.ctx.close().catch(() => undefined);
      d.stream.getTracks().forEach((t) => t.stop());
    }
    setVoice('idle');
  };

  const toggleVoice = async () => {
    setVoiceErr(null);
    if (voice === 'live') {
      stopVoice();
      return;
    }
    const token = useAuthStore.getState().accessToken;
    if (!token) {
      setVoiceErr('未鉴权,请重新登录');
      return;
    }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
      });
      const socket = io(`${env.apiBaseUrl}/pac/v1/assistant/dictation`, {
        query: { token },
        transports: ['websocket'],
        forceNew: true,
      });
      const ctx = new AudioContext();
      const source = ctx.createMediaStreamSource(stream);
      const proc = ctx.createScriptProcessor(4096, 1, 1);
      // 静音门控:无声不发帧 → 帧间隙就是网关的断句信号(同 realtime-coach 参数)
      const SILENCE_RMS = 0.03;
      const ONSET_FRAMES = 2;
      const HANGOVER_FRAMES = 6;
      let hangover = 0;
      let loudStreak = 0;
      proc.onaudioprocess = (e) => {
        if (socket.disconnected) return;
        const f32 = e.inputBuffer.getChannelData(0);
        let sum = 0;
        for (let i = 0; i < f32.length; i++) sum += f32[i]! * f32[i]!;
        const rms = Math.sqrt(sum / f32.length);
        if (rms > SILENCE_RMS) {
          loudStreak++;
          if (loudStreak >= ONSET_FRAMES) hangover = HANGOVER_FRAMES;
        } else {
          loudStreak = 0;
        }
        if (hangover <= 0) return;
        hangover--;
        const pcm = floatToPcm16(downsampleTo16k(f32, ctx.sampleRate));
        socket.emit('audio:frame', { frame: bytesToBase64(pcm) });
      };
      source.connect(proc);
      proc.connect(ctx.destination);

      const d = { socket, stream, ctx, base: input.trim() ? `${input.trimEnd()} ` : '', finalized: '' };
      dictRef.current = d;
      const render = (partial: string) => setInput(`${d.base}${d.finalized}${partial}`);
      socket.on('dictation:partial', ({ text }: { text: string }) => render(text));
      socket.on('dictation:final', ({ text }: { text: string }) => {
        if (text) d.finalized += text;
        render('');
      });
      socket.on('dictation:error', ({ message }: { message: string }) => setVoiceErr(message));
      socket.on('connect_error', () => {
        setVoiceErr('听写连接失败');
        stopVoice();
      });
      setVoice('live');
      taRef.current?.focus();
    } catch {
      setVoiceErr('无法访问麦克风(请检查浏览器授权)');
      stopVoice();
    }
  };

  const isWidget = variant === 'widget';
  return (
    <div className={cn('flex flex-col bg-slate-50', isWidget ? 'h-full' : 'h-[100dvh]')}>
      {/* Header */}
      <header className="flex-none border-b bg-white/90 backdrop-blur">
        <div
          className={cn(
            'mx-auto flex items-center gap-2.5',
            isWidget ? 'h-10 px-2.5' : 'h-[3.25rem] max-w-3xl px-4 py-2.5',
          )}
        >
          <span
            className={cn(
              'inline-flex flex-none items-center justify-center rounded-lg bg-brand-600 text-white',
              isWidget ? 'h-6 w-6' : 'h-7 w-7',
            )}
          >
            <Bot className={isWidget ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
          </span>
          <h1 className={cn('font-semibold text-slate-900', isWidget ? 'text-[13px]' : 'text-[14px]')}>
            {isWidget ? '助手' : '外部助手'}
          </h1>
          <div className="ml-auto flex items-center gap-1.5">
            <ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
            {onToggleMaximize && (
              <button
                type="button"
                onClick={onToggleMaximize}
                title={maximized ? '还原' : '最大化'}
                className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
              >
                {maximized ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
              </button>
            )}
            {onClose && (
              <button
                type="button"
                onClick={onClose}
                title="收起"
                className="inline-flex h-6 w-6 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600"
              >
                <ChevronDown className="h-4 w-4" />
              </button>
            )}
          </div>
        </div>
      </header>

      {/* Messages */}
      <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
        <div className="mx-auto max-w-3xl space-y-5 px-4 py-5">
          {messages.length === 0 ? (
            <div className={cn('flex flex-col items-center justify-center gap-4 text-center', isWidget ? 'py-8' : 'py-20')}>
              <span className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-600 text-white">
                <Bot className="h-6 w-6" />
              </span>
              <div className="max-w-md text-[13.5px] text-slate-500">
                问我关于患者的问题，我会查询 PAC 实时数据。
              </div>
              <div className="flex flex-wrap justify-center gap-1.5">
                {(examples ?? (canDispatch ? EXAMPLES_LEADER : EXAMPLES_STAFF)).map((ex) => (
                  <button
                    key={ex}
                    type="button"
                    onClick={() => fire(ex)}
                    className="rounded-full border bg-white px-3 py-1.5 text-[11.5px] text-slate-600 transition-colors hover:border-brand-300 hover:bg-brand-50/50 hover:text-brand-700"
                  >
                    {ex}
                  </button>
                ))}
              </div>
            </div>
          ) : (
            messages.map((m, i) => (
              <MessageView
                key={m.id}
                message={m}
                streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
                onSheetConfirmed={onSheetConfirmed}
                onSheetEdited={onSheetEdited}
                onSheetReplaced={onSheetReplaced}
                onSignalIntent={applySignalIntent}
              />
            ))
          )}
        </div>
      </div>

      {/* Composer —— 与会话区**同底色、无边框、无分隔线**(2026-08-03 走查,对齐 Claude Code 那种)。
          ⚠️ 底色写死 `bg-slate-50` 就会跟根容器漂;这里直接 `bg-inherit`,
          根容器换底色时它自动跟上。 */}
      <div className="relative flex-none bg-inherit">
        {/* ⭐ 「回到底部」—— 只在**没贴底**时出现,浮在输入框正上方居中(对齐 Claude Code)。
            ⚠️ 判据复用 onScroll 里那个 80px 阈值,⛔ 别再写第二个 —— 两个阈值不一致时,
            会出现"按钮还在、但其实已经到底了"这种解释不清的状态。 */}
        {!atBottom && messages.length > 0 && (
          <button
            type="button"
            onClick={toBottom}
            title="回到最新"
            className="absolute -top-4 left-1/2 z-10 inline-flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-full border bg-white text-slate-500 shadow-md transition-colors hover:bg-slate-50 hover:text-slate-700"
          >
            <ChevronDown className="h-4 w-4" />
          </button>
        )}
        <div className="mx-auto max-w-3xl px-4 pb-3 pt-1">
          {/* 输入框**有边框**(与会话同底,但自己成一块) —— 无边框那版太散,找不到落点 */}
          <div className="flex items-end gap-2 rounded-xl border bg-white px-3 py-2 transition-colors">
            <textarea
              ref={taRef}
              rows={1}
              value={input}
              onChange={(e) => {
                setInput(e.target.value);
                e.target.style.height = 'auto';
                e.target.style.height = Math.min(e.target.scrollHeight, 140) + 'px';
              }}
              onKeyDown={(e) => {
                if (e.key === 'Enter' && !e.shiftKey) {
                  e.preventDefault();
                  submit();
                }
              }}
              placeholder={placeholder}
              className="max-h-[140px] flex-1 resize-none bg-transparent py-1 text-[13.5px] leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
            />
            {VOICE_INPUT_ENABLED && (
              <button
                type="button"
                onClick={toggleVoice}
                title={voice === 'live' ? '结束听写' : '语音输入(边说边出字)'}
                className={cn(
                  'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
                  voice === 'live'
                    ? 'animate-pulse bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-300'
                    : 'bg-slate-100 text-slate-500 hover:bg-brand-50 hover:text-brand-700',
                )}
              >
                <Mic className="h-4 w-4" />
              </button>
            )}
            {status === 'streaming' ? (
              <button
                type="button"
                onClick={stop}
                title="停止"
                className="mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-slate-100 text-slate-500 transition-colors hover:bg-slate-200"
              >
                <Square className="h-3.5 w-3.5" />
              </button>
            ) : (
              <button
                type="button"
                onClick={submit}
                disabled={!input.trim()}
                title="发送"
                className={cn(
                  'mb-0.5 inline-flex h-8 w-8 flex-none items-center justify-center rounded-lg transition-colors',
                  input.trim() ? 'bg-brand-600 text-white hover:bg-brand-700' : 'bg-slate-100 text-slate-400',
                )}
              >
                <Send className="h-4 w-4" />
              </button>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}
