import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta, subLabelZh, treatmentCategoryNameZh } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/call';
import type {
  DraftPlanScriptInput,
  DraftPlanScriptOutput,
} from '../calls/draft-plan-script/input.types';

/**
 * 流式事件 — 给前端 SSE 用。
 * 跟 AiCallRunner.StreamEvent 平行但多了渲染好的 content/sections,前端零计算直接渲染。
 */
export type PlanScriptStreamEvent =
  | { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
  | {
      type: 'partial';
      structured: Partial<DraftPlanScriptOutput>;
      sections: ScriptSectionDto[];
    }
  | {
      type: 'done';
      planScriptId: string | null;
      agentInvocationId: string;
      source: 'agent' | 'template_fallback';
      structured: DraftPlanScriptOutput;
      content: string;
      sections: ScriptSectionDto[];
      costYuan: number;
      promptTokens: number;
      completionTokens: number;
      fallbackReason?: string;
    }
  | { type: 'error'; message: string };

/**
 * 渲染后的 section,前端直接消费(2026-06 重构:4 模块对齐业务"参考话术")。
 *   - opening       开场白
 *   - informMissed  告知漏诊项目
 *   - reviewAdvice  复查建议
 *   - closing       结束回访语
 */
export interface ScriptSectionDto {
  id: 'opening' | 'informMissed' | 'reviewAdvice' | 'closing';
  label: string;
  durationHint: string;
  markdown: string;
}

/**
 * PlanScriptOrchestrator — 业务侧编排:planId → AI 话术 → 写 PlanScript 表。
 *
 * 与 AiCallRunner 的分工:
 *   - Runner 是 harness 基建,只关心"跑一次 AI 调用"
 *   - Orchestrator 是业务,关心"从 DB 装 input、决定调哪个 AiCall、把 output 渲染成 Markdown 写库"
 *
 * 这一层属于"业务编排",不直接 import @ai-sdk;AiCall / Runner 是 SDK 边界。
 */

export interface PlanScriptGenerateOptions {
  /** 强制跳过缓存(重新生成场景) */
  bustCache?: boolean;
  /** eval / 测试场景下覆盖模型 */
  modelIdOverride?: string;
  /** 测试模式:不写 PlanScript 表(只看输出) */
  dryRun?: boolean;
}

export interface PlanScriptGenerateResult {
  planId: string;
  planScriptId: string | null;        // dryRun 时为 null
  agentInvocationId: string;
  source: 'agent' | 'template_fallback';
  cacheHit: boolean;
  costYuan: number;
  content: string;                     // 渲染后的 Markdown
  structured: DraftPlanScriptOutput;   // 结构化原始输出(给调试 / 前端做高级展示)
}

@Injectable()
export class PlanScriptOrchestrator {
  private readonly logger = new Logger(PlanScriptOrchestrator.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly runner: AiCallRunnerService,
    private readonly call: DraftPlanScriptCall,
  ) {}

  /**
   * 复用入口:给实时坐席辅助教练装配同一套 DraftPlanScriptInput(纯 DB 读,无副作用)。
   * 实时教练用它的 patient/plan/clinicalContext 拼 Qwen instructions,跟话术生成共享上下文。
   */
  async buildScriptInputForPlan(planId: string): Promise<DraftPlanScriptInput> {
    const { plan, patient, persona, facts } = await this.loadPlanContext(planId);
    return this.buildCallInput({ plan, patient, persona, facts });
  }

  /**
   * 生成 / 重新生成 plan 话术。
   * - 第一次生成:写 PlanScript 行
   * - 重新生成:upsert(UNIQUE planId)— 内容覆盖,agentInvocationId 指向新调用
   *
   * 注意:本方法**不重新走患者数据 pull**,直接从 DB 读现有 plan/persona/facts。
   * 这是"测试场景下重跑话术"的标准路径 — 输入完全确定,只看 prompt/model 改动效果。
   */
  async generate(
    planId: string,
    options: PlanScriptGenerateOptions = {},
  ): Promise<PlanScriptGenerateResult> {
    // ─── 1. 装配 input(纯 DB 读,不 pull) ───
    const { plan, patient, persona, facts } = await this.loadPlanContext(planId);
    const input = this.buildCallInput({ plan, patient, persona, facts });

    // ─── 2. 跑 AI 调用 ───
    const workflowRunId = randomUUID();
    const result = await this.runner.run(this.call, input, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId,
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: options.bustCache ?? false,
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
    });

    // ─── 3. 渲染 Markdown ───
    const content = renderMarkdown(result.output, { patientNameMasked: input.patient.nameMasked });

    // ─── 4. 写 PlanScript(dryRun 时跳过) ───
    let planScriptId: string | null = null;
    if (!options.dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId: plan.id },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          content,
          status: 'ready',
          source: result.source,
          agentInvocationId: result.invocationId,
        },
        update: {
          content,
          status: 'ready',
          source: result.source,
          agentInvocationId: result.invocationId,
        },
        select: { id: true },
      });
      planScriptId = row.id;
    }

    return {
      planId: plan.id,
      planScriptId,
      agentInvocationId: result.invocationId,
      source: result.source,
      cacheHit: result.cacheHit,
      costYuan: result.costYuan,
      content,
      structured: result.output,
    };
  }

  /**
   * 流式版本 — 返回 AsyncGenerator,逐步 yield 渲染好的 sections。
   *
   * 调用方(controller)把每个事件转换成 SSE 行吐给浏览器。
   * 完成时仍然写 PlanScript 表(无论 source=agent 还是 template_fallback)。
   */
  async *generateStream(
    planId: string,
    options: PlanScriptGenerateOptions = {},
  ): AsyncGenerator<PlanScriptStreamEvent, void, void> {
    let plan: PlanWithReasons;
    let patient: PatientRow;
    let persona: PersonaWithFeatures | null;
    let input: DraftPlanScriptInput;
    try {
      const ctx = await this.loadPlanContext(planId);
      plan = ctx.plan;
      patient = ctx.patient;
      persona = ctx.persona;
      input = this.buildCallInput(ctx);
    } catch (err) {
      yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
      return;
    }

    const workflowRunId = randomUUID();
    const events = this.runner.stream(this.call, input, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId,
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: true,             // 流式强制 bust(命中缓存的话没流可流)
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
    });

    const patientNameMasked = input.patient.nameMasked;
    let lastStructured: DraftPlanScriptOutput | null = null;
    let lastInvocationId: string | null = null;
    let lastSource: 'agent' | 'template_fallback' = 'agent';
    let lastCost = 0;
    let lastPromptTokens = 0;
    let lastCompletionTokens = 0;
    let lastFallbackReason: string | undefined;

    for await (const evt of events) {
      if (evt.type === 'start') {
        lastInvocationId = evt.invocationId;
        yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion };
      } else if (evt.type === 'partial') {
        yield {
          type: 'partial',
          structured: evt.partial,
          sections: renderSections(evt.partial, { patientNameMasked }),
        };
      } else if (evt.type === 'done') {
        lastStructured = evt.output;
        lastSource = evt.source;
        lastCost = evt.costYuan;
        lastPromptTokens = evt.promptTokens;
        lastCompletionTokens = evt.completionTokens;
        lastFallbackReason = evt.fallbackReason;
      } else if (evt.type === 'error') {
        yield { type: 'error', message: evt.message };
        return;
      }
    }

    if (!lastStructured) {
      yield { type: 'error', message: '生成结束但未拿到最终对象' };
      return;
    }

    // 写库(dryRun 跳过)
    const content = renderMarkdown(lastStructured, { patientNameMasked });
    let planScriptId: string | null = null;
    if (!options.dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId: plan.id },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          content,
          status: 'ready',
          source: lastSource,
          agentInvocationId: lastInvocationId,
        },
        update: {
          content,
          status: 'ready',
          source: lastSource,
          agentInvocationId: lastInvocationId,
        },
        select: { id: true },
      });
      planScriptId = row.id;
    }

    yield {
      type: 'done',
      planScriptId,
      agentInvocationId: lastInvocationId ?? '',
      source: lastSource,
      structured: lastStructured,
      content,
      sections: renderSections(lastStructured, { patientNameMasked }),
      costYuan: lastCost,
      promptTokens: lastPromptTokens,
      completionTokens: lastCompletionTokens,
      fallbackReason: lastFallbackReason,
    };
  }

  // ─────────────────────────────────────────────
  // input 装配
  // ─────────────────────────────────────────────

  private async loadPlanContext(planId: string) {
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      include: { reasons: { orderBy: { priorityScore: 'desc' }, take: 3 } },
    });
    if (!plan) throw new NotFoundException(`Plan ${planId} not found`);

    const patient = await this.prisma.patient.findUnique({
      where: { id: plan.patientId },
    });
    if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);

    const persona = await this.prisma.persona.findFirst({
      where: { patientId: patient.id, supersededAt: null },
      include: { features: true },
      orderBy: { version: 'desc' },
    });

    const facts = await this.prisma.patientFact.findMany({
      where: {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        patientId: patient.id,
        // ⚠️ 必须包含 fulfilled — actual treatment_record 状态是 fulfilled 不是 active,
        //   漏 fulfilled 会让 AI 看不到实际治疗记录(医生 / 治疗类目 / occurredAt)→
        //   primaryDoctor 偏移、pendingTreatments 漏算等。跟 plan-aggregate 同口径。
        status: { in: ['active', 'fulfilled'] },
      },
      orderBy: { occurredAt: 'desc' },
    });

    return { plan, patient, persona, facts };
  }

  private buildCallInput(args: {
    plan: PlanWithReasons;
    patient: PatientRow;
    persona: PersonaWithFeatures | null;
    facts: FactRow[];
  }): DraftPlanScriptInput {
    const { plan, patient, persona, facts } = args;

    // ⭐ 就诊事件回退:跟 plan-aggregate.serializeProfile 同口径
    //   encounter_record(appointment.in_time != null 才产)很多 host 缺,改用 EMR 兜底
    //   场景:DW 没 appointment 标到诊但 EMR 完整 → 之前 lastVisit/daysSinceLastVisit 全 null
    const encounters = facts.filter((f) => f.type === 'encounter_record');
    const visitFacts = [...encounters, ...facts.filter((f) => f.type === 'emr_record')]
      .filter((f) => f.occurredAt)
      .sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime());
    const latestEnc = visitFacts[0];
    const daysSinceLastVisit = latestEnc?.occurredAt
      ? Math.floor((Date.now() - latestEnc.occurredAt.getTime()) / 86400_000)
      : null;
    // 上次主诉:最近一条 emr_record 的 illness_desc 原文(自由文本直喂 LLM)
    const latestEmr = facts
      .filter((f) => f.type === 'emr_record' && f.occurredAt)
      .sort((a, b) => b.occurredAt!.getTime() - a.occurredAt!.getTime())[0];
    const lastChiefComplaint =
      ((latestEmr?.content as Record<string, unknown> | undefined)?.illness_desc as string | undefined)?.trim() ||
      null;

    // 给每条 reason 查"触发该诊断/建议的医生 + 日期"
    // evidence.factIds[0] = 主触发 fact;关联 patient_facts.content.doctor_name + occurredAt
    // LLM 在 followup 段必须引用触发医生(姜医生发现智齿)而非全患者高频医生(李医生)
    const factById = new Map(facts.map((f) => [f.id, f]));
    // encounter_external_id → 该次 EMR(取主诉 illness_desc 用)
    const emrByEncounter = new Map<string, FactRow>();
    for (const f of facts) {
      if (f.type !== 'emr_record') continue;
      const enc = (f.content as Record<string, unknown> | null)?.encounter_external_id as string | undefined;
      if (enc && !emrByEncounter.has(enc)) emrByEncounter.set(enc, f);
    }
    // ⭐ 单一聚焦:trigger 信息要"项目相关"——这个漏诊项**那次诊断**的医生/日期/主诉,
    //   不是泛泛的最近一次就诊(否则"缺牙2025-12诊断"却说"自从2026-03洁牙后"就错位)
    const resolveReasonTrigger = (r: PlanWithReasons['reasons'][number]) => {
      const evidence = (r.evidence ?? {}) as { factIds?: string[] };
      const leadFactId = evidence.factIds?.[0];
      if (!leadFactId) return { doctor: null, date: null, chiefComplaint: null };
      const lead = factById.get(leadFactId);
      if (!lead) return { doctor: null, date: null, chiefComplaint: null };
      const c = lead.content as Record<string, unknown> | null;
      const doctor = (c?.doctor_name as string | undefined)?.trim() || null;
      const date = lead.occurredAt ? lead.occurredAt.toISOString().slice(0, 10) : null;
      // 该诊断所在接诊的主诉(项目相关)
      const encId =
        ((c?.source_encounter_external_id ?? c?.encounter_external_id) as string | undefined) || null;
      const emr = encId ? emrByEncounter.get(encId) : undefined;
      const chiefComplaint =
        ((emr?.content as Record<string, unknown> | undefined)?.illness_desc as string | undefined)?.trim() ||
        null;
      return { doctor, date, chiefComplaint };
    };

    return {
      patient: {
        // 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*"
        // (后者是 UI 列表展示用,客服通话不能直接念"路星")
        nameMasked: nameSpokenForm(patient.name, patient.gender),
        gender: patient.gender,
        age: patient.birthDate ? calcAge(patient.birthDate) : null,
        medicalRecordNumber: patient.medicalRecordNumber ?? null,
      },
      // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
      // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
      clinicName: resolveClinicName(plan.targetClinicId),
      plan: {
        primaryScenarioLabel: plan.reasons[0]
          ? planScenarioLabel(plan.reasons[0].scenario)
          : '常规复诊召回',
        // ⭐ raw scenario key — skill composer.applies.scenario 用
        primaryScenarioKey: plan.reasons[0]?.scenario ?? null,
        priorityScore: plan.priorityScore,
        goal: plan.goal,
        reasons: plan.reasons.map((r) => {
          const trig = resolveReasonTrigger(r);
          // sub_key 形如 'caries_no_filling@36',base 去 @ 后缀
          const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
          return {
            scenarioLabel: planScenarioLabel(r.scenario),
            subKey: baseSubKey,
            dxCode: subKeyToDxCode(baseSubKey),
            reason: r.reason,
            priorityScore: r.priorityScore,
            triggerDoctor: trig.doctor,
            triggerDate: trig.date,
            triggerChiefComplaint: trig.chiefComplaint,
          };
        }),
      },
      personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({
        label: personaFeatureMeta(f.key).label,
        description: f.description,
      })),
      clinicalContext: {
        daysSinceLastVisit,
        lastVisitSummary: summarizeLastVisit(latestEnc),
        lastChiefComplaint,
        // pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗"
        //   旧版用 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07,导致阻生牙/牙体损伤等场景空
        //   reasons 是 SQL 算出的权威集,数据已对齐 chain-composer
        pendingTreatments: extractPendingFromReasons(plan.reasons),
        treatmentChainSummary: summarizeChain(visitFacts),
        ongoingChains: summarizeOngoingChains(facts),
        completedTreatmentCount: countCompletedTreatments(facts),
        primaryDoctorName: extractPrimaryDoctor(facts),
      },
    };
  }
}

