'use client';

import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import { RefreshCw, ChevronDown } from 'lucide-react';
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 {
  cn,
  formatGender,
  formatDaysReadable,
} from '@/lib/utils';
import {
  PersonaFeatureKey,
  treatmentCategoryNameZh,
  EXECUTION_OUTCOME_META,
  type ExecutionOutcome,
} from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display';
import { ReasonLine } from './reason-line';
import { ChainSidebar } from './chain-viz';
import { ScriptView, type ScriptViewMode } from './script-viewer';
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 { CallWidget } from './call-widget';
import { submitExecution, adaptAbandonReasons } from './execution-api';

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

/// 召回历史一条(患者级,跨所有 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[];
  fmtRel: typeof mockFmtRel;
};

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

export function PlanDetailApp({
  data = FALLBACK_DATA,
  banner,
  /// W4 末:单患者从 DW 直连刷新成功后,触发外层 usePlanAggregate.refresh()
  /// 拉新 plan 详情 — 若 plan 已消失(scenario 不再命中),loader 层 useEffect
  /// 会捕获 PLAN_NOT_FOUND 自动跳列表 + toast
  onRefreshAggregate,
}: {
  data?: PlanDetailAppData;
  banner?: ReactNode;
  onRefreshAggregate?: () => void | Promise<void>;
}) {
  const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
  const facts = data.facts ?? [];
  const recallHistory = data.recallHistory ?? [];
  const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
  const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
  // 话术生成模型(具体型号);默认 deepseek-v4-flash
  const [scriptModel, setScriptModel] = useState<ScriptModel>('deepseek-v4-flash');
  const { state: streamState, regenerate, abort } = useScriptStream();
  const { state: summaryState, regenerate: regenerateSummary } = useSummaryStream();

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

  const reasons = plan.reasons;

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

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

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

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

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

  const isStreaming = streamState.status === 'streaming';

  const submitOutcome = async (formData: {
    channel: string;
    outcome: string;
    notes: string;
    scheduledNextAt: string;
    abandonReasons: string[];
    recallFeedback: string[];
  }) => {
    try {
      const { abandonReasons, abandonOther } = adaptAbandonReasons(formData.abandonReasons);
      const result = await submitExecution(plan.id, {
        channel: formData.channel,
        outcome: formData.outcome,
        notes: formData.notes || undefined,
        abandonReasons: abandonReasons.length > 0 ? abandonReasons : undefined,
        abandonOther,
        scheduledNextAt: formData.scheduledNextAt
          ? new Date(formData.scheduledNextAt).toISOString()
          : undefined,
        recallFeedback: formData.recallFeedback.length > 0 ? formData.recallFeedback : undefined,
      });

      // 本地立刻反映 plan 新态(不等下次拉数据)
      setPlanOverride({
        status: result.planStatus,
        contactAttempts: result.contactAttempts,
      });

      if (result.breakerTripped) {
        showToast(
          'amber',
          '已达触达上限,自动归档',
          `本次为第 ${result.contactAttempts} 次,Plan 自动 abandoned`,
        );
      } else {
        const statusLabel =
          ({ completed: '已完成', abandoned: '已放弃' } as Record<string, string>)[result.planStatus] ??
          '维持当前(等下次跟进)';
        showToast('emerald', '已归档', `Plan → ${statusLabel}`);
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      showToast('rose', '提交失败', msg.slice(0, 80));
    }
  };

  return (
    <div
      className="h-screen overflow-hidden bg-slate-50 text-slate-900 flex flex-col"
      style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }}
    >
      {banner}
      <TopBar
        plan={plan}
        reason={reasons[0]!}
        patientId={patient.id}
        onRefreshAggregate={onRefreshAggregate}
        showToast={showToast}
        fmtRel={fmtRel}
      />

      {/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
          抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */}
      <ResponsiveDetail
        isXl={useMediaQuery('(min-width: 1280px)')}
        leftPane={
          <aside className="min-h-0 flex flex-col gap-2.5 overflow-y-auto pr-1 h-full">
              <IdentityCard
                patient={patient}
                onOpenImage={() =>
                  showToast('slate', '影像调阅', '跳转宿主页面')
                }
                onOpenProfile={() =>
                  showToast('slate', '患者档案', '跳转宿主页面')
                }
              />
              <WhyCard
                reasons={reasons}
                chains={chains}
                onOpenMedical={() => setDrawerOpen('medical')}
              />
              <KeyFactsCard
                patient={patient}
                persona={persona}
                facts={facts}
                onOpenDetail={() => setDrawerOpen('facts')}
              />
              <SuggestionCard
                plan={effectivePlan}
                patient={patient}
                persona={persona}
                facts={facts}
              />
              {recallHistory.length > 0 && (
                <RecallHistoryCard history={recallHistory} fmtRel={fmtRel} />
              )}
              <SidebarCard
                title="治疗链"
                meta={`${chains.length} 条`}
                action={
                  <button
                    onClick={() => setDrawerOpen('chain-detail')}
                    className="text-[10.5px] text-teal-700 hover:underline"
                  >
                    详情 →
                  </button>
                }
              >
                <ChainSidebar chains={chains} />
              </SidebarCard>
              <SidebarCard
                title="患者画像"
                meta={`更新于 ${fmtRel(persona.computedAt)}`}
                action={
                  <button
                    onClick={() => setDrawerOpen('persona')}
                    className="text-[10.5px] text-teal-700 hover:underline"
                  >
                    详情 →
                  </button>
                }
              >
                <PersonaQuickList features={persona.features.slice(0, 4)} />
              </SidebarCard>
            </aside>
        }
        centerPane={
            <main className="relative min-h-0 flex flex-col h-full">
              <section className="bg-white rounded-lg border border-slate-200 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 flex flex-wrap items-center justify-between gap-x-2 gap-y-2">
                  <div className="min-w-0">
                    <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">参考话术</h2>
                    <p className="text-[10.5px] text-slate-500 mt-0.5">
                      共 {displayedSections.length} 步
                    </p>
                  </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}
                      onStop={() => {
                        abort();
                        showToast('slate', '已停止', '本次 AI 生成被中断');
                      }}
                      onRegen={(m) => {
                        setScriptModel(m);
                        void regenerate(plan.id, { model: m });
                      }}
                    />
                    {/* AI 时间戳 — 窄屏隐藏(信息不关键,腾空间) */}
                    <span className="hidden md:inline-flex">
                      <AIStamp
                        relative={
                          streamState.status === 'done'
                            ? '刚刚'
                            : fmtRel(script.generatedAt)
                        }
                        source={
                          streamState.status === 'done' ? streamState.source : script.source
                        }
                      />
                    </span>
                  </div>
                </header>
                <div className="flex-1 min-h-0 overflow-y-auto p-4">
                  <ScriptView mode={scriptMode} sections={displayedSections} streaming={isStreaming} />
                </div>
                <AIDisclaimerFooter
                  onFeedback={async (v) => {
                    try {
                      const r = await plansApi.submitScriptFeedback(plan.id, v);
                      if (!r.ok) {
                        // 后端找不到 invocation(纯模板兜底或老数据无 invocation 关联)
                        showToast('slate', '本话术暂无 AI 调用记录', '兜底模板话术无法评价');
                        return;
                      }
                      showToast(
                        v === 'up' ? 'emerald' : 'amber',
                        v === 'up' ? '感谢反馈 · 已标记为有用' : '感谢反馈 · 已标记为待改进',
                        '用于后续 AI 话术优化',
                      );
                    } catch (err) {
                      const msg = err instanceof Error ? err.message : String(err);
                      showToast('rose', '反馈提交失败', msg.slice(0, 100));
                    }
                  }}
                />
              </section>
              {/* 实时坐席辅助教练:正中底部麦克风悬浮按钮 + 悬浮面板(独立模块) */}
              <RealtimeCoach planId={plan.id} />
            </main>
        }
        rightPane={
            <aside className="min-h-0 flex flex-col gap-2.5 overflow-hidden h-full">
              <section className="bg-white rounded-lg border border-slate-200 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 flex items-center justify-between gap-2">
                  <div>
                    <h2 className="text-[14px] font-semibold text-slate-900 leading-tight">通话结果</h2>
                    <p className="text-[10.5px] text-slate-500 mt-0.5">提交后本次任务自动归档</p>
                  </div>
                  <CallWidget />
                </header>
                <div className="flex-1 min-h-0 overflow-y-auto p-3">
                  <OutcomeForm
                    plan={effectivePlan}
                    onSubmit={submitOutcome}
                    defaultChannel={effectivePlan.recommendedChannel}
                    onCreateAppointment={() =>
                      showToast('emerald', '打开宿主预约页', '带上患者 + 医生 + 治疗项预填')
                    }
                  />
                </div>
              </section>
            </aside>
        }
      />

      <Drawer
        open={!!drawerOpen}
        onClose={() => setDrawerOpen(null)}
        kind={drawerOpen}
        chains={chains}
        persona={persona}
        summaries={summaries}
        reason={reasons[0]!}
        facts={facts}
        fmtRel={fmtRel}
        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;
  rightPane: ReactNode;
}) {
  return (
    <div className="flex-1 min-h-0">
      <div className="h-full mx-auto px-5 py-3">
        {isXl ? (
          <div className="grid h-full gap-3" style={{ gridTemplateColumns: '300px 1fr 380px' }}>
            {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>
                <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>
            <TabsContent value="outcome" className="flex-1 min-h-0 mt-0 data-[state=inactive]:hidden">
              {rightPane}
            </TabsContent>
          </Tabs>
        )}
      </div>
    </div>
  );
}

// ──────────────────────────────────────────
// TopBar
// ──────────────────────────────────────────
function TopBar({
  plan,
  reason,
  patientId,
  onRefreshAggregate,
  showToast,
  fmtRel,
}: {
  plan: typeof mockPlan;
  reason: typeof mockPlan.reasons[0];
  patientId?: string;
  onRefreshAggregate?: () => void | Promise<void>;
  showToast?: (kind: string, title: string, msg: string) => void;
  fmtRel?: (d: Date) => string;
}) {
  const user = useAuthStore((s) => s.user);
  const [refreshing, setRefreshing] = useState(false);

  /// 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-200 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">
        <div className="inline-flex flex-none items-center gap-2">
          <span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
            PAC
          </span>
          {/* "疗效保障"标题字在窄屏隐藏,腾出空间给关键信息 */}
          <span className="hidden lg:inline text-[13px] font-semibold text-slate-900">疗效保障</span>
        </div>
        <span className="hidden lg:inline text-slate-300">/</span>
        <div className="min-w-0">
          <h1 className="truncate text-[14px] sm:text-[15px] font-semibold leading-tight text-slate-900">任务详情</h1>
          <p className="hidden sm:block mt-0.5 truncate text-[11px] text-slate-500">召回中心</p>
        </div>
        {/* scenario chip + 优先级 — 始终显示,核心信息 */}
        <Chip tone="rose" icon size="xs" className="ml-1 sm:ml-2 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>
      </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-200 bg-slate-50 text-slate-400'
                : 'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700',
            )}
          >
            <RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
            <span className="hidden sm:inline">{refreshing ? '同步中…' : '刷新'}</span>
          </button>
        )}
        {/* 回收倒计时 — 核心信息,始终显示 */}
        <RecycleCountdown recycleAt={plan.recycleAt} />
        {/* 用户名块 — md+ 才显,移动端只剩头像 */}
        <div className="hidden md:block text-right text-[11.5px] leading-tight text-slate-500">
          <div className="font-medium text-slate-700">{user?.sub ?? '—'}</div>
          <div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {user?.role ?? '—'}</div>
        </div>
        <span className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-full bg-gradient-to-br from-teal-400 to-teal-600 text-[12px] font-bold text-white">
          {(user?.sub ?? '?').charAt(0).toUpperCase()}
        </span>
      </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
