'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 { useAuthStore } from '@/stores/auth-store';
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, openHostUrl, HOST_LINK_PROPS } from '@/lib/action-url';
import {
  cn,
  formatGender,
  formatDaysReadable,
  formatToothPosition,
} from '@/lib/utils';
import {
  PersonaFeatureKey,
  treatmentCategoryNameZh,
  treatmentCategoryNameZhForTeeth,
  treatmentCategoryNameZhFor,
  diagnosisCodeNameZh,
  EXECUTION_OUTCOME_META,
  RECALL_FEEDBACK_OPTIONS,
  ABANDON_REASON_META,
  personaFeatureSortKey,
  isKeyPersonaFeature,
  potentialTreatmentItemName,
  HOST_CASE_STAGE_CONSULTED,
  type HostPotentialTreatmentPayload,
  personaFeatureCategoryTone,
  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,
};

/**
 * 认领闸句柄 —— 在组件树里传递「能不能操作」+ 统一的拦截提示。
 * 只传函数不传状态判断,保证提示文案单一来源(见 PlanDetailApp 里 gateCheck 的注释)。
 */
export interface ClaimGate {
  canOperate: boolean;
  claimState: 'mine' | 'unclaimed' | 'other';
  /** 放行返 true;拦下则弹提示并返 false */
  check: () => boolean;
}

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);
  // 顶部那句 AI 召回简报 —— 由 RecallBriefLine 拿到后回报(它负责 get-or-generate)。
  // 「打开潜在治疗」的 postMessage 要发同一句话给宿主,所以提到这层存。
  const [recallBrief, setRecallBrief] = useState<string | null>(null);
  // 画像抽屉打开时要定位到哪个标签(点身份卡首屏 chip 进来时带上);从「详情 →」进则为 null
  const [personaFocusKey, setPersonaFocusKey] = useState<string | null>(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;
    /// 认领成功后就地切到"我的",不等重拉 —— 否则按钮要等一个来回才解锁
    assigneeUserId?: string | null;
  }>({});
  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);
  };

  // ── 认领闸:「未认领只能看,不能操作」──────────────────────────────────
  //   ⚠️ 真正的约束在**服务端**(plan/claim-guard.ts,拒绝码 20504/20505)—— 前端这层
  //   只是把拒绝**前置**:点下去立刻给解释,而不是发一个注定失败的请求再报错。
  //   所以这里绝不能用 disabled 了事:按钮保持可点,点了才讲原因,否则客服不知道为什么点不动。
  //   两种拒绝态文案必须分开 —— "没人认领"要引导去认领(顶栏有按钮),
  //   "别人认领了"要说清占用人,否则组员会以为是自己没点对。
  const meId = useAuthStore((s) => s.user?.sub ?? null);
  const assigneeUserId = effectivePlan.assigneeUserId ?? null;
  const claimState: 'mine' | 'unclaimed' | 'other' = !assigneeUserId
    ? 'unclaimed'
    : assigneeUserId === meId
      ? 'mine'
      : 'other';
  const canOperate = claimState === 'mine';
  /**
   * 操作前置校验:放行返 true;拦下则弹提示并返 false(调用方 `if (!gateCheck()) return;`)。
   * ⭐ 认领入口只留**列表页**那一处(左栏行内「认领」/ 批量认领)—— 详情页顶栏不再放认领按钮,
   *   避免同一动作两个入口、两套状态同步。所以文案要把人**指回列表**,否则等于只说"不行"不说去哪。
   */
  const gateCheck = (): boolean => {
    if (canOperate) return true;
    if (claimState === 'unclaimed') {
      showToast('amber', '请先认领后再操作', '这条召回还没人认领,在左侧列表点该患者的「认领」接手后即可操作');
    } else {
      showToast('amber', '无法操作', `该召回已被其他同事认领(${assigneeUserId}),如需接手请让其返池`);
    }
    return false;
  };
  const claimGate: ClaimGate = { canOperate, claimState, check: gateCheck };

  // 反向通道(列表 → 详情):在列表点了「认领」/「返池」→ 本页认领闸即时解锁 / 上锁,
  // 不用等重拉。assigneeUserId===undefined 表示该事件跟认领无关(如提交执行),跳过。
  const syncSeq = usePlanSyncStore((s) => s.seq);
  useEffect(() => {
    const { planId: changedId, assigneeUserId: nextAssignee } = usePlanSyncStore.getState();
    if (changedId !== plan.id || nextAssignee === undefined) return;
    setPlanOverride((p) => ({ ...p, assigneeUserId: nextAssignee }));
  }, [syncSeq, plan.id]);

  // 流式 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 sig = focusedReason?.signals;
    // ⭐ 优先用后端按年龄适配过的主类目(focusCategory);旧 plan 无此字段 → 回落候选集首项。
    //   直接取 expectedCategories[0] 会让高龄缺牙患者显示「目标 · 种植」(临床不合适)。
    const cat = sig?.focusCategory ?? sig?.expectedCategories?.[0];
    if (!cat) return null;
    // 年龄 + 牙位双适配(高龄缺牙 → 活动义齿;乳牙龋齿 → 只显充填):共享口径,前后端一致
    return treatmentCategoryNameZhFor(cat, sig?.toothPosition, sig?.patientAge ?? null);
  })();

  const submitOutcome = async (formData: {
    channel: string;
    outcome: string;
    notes: string;
    scheduledNextAt: string;
    abandonReasons: string[];
  }) => {
    if (!gateCheck()) return; // 认领闸
    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,
  };
  // 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
  //   它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
  //   而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。
  // 「打开潜在治疗」的附加载荷(仅 postMessage 模式带上;契约见 @pac/types/host-action-message)。
  //   三个字段一律取**客服此刻在页上看到的东西**,不另算一套 —— 宿主建出来的单要和客服
  //   刚读的那句话对得上,否则对账时说不清是谁改的。
  //   · 描述:顶部那句 AI 召回简报;还没生成好 → 退回结构化召回原因文本(跟 UI 的回退口径一致)
  //   · 治疗项目:画像 potential_treatment 的 code → **项目名**(卡片措辞去掉「治疗」后缀,
  //     宿主拿它当项目名落单;界面上客服看的仍是「种植治疗」。见 labels.ts)
  //   · 阶段:恒「已咨询」,PAC 不猜宿主流程内的状态
  const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => {
    const codes = ((persona.features.find((f) => f.key === PersonaFeatureKey.POTENTIAL_TREATMENT)
      ?.data ?? null) as { types?: unknown } | null)?.types;
    return {
      desc: recallBrief ?? visibleReasons[0]?.reason ?? '',
      treatments: Array.isArray(codes)
        ? codes.filter((c): c is string => typeof c === 'string').map(potentialTreatmentItemName)
        : [],
      stage: HOST_CASE_STAGE_CONSULTED,
    };
  };
  const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
    ? () => {
        if (!gateCheck()) return;
        openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
      }
    : undefined;
  const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
    ? () => {
        if (!gateCheck()) return;
        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) => {
    if (!gateCheck()) return; // 认领闸(弹窗入口已拦一次,这里兜底防绕过)
    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 = () => {
    if (!gateCheck()) return; // 认领闸:建预约是实打实的操作
    // 宿主 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;
    }
    // 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。
    // 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。
    openHostUrl(url);
  };

  // ── 宠物引导 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
              ? () => {
                  if (!gateCheck()) return;
                  setCloseOpen(true);
                }
              : undefined
          }
          claimGate={claimGate}
        />
      </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}
                features={persona.features}
                onOpenPersonaFeature={(key) => {
                  setPersonaFocusKey(key);
                  setDrawerOpen('persona');
                }}
                onOpenImage={() =>
                  showToast('slate', '影像调阅', '跳转宿主页面')
                }
                onOpenProfile={() =>
                  showToast('slate', '患者档案', '跳转宿主页面')
                }
              />
              <PersonaTagsCard
                planId={plan.id}
                features={persona.features}
                onOpenDetail={() => {
                  setPersonaFocusKey(null); // 走「详情 →」是看全量,不定位到某一条
                  setDrawerOpen('persona');
                }}
              />
              <KeyFactsCard
                patient={patient}
                persona={persona}
                facts={facts}
                focusedReason={focusedReason}
                onOpenDetail={() => setDrawerOpen('facts')}
                // 「牙位 →」按业务要求隐藏(2026-07-29):不传 onOpenTeeth 即不渲染。
                // 恢复只需 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}
                  // 「详情 →」按业务要求隐藏(2026-07-29):不传 onOpenDetail 即不渲染。
                  // 恢复: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-brand-50 text-brand-700 border border-brand-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 生成被中断');
                      }}
                      // 认领闸:重生成要真花 AI 钱,不该让没认领的人点(服务端同样会拒)
                      onRegen={() => {
                        if (!gateCheck()) return;
                        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}
                      onSummary={setRecallBrief}
                    />
                  </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) => {
                    if (!gateCheck()) return; // 认领闸
                    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);
          setPersonaFocusKey(null); // 定位高亮只属于这一次打开,关抽屉即清
        }}
        kind={drawerOpen}
        personaFocusKey={personaFocusKey}
        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"
            // 左栏 320px:与选患者列同宽(业务 2026-07-30)。原 300px 下画像标签 chip 折得太碎。
            style={{ gridTemplateColumns: rightPane ? '320px 1fr 380px' : '320px 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,
  claimGate,
}: {
  planId: string;
  initialFeedback: 'up' | 'down' | null;
  initialNote: string | null;
  showToast?: (kind: string, title: string, msg: string) => void;
  /** 认领闸;未传 = 不约束(mock / 独立预览) */
  claimGate?: ClaimGate;
}) {
  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;
    if (claimGate && !claimGate.check()) 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;
    if (claimGate && !claimGate.check()) 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={() => {
            if (claimGate && !claimGate.check()) return;
            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,
  claimGate,
}: {
  plan: typeof mockPlan;
  reason: typeof mockPlan.reasons[0];
  patientId?: string;
  /** 认领闸(见 ClaimGate);未传 = 不做认领约束(mock / 独立预览场景)。
   *  顶栏只用它渲染**只读**徽标 + 传给召回反馈控件;认领动作在列表页,不在这。 */
  claimGate?: ClaimGate;
  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-brand-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}
            claimGate={claimGate}
          />
        )}
        {/* 认领态徽标(**只读**,不是入口)—— 让"为什么点不动"在点之前就看得见。
            认领动作统一收口到列表页,顶栏不放按钮:同一动作两个入口会让状态同步和归属口径都变复杂。 */}
        {claimGate && claimGate.claimState !== 'mine' && (
          <span
            title={
              claimGate.claimState === 'unclaimed'
                ? '未认领 —— 只能查看;在左侧列表点该患者的「认领」接手后才能操作'
                : '该召回已被其他同事认领,你只能查看'
            }
            className={cn(
              'inline-flex flex-none items-center rounded-md px-2 py-1 text-[11.5px] font-medium',
              claimGate.claimState === 'unclaimed'
                ? 'bg-amber-50 text-amber-700'
                : 'bg-slate-100 text-slate-500',
            )}
          >
            {claimGate.claimState === 'unclaimed' ? '未认领 · 仅查看' : '仅查看'}
          </span>
        )}
      </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-brand-300 hover:bg-brand-50 hover:text-brand-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-brand-300 hover:bg-brand-50 hover:text-brand-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-brand-300 hover:bg-brand-50 hover:text-brand-700"
              >
                <CalendarPlus className="h-3.5 w-3.5" />
                <span className="hidden sm:inline">预约</span>
              </button>
            )}
            {onOpenReturnVisit && (
              <button
                type="button"
                onClick={onOpenReturnVisit}
                onMouseEnter={onHoverReturnVisit}
                // 按钮文案「跟进」而非「回访」(业务 2026-07-30):这个按钮跳的是宿主侧的动作页,
                // 落到宿主那边不一定叫回访;而 PAC 里「回访」已被"诊所回访记录 / 历史联系"占着,
                // 同一个词指两件事。⚠️ 只改按钮字面,槽位 key(OPEN_RETURN_VISIT)不动 ——
                // 那是宿主配置里的键名,改了所有宿主都得重配。
                title="跟进"
                className="inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-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,
  features,
  onOpenPersonaFeature,
  onOpenImage,
  onOpenProfile,
}: {
  patient: typeof mockPatient;
  /** 全量画像标签;卡内只挑「关键标签」露出(PERSONA_KEY_FEATURE_KEYS),其余在画像详情抽屉 */
  features: typeof mockPersona.features;
  /** 点关键标签 → 开画像详情抽屉并滚到那一条 */
  onOpenPersonaFeature: (featureKey: string) => void;
  onOpenImage: () => void;
  onOpenProfile: () => void;
}) {
  // 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
  // 选哪些 = PERSONA_KEY_FEATURE_KEYS 白名单;顺序和颜色 = 业务字典的 A/B/C/D 类区(见 types)。
  const keyFeatures = useMemo(
    () =>
      features
        .filter((f) => isKeyPersonaFeature(f.key))
        // ⭐ 与画像详情抽屉**同一个排序键**(类区序 C→B→D→A + 类区内序)——
        //   以前首屏走白名单数组序、抽屉走分组序,同一批标签两个顺序,客服得重新找一遍。
        .sort((a, b) => {
          const [ca, oa] = personaFeatureSortKey(a.key);
          const [cb, ob] = personaFeatureSortKey(b.key);
          return ca - cb || oa - ob;
        }),
    [features],
  );
  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}
                  {...HOST_LINK_PROPS}
                  // 走 JS 才有兜底:宿主 sandbox iframe 会把声明式 target="_blank" 直接拦掉,
                  // 而声明式那条路拦了我们既拦不到也补不了。href 留着让右键"复制链接"可用。
                  onClick={(e) => {
                    e.preventDefault();
                    openHostUrl(originalArchiveUrl);
                  }}
                  className="text-[10.5px] text-brand-700 hover:underline"
                >
                  原始档案 →
                </a>
              ) : null}
            </div>
          </div>
          {/* ⭐ 关键标签 —— 紧贴姓名下方(业务 2026-07-29:可快速查看,要细看再进画像详情)。
              chip 与画像标签卡/抽屉同一套组件,hover 仍能看「怎么算的」,不另起一套渲染。
              plain:只出取值、统一素色、点开抽屉定位(见 PersonaTagCloud 注释)。 */}
          {keyFeatures.length > 0 && (
            <div className="mt-1.5">
              <PersonaTagCloud features={keyFeatures} plain onSelect={onOpenPersonaFeature} />
            </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-brand-50 text-brand-700 ring-brand-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-brand-700 hover:bg-brand-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-brand-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-brand-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,
  onSummary,
}: {
  planId: string;
  visibleReasons: PlanReason[];
  /** 简报拿到后报给父层 —— 「打开潜在治疗」的 postMessage 要发同一句话给宿主,
   *  不能各查一次(那会出现"页面显示 A、发过去 B")。 */
  onSummary?: (summary: string | null) => void;
}) {
  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) return;
        const s = r.status === 'ready' ? r.summary : null;
        setSummary(s);
        onSummary?.(s);
      })
      .catch(() => {
        /* 生成失败:静默,回退结构化原因 */
      })
      .finally(() => {
        if (alive) setLoading(false);
      });
    return () => {
      alive = false;
    };
  }, [planId]);

  if (summary) {
    // 主题色阶背景:左侧品牌色实条 + 向右渐隐的品牌色底,突出"这通电话的由头"。
    // 右侧箭头展开 → 看摘要"压掉"的结构化召回原因(=以前的「为什么召回」,样式沿用 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-brand-400 bg-gradient-to-r from-brand-50 via-brand-50/60 to-transparent py-1.5 pl-2 pr-2 text-left',
            canExpand ? 'cursor-pointer hover:from-brand-100/70' : 'cursor-default',
          )}
        >
          <svg viewBox="0 0 24 24" className="mt-[1px] h-3.5 w-3.5 flex-none text-brand-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-brand-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-brand-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} />;
}