// ─────────────────────────────────────────────
// 渲染:LLM 结构化输出 → Markdown(给 PlanScript.content)
// ─────────────────────────────────────────────

/**
 * 渲染整篇 markdown(写到 PlanScript.content 字段)。
 * B 方案:4 段直接拼接(LLM 已经写好每段 markdown,后端不做模板加工)。
 */
function renderMarkdown(
  out: DraftPlanScriptOutput,
  meta: { patientNameMasked: string },
): string {
  return `> 患者:${meta.patientNameMasked}  ·  语气:${toneLabel(out.tone)}

## 开场白
${out.opening}

## 告知漏诊项目
${out.informMissed}

## 复查建议
${out.reviewAdvice}

## 结束回访语
${out.closing}
`;
}

function toneLabel(tone: DraftPlanScriptOutput['tone']): string {
  return { warm: '温和家常', professional: '专业稳重', urgent: '时效紧迫' }[tone];
}

/**
 * 把(可能不完整的)结构化输出渲染成 4 段 ScriptSection,前端直接消费。
 *
 * B 方案设计要点:
 *   - LLM 直出 4 段 markdown(含 `**目的**:` / `**注意**` / `**异议预判**` / `**回写要点**` 子结构)
 *   - 这里不做任何模板加工,只是把 string 字段透传到 sections.markdown
 *   - partial 流式阶段:某段还没生成时为 undefined,渲染成空串(前端 ScriptMarkdown 自然占位)
 *   - 4 段顺序 + id 跟前端 mockScript.sections 完全一致,前端零适配消费
 */