// ──────────────────────────────────────────
function RecycleCountdown({ recycleAt }: { recycleAt: Date | null }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    const i = setInterval(() => setTick((t) => t + 1), 1000);
    return () => clearInterval(i);
  }, []);
  // 引用 tick 避免 lint warn(实际效果靠 setTick re-render)
  void tick;
  if (!recycleAt) {
    return (
      <span className="tabular-nums text-slate-400">未设置回收时间</span>
    );
  }
  const ms = recycleAt.getTime() - Date.now();
  if (ms <= 0) {
    return (
      <span className="tabular-nums">
        回收倒计时 <strong className="text-rose-600">已到期</strong>
      </span>
    );
  }
  const s = Math.floor(ms / 1000);
  const hh = String(Math.floor(s / 3600)).padStart(2, '0');
  const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
  const ss = String(s % 60).padStart(2, '0');
  const tone =
    s < 1800 ? 'text-rose-600' : s < 3600 ? 'text-amber-600' : 'text-emerald-600';
  return (
    <span className="tabular-nums">
      回收倒计时 <strong className={tone}>{`${hh}:${mm}:${ss}`}</strong>
    </span>
  );
}

// ──────────────────────────────────────────
// IdentityCard
// ──────────────────────────────────────────
function IdentityCard({
  patient,
  onOpenImage,
  onOpenProfile,
}: {
  patient: typeof mockPatient;
  onOpenImage: () => void;
  onOpenProfile: () => void;
}) {
  const [copied, setCopied] = useState(false);
  const [revealed, setRevealed] = useState(false);
  const [revealedPhone, setRevealedPhone] = useState<string | null>(null);
  // patient.phone 当前 backend 已返回明文(plan-aggregate.service.ts:131),前端优先用;
  // 若未来 backend 隐藏明文(只发 phoneMasked),则降级调 /patients/:id/phone-reveal 拉
  // TODO(W5+):backend 应只回 phoneMasked,reveal 走专用端点 + audit log
  const toggleReveal = async (e: React.MouseEvent) => {
    e.stopPropagation();
    if (revealed) {
      setRevealed(false);
      return;
    }
    // 已有明文 → 直接显示;否则调 API
    if (patient.phone && patient.phone !== patient.phoneMasked) {
      setRevealedPhone(patient.phone);
      setRevealed(true);
      return;
    }
    try {
      const { phone } = await plansApi.revealPhone(patient.id);
      setRevealedPhone(phone);
      setRevealed(true);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : '获取号码失败');
    }
  };
  const copyPhone = async (e: React.MouseEvent) => {
    e.stopPropagation();
    // 复制总是明文;若未 reveal 则先拉(用户意图明确想要 raw)
    let raw = revealedPhone ?? 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);
  };
  return (
    <section className="bg-white rounded-lg border border-slate-200 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-center 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>
              <span className="text-[10.5px] text-slate-500">
                {formatGender(patient.gender)}·{patient.age ?? '?'}岁
              </span>
            </div>
            <div className="flex items-center gap-2 flex-none">
              <button onClick={onOpenImage} className="text-[10.5px] text-teal-700 hover:underline">
                影像 →
              </button>
              <button onClick={onOpenProfile} className="text-[10.5px] text-teal-700 hover:underline">
                档案 →
              </button>
            </div>
          </div>
          <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>
          <div className="mt-1 flex items-center gap-1.5">
            <span className="text-[12px] tabular-nums font-mono text-slate-700">
              {revealed && revealedPhone ? revealedPhone : patient.phoneMasked}
            </span>
            {/* 查看明文 — eye / eye-off toggle(reveal,需 PATIENT_VIEW 权限) */}
            <button
              onClick={toggleReveal}
              title={revealed ? '隐藏号码' : '查看号码'}
              className={cn(
                'inline-flex items-center justify-center w-5 h-5 rounded transition-colors',
                revealed ? 'text-teal-700 bg-teal-50' : 'text-slate-400 hover:text-teal-700 hover:bg-teal-50',
              )}
            >
              {revealed ? (
                // eye-off
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
                  <path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" strokeLinecap="round" strokeLinejoin="round" />
                  <line x1="1" y1="1" x2="23" y2="23" strokeLinecap="round" />
                </svg>
              ) : (
                // eye
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
                  <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
                  <circle cx="12" cy="12" r="3" />
                </svg>
              )}
            </button>
            {/* 复制(总是复制明文,未 reveal 时自动拉) */}
            <button
              onClick={copyPhone}
              title="复制号码"
              className={cn(
                'inline-flex items-center justify-center w-5 h-5 rounded transition-colors',
                copied ? 'text-emerald-600 bg-emerald-50' : 'text-slate-400 hover:text-teal-700 hover:bg-teal-50',
              )}
            >
              {copied ? (
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2.5">
                  <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="1.8">
                  <rect x="9" y="9" width="13" height="13" rx="2" />
                  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                </svg>
              )}
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

// ──────────────────────────────────────────
// WhyCard — 召回原因列表(W3 末:plan_reasons 按 sub_key 拆分;
// 每行用 signals JSON + @pac/types 字典翻译富文本渲染,关键字高亮。reason 文本仅作 signals 缺失时 fallback)
// ──────────────────────────────────────────
function WhyCard({
  reasons,
  chains,
  onOpenMedical,
}: {
  reasons: PlanReason[];
  chains: typeof mockChains;
  onOpenMedical: () => void;
}) {
  // 替代闭环联动:若 reason.toothPosition 命中任一 chain.alternativeClosedBy(同牙位被后续替代方案覆盖),
  // 该 reason 已无召回意义 — 直接从列表 drop,不显示。
  // 治疗链全景里那条 chain 自带 amber "已被替代" chip,客服需要时去全景看,WhyCard 不再重复噪音。
  // (scenario SQL 是 patient 级粗粒度,跨 chain alternativeClosedBy 只在展示层 chain-composer 跑,故过滤只在 UI 侧做)
  const altCoveredTeeth = new Set<string>();
  for (const c of chains) {
    if (!c.alternativeClosedBy || !c.toothPosition) continue;
    altCoveredTeeth.add(c.toothPosition);
  }
  const visibleReasons = reasons.filter((r) => {
    const tooth = r.signals?.toothPosition ?? '';
    return !tooth || !altCoveredTeeth.has(tooth);
  });
  if (visibleReasons.length === 0) {
    return (
      <SidebarCard
        title="为什么召回"
        action={
          <button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
            病历快读 →
          </button>
        }
      >
        <div className="text-[11.5px] text-slate-400 italic">所有召回原因均已被替代方案覆盖,可不召回</div>
      </SidebarCard>
    );
  }
  return (
    <SidebarCard
      title="为什么召回"
      action={
        <button onClick={onOpenMedical} className="text-[10.5px] text-teal-700 hover:underline">
          病历快读 →
        </button>
      }
    >
      <ul className="space-y-2 text-[12.5px] text-slate-700 leading-relaxed">
        {visibleReasons.map((r) => (
          <li key={r.id} className="flex gap-1.5">
            {visibleReasons.length > 1 && <span className="text-rose-500 flex-none mt-[2px]">•</span>}
            <div className="flex-1 min-w-0">
              <ReasonLine reason={r} />
            </div>
          </li>
        ))}
      </ul>
    </SidebarCard>
  );
}

// ──────────────────────────────────────────
// 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
//   - 联系人:profile.primaryContactType(host 暂无数据,统一 '—' 占位)
// hint 列:除累计消费用 persona value 外,其他用 facts 推断的主要治疗类目 top 1-2
// ──────────────────────────────────────────
function KeyFactsCard({
  patient,
  persona,
  facts,
  onOpenDetail,
}: {
  patient: typeof mockPatient;
  persona: typeof mockPersona;
  facts: AdaptedFact[];
  onOpenDetail: () => void;
}) {
  // ─ 主诊医生 ─ 取 facts 中 doctor_id 出现频次最高的,并解析 doctor_name
  // 同 chain-composer.buildDoctorMap 逻辑同源:同 patient (id,name) 双全的 fact 学一遍 map
  const attendingDoctor = (() => {
    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;
      if (!c) continue;
      const id = c.doctor_id ? String(c.doctor_id) : '';
      const name = c.doctor_name ? String(c.doctor_name) : '';
      if (!id) 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();
  const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.VALUE);
  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(' · ') : '';
  })();

  // ─ 某次就诊"做了什么"摘要(上次到诊 / 首次就诊 hint)─
  // 窗口 = visit day ±1 day(EMR/治疗结算常延后一天,纳入)
  // 优先级:
  //   ① actual treatment 治疗类目(去重,如 "牙周 · 预防")
  //   ② 诊断码 + 中文名(如 "K05 慢性牙周炎")
  //   ③ EMR 主诉(illness_desc / pre_illness 截 12 字,如 "下前牙区牙龈肿痛…")
  //   ④ 兜底 "到诊"
  const visitPurpose = (day: string | undefined): string => {
    if (!day) return '';
    const within1Day = (occurredAt: string | null): boolean => {
      if (!occurredAt) return false;
      const d = occurredAt.slice(0, 10);
      // d ∈ [day, day+1](正向 1 日窗口,容忍 EMR/治疗结算延后)
      if (d === day) return true;
      const dDate = new Date(d).getTime();
      const dayDate = new Date(day).getTime();
      const diff = (dDate - dayDate) / 86400_000;
      return diff > 0 && diff <= 1;
    };

    // ① 实际治疗类(去重)
    const cats = new Set<string>();
    for (const f of facts) {
      if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
      if (!within1Day(f.occurredAt)) continue;
      const c = String((f.content as Record<string, unknown> | null)?.category ?? '');
      if (c && c !== 'review') cats.add(c);
    }
    if (cats.size > 0) return [...cats].map(treatmentCategoryNameZh).join(' · ');

    // ② 诊断名(取第一条非空)
    for (const f of facts) {
      if (f.type !== 'diagnosis_record') continue;
      if (!within1Day(f.occurredAt)) continue;
      const c = f.content as Record<string, unknown> | null;
      const name = String(c?.name_zh ?? c?.name ?? '');
      if (name) return `${name ? ' ' : ''}${name}`.trim();
    }

    // ③ EMR 主诉(illness_desc 优先,fallback pre_illness)
    for (const f of facts) {
      if (f.type !== 'emr_record') continue;
      if (!within1Day(f.occurredAt)) continue;
      const c = f.content as Record<string, unknown> | null;
      const text = String(c?.illness_desc ?? c?.pre_illness ?? '').trim();
      if (text) return text.length > 12 ? text.slice(0, 12) + '…' : text;
    }

    // ④ 兜底
    return '到诊';
  };

  // 顺序:累计消费 → 主诊医生 → 上次到诊 → 首次就诊 → 联系人(user 反馈调整)
  const rows = [
    { label: '累计消费', value: `¥${ltvYuan}`, hint: valueHint },
    { label: '主诊医生', value: attendingDoctor, hint: mainCategories || '' },
    { label: '上次到诊', value: patient.profile.lastVisit || '—', hint: visitPurpose(patient.profile.lastVisit) },
    { label: '首次就诊', value: patient.profile.firstVisit || '—', hint: visitPurpose(patient.profile.firstVisit) },
    { label: '联系人', value: patient.profile.primaryContactType || '—', hint: '' },
  ];
  return (
    <SidebarCard
      title="关键事实"
      defaultOpen={false}
      action={
        <button onClick={onOpenDetail} className="text-[10.5px] text-teal-700 hover:underline">
          详情 →
        </button>
      }
    >
      <div className="space-y-1">
        {rows.map((r) => (
          <div key={r.label} className="flex items-center gap-2 text-[11px]">
            <span className="text-slate-500 w-14 flex-none">{r.label}</span>
            <span className="text-slate-900 font-medium flex-1 truncate tabular-nums" title={r.value}>
              {r.value}
            </span>
            <span className="text-slate-400 text-[10px] truncate max-w-[100px]" title={r.hint}>
              {r.hint}
            </span>
          </div>
        ))}
      </div>
    </SidebarCard>
  );
}

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

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

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

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

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

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