// ──────────────────────────────────────────
/**
 * contraindication 特征的 data 形态(后端 contraindication.feature.ts)。
 * `detail` 是禁忌(absolute/relative),`checks` 是核查项 —— 两者刻意分开,
 * 核查项按定义不是禁忌,合并显示会让客服把「问一句」当成「不能治」。
 */
interface ContraindicationEntry {
  code: string;
  label: string;
  reason?: string;
  phrase?: string | null;
  source?: { factId: string; field: string; occurredAt: string | null } | null;
}
interface ContraindicationData {
  labels?: string[];
  detail?: ContraindicationEntry[];
}

/**
 * 关键事实卡底部「风险提示位」的一条。
 * 目前只有画像禁忌产它;这是**通用形态** —— 以后投诉 / 退费纠纷 / 医疗争议接入时
 * 按同一形状追加,渲染不用改。
 */
interface RiskNote {
  /** 一行标题,如「手术禁忌 / 种植禁忌」 */
  title: string;
  /** 具体依据,如「放疗史 · 2022-12-02」—— 必须直接露出,不能只塞 tooltip */
  detail?: string;
  /** hover 补充(病历原文全文) */
  tooltip?: string;
  /** true = 次要提示(核查项),弱化显示;false = 硬风险 */
  muted?: boolean;
}