function renderSections(
  out: Partial<DraftPlanScriptOutput>,
  _meta: { patientNameMasked: string },
): ScriptSectionDto[] {
  return [
    {
      id: 'opening',
      label: '开场白',
      durationHint: '30 秒',
      markdown: out.opening ?? '',
    },
    {
      id: 'informMissed',
      label: '告知漏诊项目',
      durationHint: '1–2 分钟',
      markdown: out.informMissed ?? '',
    },
    {
      id: 'reviewAdvice',
      label: '复查建议',
      durationHint: '1 分钟',
      markdown: out.reviewAdvice ?? '',
    },
    {
      id: 'closing',
      label: '结束回访语',
      durationHint: '30 秒',
      markdown: out.closing ?? '',
    },
  ];
}

// ─────────────────────────────────────────────
// 小工具(从 fact 提炼 LLM 真正需要的几条信息)
// ─────────────────────────────────────────────

function calcAge(birthDate: Date): number {
  const now = new Date();
  let age = now.getFullYear() - birthDate.getFullYear();
  const m = now.getMonth() - birthDate.getMonth();
  if (m < 0 || (m === 0 && now.getDate() < birthDate.getDate())) age--;
  return age;
}

function summarizeLastVisit(enc: FactRow | undefined): string | null {
  if (!enc) return null;
  const c = enc.content as Record<string, unknown> | null;
  let summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined);
  // 过滤内部 ID 类字符串(如 EMR 的 "关联接诊:16737540" / 纯 UUID / 纯数字串)
  //   这些是 raw ID,LLM 把它念给患者会很奇怪
  if (summary && /关联接诊[:：]|^[0-9a-f-]{8,}$|^\d{6,}$/.test(summary)) {
    summary = undefined;
  }
  if (!summary && !enc.occurredAt) return null;
  const when = enc.occurredAt ? fmtYearMonth(enc.occurredAt) : '近期';
  return `${when} — ${summary ?? '到店就诊'}`;
}

