'use client';

import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom';
import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown, Sparkles, CalendarPlus, CalendarClock, XCircle } from 'lucide-react';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from '@/components/ui/dialog';
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api';
import { emitPetEvent } from '@/lib/pet-events';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed';
import { hostActionMode, openHostAction } from '@/lib/host-message';
import { CloseOpportunityDialog } from './close-opportunity-dialog';
import { resolveActionUrl } from '@/lib/action-url';
import {
  cn,
  formatGender,
  formatDaysReadable,
  formatToothPosition,
} from '@/lib/utils';
import {
  PersonaFeatureKey,
  treatmentCategoryNameZh,
  treatmentCategoryNameZhForTeeth,
  diagnosisCodeNameZh,
  EXECUTION_OUTCOME_META,
  RECALL_FEEDBACK_OPTIONS,
  ABANDON_REASON_META,
  type AbandonReason,
  type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { shortPersonaValueLabel, compactPersonaValue } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import { ReasonLine } from './reason-line';
import { ScriptView, type ScriptViewMode } from './script-viewer';
import { ScriptDeepProcess } from './script-deep-process';
import { OutcomeForm } from './outcome-form';
import { Drawer, type DrawerKind } from './drawer';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useMediaQuery } from '@/hooks/use-media-query';
import {
  mockPatient,
  mockChains,
  mockPersona,
  mockPlan,
  mockSummaries,
  mockScript,
  fmtRel as mockFmtRel,
  type PlanReason,
} from './mock-data';
import type { AdaptedFact } from './adapt-data';
import { useScriptStream } from './use-script-stream';
import { useSummaryStream } from './use-summary-stream';
// import { RealtimeCoach } from '@/components/realtime-coach'; // 暂隐藏(功能未上线)
import { submitExecution } from './execution-api';

/// 话术生成模型(具体型号,直传后端 AiProviderService.resolve)
export type ScriptModel = 'deepseek-v4-pro' | 'deepseek-v4-flash' | 'gemini-3.5-flash' | 'qwen3.7-max';

/// 投入档(话术生成档位):稳健=4段模板填空 / 标准=去模板自由编排 / 深度=多步多段+对抗校验。
export type ScriptTier = 'stable' | 'standard' | 'deep';

/// 召回历史一条(患者级,跨所有 plan 版本)— 后端 plan-aggregate.recallHistory
export type RecallHistoryItem = {
  id: string;
  outcome: string;
  channel: string;
  notes: string | null;
  scheduledNextAt: string | null;
  createdAt: string;
  planVersion: number | null;
};

export type PlanDetailAppData = {
  patient: typeof mockPatient;
  chains: typeof mockChains;
  persona: typeof mockPersona;
  plan: typeof mockPlan;
  /// v2.1:所有 active fact 透传(FactsTimeline / ImageDrawer 用);可选,缺省走 mock 兜底
  facts?: AdaptedFact[];
  summaries: typeof mockSummaries;
  script: typeof mockScript;
  /// 召回历史(患者级)— 可选,缺省空数组
  recallHistory?: RecallHistoryItem[];
  /// 诊所回访记录(5 试点,展示用)— 可选,缺省空数组
  returnVisits?: ReturnVisitItem[];
  fmtRel: typeof mockFmtRel;
};

/// 回访记录一条 — 后端 plan-aggregate.returnVisits
export type ReturnVisitItem = {
  taskDate: string | null;
  type: string | null;
  status: string | null;
  taskStatus: string | null;
  treatmentItems: string | null;
  followContent: string | null;
  result: string | null;
};

const FALLBACK_DATA: PlanDetailAppData = {
  patient: mockPatient,
  chains: mockChains,
  persona: mockPersona,
  plan: mockPlan,
  facts: [],
  summaries: mockSummaries,
  script: mockScript,
  fmtRel: mockFmtRel,
};