/**
 * 风险提示位开关 —— **当前关闭**。
 *
 * 关掉的理由(业务口径):画像标签卡的那句摘要已经会提到禁忌,关键事实卡再来一条
 * 红块在同屏内重复,信息密度不划算。禁忌本身照常在画像标签 chip + 画像详情抽屉里露出。
 *
 * 保留代码而不是删掉:这个位置将来要接投诉 / 退费纠纷 / 医疗争议(业务已提出设想,
 * 只是现在没数据)。届时把这里改回 true,再往 buildRiskNotes 里追加来源即可。
 */
const SHOW_RISK_NOTES = false;

/** 中文顿号连接,去重去空 */
function joinZh(xs: Array<string | null | undefined>): string {
  return [...new Set(xs.filter((x): x is string => !!x))].join('、');
}

function buildRiskNotes(persona: typeof mockPersona): RiskNote[] {
  if (!SHOW_RISK_NOTES) return [];
  const notes: RiskNote[] = [];
  const d = (persona.features.find((f) => f.key === PersonaFeatureKey.CONTRAINDICATION)?.data ??
    null) as ContraindicationData | null;
  const bans = (d?.labels ?? []).filter(Boolean);
  if (bans.length > 0) {
    const detail = (d?.detail ?? []).map((x) =>
      x.source?.occurredAt ? `${x.label} · ${x.source.occurredAt}` : x.label,
    );
    notes.push({
      title: bans.join(' / '),
      detail: joinZh(detail),
      // 全文原话留 hover —— 一行放不下「有放射治疗史,三周前进行颈淋巴结清扫(舌癌淋巴结转移)」
      tooltip:
        (d?.detail ?? [])
          .map((x) => `${x.source?.occurredAt ? `${x.source.occurredAt} ` : ''}${x.phrase ?? x.reason ?? x.label}`)
          .join('\n') || undefined,
    });
  }
  // ⬆ 以后:投诉记录 / 退费纠纷 / 医疗争议 —— 各自 push 一条 RiskNote 即可(现无数据)
  return notes;
}