/**
 * sub_key → ICD-10 K-code(K00-K09)— skill composer.applies.diagnosisCodePrefix 用。
 * 跟 packages/types/src/canonical-codes.ts 的 PACScenarioSubLabels 一一对齐(K00-K09 全套)。
 *
 * ⚠️ 加新 sub_scenario 时记得在这里加一条 — 没有则 dxCode=null,该 reason 不参与 diagnosis-K0X skill match。
 */
const SUB_KEY_TO_K_CODE: Record<string, string> = {
  development_eruption: 'K00',
  impacted_tooth: 'K01',
  caries_no_filling: 'K02',
  hard_tissue_damage: 'K03',
  endo_no_rct: 'K04',
  perio_no_srp: 'K05',
  gum_alveolar_lesion: 'K06',
  ortho_no_consult: 'K07',
  missing_tooth: 'K08',
  jaw_cyst: 'K09',
};

function subKeyToDxCode(subKey: string | null): string | null {
  if (!subKey) return null;
  return SUB_KEY_TO_K_CODE[subKey] ?? null;
}

/**
 * 从 plan.reasons 派生 pendingTreatments(待办治疗列表,LLM 在 followup 段引用)。
 *
 * 为什么从 reasons 而不是 facts 派生:
 *   - reasons 是 SQL 算出的权威"未启动治疗"集,跟 chain-composer / 召回算法对齐
 *   - facts 派生需要 DX_TO_CAT 内置 map,K01/K03/K06/K07 等场景缺映射 → 漏算
 *   - reason.subKey 直接映射子场景中文 label(阻生牙未拔除 / 龋齿未做充填 等)
 *   - signals.toothPosition 是 SQL 已合并的牙位 union,字段一致可信
 */