export function PlanDetailApp({
  data = FALLBACK_DATA,
  banner,
  /// W4 末:单患者从 DW 直连刷新成功后,触发外层 usePlanAggregate.refresh()
  /// 拉新 plan 详情 — 若 plan 已消失(scenario 不再命中),loader 层 useEffect
  /// 会捕获 PLAN_NOT_FOUND 自动跳列表 + toast
  onRefreshAggregate,
}: {
  data?: PlanDetailAppData;
  banner?: ReactNode;
  onRefreshAggregate?: () => void | Promise<void>;
}) {
  const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
  const facts = data.facts ?? [];
  const recallHistory = data.recallHistory ?? [];
  const returnVisits = data.returnVisits ?? [];
  const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
  const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
  // 话术生成模型(具体型号);默认 qwen3.7-max(极快 · 简洁)
  const [scriptModel, setScriptModel] = useState<ScriptModel>('qwen3.7-max');
  // 投入档(默认稳健);跟模型并列,客服在重新生成处选
  const [scriptTier, setScriptTier] = useState<ScriptTier>('stable');
  const { state: streamState, regenerate, abort } = useScriptStream();
  const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream();

  // 本地覆盖 plan.status / contactAttempts(execution 提交成功后立刻反映,
  // 不用等数据重拉);永久态还是数据库,刷新会从后端取最新
  const [planOverride, setPlanOverride] = useState<{
    status?: string;
    contactAttempts?: number;
  }>({});
  const effectivePlan = { ...plan, ...planOverride };

  const reasons = plan.reasons;

  // 统一用全局 sonner toast(shadcn 体系,已在 layout 挂 <Toaster/>),自动消失
  const showToast = (kind: string, title: string, msg: string) => {
    const opts = msg ? { description: msg } : undefined;
    if (kind === 'rose') toast.error(title, opts);
    else if (kind === 'emerald') toast.success(title, opts);
    else if (kind === 'amber') toast.warning(title, opts);
    else toast(title, opts);
  };

  // 流式 done / error 时弹 toast
  useEffect(() => {
    if (streamState.status === 'done') {
      const cost = streamState.costYuan.toFixed(4);
      const label =
        streamState.source === 'template_fallback' ? '模板兜底' : `AI 生成 · ¥${cost}`;
      showToast('emerald', '话术已重新生成', label);
    } else if (streamState.status === 'error') {
      showToast('rose', '生成失败', streamState.message.slice(0, 80));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [streamState.status]);

  // 摘要 done / error 时弹 toast
  useEffect(() => {
    if (summaryState.status === 'done') {
      const cost = summaryState.costYuan.toFixed(4);
      const label =
        summaryState.source === 'template_fallback' ? '模板兜底' : `AI 生成 · ¥${cost}`;
      showToast('emerald', '摘要已重新生成(3 段)', label);
    } else if (summaryState.status === 'error') {
      showToast('rose', '摘要生成失败', summaryState.message.slice(0, 80));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [summaryState.status]);

  // 给 Drawer 用的 summary 覆盖(优先流式结果)
  const summaryOverride =
    summaryState.status === 'streaming'
      ? {
          medicalRecord: summaryState.partial.medicalRecord,
          treatmentChain: summaryState.partial.treatmentChain,
        }
      : summaryState.status === 'done'
        ? {
            medicalRecord: summaryState.parts.medicalRecord,
            treatmentChain: summaryState.parts.treatmentChain,
          }
        : undefined;
  const summaryStreaming = summaryState.status === 'streaming';

  /**
   * 实时话术 — 优先用流式结果(streaming/done),否则回退到 initial data(mockScript / 后端 mock)。
   * sections 缺字段时 ScriptMarkdown 会自然展示为空段(占位感强,有"正在打字"的味道)。
   */
  const displayedSections = useMemo(() => {
    if (streamState.status === 'streaming' || streamState.status === 'done') {
      return streamState.sections;
    }
    return script.sections;
  }, [streamState, script.sections]);

  const isStreaming = streamState.status === 'streaming';
  // 深度档可见过程步骤(规划→撰写→自检→[修订]);仅深度档有 step 事件
  const deepSteps =
    streamState.status === 'streaming' || streamState.status === 'done' ? streamState.steps : undefined;
  // 是否已有话术内容(没生成过 → 空态提示,不显示默认 demo / 空标题)
  const hasScriptContent = displayedSections.some((s) => s.markdown.trim().length > 0);

  // 可见召回原因 = 召回算法产出的全集,按优先级排序。
  // ⭐ 治疗链已弃用:不再用链(reasonAltClosed)来判断/过滤显示哪些 reason。
  //   口径统一收口到「召回算法 + 牙位事实」单一来源 —— 召回判定什么,这里就显示什么
  //   (避免链与召回背离:李梦维 1B 乳牙滞留贴面,链判"替代闭环"误删,而召回算法仍召 → 错隐藏)。
  //   "是否该召 1B" 这类问题改由召回算法本身回答(如 K00 是否认 cosmetic),不再借链做二次抑制。
  const visibleReasons = useMemo(
    () => [...reasons].sort((a, b) => b.priorityScore - a.priorityScore),
    [reasons],
  );

  // 本次聚焦的应治未治项(priorityScore 最高那条 = 话术讲的那个)的 诊断 + 目标治疗 标签
  const focusedReason = visibleReasons[0];
  const focusDiagnosis = (() => {
    const code = focusedReason?.signals?.triggers?.find((t) => /^K\d/i.test(t.code ?? ''))?.code;
    return code ? diagnosisCodeNameZh(code) : null;
  })();
  const focusTreatment = (() => {
    const cat = focusedReason?.signals?.expectedCategories?.[0];
    if (!cat) return null;
    // 乳牙龋齿只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致)
    return treatmentCategoryNameZhForTeeth(cat, focusedReason?.signals?.toothPosition);
  })();

  const submitOutcome = async (formData: {
    channel: string;
    outcome: string;
    notes: string;
    scheduledNextAt: string;
    abandonReasons: string[];
  }) => {
    try {
      const result = await submitExecution(plan.id, {
        channel: formData.channel,
        outcome: formData.outcome,
        notes: formData.notes || undefined,
        // 表单直接给 enum key(不再经中译映射),原样提交
        abandonReasons: formData.abandonReasons.length > 0 ? formData.abandonReasons : undefined,
        scheduledNextAt: formData.scheduledNextAt
          ? new Date(formData.scheduledNextAt).toISOString()
          : undefined,
      });

      // 本地立刻反映 plan 新态(不等下次拉数据)
      setPlanOverride({
        status: result.planStatus,
        contactAttempts: result.contactAttempts,
      });
      // 通知左栏选患者列同步该行状态 + 本次 outcome(工作台跨栏一致,左栏角标即时反映)
      usePlanSyncStore.getState().notify(plan.id, result.planStatus, formData.outcome);

      // 触达熔断是用户应知的"非预期"事件 → 保留 toast;普通归档不弹(setPlanOverride 已就地更新状态徽标即反馈)
      if (result.breakerTripped) {
        showToast(
          'amber',
          '已达触达上限,自动归档',
          `本次为第 ${result.contactAttempts} 次,Plan 自动 abandoned`,
        );
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      showToast('rose', '提交失败', msg.slice(0, 80));
    }
  };

  // 顶栏三动作(潜在 / 预约 / 回访)—— 原分散在身份卡、通话结果头、执行表单,统一收口到右上角。
  //   潜在 / 回访:actionUrls.OPEN_* 值形态决定模式(URL 模板 → 跳转;哨兵 postMessage → 喊宿主父页);
  //   未配置 → 回调传 undefined → 按钮不渲染(PAC 默认无此动作)。预约:CREATE_APPOINTMENT 跳转。
  const hostActionCtx = {
    patientId: patient.externalId,
    brandId: patient.brandId,
    clinicId: plan?.targetClinicId,
    medicalRecordNumber: patient.medicalRecordNumber,
  };
  const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
    ? () => openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx)
    : undefined;
  const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
    ? () => openHostAction('OPEN_RETURN_VISIT', hostActionCtx)
    : undefined;
  // 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。
  const hasHostReturnVisit = !!openReturnVisit;
  const [closeOpen, setCloseOpen] = useState(false);

  // 关闭机会提交 —— **不是新机制**:走 execution 通道(channel='other'),
  //   outcome='abandoned' + abandonReasons,跟 PAC 自带表单同一条路、同一列。
  //   · 抑制窗由服务端按已选原因算(ABANDON_REASON_META.suppressDays 取 max),前端不参与
  //   · 「识别不准确」**不再**双写 recall_feedback —— 放弃原因本身就是这条反馈的载体,
  //     双写会让同一件事出现在两处、口径还可能打架(recall_feedback 保留给拇指控件那条路)
  // 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
  const router = useRouter();
  const submitClose = async (reasons: AbandonReason[], note?: string) => {
    const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、');
    try {
      const result = await submitExecution(plan.id, {
        channel: 'other',
        outcome: 'abandoned',
        notes: note?.trim() || undefined,
        abandonReasons: reasons,
      });
      setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts });
      usePlanSyncStore.getState().notify(plan.id, result.planStatus, 'abandoned');
      showToast('emerald', '机会已关闭', labels);
      // pageSize 2 + 排除本单:防状态回写与列表查询的竞态把刚关的单又选回来
      try {
        const mine = await plansApi.list({ view: 'mine', status: 'assigned', sort: 'priority_desc', page: 1, pageSize: 2 });
        const next =
          mine.items.find((i) => i.id !== plan.id) ??
          (await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 2 })).items.find(
            (i) => i.id !== plan.id,
          );
        router.replace(next ? `/plans/${next.id}` : '/plans');
      } catch {
        router.replace('/plans');
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      showToast('rose', '关闭失败', msg.slice(0, 80));
    }
  };
  const createAppointment = () => {
    // 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
    // 未配置则提示去配,不写死任何 URL。缺失键清空。
    const url = resolveActionUrl('CREATE_APPOINTMENT', {
      patientId: patient.externalId,
      brandId: patient.brandId,
      clinicId: plan?.targetClinicId,
      medicalRecordNumber: patient.medicalRecordNumber,
    });
    if (!url) {
      showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
      return;
    }
    window.open(url, '_top');
    showToast('emerald', '已打开宿主预约页', '带上患者 id');
  };

  // ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
  //   目标 = 中栏话术底部 👍/👎(AIDisclaimerFooter)。每患者只提示一次,切患者/无话术不提示;
  //   宠物只发话(director 气泡),不代评价。
  const scriptHintShownRef = useRef<Set<string>>(new Set());
  useEffect(() => {
    if (!hasScriptContent) return;
    const pid = plan.id;
    if (scriptHintShownRef.current.has(pid)) return;
    const t = setTimeout(() => {
      scriptHintShownRef.current.add(pid);
      emitPetEvent({
        type: 'director',
        script: {
          gesture: 'greet',
          bubble: '这套话术还顺手吗?点话术区右下角的「👍 / 👎」帮我评一下呗~',
          ttlMs: 6_000,
        },
      });
    }, 15_000);
    return () => clearTimeout(t);
  }, [plan.id, hasScriptContent]);

  // ── 宠物引导 2:hover「回访」按钮 / 「通话结果」区 → 发话引导给「本次召回」打反馈 ──
  //   目标 = 顶栏「召回反馈」👍/👎(RecallFeedbackControl,plan 级、正交于执行)。
  //   冷却 60s(避免一 hover 就刷屏);本 plan 已给过召回反馈则不再打扰。
  const recallHintAtRef = useRef(0);
  const hintRecallFeedback = () => {
    if (plan.recallFeedback) return;
    const now = Date.now();
    if (now - recallHintAtRef.current < 60_000) return;
    recallHintAtRef.current = now;
    emitPetEvent({
      type: 'director',
      script: {
        gesture: 'greet',
        bubble: '顺手评价下这次召回准不准?顶部「召回反馈」点个 👍 / 👎 就行~',
        ttlMs: 5_500,
      },
    });
  };

  return (
    <div
      className="h-full overflow-hidden bg-slate-50 text-slate-900 flex flex-col"
      style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }}
    >
      {banner}
      <HeaderSlotPortal>
        <TopBar
          plan={plan}
          reason={focusedReason ?? reasons[0]!}
          patientId={patient.id}
          onRefreshAggregate={onRefreshAggregate}
          showToast={showToast}
          fmtRel={fmtRel}
          onOpenPotential={openPotential}
          onCreateAppointment={createAppointment}
          onOpenReturnVisit={openReturnVisit}
          // 宿主回访模式下「召回反馈」控件隐藏,宠物引导跟着停(否则指向不存在的控件)
          onHoverReturnVisit={hasHostReturnVisit ? undefined : hintRecallFeedback}
          onCloseOpportunity={hasHostReturnVisit ? () => setCloseOpen(true) : undefined}
        />
      </HeaderSlotPortal>

      {/* 关闭机会弹窗 —— 只是展示层的皮:落库 outcome='abandoned' + abandon_reasons,
          原因清单/抑制窗见 ABANDON_REASON_META(@pac/types,shownIn='close' 子集) */}
      <CloseOpportunityDialog
        open={closeOpen}
        onOpenChange={setCloseOpen}
        onConfirm={(reason, note) => void submitClose(reason, note)}
      />

      {/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
          抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */}
      <ResponsiveDetail
        isXl={useMediaQuery('(min-width: 1280px)')}
        leftPane={
          <aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full">
              <IdentityCard
                patient={patient}
                onOpenImage={() =>
                  showToast('slate', '影像调阅', '跳转宿主页面')
                }
                onOpenProfile={() =>
                  showToast('slate', '患者档案', '跳转宿主页面')
                }
              />
              <PersonaTagsCard
                planId={plan.id}
                features={persona.features}
                onOpenDetail={() => setDrawerOpen('persona')}
              />
              <KeyFactsCard
                patient={patient}
                persona={persona}
                facts={facts}
                focusedReason={focusedReason}
                onOpenDetail={() => setDrawerOpen('facts')}
                onOpenTeeth={() => setDrawerOpen('teeth')}
              />
              <TreatmentHistoryCard
                facts={facts}
                onOpenMedical={() => setDrawerOpen('medical')}
                onOpenTimeline={() => setDrawerOpen('treatments')}
                // 「详情」槽:配了 VIEW_MEDICAL_RECORD 就打开宿主电子病历原文(占位替换 4 个 id),
                //   替换掉原来的 PAC 事实时间轴抽屉;未配置则回退原详情。
                emrUrl={resolveActionUrl('VIEW_MEDICAL_RECORD', {
                  brandId: patient.brandId,
                  clinicId: plan?.targetClinicId,
                  medicalRecordNumber: patient.medicalRecordNumber,
                  patientId: patient.externalId,
                })}
              />
              {/* 召回建议 — 暂时隐藏(SuggestionCard) */}
              {recallHistory.length > 0 && (
                <RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
              )}
              {returnVisits.length > 0 && (
                <ReturnVisitsCard
                  planId={plan.id}
                  visits={returnVisits}
                  onOpenDetail={() => setDrawerOpen('return-visits')}
                />
              )}
              {/* 治疗链卡片已隐藏(链已弃用;chains 仍传 drawer 备用)*/}
            </aside>
        }
        centerPane={
            <main className="relative min-h-0 min-w-0 flex flex-col h-full">
              <section className="bg-white rounded-lg border border-slate-100 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden">
                {/* 窄屏 flex-wrap 自然换行,gap-y 给行间距 */}
                <header className="flex-none px-3 sm:px-4 py-2.5 border-b border-slate-100">
                  {/* 第一行:标题 + 标签 ←→ 控件(模式切换 / 重新生成 / AI 时间戳)*/}
                  <div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-2">
                  <div className="min-w-0">
                    <div className="flex flex-wrap items-center gap-1.5">
                      <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
                      {focusDiagnosis && (
                        <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10.5px] font-medium bg-rose-50 text-rose-700 border border-indigo-100">
                          {focusDiagnosis}
                        </span>
                      )}
                      {focusTreatment && (
                        <span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10.5px] font-medium bg-teal-50 text-teal-700 border border-teal-100">
                          目标 · {focusTreatment}
                        </span>
                      )}
                    </div>
                  </div>
                  <div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
                    {/* 话术 3 模式切换:伴飞 / 卡片 / 原文 */}
                    <div className="inline-flex flex-none items-center rounded-md bg-slate-100 p-0.5 text-[11.5px]">
                      {([
                        ['copilot', '伴飞'],
                        ['cards', '卡片'],
                        ['markdown', '原文'],
                      ] as [ScriptViewMode, string][]).map(([m, label]) => (
                        <button
                          key={m}
                          onClick={() => setScriptMode(m)}
                          className={cn(
                            'px-2 sm:px-2.5 py-1 rounded transition-colors',
                            scriptMode === m
                              ? 'bg-white font-semibold text-slate-900 shadow-sm'
                              : 'text-slate-500 hover:text-slate-800',
                          )}
                        >
                          {label}
                        </button>
                      ))}
                    </div>
                    <RegenBtn
                      streaming={isStreaming}
                      model={scriptModel}
                      tier={scriptTier}
                      onSelectModel={setScriptModel}
                      onSelectTier={setScriptTier}
                      onStop={() => {
                        abort();
                        showToast('slate', '已停止', '本次 AI 生成被中断');
                      }}
                      onRegen={() => void regenerate(plan.id, { model: scriptModel, tier: scriptTier })}
                    />
                    {/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
                    <span className="hidden md:inline-flex">
                      {(streamState.status === 'done' || (hasScriptContent && script.generatedAt)) && (
                      <AIStamp
                        relative={
                          streamState.status === 'done'
                            ? '刚刚'
                            : fmtRel(script.generatedAt)
                        }
                        source={
                          streamState.status === 'done' ? streamState.source : script.source
                        }
                      />
                      )}
                    </span>
                  </div>
                  </div>
                  {/* 第二行:本次召回一句话简报(LLM:谁/解决什么/到诊做什么;生成中 shimmer,失败回退结构化原因)*/}
                  <div className="text-[11.5px] text-slate-600 leading-snug mt-1.5">
                    <RecallBriefLine planId={plan.id} visibleReasons={visibleReasons} />
                  </div>
                </header>
                <div className="flex-1 min-h-0 overflow-y-auto p-4">
                  {deepSteps && deepSteps.length > 0 && (
                    <ScriptDeepProcess steps={deepSteps} />
                  )}
                  {!isStreaming && !hasScriptContent ? (
                    <div className="h-full min-h-[160px] flex flex-col items-center justify-center text-center gap-2 text-slate-400">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="w-8 h-8">
                        <path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
                      </svg>
                      <p className="text-[13px] text-slate-500">尚未生成参考话术</p>
                      <p className="text-[12px] text-slate-400">点击右上角「生成」按本患者实时生成</p>
                    </div>
                  ) : (
                    <ScriptView mode={scriptMode} sections={displayedSections} streaming={isStreaming} />
                  )}
                </div>
                <AIDisclaimerFooter
                  onFeedback={async (v) => {
                    try {
                      const r = await plansApi.submitScriptFeedback(plan.id, v);
                      if (!r.ok) {
                        // 后端找不到 invocation(纯模板兜底或老数据无 invocation 关联)
                        showToast('slate', '本话术暂无 AI 调用记录', '兜底模板话术无法评价');
                        return;
                      }
                      // 成功不弹 toast —— 按钮已变色填充 + 禁用(chosen 态),本身即反馈
                    } catch (err) {
                      const msg = err instanceof Error ? err.message : String(err);
                      showToast('rose', '反馈提交失败', msg.slice(0, 100));
                    }
                  }}
                />
              </section>
              {/* 实时坐席辅助教练:暂时隐藏(功能未上线)。恢复:取消下行 + 顶部 import 注释。 */}
              {/* <RealtimeCoach planId={plan.id} /> */}
            </main>
        }
        rightPane={
          // 宿主回访模式:回访结果在宿主侧记录 → 整列隐藏(顶栏「关闭」承接机会关闭)
          hasHostReturnVisit ? null : (
            <aside
              className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full"
              onMouseEnter={hintRecallFeedback}
            >
              <section className="bg-white rounded-lg border border-slate-100 shadow-sm flex flex-col min-h-0 flex-1 overflow-hidden">
                <header className="flex-none px-4 py-2.5 border-b border-slate-100">
                  {/* 「回访」按钮已上移到顶栏右上角动作区(主按钮) */}
                  <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2>
                  <p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p>
                </header>
                <div className="flex-1 min-h-0 overflow-y-auto p-3">
                  <OutcomeForm
                    plan={effectivePlan}
                    onSubmit={submitOutcome}
                    defaultChannel={effectivePlan.recommendedChannel}
                  />
                </div>
              </section>
            </aside>
          )
        }
      />

      <Drawer
        open={!!drawerOpen}
        onClose={() => setDrawerOpen(null)}
        kind={drawerOpen}
        chains={chains}
        persona={persona}
        summaries={summaries}
        reason={focusedReason ?? reasons[0]!}
        reasons={reasons}
        facts={facts}
        fmtRel={fmtRel}
        returnVisits={returnVisits}
        summaryOverride={summaryOverride}
        summaryStreaming={summaryStreaming}
        onRegenerateSummary={() => void regenerateSummary(plan.id)}
      />
    </div>
  );
}