// ──────────────────────────────────────────
// 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: '' };
  })();

  // ─ 风险提示位 ─ 关键事实卡底部的独立区块。
  //   为什么不混进标签云:标签云里禁忌跟「急迫等级/特别关注」同为 rose 底色、同排平铺,
  //   客服扫不出来;而这是**打电话前必须先看到**的东西。
  //   目前只有画像禁忌一个来源;这个位置是**通用风险位**,以后投诉/退费/纠纷之类
  //   接入时按同一形态往 notes 里追加即可(现在还没这些数据,不预建)。
  const riskNotes = buildRiskNotes(persona);

  // 联系人行已移除 — 监护人改在头部手机号旁标注(身份 姓名)(⚠️ 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">
          {/* 「牙位 →」按业务要求隐藏(2026-07-29)。抽屉(kind='teeth')整套留着,
              把 onOpenTeeth 传回来就恢复 —— 不删是因为牙位图本身没问题,只是这一屏不需要。 */}
          {onOpenTeeth && (
            <button onClick={onOpenTeeth} className="text-[10.5px] text-brand-700 hover:underline">
              牙位 →
            </button>
          )}
          <button onClick={onOpenDetail} className="text-[10.5px] text-brand-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>
        ))}
        {riskNotes.length > 0 && (
          <div className="mt-1.5 space-y-1 rounded border border-rose-200 bg-rose-50/70 px-2 py-1.5">
            {riskNotes.map((n) => (
              <div key={n.title} className="flex items-start gap-1.5 text-[11px]" title={n.tooltip}>
                <span className={`flex-none leading-4 ${n.muted ? 'text-rose-400' : 'text-rose-600'}`}>⚠</span>
                <div className="min-w-0 flex-1">
                  <div className={`leading-4 ${n.muted ? 'text-rose-700/80' : 'text-rose-800 font-medium'}`}>
                    {n.title}
                  </div>
                  {/* 具体原因必须直接露出,不能只在 hover 里 —— 只给分类不给依据,
                      客服无从判断,标签就会被当噪音忽略(旧版 evidence.factIds 为空就是这个下场) */}
                  {n.detail && (
                    <div className="text-[10px] leading-4 text-rose-700/70">{n.detail}</div>
                  )}
                </div>
              </div>
            ))}
          </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-brand-700 hover:underline">
              病历快读 →
            </button>
          )}
          {emrUrl ? (
            // 宿主电子病历原文(占位插槽)—— 替换原「详情」时间轴抽屉
            <a
              href={emrUrl}
              {...HOST_LINK_PROPS}
              // 同「原始档案」:点击走 openHostUrl 才有 sandbox 兜底(见 action-url.ts)
              onClick={(e) => {
                e.preventDefault();
                openHostUrl(emrUrl);
              }}
              className="text-[10.5px] text-brand-700 hover:underline"
            >
              原始病历 →
            </a>
          ) : (
            onOpenTimeline && (
              <button onClick={onOpenTimeline} className="text-[10.5px] text-brand-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-brand-100 px-1 text-[10px] font-medium text-brand-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: 'brand',
    },
    {
      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-brand-50/60 rounded px-1 -mx-1">
            <span className="flex-none w-5 h-5 rounded flex items-center justify-center mt-px bg-brand-100 text-brand-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-brand-700 font-semibold">暂缓跟进</span>
            <span className="font-medium flex-1 leading-snug min-w-0 text-brand-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 时第一格高亮被误读为已用
                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[];
  /** 未传 = 不渲染「详情 →」(业务要求隐藏;抽屉 kind='return-visits' 保留,传回来即恢复) */
  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={
        onOpenDetail ? (
          <button onClick={onOpenDetail} className="text-[10.5px] text-brand-700 hover:underline">
            详情 →
          </button>
        ) : undefined
      }
    >
      {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。
//
//   plain 模式(身份卡首屏):**去 label、去彩色、去 hover**,只留取值,样式对齐左栏列表卡片。
//   理由:首屏十来颗标签各自一种颜色 = 满屏彩条,反而看不出哪个要紧;取值本身自解释
//   (「种植禁忌」「储值会员」),label 是冗余的。
//   hover 说明卡也去掉了 —— 客服扫首屏时鼠标划过就弹卡是干扰,改成**点开抽屉定位到那一条**
//   (onSelect):想知道"这标签怎么算的"的人本来就该看完整那张卡,而不是一张浮层。
// ──────────────────────────────────────────
function PersonaTagCloud({
  features,
  plain = false,
  onSelect,
}: {
  features: typeof mockPersona.features;
  plain?: boolean;
  /** plain 专用:点 chip → 开画像详情抽屉并定位到该标签 */
  onSelect?: (featureKey: string) => void;
}) {
  if (!features.length) {
    return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
  }
  // 与详情抽屉同一套序(跟进要点 → 价值与阶段 → 基础属性)。
  // 后端返回顺序不保证(同版 feature 行 createdAt 相同),不排就是随机的。
  const ordered = [...features].sort((a, b) => {
    const [ga, oa] = personaFeatureSortKey(a.key);
    const [gb, ob] = personaFeatureSortKey(b.key);
    return ga - gb || oa - ob || a.key.localeCompare(b.key);
  });
  return (
    <div className={cn('flex flex-wrap', plain ? 'gap-1' : 'gap-1.5')}>
      {ordered.map((f) => {
        const T = tone(f.tone);
        const { text: short, more } = compactPersonaValue(f.value, f.data);
        // plain 下取值就是全部内容 —— 取不到值(理论上不该有)兜底显示标签名,不出空 chip
        const text = plain ? short || f.label : short;
        const inner = (
          <>
            {!plain && <span className={cn('flex-none text-[10px] font-semibold', T.text)}>{f.label}</span>}
            {text && (plain || text !== f.label) && (
              <span className={cn('truncate', !plain && 'text-[10.5px] text-slate-700')}>{text}</span>
            )}
            {more > 0 && (
              <span className="flex-none text-[9.5px] font-medium text-slate-400 tabular-nums">
                +{more}
              </span>
            )}
          </>
        );

        // 首屏:真 button —— 可 Tab 聚焦、读屏能念出标签名(视觉上 label 已去掉,靠 aria-label 补)
        //   ⭐ 颜色按**类区**(4 色)而不是按标签(16 色):业务 2026-07-30。
        //   一标签一色等于没有色彩层次(全是重点=没重点);按类区上色,客服扫一眼就知道
        //   "这排是该聊什么、那排是要避让的"。色板与抽屉分组标题同源,两处不会各自漂。
        if (plain) {
          const C = tone(personaFeatureCategoryTone(f.key));
          return (
            <button
              key={f.key}
              type="button"
              onClick={() => onSelect?.(f.key)}
              aria-label={`${f.label}:${text} —— 查看画像详情`}
              // ⚠️ 颜色类必须整串来自 TONE 表(shared.tsx 里是字面量,Tailwind 扫得到)。
              //   别写 `'hover:' + C.bg` 这种拼接 —— Tailwind 是静态扫源码的,拼出来的类名不会被生成,
              //   页面上表现为"这个颜色没生效",还很难查。
              className={cn(
                'inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 ring-1 ring-inset hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
                C.bg,
                C.text,
                C.ring,
              )}
            >
              {inner}
            </button>
          );
        }
        return (
          <PersonaFeatureHover key={f.key} featureKey={f.key} value={f.value}>
            <span className={cn('inline-flex max-w-full cursor-help items-center gap-1 rounded px-1.5 py-0.5 ring-1', T.bg, T.ring)}>
              {inner}
            </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-brand-700 hover:bg-brand-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-brand-600 hover:text-brand-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
// ──────────────────────────────────────────