function extractPendingFromReasons(
  reasons: Array<{ scenario: string; subKey: string | null; signals: unknown }>,
): string[] {
  const items: string[] = [];
  for (const r of reasons) {
    // sub_key 形如 'caries_no_filling@36' / 'perio_no_srp@whole';subLabelZh 接受不带 @ 的 base
    const baseSubKey = (r.subKey ?? '').split('@')[0]!;
    const subLabel = subLabelZh(r.scenario, baseSubKey);
    const sig = (r.signals ?? {}) as { toothPosition?: string | null };
    const toothRaw = (sig.toothPosition ?? '').trim();
    const friendly = toothRaw ? toothFriendly(toothRaw) : '';
    items.push(friendly ? `${subLabel} · ${friendly}` : subLabel);
  }
  // 去重(同 subLabel 多牙位会被 union-find 合并到一行,这里防御性 dedup)
  return Array.from(new Set(items)).slice(0, 5);
}

/**
 * v2.1:读独立 diagnosis_record / recommendation_record fact,找出"有诊断/建议但未启动治疗"的待办。
 * 不再读 encounter_record 嵌套字段(encounter 已退化只元数据)。
 * @deprecated 改用 extractPendingFromReasons — facts 派生有 DX_TO_CAT 漏映射 bug
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function extractPendingTreatments(facts: FactRow[]): string[] {
  // 已做的治疗 category
  const doneCats = new Set<string>();
  for (const f of facts) {
    if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
    const c = f.content as Record<string, unknown> | null;
    const cat = c?.category as string | undefined;
    if (cat) doneCats.add(cat);
  }
  // 期望治疗 category 映射(跟 chain-composer / treatment-chain-status feature 一致)
  const DX_TO_CAT: Record<string, string[]> = {
    K02: ['restorative'],
    K04: ['endodontic'],
    K05: ['periodontic'],
    K08: ['implant', 'prosthodontic'],
    IMPLANT_RECOMMENDED: ['implant'],
    CROWN_RECOMMENDED: ['prosthodontic'],
    FILLING_RECOMMENDED: ['restorative'],
    SRP_RECOMMENDED: ['periodontic'],
  };
  const items: string[] = [];
  for (const f of facts) {
    if (f.type !== 'diagnosis_record' && f.type !== 'recommendation_record') continue;
    const c = f.content as Record<string, unknown> | null;
    const code = (c?.code as string | undefined) ?? '';
    const name = (c?.name_zh as string | undefined) ?? (c?.name as string | undefined) ?? code;
    const tooth = (c?.tooth_position as string | undefined) ?? '';
    const expectedCats = DX_TO_CAT[code];
    if (!expectedCats) continue;
    const fulfilled = expectedCats.some((cat) => doneCats.has(cat));
    if (fulfilled) continue;
    // 牙位转俗称(LLM 不能对患者说"牙位 21")
    const friendly = tooth ? toothFriendly(tooth) : '';
    items.push(friendly ? `${name}(${friendly})` : name);
  }
  return Array.from(new Set(items)).slice(0, 3);
}

// ─────────────────────────────────────────────
// 事实漂移防护(W4 修)— 诊所/牙位/医生/称呼 必须用真实信息,不让 LLM 编
// ─────────────────────────────────────────────

/**
 * 通话称呼 — "姓 + 先生/女士"(性别未知用"您")。
 *
 * 跟 UI 列表的 maskName 区分:
 *   - UI 列表展示 → maskName("路星") = "路*"(脱敏)
 *   - 客服通话 → nameSpokenForm("路星","男") = "路先生"(真实可念)
 * LLM 的 input.patient.nameMasked 字段用本函数产物(命名是历史,语义已转通话名)。
 */
