import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { calcAge, fmtYearMonth } from '@pac/utils';
import {
  focusOrderReasons,
  planScenarioLabel,
  personaFeatureMeta,
  subLabelZh,
  treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import type { StreamEvent } from '../ai-call-runner.service';
import type { AiCall, AiCallContext } from '../ai-call.interface';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/tiers/stable/stable.call';
import { StandardScriptCall } from '../calls/draft-plan-script/tiers/standard/standard.call';
import { DeepScriptStrategy, type DeepScriptResult } from '../calls/draft-plan-script/tiers/deep/deep.strategy';
import type { DeepDraft } from '../calls/draft-plan-script/tiers/deep/types';
import type { ScriptTier } from '../calls/draft-plan-script/shared/skill.types';
import { callSalutation, pickGuardian } from '../calls/draft-plan-script/shared/pii';
import { TONE_LABEL } from '../calls/draft-plan-script/shared/tone';
import type {
  DraftPlanScriptInput,
  DraftPlanScriptOutput,
  ScriptMedicalRecord,
} from '../calls/draft-plan-script/shared/input.types';

/**
 * 流式事件 — 给前端 SSE 用。
 * 跟 AiCallRunner.StreamEvent 平行但多了渲染好的 content/sections,前端零计算直接渲染。
 */
export type PlanScriptStreamEvent =
  | { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
  | {
      // 深度档可见过程:步骤切换(规划→撰写→自检→[修订])+ 各步收尾摘要(大纲/质量分/问题数)
      type: 'step';
      step: 'plan' | 'write' | 'verify' | 'repair';
      status: 'running' | 'done';
      detail?: {
        outline?: { title: string; intent: string }[];
        pass?: boolean;
        quality?: { natural: number; warmth: number; focus: number; nonPushy: number; overall: number } | null;
        issuesCount?: number;
        issues?: { section: string; problem: string; fix: string }[];
      };
    }
  | {
      type: 'partial';
      structured: unknown; // 稳健/标准=Partial<DraftPlanScriptOutput>;深度=Partial<DeepDraft>。前端只用 sections
      sections: ScriptSectionDto[];
    }
  | {
      type: 'done';
      planScriptId: string | null;
      agentInvocationId: string;
      source: 'agent' | 'template_fallback';
      structured: unknown; // 稳健/标准=DraftPlanScriptOutput;深度=DeepDraft。前端只用 sections/content
      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 {
  /** 稳健/标准:固定 4 id(opening/informMissed/reviewAdvice/closing);深度:段数不定,id = s0/s1/…(string) */
  id: string;
  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;
  /** ⭐ 投入档(默认 stable)。stable=4段模板填空 / standard=4段去模板自由编排。深度档后续。 */
  tier?: ScriptTier;
  /** 取消信号(SSE 客户端断连 → controller abort)→ 透传到 runner 取消在途 LLM(尤其深度 3-4 步) */
  signal?: AbortSignal;
}

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 | DeepDraft;   // 结构化原始输出(稳健/标准=4字段;深度=多段 DeepDraft)
}

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly runner: AiCallRunnerService,
    private readonly call: DraftPlanScriptCall,
    private readonly standardCall: StandardScriptCall,
    private readonly deepStrategy: DeepScriptStrategy,
  ) {}

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

  /**
   * 生成 / 重新生成 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, guardian, returnVisits } = await this.loadPlanContext(planId);
    const input = this.buildCallInput({ plan, patient, persona, facts, guardian, returnVisits });
    const patientNameMasked = input.patient.salutation;
    const runCtx: AiCallContext = {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId: randomUUID(),
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: options.bustCache ?? false,
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
    };

    // ─── 2. 跑(按投入档选策略;深度走 3 步 pipeline,其余单 call)+ 渲染 ───
    let content: string;
    let source: 'agent' | 'template_fallback';
    let invocationId: string;
    let cacheHit: boolean;
    let costYuan: number;
    let structured: DraftPlanScriptOutput | DeepDraft;
    if (options.tier === 'deep') {
      const r = await this.deepStrategy.run(input, runCtx);
      content = renderMarkdownFromSections(deepDraftToSections(r.draft), r.draft.tone, { patientNameMasked });
      source = r.source;
      invocationId = r.invocationId;
      cacheHit = r.cacheHit;
      costYuan = r.costYuan;
      structured = r.draft;
    } else if (options.tier === 'standard') {
      // 标准档:单步,但输出是自由段数组(DeepDraft 形态)→ 走段渲染(同深度)
      const result = await this.runner.run(this.standardCall, input, runCtx);
      content = renderMarkdownFromSections(deepDraftToSections(result.output), result.output.tone, { patientNameMasked });
      source = result.source;
      invocationId = result.invocationId;
      cacheHit = result.cacheHit;
      costYuan = result.costYuan;
      structured = result.output;
    } else {
      const result = await this.runner.run(this.call, input, runCtx);
      content = renderMarkdown(result.output, { patientNameMasked });
      source = result.source;
      invocationId = result.invocationId;
      cacheHit = result.cacheHit;
      costYuan = result.costYuan;
      structured = result.output;
    }

    // ─── 3. 写 PlanScript(dryRun 时跳过) ───
    let planScriptId: string | null = null;
    if (!options.dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId_channel: { planId: plan.id, channel: 'phone' } },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,

          channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
          content,
          status: 'ready',
          source,
          agentInvocationId: invocationId,
        },
        update: { content, status: 'ready', source, agentInvocationId: invocationId },
        select: { id: true },
      });
      planScriptId = row.id;
    }

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

  /**
   * 流式版本 — 返回 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 patientNameMasked = input.patient.salutation;

    // ── 深度档:3 步 pipeline 不走 runner.stream(无逐字 partial);start → 跑完 → done ──
    if (options.tier === 'deep') {
      const runCtx: AiCallContext = {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        workflowRunId: randomUUID(),
        linkedPatientId: patient.id,
        linkedPersonaId: persona?.id,
        linkedPlanId: plan.id,
        bustCache: true,
        modelIdOverride: options.modelIdOverride,
        evalMode: 'production',
        signal: options.signal,
      };
      yield {
        type: 'start',
        invocationId: '',
        modelId: options.modelIdOverride ?? 'deepseek-v4-flash',
        promptVersion: 'draft_plan_script@deep-pipeline',
      };
      let r: DeepScriptResult;
      try {
        // 逐步消费深度档生成器:step 事件 → 步骤可见;partial → 撰写/修订逐字流
        const g = this.deepStrategy.runStream(input, runCtx);
        let res = await g.next();
        while (!res.done) {
          const ev = res.value;
          if (ev.kind === 'step') {
            yield { type: 'step', step: ev.step, status: ev.status, detail: ev.detail };
          } else {
            yield { type: 'partial', structured: ev.draft, sections: deepDraftPartialToSections(ev.draft) };
          }
          res = await g.next();
        }
        r = res.value;
      } catch (err) {
        yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
        return;
      }
      const sections = deepDraftToSections(r.draft);
      const content = renderMarkdownFromSections(sections, r.draft.tone, { patientNameMasked });
      let planScriptId: string | null = null;
      if (!options.dryRun) {
        const row = await this.prisma.planScript.upsert({
          where: { planId_channel: { planId: plan.id, channel: 'phone' } },
          create: {
            hostId: plan.hostId,
            tenantId: plan.tenantId,
            planId: plan.id,

            channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
            content,
            status: 'ready',
            source: r.source,
            agentInvocationId: r.invocationId,
          },
          update: { content, status: 'ready', source: r.source, agentInvocationId: r.invocationId },
          select: { id: true },
        });
        planScriptId = row.id;
      }
      yield {
        type: 'done',
        planScriptId,
        agentInvocationId: r.invocationId,
        source: r.source,
        structured: r.draft,
        content,
        sections,
        costYuan: r.costYuan,
        promptTokens: r.promptTokens,
        completionTokens: r.completionTokens,
        fallbackReason: r.fallbackReason,
      };
      return;
    }

    const streamCtx: AiCallContext = {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId: randomUUID(),
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: true, // 流式强制 bust(命中缓存没流可流)
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
      signal: options.signal,
    };

    // ── 标准档:段数组形态,走 runner.stream 逐字 partial(sections 边出边渲染) ──
    if (options.tier === 'standard') {
      yield* this.streamAndPersist(this.runner.stream(this.standardCall, input, streamCtx), {
        plan,
        dryRun: options.dryRun ?? false,
        patientNameMasked,
        toSections: (o) => deepDraftPartialToSections(o),
        toContent: (o) => renderMarkdownFromSections(deepDraftToSections(o), o.tone, { patientNameMasked }),
      });
      return;
    }

    // ── 稳健档:4 段 flat schema,走 runner.stream 逐字 partial ──
    yield* this.streamAndPersist(this.runner.stream(this.call, input, streamCtx), {
      plan,
      dryRun: options.dryRun ?? false,
      patientNameMasked,
      toSections: (o) => renderSections(o, { patientNameMasked }),
      toContent: (o) => renderMarkdown(o, { patientNameMasked }),
    });
  }

  /**
   * 流式收尾共用件:消费 runner.stream 事件 → 逐 partial yield 渲染好的 sections,
   * done 时写 PlanScript + yield 终事件。稳健(4字段)/标准(段数组)各传自己的渲染器。
   */
  private async *streamAndPersist<O extends DraftPlanScriptOutput | DeepDraft>(
    events: AsyncGenerator<StreamEvent<O>>,
    args: {
      plan: PlanWithReasons;
      dryRun: boolean;
      patientNameMasked: string;
      toSections: (o: Partial<O> | O) => ScriptSectionDto[];
      toContent: (o: O) => string;
    },
  ): AsyncGenerator<PlanScriptStreamEvent, void, void> {
    const { plan, dryRun, patientNameMasked: _pn, toSections, toContent } = args;
    let last: O | null = null;
    let invId: string | null = null;
    let source: 'agent' | 'template_fallback' = 'agent';
    let cost = 0;
    let promptTokens = 0;
    let completionTokens = 0;
    let fallbackReason: string | undefined;

    for await (const evt of events) {
      if (evt.type === 'start') {
        invId = 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: toSections(evt.partial) };
      } else if (evt.type === 'done') {
        last = evt.output;
        source = evt.source;
        cost = evt.costYuan;
        promptTokens = evt.promptTokens;
        completionTokens = evt.completionTokens;
        fallbackReason = evt.fallbackReason;
        invId = evt.invocationId;
      } else if (evt.type === 'error') {
        yield { type: 'error', message: evt.message };
        return;
      }
    }

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

    const content = toContent(last);
    const sections = toSections(last);
    let planScriptId: string | null = null;
    if (!dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId_channel: { planId: plan.id, channel: 'phone' } },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,

          channel: 'phone', // 显式写死:同表现在还存企微稿,别依赖列默认值
          content,
          status: 'ready',
          source,
          agentInvocationId: invId,
        },
        update: { content, status: 'ready', source, agentInvocationId: invId },
        select: { id: true },
      });
      planScriptId = row.id;
    }

    yield {
      type: 'done',
      planScriptId,
      agentInvocationId: invId ?? '',
      source,
      structured: last,
      content,
      sections,
      costYuan: cost,
      promptTokens,
      completionTokens,
      fallbackReason,
    };
  }

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

  private async loadPlanContext(planId: string) {
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      include: {
        /**
         * ⚠️ take 从 3 提到 12:批次意图聚焦要求"主管选的那条必须在候选里"。
         *   原来只取分最高的 3 条 —— 若主管选的标签排第 4,它压根没被加载,
         *   focusOrderReasons 就无从提前,**改了等于没改且不报错**。
         *   实测每单最多 8 条 reason,12 是留了余量的安全上界。
         * ⚠️ 进 prompt 的仍然只有 3 条 —— 聚焦排序**之后**再 slice(见下方 sortedReasons),
         *   所以 prompt 体积不变,变的只是"哪 3 条"。
         */
        reasons: { orderBy: { priorityScore: 'desc' }, take: 12 },
        // ⭐ 本批次的福利要进话术(T4)。⚠️ 只认 **confirmed** 的批次 ——
        //   撤销后的批次福利已不适用,带进去就是对患者念一个作废的优惠。
        // criteria:批次选的是哪一格(potentialTreatment)→ 话术聚焦哪条 reason,判据同福利。
        assignment: { select: { status: true, attributes: true, expiresAt: true, criteria: true } },
      },
    });
    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 patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;
    // 监护人(未成年触达打给监护人):优先 妈妈 > 爸爸 > 祖辈,优先已建档(有姓名)
    const guardian =
      patientAge != null && patientAge <= 12
        ? pickGuardian(
            await this.prisma.patientRelation.findMany({
              where: {
                patientId: patient.id,
                relationship: { in: ['mother', 'father', 'grandparent'] },
              },
              include: { relatedPatient: { select: { name: true } } },
            }),
          )
        : null;

    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' },
    });

    // 历史联系(诊所回访/联系记录)— 最近 5 条(深度档用;页面"历史联系"同源)
    const returnVisits = await this.prisma.patientReturnVisit.findMany({
      where: { patientId: patient.id },
      orderBy: { taskDate: 'desc' },
      take: 5,
    });

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

  private buildCallInput(args: {
    plan: PlanWithReasons;
    patient: PatientRow;
    persona: PersonaWithFeatures | null;
    facts: FactRow[];
    /** 监护人(未成年触达对象)— loadPlanContext 查好传入;成人/无 → null */
    guardian?: { relationship: string; relationshipLabel: string; name: string | null } | null;
    /** 历史联系(诊所回访记录,最近 5 条)— loadPlanContext 查好传入 */
    returnVisits?: Array<{
      taskDate: Date | null;
      type: string | null;
      status: string | null;
      treatmentItems: string | null;
      followContent: string | null;
      result: string | null;
    }>;
  }): DraftPlanScriptInput {
    const { plan, patient, persona, facts, guardian, returnVisits } = args;
    const patientAge = patient.birthDate ? calcAge(patient.birthDate) : null;

    // ⭐ "最近一次就诊"= 患者实际到店的最新一次。口径要全:encounter/emr 之外,
    //   **实际治疗(actual treatment_record)也是到店**——很多 host 治疗有日期但缺 encounter/emr fact,
    //   只看 encounter/emr 会把"最近就诊"低估到更早(诊断那次),开场"自从…来过"就错位。
    //   故并入 actual treatment_record 取三者最新。(诊断日另走 triggerDate,不在此。)
    const visitFacts = facts
      .filter(
        (f) =>
          f.type === 'encounter_record' ||
          f.type === 'emr_record' ||
          (f.type === 'treatment_record' && f.kind === 'actual'),
      )
      .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]));
    // EMR 两套关联键(对齐前端 emr-soap-view):
    //   主键 emr_external_id ← diagnosis/treatment.source_encounter_external_id 指向它(parser 口径)
    //   兜底 encounter_external_id(挂号号)
    const emrByExternalId = new Map<string, FactRow>();
    const emrByEncounter = new Map<string, FactRow>();
    for (const f of facts) {
      if (f.type !== 'emr_record') continue;
      const cc = f.content as Record<string, unknown> | null;
      const extId = cc?.emr_external_id as string | undefined;
      const enc = cc?.encounter_external_id as string | undefined;
      if (extId && !emrByExternalId.has(extId)) emrByExternalId.set(extId, f);
      if (enc && !emrByEncounter.has(enc)) emrByEncounter.set(enc, f);
    }
    /** 诊断 fact → 它那次接诊的 EMR fact(主键 emr_external_id,兜底挂号号) */
    const emrForDiagnosis = (lead: FactRow): FactRow | undefined => {
      const c = lead.content as Record<string, unknown> | null;
      const encId = (c?.source_encounter_external_id ?? c?.encounter_external_id) as string | undefined;
      if (!encId) return undefined;
      return emrByExternalId.get(encId) ?? emrByEncounter.get(encId);
    };
    // ⭐ 单一聚焦:trigger 信息要"项目相关"——这个漏诊项**那次诊断**的医生/日期/主诉,
    //   不是泛泛的最近一次就诊(否则"缺牙2025-12诊断"却说"自从2026-03洁牙后"就错位)
    // 每条 reason 解析:触发医生/日期 + 该次接诊的完整病历(挂到 reason.medicalRecord)
    const resolveReasonTrigger = (
      r: PlanWithReasons['reasons'][number],
    ): { doctor: string | null; date: string | null; medicalRecord: ScriptMedicalRecord | null } => {
      const evidence = (r.evidence ?? {}) as { factIds?: string[] };
      /**
       * 🔴 取**最新的未治疗那条**,⛔ 不是 `factIds[0]`。
       *
       * 两个理由,缺一不可:
       * ① **与矩阵同锚**。初选矩阵的温度档按"该标签最新的未治疗诊断"算,话术若拿别的日期,
       *    就会出现「矩阵说这人在 1 年内、话术开口说三年前查出的」——
       *    主管和客服看到两个时间,而没人知道该信哪个。
       *    实测(2026-08-05 本地库):4,573 条 reason 里 269 条有多份证据,其中 **184 条**
       *    `factIds[0]` 不是最新的,**平均差 952 天(2.6 年)**。
       * ② **已治疗的不能当锚**。fact 治完是 `status='fulfilled'` 不是删除(见 patient_facts 口径),
       *    拿它当触发日 = 对着一个已经做完的诊断念"您还没做"。
       *
       * 顺带:医生和病历也跟着落到那次接诊 —— 最近一次确认的医生/主诉比最早那次更贴当下。
       */
      const lead = (evidence.factIds ?? [])
        .map((id) => factById.get(id))
        .filter((f): f is NonNullable<typeof f> => !!f && f.status === 'active')
        .sort((a, b) => {
          const ta = (a.occurredAt ?? a.plannedFor)?.getTime() ?? 0;
          const tb = (b.occurredAt ?? b.plannedFor)?.getTime() ?? 0;
          return tb - ta;
        })[0];
      if (!lead) return { doctor: null, date: null, medicalRecord: null };
      const c = lead.content as Record<string, unknown> | null;
      const doctor = (c?.doctor_name as string | undefined)?.trim() || null;
      // ⚠️ 与矩阵锚点同口径 `COALESCE(occurred_at, planned_for)` —— 建议类信号(*_RECOMMENDED)
      //   常常只有 plannedFor,只读 occurredAt 会得到 null,话术就没了日期而矩阵有,又是一处不一致。
      const anchorAt = lead.occurredAt ?? lead.plannedFor;
      const date = anchorAt ? anchorAt.toISOString().slice(0, 10) : null;
      const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts);
      return { doctor, date, medicalRecord };
    };

    /**
     * ⭐ reasons 单一排序(自包含,不依赖调用方 query 顺序)→ `reasons[0]` = 聚焦项,
     *   而聚焦项决定**整篇话术**讲什么(fact-block:主诉/病种/牙位/病历/医生姓/日期锚)。
     *
     * ⚠️ 批次意图优先:主管点「补牙」那一格分下来的单,话术就该讲补牙,哪怕缺牙分更高。
     *   判据 `status === 'confirmed'` 与下方 benefit 完全一致 —— 撤销后批次意图和福利一起作废。
     * ⚠️ 详情页(plan-aggregate + plan-detail-app)走同一个 focusOrderReasons,三处必须一致,
     *   否则会出现"界面聚焦补牙、话术讲缺牙",比不改更糟。
     */
    const scriptFocusLabel =
      plan.assignment?.status === 'confirmed'
        ? ((plan.assignment.criteria as { potentialTreatment?: string } | null)?.potentialTreatment ?? null)
        : null;
    // slice 在**排序之后** —— 先让批次选的那条冒到最前,再截 3 条进 prompt(体积同改动前)
    const sortedReasons = focusOrderReasons(plan.reasons, scriptFocusLabel).slice(0, 3);

    return {
      patient: {
        // 真名(数据;不直出 user prompt,走 token 回填)。ID 类不进(关联走 linked_patient_id 列)
        name: patient.name,
        // 通话称呼(单一源,年龄+性别+监护人 aware):未成年→监护人("X女士")/家长,成人→先生女士
        salutation: callSalutation(patient.name, patient.gender, patientAge, guardian ?? null),
        gender: patient.gender,
        age: patientAge,
        medicalRecordNumber: patient.medicalRecordNumber ?? null,
        // 监护人(未成年):让话术知道"打给谁、患者是孩子",可空。relationship=原始键(结构化)
        guardian: guardian
          ? { relationship: guardian.relationship, relationshipLabel: guardian.relationshipLabel, name: guardian.name }
          : null,
      },
      // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
      // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
      clinicName: resolveClinicName(plan.targetClinicId),
      // 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时限是"客服什么时候该打完",
      //   不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。
      benefit:
        plan.assignment?.status === 'confirmed'
          ? readBenefitText(plan.assignment.attributes)
          : null,
      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: sortedReasons.map((r) => {
          const trig = resolveReasonTrigger(r);
          // sub_key 形如 'caries_no_filling@36',base 去 @ 后缀
          const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
          // 牙位结构化:优先 signals.toothPosition,兜底 sub_key 的 @后缀
          const sig = (r.signals ?? {}) as {
            toothPosition?: string | null;
            triggers?: Array<{ type: string; code: string }>;
          };
          const toothPositions = splitToothPositions(
            sig.toothPosition ?? (r.subKey ?? '').split('@')[1] ?? null,
          );
          return {
            scenarioLabel: planScenarioLabel(r.scenario),
            subKey: baseSubKey,
            dxCode: subKeyToDxCode(baseSubKey),
            // 最高优先触发源(signals.triggers 已按 诊断>建议>影像 排序)→ 病种文案分流
            leadTrigger: sig.triggers?.[0] ?? null,
            toothPositions,
            reason: r.reason,
            priorityScore: r.priorityScore,
            triggerDoctor: trig.doctor,
            triggerDate: trig.date,
            medicalRecord: trig.medicalRecord,
          };
        }),
      },
      // 全量传(buildPersonaGuide 按白名单 + 用途排序挑,不在此截断)
      personaHighlights: (persona?.features ?? []).map((f) => ({
        key: f.key,
        label: personaFeatureMeta(f.key).label,
        description: f.description,
      })),
      clinicalContext: {
        daysSinceLastVisit,
        lastVisit: summarizeLastVisit(latestEnc),
        lastChiefComplaint,
        // pendingTreatments 直接从 plan.reasons 派生 — 召回触发的 reason 本身就是"未启动治疗"
        //   reasons 是 SQL 算出的权威集(旧版 DX_TO_CAT 内置 map 漏 K01/K03/K06/K07)
        pendingTreatments: extractPendingFromReasons(sortedReasons),
        // ⚠️ 治疗链(chain-composer)已废弃 → 不再给"链阶段/在管"摘要,只给"做过什么治疗"
        recentTreatments: summarizeRecentTreatments(facts),
        completedTreatmentCount: countCompletedTreatments(facts),
        primaryDoctorName: extractPrimaryDoctor(facts),
        contactHistory: (returnVisits ?? []).map((r) => ({
          date: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
          type: r.type,
          status: r.status,
          treatmentItems: r.treatmentItems,
          followContent: r.followContent,
          result: r.result,
        })),
      },
    };
  }
}

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

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