/**
 * 计算风险规避提示 — 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('⛔ 已故');

  // ② persona recall_risk
  const riskFeature = persona.features.find((f) => f.key === 'recall_risk');
  if (riskFeature && /高|high/i.test(riskFeature.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;
}

// ──────────────────────────────────────────
// PersonaQuickList
// ──────────────────────────────────────────
function PersonaQuickList({ features }: { features: typeof mockPersona.features }) {
  return (
    <ul className="space-y-1">
      {features.map((f) => {
        const T = tone(f.tone);
        const { tag, text } = cleanPersonaValue(f.value);
        return (
          <li key={f.key} className="flex items-center gap-2">
            <span className={cn('flex-none w-1.5 h-1.5 rounded-full', T.dot)} />
            <span className={cn('text-[10.5px] font-semibold', T.text)}>{f.label}</span>
            {tag && (
              <span className="text-[10px] px-1 py-0 rounded bg-slate-100 text-slate-600 tabular-nums">
                {tag}
              </span>
            )}
            <span className="text-[10.5px] text-slate-700 truncate flex-1" title={f.value}>
              {text}
            </span>
          </li>
        );
      })}
    </ul>
  );
}


// 话术生成模型选项(具体型号,直传后端)
const SCRIPT_MODELS: { key: ScriptModel; label: string }[] = [
  { key: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' },
  { key: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' },
  { key: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash' },
];

// ──────────────────────────────────────────
// RegenBtn — 流式重新生成拆分按钮
//   - 主键:空闲态点击触发 SSE 生成(用当前选中模型);流式态变"停止",可中断
//   - 右侧 caret:DropdownMenu 选模型(DeepSeek / Gemini),选中即用该模型重新生成
// ──────────────────────────────────────────
function RegenBtn({
  streaming,
  model,
  onStop,
  onRegen,
}: {
  streaming: boolean;
  model: ScriptModel;
  onStop: () => void;
  onRegen: (model: ScriptModel) => void;
}) {
  const current = SCRIPT_MODELS.find((m) => m.key === model) ?? SCRIPT_MODELS[0]!;
  return (
    <div className="inline-flex flex-none items-stretch rounded overflow-hidden">
      {/* 主键 */}
      <button
        onClick={() => (streaming ? onStop() : onRegen(model))}
        title={streaming ? '点击停止本次生成' : `用 ${current.label} 重新生成话术`}
        className={cn(
          'inline-flex items-center gap-1 px-2 py-0.5 text-[10.5px] transition-colors',
          streaming
            ? 'text-rose-600 bg-rose-50 hover:bg-rose-100'
            : 'text-slate-500 hover:text-indigo-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>
      {/* 模型选择 caret */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild disabled={streaming}>
          <button
            type="button"
            title="选择生成模型"
            disabled={streaming}
            className={cn(
              'inline-flex items-center gap-0.5 px-1 py-0.5 text-[10.5px] border-l transition-colors',
              streaming
                ? 'text-rose-300 border-rose-100 cursor-not-allowed'
                : 'text-slate-400 border-slate-200 hover:text-indigo-700 hover:bg-indigo-50',
            )}
          >
            <span className="hidden sm:inline">{current.label}</span>
            <ChevronDown className="w-3 h-3" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end" className="min-w-[11rem]">
          <DropdownMenuRadioGroup
            value={model}
            onValueChange={(v) => onRegen(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>
    </div>
  );
}

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