function nameSpokenForm(name: string | null, gender: string | null): string {
  if (!name) return '您';
  const surname = name.charAt(0);
  if (!surname) return '您';
  if (gender === '男' || gender === 'M' || gender === 'male') return `${surname}先生`;
  if (gender === '女' || gender === 'F' || gender === 'female') return `${surname}女士`;
  return surname ? `${surname}先生` : '您'; // 性别未知默认先生(亚洲诊所语境),可调
}


/**
 * 临时 jvs-dw 诊所字典(TODO #56:接 host clinic 字典或建 PAC clinics 表)。
 * 直接吐 clinicId UUID 进 prompt → LLM 当成乱码会编造"XX 客服中心",必须翻译。
 */
const JVS_DW_CLINIC_NAMES: Record<string, string> = {
  c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
  e83d432a38bb4f6284713b36db4e7497: '通善口腔上海世纪公园诊所',
  dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所',
  '7d49539c7573490387c03e6496ff1a6c': '通善口腔杭州大厦诊所',
  '66701845dd2342e19f9e9f576c4ffe9c': '通善口腔北京朝阳公园诊所',
};

function resolveClinicName(clinicId: string | null): string {
  if (!clinicId) return '本诊所';
  return JVS_DW_CLINIC_NAMES[clinicId] ?? '本诊所';
}