## ${t?.opening ?? '开场白'}
${out.opening}

## ${t?.informMissed ?? '告知潜在治疗'}
${out.informMissed}

## ${t?.reviewAdvice ?? '复查建议'}
${out.reviewAdvice}

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

function toneLabel(tone: DraftPlanScriptOutput['tone']): string {
  return TONE_LABEL[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[] {
  // 标准/深度档:label 用 LLM 起的标题(out.sectionTitles);稳健档无 → 回退固定标题
  const t = out.sectionTitles;
  return [
    {
      id: 'opening',
      label: t?.opening ?? '开场白',
      durationHint: '30 秒',
      markdown: out.opening ?? '',
    },
    {
      id: 'informMissed',
      label: t?.informMissed ?? '告知潜在治疗',
      durationHint: '1–2 分钟',
      markdown: out.informMissed ?? '',
    },
    {
      id: 'reviewAdvice',
      label: t?.reviewAdvice ?? '复查建议',
      durationHint: '1 分钟',
      markdown: out.reviewAdvice ?? '',
    },
    {
      id: 'closing',
      label: t?.closing ?? '结束回访语',
      durationHint: '30 秒',
      markdown: out.closing ?? '',
    },
  ];
}

/** 深度档多段草稿 → ScriptSectionDto[](id=s0/s1/…,label=LLM 起的标题) */
function deepDraftToSections(draft: DeepDraft): ScriptSectionDto[] {
  return draft.sections.map((s, i) => ({
    id: `s${i}`,
    label: s.title,
    durationHint: '',
    markdown: s.markdown,
  }));
}

/**
 * 段数组形态的**流式 partial** → ScriptSectionDto[](标准/深度共用)。
 * partial 阶段 sections 可能还没出、或元素只填了一半(title/markdown 缺一)→ 全部容错兜空串。
 */
function deepDraftPartialToSections(
  partial: Partial<DeepDraft> | DeepDraft,
): ScriptSectionDto[] {
  const secs = partial.sections ?? [];
  return secs.map((s, i) => ({
    id: `s${i}`,
    label: s?.title ?? '',
    durationHint: '',
    markdown: s?.markdown ?? '',
  }));
}

/** 从 ScriptSectionDto[] 拼 PlanScript.content markdown(深度档/统一用) */
function renderMarkdownFromSections(
  sections: ScriptSectionDto[],
  tone: DraftPlanScriptOutput['tone'],
  meta: { patientNameMasked: string },
): string {
  const body = sections.map((s) => `## ${s.label}\n${s.markdown}`).join('\n\n');
  return `> 患者:${meta.patientNameMasked}  ·  语气:${toneLabel(tone)}\n\n${body}\n`;
}

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

/**
 * 病历 — 一次接诊的完整病历(SOAP 全字段,结构化),挂到每条 reason.medicalRecord。
 * 字段集 + 同次诊断关联(source_encounter_external_id === emr_external_id)对齐前端 emr-soap-view。
 */
function buildMedicalRecord(
  emr: FactRow | undefined,
  facts: FactRow[],
): ScriptMedicalRecord | null {
  if (!emr) return null;
  const c = (emr.content as Record<string, unknown> | null) ?? {};
  const emrExtId = String(c.emr_external_id ?? '');
  // A 评估:同次接诊的诊断(被召回诊断所在)
  const diagnoses = emrExtId
    ? facts
        .filter(
          (f) =>
            f.type === 'diagnosis_record' &&
            String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') ===
              emrExtId,
        )
        .map((f) => {
          const dc = (f.content as Record<string, unknown> | null) ?? {};
          return {
            code: (dc.code as string | undefined)?.trim() || null,
            nameZh: ((dc.name_zh ?? dc.name) as string | undefined)?.trim() || null,
            toothPosition: (dc.tooth_position as string | undefined)?.trim() || null,
            fromImageAi: String(dc.code_source ?? '') === 'image_ai',
          };
        })
    : [];
  // 医生建议(同次接诊 recommendation_record,如"建议拔除18/38/48")
  const recommendations = emrExtId
    ? facts
        .filter(
          (f) =>
            f.type === 'recommendation_record' &&
            String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') ===
              emrExtId,
        )
        .map((f) => {
          const rc = (f.content as Record<string, unknown> | null) ?? {};
          return {
            text: ((rc.name_zh ?? rc.name ?? rc.code) as string | undefined)?.trim() || '',
            toothPosition: (rc.tooth_position as string | undefined)?.trim() || null,
          };
        })
        .filter((r) => r.text)
    : [];
  const str = (v: unknown): string | null => {
    const s = (v as string | undefined)?.trim?.();
    return s && s !== 'null' ? s : null;
  };
  // 计划治疗(同次接诊的 treatment_record kind=planned)—— 结构化"治疗计划"。
  //   host treatment_plan 自由文本常空,真正的治疗计划落在独立 treatment_record(planned)。
  const plannedTreatments = emrExtId
    ? facts
        .filter(
          (f) =>
            f.type === 'treatment_record' &&
            String((f.content as Record<string, unknown> | null)?.status ?? '') === 'planned' &&
            String((f.content as Record<string, unknown> | null)?.source_encounter_external_id ?? '') === emrExtId,
        )
        .map((f) => {
          const tc = (f.content as Record<string, unknown> | null) ?? {};
          const cat = (tc.category as string | undefined)?.trim() || '';
          return {
            categoryLabel: (cat && treatmentCategoryNameZh(cat)) || cat,
            subtype: str(tc.subtype),
            toothPosition: (tc.tooth_position as string | undefined)?.trim() || null,
          };
        })
        .filter((t) => t.categoryLabel)
    : [];
  return {
    date: emr.occurredAt ? emr.occurredAt.toISOString().slice(0, 10) : null,
    doctorName: str(c.doctor_name),
    chiefComplaint: str(c.illness_desc),
    presentIllness: str(c.pre_illness),
    pastHistory: str(c.past_history),
    generalCondition: str(c.general_condition),
    examFindings: parseEmrJsonArray(c.exam_findings),
    diagnoses,
    disposals: parseEmrJsonArray(c.disposal),
    doctorAdvice: str(c.doctor_advice),
    treatmentPlanText: str(c.treatment_plan),
    diagnosisText: str(c.diagnosis_text),
    recommendations,
    plannedTreatments,
  };
}

/** 解析 host EMR 的 JSON 数组字段(exam_findings / disposal):字符串或 array 都接受;只保留有 message 的 */
function parseEmrJsonArray(raw: unknown): Array<{ toothPosition: string | null; message: string }> {
  let arr: unknown[] = [];
  if (Array.isArray(raw)) arr = raw;
  else if (typeof raw === 'string' && raw.trim() && raw !== 'null') {
    try {
      const parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) arr = parsed;
    } catch {
      arr = [];
    }
  }
  return arr
    .map((x) => {
      const o = (x ?? {}) as { toothPosition?: string; message?: string };
      return {
        toothPosition: (o.toothPosition ?? '').trim() || null,
        message: (o.message ?? '').trim(),
      };
    })
    .filter((x) => x.message);
}

function summarizeLastVisit(
  enc: FactRow | undefined,
): DraftPlanScriptInput['clinicalContext']['lastVisit'] {
  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;
  // 结构化:date(年月)+ summary(做了什么);消费方按需拼"{date} — {summary}"
  return {
    date: enc.occurredAt ? fmtYearMonth(enc.occurredAt) : null,
    summary: summary ?? null,
  };
}

/**
 * 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 }>,
): DraftPlanScriptInput['clinicalContext']['pendingTreatments'] {
  const out: DraftPlanScriptInput['clinicalContext']['pendingTreatments'] = [];
  const seen = new Set<string>();
  for (const r of reasons) {
    // sub_key 形如 'caries_no_filling@36' / 'perio_no_srp@whole';subLabelZh 接受不带 @ 的 base
    const baseSubKey = (r.subKey ?? '').split('@')[0] || null;
    const label = subLabelZh(r.scenario, baseSubKey ?? '');
    const sig = (r.signals ?? {}) as { toothPosition?: string | null };
    const toothPositions = splitToothPositions(sig.toothPosition);
    const dedup = `${label}|${toothPositions.join(',')}`;
    if (seen.has(dedup)) continue;
    seen.add(dedup);
    out.push({ subKey: baseSubKey, label, toothPositions });
  }
  return out.slice(0, 5);
}

/** FDI 牙位串("36;46" / "*whole")→ 结构化数组;全口/空 → []。@tooth 后缀也接受。 */
function splitToothPositions(raw: string | null | undefined): string[] {
  const s = (raw ?? '').trim();
  if (!s || s === '*whole' || s.toLowerCase() === 'whole') return [];
  return Array.from(
    new Set(
      s
        .split(/[;,，;\s]+/)
        .map((x) => x.trim())
        .filter((x) => x && x !== '*whole' && x.toLowerCase() !== 'whole'),
    ),
  );
}

// 称呼/监护人(PII 称呼派生)统一在 shared/pii.ts(callSalutation / pickGuardian),本文件 import。


/**
 * 临时 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] ?? '本诊所';
}

/**
 * 从最近的 treatment/diagnosis fact 抽主治医生名。
 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主治医生"泛指,不编)。
 */
function extractPrimaryDoctor(facts: FactRow[]): string | null {
  // 主治医生 = 跨所有 facts 中 doctor_id 出现频次 top 1 的医生(长期管该患者的人,非最近一次接诊)。
  //
  // ⚠️ 这是 **fallback** 口径,不是话术里用的主口径。话术医生名优先用聚焦诊断的触发医生:
  //    prompt/fact-block 都是 `top.triggerDoctor ?? primaryDoctorName`(见 stable/prompt.ts、shared/fact-block.ts),
  //    跟前端 KeyFactsCard 的 attendingDoctor(也优先聚焦诊断医生、再退最高频)对齐 —— 对齐发生在那行,不在本函数。
  //    本函数只在 reason 无 triggerDoctor 时兜底,故口径是"最高频"而非"聚焦诊断医生"。
  //
  // 老版本(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;
}

/**
 * 近期做过的治疗(每个 category 一句):"做过牙周 · 上次龈上洁治 · 吴医生 · 2024.04"
 *
 * ⚠️ 治疗链(chain-composer)已废弃 → 这里**只读 treatment_record**("做过什么"),
 *   不含链/阶段/在管概念。
 * 算法:按 category 分组 actual treatments(排除 review),每组取最新 1 条,时间 DESC 取前 4。
 * 给 LLM 用处:① 不重复邀约已做过的治疗 ② 引用历史治疗体现"诊所记得 ta"。
 */
function summarizeRecentTreatments(
  facts: FactRow[],
): DraftPlanScriptInput['clinicalContext']['recentTreatments'] {
  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);
  });
  // 结构化:category/label/subtype/医生/日期;散文("做过牙周 · ...")由消费方渲染
  return sorted.slice(0, 4).map(([cat, latest]) => {
    const c = latest.content as Record<string, unknown> | null;
    return {
      category: cat,
      categoryLabel: treatmentCategoryNameZh(cat),
      subtype: (c?.subtype as string | undefined)?.trim() || null,
      doctorName: (c?.doctor_name as string | undefined)?.trim() || null,
      date: latest.occurredAt
        ? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}`
        : null,
    };
  });
}

/**
 * 历史已做治疗总次数(信任锚)— 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<{
  // assignment:本批次的福利要进话术(T4);select 收窄,别把整个批次拖进来
  include: {
    reasons: true;
    assignment: { select: { status: true; attributes: true; expiresAt: true; criteria: true } };
  };
}>;

type PatientRow = Prisma.PatientGetPayload<{}>;

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

type FactRow = Prisma.PatientFactGetPayload<{}>;

/**
 * 批次福利文本。⚠️ 别在多处 `as any` 解这个 JSON —— 解错了不报错,只是福利段静默消失。
 * (同名函数在 plan-assignment.service 里也有一份:那边是写路径、这边是读路径,
 *  刻意各自持有,避免为一个三行函数在两个模块间建依赖。改口径时两处都要改。)
 */
function readBenefitText(attributes: unknown): { text: string } | null {
  const a = attributes as { benefit?: { text?: string } } | null | undefined;
  const t = a?.benefit?.text;
  return typeof t === 'string' && t.trim() ? { text: t.trim() } : null;
}