// ──────────────────────────────────────────
// ResponsiveDetail — xl ≥1280 走 3 列 grid;<xl 走 shadcn Tabs(原因/话术/操作)
//   Tabs 默认 "script" 优先(客服主战场是话术,先看话术再切原因/操作)
//   3 个 pane 是单实例 ReactNode(父组件创建),无 state desync
// ──────────────────────────────────────────
function ResponsiveDetail({
  isXl,
  leftPane,
  centerPane,
  rightPane,
}: {
  isXl: boolean;
  leftPane: ReactNode;
  centerPane: ReactNode;
  /** null = 无操作列(宿主回访模式)→ grid 收成 2 列,窄屏隐藏「操作」tab */
  rightPane: ReactNode | null;
}) {
  return (
    <div className="flex-1 min-h-0">
      <div className="h-full mx-auto px-5 py-3">
        {isXl ? (
          <div
            className="grid h-full gap-3"
            style={{ gridTemplateColumns: rightPane ? '300px 1fr 380px' : '300px 1fr' }}
          >
            {leftPane}
            {centerPane}
            {rightPane}
          </div>
        ) : (
          <Tabs defaultValue="script" className="h-full flex flex-col">
            <div className="flex-none flex justify-center pb-2">
              <TabsList>
                <TabsTrigger value="why">原因 · 画像</TabsTrigger>
                <TabsTrigger value="script">话术</TabsTrigger>
                {rightPane && <TabsTrigger value="outcome">操作</TabsTrigger>}
              </TabsList>
            </div>
            <TabsContent value="why" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
              {leftPane}
            </TabsContent>
            <TabsContent value="script" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
              {centerPane}
            </TabsContent>
            {rightPane && (
              <TabsContent value="outcome" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
                {rightPane}
              </TabsContent>
            )}
          </Tabs>
        )}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────
// TopBar
// ──────────────────────────────────────────
// ──────────────────────────────────────────
// RecallFeedbackControl — 召回反馈(plan 级,正交于执行)
//   标题栏拇指:👍 直接记;👎 弹 shadcn Dialog(预选项=快速输入到文本框)+ 文字 → 存 followup_plans
// ──────────────────────────────────────────
function RecallFeedbackControl({
  planId,
  initialFeedback,
  initialNote,
  showToast,
}: {
  planId: string;
  initialFeedback: 'up' | 'down' | null;
  initialNote: string | null;
  showToast?: (kind: string, title: string, msg: string) => void;
}) {
  const [feedback, setFeedback] = useState<'up' | 'down' | null>(initialFeedback);
  const [note, setNote] = useState(initialNote ?? '');
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(false);

  const submitUp = async () => {
    if (busy) return;
    setBusy(true);
    try {
      await plansApi.submitRecallFeedback(planId, 'up');
      setFeedback('up');
      setNote('');
      // 成功不弹 toast —— setFeedback 已把按钮切到"已选"态,本身即反馈
    } catch (e) {
      showToast?.('rose', '反馈失败', e instanceof Error ? e.message.slice(0, 80) : String(e));
    } finally {
      setBusy(false);
    }
  };

  const submitDown = async () => {
    if (busy) return;
    setBusy(true);
    try {
      await plansApi.submitRecallFeedback(planId, 'down', note.trim() || undefined);
      setFeedback('down');
      setOpen(false);
      // 成功不弹 toast —— setFeedback 已把按钮切到"已选"态 + 弹层收起,本身即反馈
    } catch (e) {
      showToast?.('rose', '反馈失败', e instanceof Error ? e.message.slice(0, 80) : String(e));
    } finally {
      setBusy(false);
    }
  };

  // 预选项 = 快速输入:点一下把标签文字插进文本框(已含则不重复,顿号分隔)
  const quickInsert = (label: string) => {
    setNote((cur) => {
      const has = cur.split(/[、\n]/).map((s) => s.trim()).includes(label);
      if (has) return cur;
      return cur.trim() ? `${cur.trim()}、${label}` : label;
    });
  };

  return (
    <>
      <div
        className="hidden sm:flex flex-none items-center gap-0.5 rounded-md border border-slate-100 px-1 py-0.5"
        title="召回反馈:这条召回准不准?"
      >
        <span className="hidden lg:inline px-1 text-[10.5px] text-slate-400">召回反馈</span>
        <button
          type="button"
          onClick={submitUp}
          disabled={busy}
          aria-label="召回准 / 有用"
          className={cn(
            'inline-flex items-center justify-center rounded p-1 transition-colors disabled:opacity-50',
            feedback === 'up'
              ? 'text-emerald-600 bg-emerald-50'
              : 'text-slate-400 hover:text-emerald-600 hover:bg-slate-50',
          )}
        >
          <ThumbsUp className="h-3.5 w-3.5" fill={feedback === 'up' ? 'currentColor' : 'none'} />
        </button>
        <button
          type="button"
          onClick={() => setOpen(true)}
          disabled={busy}
          aria-label="召回有问题"
          className={cn(
            'inline-flex items-center justify-center rounded p-1 transition-colors disabled:opacity-50',
            feedback === 'down'
              ? 'text-rose-600 bg-rose-50'
              : 'text-slate-400 hover:text-rose-600 hover:bg-slate-50',
          )}
        >
          <ThumbsDown className="h-3.5 w-3.5" fill={feedback === 'down' ? 'currentColor' : 'none'} />
        </button>
      </div>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>召回反馈 · 哪里不对?</DialogTitle>
            <DialogDescription>
              帮我们改进召回算法。点下面的标签可快速填入,也可直接补充说明。
            </DialogDescription>
          </DialogHeader>
          <div className="space-y-3">
            <div className="flex flex-wrap gap-1.5">
              {RECALL_FEEDBACK_OPTIONS.map((f) => (
                <button
                  key={f.value}
                  type="button"
                  title={f.hint}
                  onClick={() => quickInsert(f.labelZh)}
                  className="px-2 py-1 rounded-full text-[12px] border border-slate-100 bg-white text-slate-600 hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700 transition-colors"
                >
                  {f.labelZh}
                </button>
              ))}
            </div>
            <textarea
              value={note}
              onChange={(e) => setNote(e.target.value)}
              placeholder="补充说明(选填)…"
              rows={3}
              className="w-full px-2.5 py-2 rounded border border-slate-100 bg-white text-[13px] resize-none focus:outline-none focus:ring-2 focus:ring-rose-200 focus:border-rose-300"
            />
          </div>
          <DialogFooter>
            <button
              type="button"
              onClick={() => setOpen(false)}
              className="px-3 py-1.5 rounded-md border border-slate-100 text-[13px] text-slate-600 hover:bg-slate-50"
            >
              取消
            </button>
            <button
              type="button"
              onClick={submitDown}
              disabled={busy}
              className="px-3 py-1.5 rounded-md bg-rose-600 text-white text-[13px] font-medium hover:bg-rose-700 disabled:opacity-50"
            >
              提交反馈
            </button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

/** TopBar 渲染容器:工作台布局下 portal 到全宽 header 槽(延伸到最左);无槽(独立打开)原位渲染。 */
function HeaderSlotPortal({ children }: { children: React.ReactNode }) {
  const [slot, setSlot] = useState<HTMLElement | null>(null);
  useEffect(() => {
    setSlot(document.getElementById('plan-workspace-header'));
  }, []);
  return slot ? createPortal(children, slot) : <>{children}</>;
}

function TopBar({
  plan,
  reason,
  patientId,
  onRefreshAggregate,
  showToast,
  fmtRel,
  onOpenPotential,
  onCreateAppointment,
  onOpenReturnVisit,
  onHoverReturnVisit,
  onCloseOpportunity,
}: {
  plan: typeof mockPlan;
  reason: typeof mockPlan.reasons[0];
  patientId?: string;
  onRefreshAggregate?: () => void | Promise<void>;
  showToast?: (kind: string, title: string, msg: string) => void;
  fmtRel?: (d: Date) => string;
  /** 右上角动作:潜在治疗机会(actionUrls.OPEN_POTENTIAL_TREATMENT;未配置传 undefined → 不渲染) */
  onOpenPotential?: () => void;
  /** 右上角动作:新建预约(宿主 CREATE_APPOINTMENT 跳转) */
  onCreateAppointment?: () => void;
  /** 右上角动作:回访(actionUrls.OPEN_RETURN_VISIT;未配置传 undefined → 不渲染),主按钮样式 */
  onOpenReturnVisit?: () => void;
  /** 右上角动作:关闭机会(危险级;仅宿主回访模式下出现,替代通话结果区的关闭路径) */
  onCloseOpportunity?: () => void;
  /** hover「回访」按钮时的引导回调(宠物发话引导做召回反馈) */
  onHoverReturnVisit?: () => void;
}) {
  const [refreshing, setRefreshing] = useState(false);
  // 嵌入宿主 iframe 时:隐藏 PAC 品牌 / 标题 / 用户三件套(宿主已提供),只留召回功能区。
  const embedded = isEmbedded();

  /// W4 末:从 DW 直连重拉该 patient 全量数据 → 触发 persona + plan 重算
  /// 完成后调外层 refresh() 拿新 plan;plan 没了(scenario 不再命中)由 loader 层
  /// useEffect 捕获 PLAN_NOT_FOUND 自动跳列表
  const handleRefresh = async () => {
    if (refreshing || !patientId) return;
    setRefreshing(true);
    try {
      const r = await plansApi.refreshPatient(patientId);
      const facts = r.factsEmitted ?? 0;
      // 用户口径(不暴露 txn / plan 内部计数):
      //   - facts > 0  → 发现 N 项更新
      //   - facts = 0  → 已是最新(数据无变化)
      const msg = facts > 0
        ? `发现 ${facts} 项更新`
        : '本次同步未发现新数据';
      showToast?.('emerald', '已同步该患者最新资料', msg);
      // 触发外层 usePlanAggregate refetch — 若 plan 已被 supersede / abandoned,
      // /full 返回 PLAN_NOT_FOUND → loader 层 useEffect 跳列表 + toast
      if (onRefreshAggregate) await onRefreshAggregate();
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      showToast?.('rose', '刷新失败', msg.slice(0, 100));
    } finally {
      setRefreshing(false);
    }
  };

  return (
    <header className="flex flex-none items-center justify-between gap-2 border-b border-slate-100 bg-white px-3 py-2 sm:px-5 sm:py-3 sm:gap-3">
      <div className="flex min-w-0 items-center gap-2 sm:gap-3">
        {/* PAC 品牌 + 面包屑标题 —— 嵌入宿主时隐藏(宿主自有品牌与导航) */}
        {!embedded && (
          <>
            <div className="inline-flex flex-none items-center gap-2">
              <span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
                PAC
              </span>
              {/* "疗效保障"标题字在窄屏隐藏,腾出空间给关键信息 */}
              <span className="hidden lg:inline text-[13px] font-semibold text-slate-900">疗效保障</span>
            </div>
            <span className="hidden lg:inline text-slate-300">/</span>
            <div className="min-w-0">
              <h1 className="truncate text-[14px] sm:text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
              <p className="hidden sm:block mt-0.5 truncate text-[11px] text-slate-500">召回中心</p>
            </div>
          </>
        )}
        {/* scenario chip + 优先级 — 始终显示,核心信息 */}
        <Chip tone="rose" icon size="xs" className="flex-none">
          {reason.scenarioLabel}
        </Chip>
        <PriorityHover
          score={plan.priorityScore}
          breakdown={(plan.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
        >
          <span className="hidden md:inline-flex cursor-help flex-none">
            <PriorityBar score={plan.priorityScore} label="优先级" />
          </span>
        </PriorityHover>
        {/* 召回反馈(plan 级)— 拇指 + 弹窗收集;放优先级右边。
            宿主回访模式(有「关闭」入口)下隐藏:召回好坏由关闭原因表达,不再单独收拇指 */}
        {!onCloseOpportunity && (
          <RecallFeedbackControl
            planId={plan.id}
            initialFeedback={plan.recallFeedback}
            initialNote={plan.recallFeedbackNote}
            showToast={showToast}
          />
        )}
      </div>
      <div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600">
        {/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */}
        {plan.updatedAt && fmtRel && (
          <span
            className="hidden md:inline text-[11px] text-slate-400"
            title={`本召回计划最近重算于 ${plan.updatedAt.toLocaleString('zh-CN')}`}
          >
            更新于 {fmtRel(plan.updatedAt)}
          </span>
        )}
        {/* 刷新 — 窄屏只显图标节省空间 */}
        {patientId && (
          <button
            type="button"
            onClick={handleRefresh}
            disabled={refreshing}
            title={refreshing ? '正在从 DW 拉数据并重算…' : '从 DW 直连重拉该患者最新数据 → 触发画像+召回重算'}
            className={cn(
              'inline-flex items-center gap-1.5 rounded-md border px-2 sm:px-2.5 py-1 text-[11.5px] font-medium transition-colors',
              refreshing
                ? 'cursor-not-allowed border-slate-100 bg-slate-50 text-slate-400'
                : 'border-slate-100 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700',
            )}
          >
            <RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
            <span className="hidden sm:inline">{refreshing ? '同步中…' : '刷新'}</span>
          </button>
        )}
        {/* 患者动作三连:潜在 / 预约(默认样式)+ 回访(主按钮)。原分散在身份卡 / 通话结果头 / 执行表单,统一收口到此。 */}
        {(onOpenPotential || onCreateAppointment || onOpenReturnVisit) && (
          <div className="flex flex-none items-center gap-1.5">
            {onOpenPotential && (
              <button
                type="button"
                onClick={onOpenPotential}
                title="查看潜在治疗机会"
                className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700"
              >
                <Sparkles className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">潜在</span>
              </button>
            )}
            {onCreateAppointment && (
              <button
                type="button"
                onClick={onCreateAppointment}
                title="新建预约"
                className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700"
              >
                <CalendarPlus className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">预约</span>
              </button>
            )}
            {onOpenReturnVisit && (
              <button
                type="button"
                onClick={onOpenReturnVisit}
                onMouseEnter={onHoverReturnVisit}
                title="回访"
                className="inline-flex items-center gap-1.5 rounded-md bg-teal-600 px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-teal-700"
              >
                <CalendarClock className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">回访</span>
              </button>
            )}
            {onCloseOpportunity && (
              <button
                type="button"
                onClick={onCloseOpportunity}
                title="关闭机会"
                className="inline-flex items-center gap-1.5 rounded-md border border-rose-200 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-rose-600 transition-colors hover:border-rose-300 hover:bg-rose-50"
              >
                <XCircle className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">关闭</span>
              </button>
            )}
          </div>
        )}
        {/* 回收倒计时已隐藏 —— 暂无自动回收机制(返池改为手动)。RecycleCountdown 组件保留,需要时再挂回。 */}
        {/* 用户名 + 头像 + 退出 —— 嵌入宿主时整体隐藏(见 IdentityCluster) */}
        <IdentityCluster />
      </div>
    </header>
  );
}

// ──────────────────────────────────────────
// AIDisclaimerFooter — 话术底部 AI 声明 + 拇指评价
// ──────────────────────────────────────────
function AIDisclaimerFooter({
  onFeedback,
}: {
  onFeedback: (v: 'up' | 'down') => void | Promise<void>;
}) {
  const [chosen, setChosen] = useState<'up' | 'down' | null>(null);
  const click = (v: 'up' | 'down') => {
    setChosen(v);
    onFeedback(v);
  };
  return (
    <div className="flex-none border-t border-slate-100 px-3 sm:px-4 py-2 flex items-center justify-between gap-2 sm:gap-3">
      <div className="min-w-0 text-[10.5px] text-slate-500 flex items-center gap-1.5 leading-tight">
        <svg viewBox="0 0 24 24" className="w-3 h-3 flex-none" fill="none" stroke="currentColor" strokeWidth="1.8">
          <circle cx="12" cy="12" r="10" />
          <path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
        </svg>
        {/* 移动端只显短版,腾空间给反馈按钮 */}
        <span className="hidden sm:inline">
          本话术由 <strong className="text-slate-700">AI 辅助生成</strong>,请核对后使用 · 涉及医疗建议以医生意见为准
        </span>
        <span className="sm:hidden">
          <strong className="text-slate-700">AI 辅助生成</strong>,请核对
        </span>
      </div>
      <div className="flex flex-none items-center gap-1.5 text-[10.5px] text-slate-500">
        <span className="hidden sm:inline">本段是否好用?</span>
        <button
          onClick={() => click('up')}
          disabled={chosen !== null}
          className={cn(
            'p-0.5 rounded transition-colors disabled:cursor-default',
            chosen === 'up' ? 'text-emerald-600' : 'hover:text-slate-700 text-slate-400',
          )}
          aria-label="好用"
        >
          <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill={chosen === 'up' ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8">
            <path d="M7 10v12M15 5.88L14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7" />
          </svg>
        </button>
        <button
          onClick={() => click('down')}
          disabled={chosen !== null}
          className={cn(
            'p-0.5 rounded transition-colors disabled:cursor-default',
            chosen === 'down' ? 'text-rose-600' : 'hover:text-slate-700 text-slate-400',
          )}
          aria-label="待改进"
        >
          <svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill={chosen === 'down' ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8">
            <path d="M17 14V2M9 18.12L10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17" />
          </svg>
        </button>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────
// RecycleCountdown — 实时倒计时 HH:MM:SS,基于 plan.recycleAt
// 暂无自动回收机制,头部已不挂载(见上方);组件保留以便日后恢复。
// ──────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const i = setInterval(() => setTick((t) => t + 1), 1000);
    return () => clearInterval(i);
  }, []);
  // 引用 tick 避免 lint warn(实际效果靠 setTick re-render)
  void tick;
  if (!recycleAt) {
    return (
      <span className="tabular-nums text-slate-400">未设置回收时间</span>
    );
  }
  const ms = recycleAt.getTime() - Date.now();
  if (ms <= 0) {
    return (
      <span className="tabular-nums">
        回收倒计时 <strong className="text-rose-600">已到期</strong>
      </span>
    );
  }
  const s = Math.floor(ms / 1000);
  const hh = String(Math.floor(s / 3600)).padStart(2, '0');
  const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
  const ss = String(s % 60).padStart(2, '0');
  const tone =
    s < 1800 ? 'text-rose-600' : s < 3600 ? 'text-amber-600' : 'text-emerald-600';
  return (
    <span className="tabular-nums">
      回收倒计时 <strong className={tone}>{`${hh}:${mm}:${ss}`}</strong>
    </span>
  );
}

// ──────────────────────────────────────────
// IdentityCard
// ──────────────────────────────────────────
function IdentityCard({
  patient,
  onOpenImage,
  onOpenProfile,
}: {
  patient: typeof mockPatient;
  onOpenImage: () => void;
  onOpenProfile: () => void;
}) {
  const [copied, setCopied] = useState(false);
  // patient.phone 当前 backend 已返回明文(plan-aggregate.service.ts:131)—— 详情页直接显示明文,
  // 不脱敏、不做 eye toggle;若未来 backend 只发 phoneMasked,复制时降级调 phone-reveal 端点。
  const copyPhone = async (e: React.MouseEvent) => {
    e.stopPropagation();
    let raw = patient.phone;
    if (!raw || raw === patient.phoneMasked) {
      try {
        const { phone } = await plansApi.revealPhone(patient.id);
        raw = phone ?? '';
      } catch (err) {
        toast.error(err instanceof Error ? err.message : '获取号码失败');
        return;
      }
    }
    if (!raw) return;
    await navigator.clipboard?.writeText(raw);
    setCopied(true);
    setTimeout(() => setCopied(false), 1500);
  };
  // 原始档案跳转 URL(宿主 VIEW_PATIENT 配了才有)。有原始档案入口时隐藏手机号行:
  //   PAC 侧号码是宿主同步的造数假号,真号在宿主档案页,避免客服误拨假号。
  const originalArchiveUrl = resolveActionUrl('VIEW_PATIENT', {
    patientId: patient.externalId,
    medicalRecordNumber: patient.medicalRecordNumber,
  });
  return (
    <section className="bg-white rounded-lg border border-slate-100 shadow-sm flex-none">
      <div className="p-3 flex items-start gap-2.5">
        <div className="min-w-0 flex-1">
          <div className="flex items-start justify-between gap-2">
            <div className="flex items-center gap-1.5 flex-wrap min-w-0">
              <span className="text-[15px] font-semibold text-slate-900 leading-tight">{patient.name}</span>
              {patient.medicalRecordNumber && (
                <span className="text-[11px] text-slate-400">
                  <span className="font-mono text-slate-500">{patient.medicalRecordNumber}</span>
                </span>
              )}
              <span className="text-[10.5px] text-slate-500">
                {formatGender(patient.gender)}·{patient.age ?? '?'}岁
              </span>
            </div>
            <div className="flex items-center gap-2 flex-none">
              {/* 潜在治疗入口已上移到顶栏「潜在」按钮(与 预约 / 回访 同处右上角动作区)。 */}
              {/* 原始档案 —— 宿主 VIEW_PATIENT 跳转(占位 {patientId}/{medicalRecordNumber});未配则不渲染 */}
              {originalArchiveUrl ? (
                <a
                  href={originalArchiveUrl}
                  target="_top"
                  rel="noopener"
                  className="text-[10.5px] text-teal-700 hover:underline"
                >
                  原始档案 →
                </a>
              ) : null}
            </div>
          </div>
          {patient.tags.length > 0 && (
            <div className="mt-1 flex items-center gap-1 flex-wrap">
              {patient.tags.map((tag) => (
                <Chip key={tag} tone={tag.includes('VIP') ? 'amber' : 'slate'} size="xs">
                  {tag}
                </Chip>
              ))}
            </div>
          )}
          {/* 有原始档案入口 → 隐藏手机号行(真号在宿主档案页,PAC 侧为造数假号) */}
          {!originalArchiveUrl && (
          <div className="mt-1 flex items-center gap-1.5">
            <span className="text-[12px] tabular-nums font-mono text-slate-700">
              {patient.phone ?? patient.phoneMasked}
            </span>
            {/* 号码真实性角标:真 = 外部对照表已核实;假 = 宿主同步号(测试库造数) */}
            <span
              title={patient.phoneVerified ? '真实手机号(外部对照表已核实)' : '未核实号码(测试库为造数假号)'}
              className={cn(
                'inline-flex items-center rounded px-1 text-[10px] font-medium leading-4 ring-1 ring-inset',
                patient.phoneVerified
                  ? 'bg-teal-50 text-teal-700 ring-teal-200'
                  : 'bg-slate-50 text-slate-400 ring-slate-200',
              )}
            >
              {patient.phoneVerified ? '真' : '假'}
            </span>
            {/* 复制号码明文 */}
            <button
              onClick={copyPhone}
              title="复制号码"
              className={cn(
                'inline-flex items-center justify-center w-5 h-5 rounded transition-colors',
                copied ? 'text-emerald-600 bg-emerald-50' : 'text-slate-400 hover:text-teal-700 hover:bg-teal-50',
              )}
            >
              {copied ? (
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2.5">
                  <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
                  <rect x="9" y="9" width="13" height="13" rx="2" />
                  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                </svg>
              )}
            </button>
            {/* ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人标注 — 儿童/老人本人常无手机,触达打监护人。
                真实手机号宿主未提供(现随机),此处仅演示"手机号旁标注(身份 姓名)"。上线前移除/重做。 */}
            {patient.profile.guardian && (
              <span className="ml-0.5 text-[11px] text-amber-700" title="监护人">
                （{patient.profile.guardian.relationshipLabel}
                {patient.profile.guardian.name ? ` ${patient.profile.guardian.name}` : ''}）
              </span>
            )}
          </div>
          )}
        </div>
      </div>
    </section>
  );
}

// ──────────────────────────────────────────
// PersonaTagsCard — 画像标签(独立卡片,放身份卡下方)
//   交互跟「历史联系」一致:卡片只展示 LLM 一句话**画像重点**(一眼抓重点);
//   标签云 + 规则收进「详情」抽屉(Drawer kind='persona':标签属性 + 取值条目 + ? hover 规则)。
//   进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
// ──────────────────────────────────────────
function PersonaTagsCard({
  planId,
  features,
  onOpenDetail,
}: {
  planId: string;
  features: typeof mockPersona.features;
  onOpenDetail: () => void;
}) {
  const [summary, setSummary] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    plansApi
      .getPersonaSummary(planId)
      .then((r) => {
        if (alive) setSummary(r.status === 'ready' ? r.summary : null);
      })
      .catch(() => {
        /* 生成失败:静默,显示兜底行 */
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, [planId]);

  return (
    <SidebarCard
      title="画像标签"
      meta={features.length > 0 ? `${features.length} 项` : undefined}
      action={
        <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
          详情 →
        </button>
      }
    >
      {summary ? (
        // 已生成 → 一句话画像重点
        <p className="text-[12.5px] text-slate-700 leading-relaxed">{summary}</p>
      ) : loading ? (
        // 生成中 → 文字闪烁占位(像 AI 话术生成)
        <div className="animate-pulse space-y-1.5 py-0.5">
          <div className="h-3 rounded bg-slate-100" />
          <div className="h-3 w-3/4 rounded bg-slate-100" />
        </div>
      ) : features.length > 0 ? (
        // 兜底(生成失败/无摘要)→ 退回标签云,信息不丢
        <PersonaTagCloud features={features} />
      ) : (
        <p className="text-[12px] text-slate-400">暂无画像标签(数据不足)</p>
      )}
    </SidebarCard>
  );
}

// ──────────────────────────────────────────
// RecallReasonLine — 本次召回原因(primary + N 其余 hover);
//   每行用 signals JSON + @pac/types 字典翻译富文本渲染(reason 文本仅 signals 缺失时 fallback)。
//   原"为什么召回"卡片已去掉,此内容移到中栏「参考话术」标题下方(占掉原"共 N 步")。
//   召回算法产出的全部 reason(治疗链已弃用,不做替代闭环二次过滤),这里只渲染。
function RecallReasonLine({ visibleReasons }: { visibleReasons: PlanReason[] }) {
  if (visibleReasons.length === 0) {
    return (
      <span className="text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</span>
    );
  }
  return (
    <span className="text-slate-700">
      <ReasonLine reason={visibleReasons[0]!} />
      {visibleReasons.length > 1 && (
        <HoverCard openDelay={120} closeDelay={80}>
          <HoverCardTrigger asChild>
            <button className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-teal-700 hover:bg-slate-100 cursor-default">
              +{visibleReasons.length - 1}
            </button>
          </HoverCardTrigger>
          <HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
            <p className="text-[11px] font-medium text-slate-500 mb-1.5">
              其余 {visibleReasons.length - 1} 项应治未治
            </p>
            <ul className="space-y-1.5 text-[12px] text-slate-700 leading-relaxed">
              {visibleReasons.slice(1).map((r) => (
                <li key={r.id} className="flex gap-1.5">
                  <span className="text-rose-400 flex-none mt-[2px]">•</span>
                  <div className="flex-1 min-w-0">
                    <ReasonLine reason={r} />
                  </div>
                </li>
              ))}
            </ul>
          </HoverCardContent>
        </HoverCard>
      )}
    </span>
  );
}

// ──────────────────────────────────────────
// RecallBriefLine — 本次召回一句话简报(LLM)
//   交互跟「历史联系 / 画像标签」一致:进来 get-or-generate;有则秒回显示一句话,
//   生成中 shimmer 占位,失败/空则回退到结构化 RecallReasonLine(信息不丢)。
//   一句话回答三问:患者是谁 / 帮其解决什么问题 / 邀约到诊做什么。
// ──────────────────────────────────────────
function RecallBriefLine({
  planId,
  visibleReasons,
}: {
  planId: string;
  visibleReasons: PlanReason[];
}) {
  const [summary, setSummary] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [expanded, setExpanded] = useState(false);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    plansApi
      .getRecallBrief(planId)
      .then((r) => {
        if (alive) setSummary(r.status === 'ready' ? r.summary : null);
      })
      .catch(() => {
        /* 生成失败:静默,回退结构化原因 */
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, [planId]);

  if (summary) {
    // 主题色阶背景:左侧 teal 实条 + 向右渐隐的 teal 底,突出"这通电话的由头"。
    // 右侧箭头展开 → 看摘要"压掉"的结构化召回原因(=以前的「为什么召回」,样式沿用 RecallReasonLine)。
    const canExpand = visibleReasons.length > 0;
    return (
      <div>
        <button
          type="button"
          onClick={canExpand ? () => setExpanded((v) => !v) : undefined}
          aria-expanded={canExpand ? expanded : undefined}
          className={cn(
            'flex w-full items-start gap-2 rounded-md border-l-[3px] border-teal-400 bg-gradient-to-r from-teal-50 via-teal-50/60 to-transparent py-1.5 pl-2 pr-2 text-left',
            canExpand ? 'cursor-pointer hover:from-teal-100/70' : 'cursor-default',
          )}
        >
          <svg viewBox="0 0 24 24" className="mt-[1px] h-3.5 w-3.5 flex-none text-teal-500" fill="none" stroke="currentColor" strokeWidth="2">
            <circle cx="12" cy="12" r="9" />
            <path d="M12 8v5M12 16h.01" strokeLinecap="round" />
          </svg>
          <span className="flex-1 min-w-0 font-medium text-teal-900 leading-snug">{summary}</span>
          {canExpand && (
            // 右指箭头(收起)→ 旋转 90° 朝下(展开)
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.5"
              className={cn('mt-[1px] h-3.5 w-3.5 flex-none text-teal-500/70 transition-transform', expanded && 'rotate-90')}
            >
              <path d="M9 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          )}
        </button>
        {canExpand && expanded && (
          // 展开 = 以前的「为什么召回」结构化原因(RecallReasonLine,样式与以前一致)
          <div className="mt-1.5 rounded-md border border-slate-100 bg-white px-2.5 py-2">
            <div className="text-[12.5px] text-slate-700 leading-relaxed">
              <RecallReasonLine visibleReasons={visibleReasons} />
            </div>
          </div>
        )}
      </div>
    );
  }
  if (loading) {
    return (
      <span className="inline-flex w-full max-w-[420px] animate-pulse items-center gap-2 align-middle">
        <span className="h-3 flex-1 rounded bg-slate-100" />
        <span className="h-3 w-1/4 rounded bg-slate-100" />
      </span>
    );
  }
  // 兜底:回退结构化召回原因
  return <RecallReasonLine visibleReasons={visibleReasons} />;
}

// ──────────────────────────────────────────
// KeyFactsCard — 关键事实(5 行)
//   主治医生 · 累计消费 · 上次到诊 · 首次就诊 · 联系人
//
// 数据源全走真实 PAC 数据(无 mock 兜底):
//   - 主治医生:从 facts 算 doctor_id 出现频次 top 1(同 chain-composer doctorMap),host 缺值 → '—'
//   - 累计消费:profile.ltv(LTV cents 已扣 refund);右侧 hint 显示 persona.value description(画像评级)
//   - 上次/首次到诊:profile.lastVisit / firstVisit
//   - 联系人:改走 PatientRelation 边表(relationsOut),读时取关系人姓名/关系;暂 '—' 占位待接前端
// hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2
// ──────────────────────────────────────────
function KeyFactsCard({
  patient,
  persona,
  facts,
  focusedReason,
  onOpenDetail,
  onOpenTeeth,
}: {
  patient: typeof mockPatient;
  persona: typeof mockPersona;
  facts: AdaptedFact[];
  /// 本次召回聚焦的 reason(priorityScore 最高那条)— 主治医生取它触发诊断的医生
  focusedReason?: PlanReason;
  onOpenDetail: () => void;
  onOpenTeeth: () => void;
}) {
  // ─ 主治医生(口径 A)─ = 触发本次召回的诊断的【真实】医生(focusedReason 证据 fact)。
  //   而非"全量最高频医生"——召回是冲某诊断来的,该露出做出该诊断的医生。
  //   ⚠️ 影像 AI 诊断(code_source=image_ai,doctor=影像AI)不是真人,不当主治医生显示:
  //      退回"全量最高频【真实】医生"(排除影像AI);真没人类医生过 → '—'。
  //      (避免缺牙这种常被影像 AI 触发的召回把"主治医生"显示成"影像AI"。)
  const attendingDoctor = (() => {
    const ev = focusedReason?.evidence ?? [];
    if (ev.length > 0) {
      const evidenceIds = new Set(ev.map((e) => e.id));
      for (const f of facts) {
        if (f.type !== 'diagnosis_record' || !evidenceIds.has(f.id)) continue;
        const c = f.content as Record<string, unknown> | null;
        const name = c?.doctor_name ? String(c.doctor_name).trim() : '';
        if (name && name !== '影像AI') return name; // A:触发诊断的真实医生
      }
      // 触发诊断只来自影像 AI / 无人类医生 → 不显示"影像AI",落到下方真实医生兜底
    }
    // 兜底:全量 fact 最高频【真实】医生(排除影像 AI;同 chain-composer.buildDoctorMap)
    const idCount = new Map<string, number>();
    const idToName = new Map<string, string>();
    for (const f of facts) {
      const c = f.content as Record<string, unknown> | null;
      const id = c?.doctor_id ? String(c.doctor_id) : '';
      const name = c?.doctor_name ? String(c.doctor_name).trim() : '';
      if (!id || name === '影像AI' || String(c?.code_source ?? '') === 'image_ai') continue;
      idCount.set(id, (idCount.get(id) ?? 0) + 1);
      if (name) idToName.set(id, name);
    }
    if (idCount.size === 0) return '—';
    const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
    return idToName.get(topId) || `医生 #${topId}`;
  })();

  // ─ 累计消费 ─ 真实 LTV(已扣 refund);hint 用 persona.value 画像评级"短标签"
  // 后端 description "新客/未消费(累计净消费 ¥58.00,含 ...)" 太长 → 取首段 "新客"(去 / 和括号),
  // 跟主值 ¥58 互补不重复
  const ltvYuan = (patient.profile.ltv ?? 0).toLocaleString();
  // W7:value 已并入 rfm(分群标签);取 rfm 短标签作累计消费 hint(如"重要价值")
  const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.RFM);
  const valueHint = shortPersonaValueLabel(valueFeature?.value);

  // ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主治医生右侧 hint)
  const mainCategories = (() => {
    const counter = new Map<string, number>();
    for (const f of facts) {
      if (f.type !== 'treatment_record') continue;
      const cat = String((f.content as Record<string, unknown> | null)?.category ?? '');
      if (!cat || cat === 'review') continue;
      counter.set(cat, (counter.get(cat) ?? 0) + 1);
    }
    const top = [...counter.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2);
    return top.length ? top.map(([c]) => treatmentCategoryNameZh(c)).join(' · ') : '';
  })();

  // ─ 专属客服 ─ 摄入时从 host 落 preferences.dedicatedCs({id,name})
  const dedicatedCs = patient.dedicatedCs?.name ?? '-';

  // ─ 保险客户 ─ entitlement_status 画像特征的结构化 data(商保保司名 / 医保 / 自费)
  const entData = (persona.features.find((f) => f.key === PersonaFeatureKey.ENTITLEMENT_STATUS)?.data ?? null) as
    | { commercialInsured?: boolean; commercialInsurers?: string[]; medicalInsured?: boolean }
    | null;
  const insurance = (() => {
    if (entData?.commercialInsured) {
      const names = (entData.commercialInsurers ?? []).filter(Boolean);
      return { value: names.length ? names.join('、') : '商保客户', hint: '商保' };
    }
    if (entData?.medicalInsured) return { value: '医保', hint: '' };
    return { value: '-', hint: '' };
  })();

  // 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ TEST ONLY,见 PatientHeader)
  const rows = [
    { label: '主治医生', value: attendingDoctor, hint: mainCategories || '' },
    { label: '专属客服', value: dedicatedCs, hint: '' },
    { label: '累计消费', value: `¥${ltvYuan}`, hint: valueHint },
    { label: '保险客户', value: insurance.value, hint: insurance.hint },
  ];
  return (
    <SidebarCard
      title="关键事实"
      action={
        <span className="inline-flex items-center gap-2.5">
          <button onClick={onOpenTeeth} className="text-[10.5px] text-teal-700 hover:underline">
            牙位 →
          </button>
          <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
            详情 →
          </button>
        </span>
      }
    >
      <div className="space-y-1">
        {rows.map((r) => (
          <div key={r.label} className="flex items-center gap-2 text-[11px]">
            <span className="text-slate-500 w-14 flex-none">{r.label}</span>
            <span className="text-slate-900 font-medium flex-1 truncate tabular-nums" title={r.value}>
              {r.value}
            </span>
            <span className="text-slate-400 text-[10px] truncate max-w-[100px]" title={r.hint}>
              {r.hint}
            </span>
          </div>
        ))}
      </div>
    </SidebarCard>
  );
}

// ──────────────────────────────────────────
// TreatmentHistoryCard — 治疗历史(时间倒序,4 行高度超出滚动)
//   顶部:治疗计划(最新一条 planned treatment_record,按 plannedFor 倒序取第一条)
//   列表:已做治疗(actual treatment_record),每行 时间 + 治疗名(有牙位带牙位)
// ──────────────────────────────────────────
function treatmentLabel(f: AdaptedFact): string {
  const c = (f.content as Record<string, unknown> | null) ?? {};
  const name =
    String(c.subtype ?? '').trim() ||
    (c.category ? treatmentCategoryNameZh(String(c.category)) : '') ||
    '治疗';
  const tooth = String(c.tooth_position ?? '').trim();
  return tooth ? `${name} ${formatToothPosition(tooth)}` : name;
}

function TreatmentHistoryCard({
  facts,
  onOpenMedical,
  onOpenTimeline,
  emrUrl,
}: {
  facts: AdaptedFact[];
  onOpenMedical?: () => void;
  /// 「详情」→ 打开患者事实时间轴(默认只勾选治疗,见 Drawer kind='treatments')
  onOpenTimeline?: () => void;
  /// 配了 VIEW_MEDICAL_RECORD 时,「详情」槽改为打开宿主电子病历原文;此时隐藏原时间轴详情。
  emrUrl?: string;
}) {
  const history = useMemo(
    () =>
      facts
        .filter((f) => f.type === 'treatment_record' && f.kind === 'actual')
        .sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? '')),
    [facts],
  );

  const latestPlan = useMemo(() => {
    const top = facts
      .filter((f) => f.type === 'treatment_record' && f.kind === 'planned')
      .sort((a, b) => (b.plannedFor ?? '').localeCompare(a.plannedFor ?? ''))[0];
    if (!top) return null;
    // 只显示"最近一次实际治疗之后"的计划:计划日期 ≤ 最新治疗日 = 已被后续治疗超越/执行,不显示
    const latestActualDay = history[0]?.occurredAt?.slice(0, 10) ?? '';
    const planDay = top.plannedFor?.slice(0, 10) ?? '';
    if (latestActualDay && planDay && planDay <= latestActualDay) return null;
    return top;
  }, [facts, history]);

  return (
    <SidebarCard
      title="治疗历史"
      meta={history.length > 0 ? `${history.length} 项` : undefined}
      action={
        <div className="flex items-center gap-2">
          {onOpenMedical && (
            <button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
              病历快读 →
            </button>
          )}
          {emrUrl ? (
            // 宿主电子病历原文(占位插槽)—— 替换原「详情」时间轴抽屉
            <a
              href={emrUrl}
              target="_top"
              rel="noopener"
              className="text-[10.5px] text-teal-700 hover:underline"
            >
              原始病历 →
            </a>
          ) : (
            onOpenTimeline && (
              <button onClick={onOpenTimeline} className="text-[10.5px] text-teal-700 hover:underline">
                详情 →
              </button>
            )
          )}
        </div>
      }
    >
      {/* 治疗计划(最新一条)— 置顶,与历史同一行格式,"治疗计划"标在最右 */}
      {latestPlan && (
        <div className="mb-1 flex items-baseline gap-2 text-[11px]">
          <span className="w-[78px] flex-none whitespace-nowrap tabular-nums text-slate-400">
            {latestPlan.plannedFor?.slice(0, 10) ?? '—'}
          </span>
          <span className="flex-1 min-w-0 truncate text-slate-800" title={treatmentLabel(latestPlan)}>
            {treatmentLabel(latestPlan)}
          </span>
          <span className="flex-none rounded bg-teal-100 px-1 text-[10px] font-medium text-teal-700">治疗计划</span>
        </div>
      )}
      {history.length === 0 ? (
        <div className="text-[11.5px] italic text-slate-400">暂无治疗记录</div>
      ) : (
        <div className="max-h-[108px] space-y-1 overflow-y-auto pr-1">
          {history.map((f) => (
            <div key={f.id} className="flex items-baseline gap-2 text-[11px]">
              <span className="w-[78px] flex-none whitespace-nowrap tabular-nums text-slate-400">
                {f.occurredAt?.slice(0, 10) ?? '—'}
              </span>
              <span className="flex-1 min-w-0 truncate text-slate-800" title={treatmentLabel(f)}>
                {treatmentLabel(f)}
              </span>
            </div>
          ))}
        </div>
      )}
    </SidebarCard>
  );
}

// ──────────────────────────────────────────
// SuggestionCard
// ──────────────────────────────────────────
function SuggestionCard({
  plan,
  patient,
  persona,
  facts,
}: {
  plan: typeof mockPlan;
  patient: typeof mockPatient;
  persona: typeof mockPersona;
  facts: AdaptedFact[];
}) {
  // 推荐渠道 / 推荐时间 / 推荐角色 已移除 —— v1 算法对所有召回写死 phone/staff/asap,
  // 无 per-patient 差异(详见接口字段定性)。保留:本次目标(plan.goal) / 归属诊所(targetClinic)。
  const items = [
    {
      label: '本次目标',
      value: plan.goal ?? '(算法未生成目标)',
      icon: (
        <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
          <circle cx="12" cy="12" r="9" />
          <circle cx="12" cy="12" r="5" />
          <circle cx="12" cy="12" r="1.5" fill="currentColor" />
        </svg>
      ),
      tone: 'teal',
    },
    {
      label: '归属诊所',
      value: plan.targetClinic,
      icon: (
        <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
          <path d="M3 21h18M5 21V7l8-4 8 4v14M9 9h1m-1 4h1m-1 4h1M14 9h1m-1 4h1m-1 4h1" />
        </svg>
      ),
      tone: 'slate',
    },
  ];
  // ⭐ 风险规避 — 多信号合一(PAC 当前已有的真实风险信号)
  const risks = computeRisks({ patient, persona, facts, plan });
  return (
    <SidebarCard title="召回建议" defaultOpen={false}>
      <div className="space-y-1">
        {items.map((it) => {
          const T = tone(it.tone);
          return (
            <div key={it.label} className="flex items-start gap-2 text-[11px] py-0.5">
              <span className={cn('flex-none w-5 h-5 rounded flex items-center justify-center mt-px', T.bg, T.text)}>
                {it.icon}
              </span>
              <span className="w-14 flex-none mt-0.5 text-slate-500">{it.label}</span>
              {/* 自动换行 + 左对齐,不再 truncate;长内容(本次目标)完整展示 */}
              <span className="font-medium flex-1 leading-snug min-w-0 text-slate-900 break-words">
                {it.value}
              </span>
            </div>
          );
        })}

        {/* 风险规避(图 2 样式:多信号 · 分隔,琥珀色调) */}
        {risks.length > 0 && (
          <div className="flex items-start gap-2 text-[11px] py-0.5 mt-1 bg-amber-50/60 rounded px-1 -mx-1">
            <span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-amber-100 text-amber-700">
              <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <path d="M12 9v4M12 17h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.86a2 2 0 0 0-3.4 0z" />
              </svg>
            </span>
            <span className="w-14 flex-none mt-0.5 text-amber-700 font-semibold">风险规避</span>
            <span className="font-medium flex-1 leading-snug min-w-0 text-amber-900 break-words">
              {risks.join(' · ')}
            </span>
          </div>
        )}

        {/* 召回冷静期 / 暂缓 — snoozedUntil 未到期时提示客服"到点再跟,别现在打" */}
        {plan.snoozedUntil && plan.snoozedUntil.getTime() > Date.now() && (
          <div className="flex items-start gap-2 text-[11px] py-0.5 mt-1 bg-teal-50/60 rounded px-1 -mx-1">
            <span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-teal-100 text-teal-700">
              <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                <circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
              </svg>
            </span>
            <span className="w-14 flex-none mt-0.5 text-teal-700 font-semibold">暂缓跟进</span>
            <span className="font-medium flex-1 leading-snug min-w-0 text-teal-900 break-words">
              {snoozeLabel(plan.snoozedUntil)}
            </span>
          </div>
        )}
      </div>
      <div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between">
        <span className="text-[10.5px] text-slate-500">
          触达限额{' '}
          <strong className="text-slate-700 tabular-nums">
            {plan.contactAttempts} / {plan.maxContactAttempts}
          </strong>
        </span>
        <div className="inline-flex items-center gap-[3px]">
          {Array.from({ length: plan.maxContactAttempts }).map((_, i) => (
            <span
              key={i}
              className={cn(
                'w-2 h-1.5 rounded-sm',
                // 仅"已触达"染色,不预亮"下一格" — 旧版 0/4 时第一格 teal 高亮被误读为已用
                i < plan.contactAttempts ? 'bg-amber-500' : 'bg-slate-200',
              )}
            />
          ))}
        </div>
      </div>
    </SidebarCard>
  );
}

/// 日期 → "M月D日"(使用者习惯,不用 7/9 这种易歧义格式)
function mdLabel(d: Date): string {
  return `${d.getMonth() + 1}月${d.getDate()}日`;
}

/// snooze 到期时间 → 人话。永久哨兵(>~3 年)显示"长期暂缓",否则显示日期 + 剩余天数。
function snoozeLabel(until: Date): string {
  const days = Math.ceil((until.getTime() - Date.now()) / 86_400_000);
  if (days > 1000) return '长期暂缓(已闭环 / 标记无效)';
  return `${mdLabel(until)} 后再跟进(约 ${days} 天)`;
}

/// RecallHistoryCard — 患者级召回历史(跨所有 plan 版本)。
/// 价值:plan 再次被召回时,客服看得到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
function RecallHistoryCard({
  history,
  fmtRel,
}: {
  history: RecallHistoryItem[];
  fmtRel: typeof mockFmtRel;
}) {
  return (
    <SidebarCard title="召回历史" meta={`${history.length} 次`}>
      <div className="space-y-1.5">
        {history.map((h) => {
          const meta = EXECUTION_OUTCOME_META[h.outcome as ExecutionOutcome];
          // 统一灰色样式(不按结果分色)— 跟"未接通"一致,弱化历史、突出当前
          return (
            <div key={h.id} className="flex items-start gap-2 text-[11px] py-0.5">
              <span className="flex-none mt-[3px] w-1.5 h-1.5 rounded-full bg-slate-300" />
              <div className="flex-1 min-w-0">
                <div className="flex items-center gap-1.5 flex-wrap">
                  <span className="font-medium text-slate-600">{meta?.labelZh ?? h.outcome}</span>
                  <span className="text-slate-400 tabular-nums">{fmtRel(new Date(h.createdAt))}</span>
                  {h.scheduledNextAt && (
                    <span className="text-slate-400">· 约 {mdLabel(new Date(h.scheduledNextAt))}回访</span>
                  )}
                </div>
                {h.notes && (
                  <div className="text-slate-500 leading-snug break-words mt-0.5">{h.notes}</div>
                )}
              </div>
            </div>
          );
        })}
      </div>
    </SidebarCard>
  );
}

/// ReturnVisitsCard — 历史联系(诊所回访)。标题"历史联系"。
/// 交互:卡片只展示 LLM 一句话**重点摘要**(一眼抓重点);结构化记录收进「详情」抽屉。
///   进卡片 get-or-generate:已生成秒回直接显示;未生成则 shimmer 占位,后端当场生成完替换。
function ReturnVisitsCard({
  planId,
  visits,
  onOpenDetail,
}: {
  planId: string;
  visits: ReturnVisitItem[];
  onOpenDetail: () => void;
}) {
  const [summary, setSummary] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    plansApi
      .getRecallSummary(planId)
      .then((r) => {
        if (alive) setSummary(r.status === 'ready' ? r.summary : null);
      })
      .catch(() => {
        /* 生成失败:静默,显示兜底行 */
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, [planId]);

  return (
    <SidebarCard
      title="历史联系"
      meta={`${visits.length} 条`}
      action={
        <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
          详情 →
        </button>
      }
    >
      {summary ? (
        // 已生成 → 一句话重点摘要
        <p className="text-[12.5px] text-slate-700 leading-relaxed">{summary}</p>
      ) : loading ? (
        // 生成中 → 文字闪烁占位(像 AI 话术生成)
        <div className="animate-pulse space-y-1.5 py-0.5">
          <div className="h-3 rounded bg-slate-100" />
          <div className="h-3 w-3/4 rounded bg-slate-100" />
        </div>
      ) : (
        // 兜底(生成失败/无摘要)
        <p className="text-[12px] text-slate-400">{visits.length} 条历史联系 · 点「详情」查看</p>
      )}
    </SidebarCard>
  );
}

/**
 * 计算风险规避提示 — PAC 已有数据驱动
 *
 * 信号源(全是 PAC 已有字段):
 *   ① profile.doNotContact / deceased     — 合规硬约束
 *   ② persona recall_risk feature          — 流失/复发风险等级
 *   ③ 历史 refund_record                   — 退费提示触达谨慎
 *   ④ plan.contactAttempts 接近熔断        — 触达≥75% 限额时提示
 *   ⑤ patient.age < 14 / >= 70             — 触达需家属(host 暂无监护人字段)
 *   ⑥ 慢性病(K05 牙周长期诊断)            — 终身维护型,触达避免逼单
 */
function computeRisks({
  patient,
  persona,
  facts,
  plan,
}: {
  patient: typeof mockPatient;
  persona: typeof mockPersona;
  facts: AdaptedFact[];
  plan: typeof mockPlan;
}): string[] {
  const out: string[] = [];

  // ① 合规硬约束
  if (patient.profile.doNotContact) out.push('⛔ 已标记不打扰');
  if (patient.profile.deceased) out.push('⛔ 已故');

  // ② 流失风险:W7 起 recall_risk 并入 rfm/lifecycle;用生命周期(流失/沉睡客)代理
  const lifecycleFeature = persona.features.find((f) => f.key === PersonaFeatureKey.LIFECYCLE_STAGE);
  if (lifecycleFeature && /流失|沉睡/.test(lifecycleFeature.value)) {
    out.push('久未到诊 · 谨慎触达,先重新拉近');
  }

  // ③ 历史退费
  const refundCount = facts.filter((f) => f.type === 'refund_record').length;
  if (refundCount > 0) out.push(`有 ${refundCount} 次历史退费 · 避免逼单`);

  // ④ 触达接近熔断(>=75% 已用)
  if (plan.maxContactAttempts > 0 && plan.contactAttempts >= plan.maxContactAttempts * 0.75) {
    out.push(`已触达 ${plan.contactAttempts}/${plan.maxContactAttempts} · 接近熔断`);
  }

  // ⑤ 年龄段触达约束
  if (patient.age != null) {
    if (patient.age < 14) out.push('未成年 · 需联系监护人');
    else if (patient.age >= 70) out.push('高龄 · 建议联系家属陪同');
  }

  // ⑥ 终身维护型慢性病(K05 诊断 + 已有 periodontic 治疗)
  const hasK05 = facts.some((f) => f.type === 'diagnosis_record' && String((f.content as Record<string, unknown> | null)?.code ?? '') === 'K05');
  const hasPerio = facts.some(
    (f) =>
      f.type === 'treatment_record' &&
      f.kind === 'actual' &&
      String((f.content as Record<string, unknown> | null)?.category ?? '') === 'periodontic',
  );
  if (hasK05 && hasPerio) {
    out.push('牙周慢性病 · 终身维护型,沟通节奏宽松');
  }

  return out;
}

// ──────────────────────────────────────────
// PersonaTagCloud — 画像标签云(chip + hover「怎么算的」)
//   每个特征 = 一颗 chip(label + 短值),按 tone 着色;hover 展开该标签的算法说明
//   (PersonaFeatureHover 读 feature.key)。空值特征不出 chip。
// ──────────────────────────────────────────
function PersonaTagCloud({ features }: { features: typeof mockPersona.features }) {
  if (!features.length) {
    return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
  }
  return (
    <div className="flex flex-wrap gap-1.5">
      {features.map((f) => {
        const T = tone(f.tone);
        const { text: short, more } = compactPersonaValue(f.value, f.data);
        return (
          <PersonaFeatureHover key={f.key} featureKey={f.key} value={f.value}>
            <span
              className={cn(
                'inline-flex items-center gap-1 px-1.5 py-0.5 rounded ring-1 cursor-help max-w-full',
                T.bg,
                T.ring,
              )}
            >
              <span className={cn('flex-none text-[10px] font-semibold', T.text)}>{f.label}</span>
              {short && short !== f.label && (
                <span className="text-[10.5px] text-slate-700 truncate">{short}</span>
              )}
              {more > 0 && (
                <span className="flex-none text-[9.5px] font-medium text-slate-400 tabular-nums">
                  +{more}
                </span>
              )}
            </span>
          </PersonaFeatureHover>
        );
      })}
    </div>
  );
}


// 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
  { key: 'deepseek-v4-flash', label: '快 · 均衡' },
  { key: 'qwen3.7-max', label: '极快 · 简洁' },
];

// 投入档选项(跟模型并列,直传后端 tier)
const SCRIPT_TIERS: { key: ScriptTier; label: string }[] = [
  { key: 'stable', label: '稳健' },
  { key: 'standard', label: '标准' },
  { key: 'deep', label: '深度' },
];

// ──────────────────────────────────────────
// RegenBtn — 档位/模型选择 + 重新生成(下拉只选,按钮才触发)
//   - 左:档位下拉(稳健/标准/深度)—— 选中只更新选择,不立即生成
//   - 中:模型下拉(DeepSeek / Gemini)—— 同上
//   - 右:重新生成按钮(唯一触发口,用当前选中档位+模型起 SSE);流式态变"停止"可中断
// ──────────────────────────────────────────
function RegenBtn({
  streaming,
  model,
  tier,
  onSelectModel,
  onSelectTier,
  onStop,
  onRegen,
}: {
  streaming: boolean;
  model: ScriptModel;
  tier: ScriptTier;
  onSelectModel: (model: ScriptModel) => void;
  onSelectTier: (tier: ScriptTier) => void;
  onStop: () => void;
  onRegen: () => void;
}) {
  const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
  const currentTier = SCRIPT_TIERS.find((t) => t.key === tier) ?? SCRIPT_TIERS[0]!;
  const selectorCls = cn(
    'inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10.5px] border-r transition-colors',
    streaming
      ? 'text-slate-300 border-slate-100 cursor-not-allowed'
      : 'text-slate-500 border-slate-100 hover:text-teal-700 hover:bg-teal-50',
  );
  return (
    <div className="inline-flex flex-none items-stretch rounded border border-slate-100 overflow-hidden">
      {/* 档位选择(只选,不触发) */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild disabled={streaming}>
          <button
            type="button"
            title="选择投入档(稳健 / 标准 / 深度)— 选中不会立即生成"
            disabled={streaming}
            className={selectorCls}
          >
            <span>{currentTier.label}</span>
            <ChevronDown className="w-3 h-3" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start" className="min-w-[6rem]">
          <DropdownMenuRadioGroup value={tier} onValueChange={(v) => onSelectTier(v as ScriptTier)}>
            {SCRIPT_TIERS.map((t) => (
              <DropdownMenuRadioItem key={t.key} value={t.key} className="text-[12px] font-medium text-slate-800">
                {t.label}
              </DropdownMenuRadioItem>
            ))}
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenu>
      {/* 模型选择(只选,不触发) */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild disabled={streaming}>
          <button
            type="button"
            title="选择生成模型 — 选中不会立即生成"
            disabled={streaming}
            className={selectorCls}
          >
            <span className="hidden sm:inline">{current.label}</span>
            <ChevronDown className="w-3 h-3" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="start" className="min-w-[11rem]">
          <DropdownMenuRadioGroup value={model} onValueChange={(v) => onSelectModel(v as ScriptModel)}>
            {SCRIPT_MODELS.map((m) => (
              <DropdownMenuRadioItem key={m.key} value={m.key} className="text-[12px] font-medium text-slate-800">
                {m.label}
              </DropdownMenuRadioItem>
            ))}
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenu>
      {/* 重新生成(右侧,唯一触发口) */}
      <button
        onClick={() => (streaming ? onStop() : onRegen())}
        title={streaming ? '点击停止本次生成' : `用 ${currentTier.label} · ${current.label} 重新生成话术`}
        className={cn(
          'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] font-medium transition-colors',
          streaming
            ? 'text-rose-600 bg-rose-50 hover:bg-rose-100'
            : 'text-teal-600 hover:text-teal-700 hover:bg-indigo-50',
        )}
      >
        {streaming ? (
          <>
            <svg viewBox="0 0 24 24" fill="currentColor" className="w-3 h-3">
              <rect x="6" y="6" width="12" height="12" rx="1.5" />
            </svg>
            停止
          </>
        ) : (
          <>
            <svg
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="w-3 h-3"
            >
              <path d="M23 4v6h-6M1 20v-6h6" />
              <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
            </svg>
            重新生成
          </>
        )}
      </button>
    </div>
  );
}

// ──────────────────────────────────────────
// Toast
// ──────────────────────────────────────────