/**
 * FDI 牙位号 → 患者俗称。
 * 设计:粗粒度即可("上门牙"够,不需要"左上中切牙"),让客服讲话自然。
 * 多牙位用"/"分隔,去重保序。
 *
 * FDI 规则:
 *   第 1 位 = 象限(1-4 恒牙,5-8 乳牙)
 *   第 2 位 = 位置(1=中切,2=侧切,3=尖牙,4-5=前磨,6-7=磨牙,8=智齿)
 *   1x/2x 在上颌,3x/4x 在下颌
 */
function toothFriendly(fdiStr: string): string {
  const parts = fdiStr.split(/[;,，;\s]+/).map((s) => s.trim()).filter(Boolean);
  const friendly: string[] = [];
  const seen = new Set<string>();
  for (const p of parts) {
    const label = fdiToFriendly(p);
    if (label && !seen.has(label)) {
      seen.add(label);
      friendly.push(label);
    }
  }
  // 兜底:都解析不出就原样返回(避免空字符串)
  return friendly.length > 0 ? friendly.join('/') : fdiStr;
}

function fdiToFriendly(fdi: string): string | null {
  // 整体全口
  if (fdi === '*whole' || fdi.toLowerCase() === 'whole') return '全口';
  const m = /^([1-8])([1-8])$/.exec(fdi);
  if (!m) return null;
  const q = Number(m[1]);
  const t = Number(m[2]);
  const upper = q === 1 || q === 2 || q === 5 || q === 6;
  const isPrimary = q >= 5; // 乳牙
  const where = upper ? '上' : '下';
  const baby = isPrimary ? '乳' : '';
  // 位置粗粒度
  if (t === 1 || t === 2) return `${where}${baby}门牙`;
  if (t === 3) return `${where}尖牙`;
  if (t === 4 || t === 5) return `${where}小磨牙`;
  if (t === 6 || t === 7) return `${where}大磨牙`;
  if (t === 8) return '智齿';
  return null;
}

/**
 * 从最近的 treatment/diagnosis fact 抽主诊医生名。
 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主诊医生"泛指,不编)。
 */
function extractPrimaryDoctor(facts: FactRow[]): string | null {
  // 主诊医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生
  //
  // ⚠️ 必须跟前端 KeyFactsCard 同口径(plan-detail-app.tsx attendingDoctor),
  //    否则会出现"UI 显李医生 / AI 话术写王医生"的不一致 → 客服困惑 + AI 编造
  //
  // 老版本(W4)用"最新一次 treatment/diagnosis 的医生" — 语义偏:
  //   患者 5 年都是李医生治,最近一次姜医生临时接诊 → 老版选姜,新版选李
  //   "主诊"业务语义 = 长期管该患者的人,不是"最近一次接诊的"
  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 as string | undefined)?.trim() ?? '';
    if (!id) continue;
    idCount.set(id, (idCount.get(id) ?? 0) + 1);
    if (name && name.length > 0 && name.length <= 10) idToName.set(id, name);
  }
  if (idCount.size === 0) return null;
  const topId = [...idCount.entries()].sort((a, b) => b[1] - a[1])[0]![0];
  return idToName.get(topId) ?? null;
}

function summarizeChain(encounters: FactRow[]): string | null {
  if (encounters.length === 0) return null;
  // v2.1:encounter_record 只元数据,不再有 treatment_stage / treatment_category 嵌套
  return `共 ${encounters.length} 次就诊记录`;
}

/**
 * 正在进行的治疗链摘要(每个 category 一句):
 *   "牙周治疗在管 · 上次龈上洁治 · 吴医生 · 2024.04.27"
 *
 * 算法:
 *   - 按 category 分组 actual treatments(排除 review)
 *   - 每组取最新 1 条(occurredAt 最大)作 cluster 代表
 *   - 按最新时间 DESC 排,取前 4 条
 *
 * 给 LLM 的用处:
 *   ① 避免话术重复邀约已经在管的治疗(牙周已在做就不该再说"建议来做牙周")
 *   ② 引用历史治疗(吴医生 4 月给做过洁牙)体现"诊所记得 ta"
 */
function summarizeOngoingChains(facts: FactRow[]): string[] {
  const latestByCategory = new Map<string, FactRow>();
  for (const f of facts) {
    if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
    if (!f.occurredAt) continue;
    const c = f.content as Record<string, unknown> | null;
    const cat = c?.category as string | undefined;
    if (!cat || cat === 'review') continue;
    const existing = latestByCategory.get(cat);
    if (!existing || (existing.occurredAt && f.occurredAt > existing.occurredAt)) {
      latestByCategory.set(cat, f);
    }
  }
  const sorted = [...latestByCategory.entries()].sort((a, b) => {
    return (b[1].occurredAt?.getTime() ?? 0) - (a[1].occurredAt?.getTime() ?? 0);
  });
  return sorted.slice(0, 4).map(([cat, latest]) => {
    const c = latest.content as Record<string, unknown> | null;
    const subtype = (c?.subtype as string | undefined)?.trim();
    const doctor = (c?.doctor_name as string | undefined)?.trim();
    const when = latest.occurredAt
      ? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}`
      : '近期';
    const bits = [`${treatmentCategoryNameZh(cat)}在管`];
    if (subtype) bits.push(`上次 ${subtype}`);
    if (doctor) bits.push(`${doctor}医生`);
    bits.push(when);
    return bits.join(' · ');
  });
}

/**
 * 历史已做治疗总次数(信任锚)— actual + status fulfilled 的 treatment_record 数。
 * LLM 用:老客可以更家常,新客需要更详细的自报家门。
 */
function countCompletedTreatments(facts: FactRow[]): number {
  return facts.filter((f) => f.type === 'treatment_record' && f.kind === 'actual').length;
}

// ─────────────────────────────────────────────
// 类型(避免循环依赖,本地声明 Prisma 结构投影)
// ─────────────────────────────────────────────

type PlanWithReasons = Prisma.FollowupPlanGetPayload<{
  include: { reasons: true };
}>;

type PatientRow = Prisma.PatientGetPayload<{}>;

type PersonaWithFeatures = Prisma.PersonaGetPayload<{
  include: { features: true };
}>;

type FactRow = Prisma.PatientFactGetPayload<{}>;
