import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import {
  FactType,
  FactKind,
  lookupDxTreatment,
  lookupTreatmentMilestone,
  lookupTreatmentLifecycle,
  isRestorationIneligibleDxName,
  parseComplaintCategories,
  LegacyStepSubtypeKeywords,
  PACTreatmentStep,
  type PACTreatmentCategory,
  type PACTreatmentStepKey,
  type TreatmentMilestone,
} from '@pac/types';
import { fmtYearMonthDay } from '@pac/utils';

/// 无牙位 actual 视为"全口覆盖"的类目 —— 整牙弓治疗,无牙位是常态(牙周/正畸)。
/// ⚠️ 必须跟召回 scenario ⑤a(b0b9705)口径一致:`tx.tooth='' AND category IN ('periodontic','orthodontic')`。
const WHOLE_MOUTH_COVERAGE_CATS = new Set(['periodontic', 'orthodontic']);

/**
 * ChainComposerService — 5 阶段治疗链分析(纯展示用,不参与召回算法)
 *
 * **重要边界**:跟 scenario SQL 召回口径**完全解耦**:
 *   - 召回算法(treatment_initiation_recall)排除条件 = "诊断后有任何同类 actual treatment"
 *     → 本质是召回 S1(发现机会)且未启动治疗的患者,该口径**不变**
 *   - chain-composer 是给客服**详情页看清治疗链真实进度**用的,5 阶段精细判定
 *   - 如果未来要做"治疗链内召回"(种植已植入未修复)再写新 scenario 读 TreatmentMilestones
 *
 * 5 阶段(对齐 design 文档 §0 / canonical-codes.ts TreatmentLifecycles):
 *   ① 发现治疗机会      = diagnosis / recommendation / image / EMR finding
 *   ② 进入治疗链        = planned treatment / appointment(planned) / payment / 发现后接诊
 *   ③ 治疗执行          = actual treatment(命中 milestone steps 数 >= minSteps)
 *   ④ 术后与复查管理    = post-S3 review encounter / planned review / *_REVIEW_RECOMMENDED
 *   ⑤ 治疗链闭环        = S3 全 milestone 满足 + S4 命中 + 无 refund 后置 + 无反弹诊断
 *                         + lifecycle.maxStage = 5(periodontic 维护型 maxStage=4 永不 closed)
 *
 * 5 status × stage 映射:
 *   - discovered(S1)    : 仅 S1 命中 — UI 显示"★ 发现治疗机会"(召回算法的目标候选)
 *   - entered(S2)       : S2 命中但无 actual treatment — UI 显示"进入治疗链"
 *   - ongoing(S3 / S4)  : 有 actual 但未到 closed — UI 显示"治疗执行中 / 复查管理中"
 *   - closed(S5)        : 全条件满足 — UI 显示"闭环"
 *
 * 共享给:
 *   - PlanSummaryOrchestrator(AI 话术输入)
 *   - PlanAggregateService(patient 详情页)
 */
@Injectable()
export class ChainComposerService {
  compose(facts: ChainComposeInputFact[]): ComposedChain[] {
    // 按 fact_type 索引,各阶段判定取所需信号
    const byType = groupByType(facts);

    // 同 patient 全 facts 学一遍 doctor_id → doctor_name,渲染时缺 name 的 fact 兜底查表
    const doctorMap = buildDoctorMap(facts);

    // 收集"链桶"(W3 末重塑):按 (category, code, tooth) 分组,每个桶 → 1 条独立链
    //   - K08 多牙位(34/21/14-17;47)→ 分别立链;
    //   - 同 K 码 + 同牙位的多次诊断 → 同桶 → 1 链(最早 sig 为主)
    //   - 无诊断但有 actual(prosthodontic 等)→ tooth=null 单桶
    // 旧版按 category 聚合 → K08 多牙位被合并成"种植修复·34"漏掉"·21",scenario 召回 21 但 chain 不显示
    const buckets = collectChainBuckets(byType);

    const chains: ComposedChain[] = [];
    for (const bucket of buckets) {
      const chain = inferChainStage(bucket, byType, doctorMap);
      if (chain) chains.push(chain);
    }

    // 去重:同 (chainLabel + toothPosition) 的链合并,保留 stage 最高的(K08 primary=implant
    // 立 1 条 + 患者做的"种植上部修复"actual.category=prosthodontic 立 1 条 → 都叫"种植修复·xx",
    // 临床上是同一条治疗链,UI 不该显示两份)
    const dedupKey = (c: ComposedChain) => `${c.name}`;
    const bestByKey = new Map<string, ComposedChain>();
    for (const c of chains) {
      const key = dedupKey(c);
      const prev = bestByKey.get(key);
      if (!prev || c.currentStage > prev.currentStage) bestByKey.set(key, c);
    }
    const deduped = [...bestByKey.values()];

    // 替代闭环 pass:同 patient 同 tooth overlap 后续有"已治"链(closed/ongoing s>=3) →
    // 把当前 discovered/entered chain 标记为 closed(by alternative)
    // 例:罗国标 K04 14;15 根管诊断 → 后来 K08 14-17;47 + implant actual → K04 chain 视为替代闭环
    markAlternativeClosed(deduped, byType);

    // ⭐ chain ★ 标记不在这里做 patient 级对齐(撤回 filter)
    //   理由:chain-composer 只产 chain 客观状态(discovered/entered/ongoing/closed)
    //   ★ 是否真"该召回" 由 scenario SQL 决定 — plan-aggregate.getPlanFull 调用 chain composer 后,
    //   按 plan_reasons 重新覆盖 chain.target,确保 ★ 数 = SQL reason 数(SQL 为准)
    const aligned = deduped;

    // 排序:discovered(召回候选)优先,然后按 stage 倒序,内部按时间倒序
    aligned.sort((a, b) => {
      const pa = STATUS_PRIORITY[a.status] ?? 99;
      const pb = STATUS_PRIORITY[b.status] ?? 99;
      if (pa !== pb) return pa - pb;
      if (a.currentStage !== b.currentStage) return b.currentStage - a.currentStage;
      return (b.gapDays ?? 0) - (a.gapDays ?? 0);
    });
    return aligned;
  }
}

/**
 * 替代闭环判定 — patient 级 cross-chain pass
 *
 * 临床场景:K04 14;15 根管诊断后,牙保不住 → 拔了做 K08 14;15 种植。
 *   K04 chain 原本被算 discovered "潜在新链",但实际方案已变 → 客服不该召回。
 *   chain-composer 标记 status='closed' + alternativeClosedBy = 替代 chain.name + target=false。
 *
 * 判定规则(只取明确"替代终止" 场景):
 *   - 当前 chain 是 target=true(discovered / entered)
 *   - 同 patient 存在 other chain 满足:
 *       ① other.tooth 跟 current.tooth 有 overlap > 0
 *       ② other.category IN ['implant','surgical','prosthodontic'] — "替代终止性"治疗类
 *         (做了种植/拔除/冠修复 → 原牙的根管/牙周/充填 chain 失去意义)
 *       ③ other.diagnosedAt >= current.diagnosedAt(后续诊断,不是更早的)
 *
 * 注:不强求 other.currentStage >= 3(已治) — 仅"同位置后续 K08 诊断"就足够说明方案变更,
 *   客服可以告诉患者"医生已经规划替代方案,先看治疗链全景再决定要不要打"。
 */
/**
 * patient 级对齐 — 跟 scenario SQL 排除口径完全一致
 *
 * 规则:同 patient 内,若 category C 下存在任一 ongoing/closed 链(即患者真做过 C 类 actual),
 *      则该 cat 所有 discovered 链**直接从 chains 列表删除**(不只是 target=false 灰显)。
 *
 * 例:张超 K04 endodontic — 11;22 已做根管(closed)→ 12;13、24;25;42 discovered 桶被 filter 掉,
 *     UI 完全不显示。chain 数 = reason 数完全对齐,客服无歧义。
 *
 * limitation:host 结算无牙位级粒度,无法精准判定"12;13 还没做"。等 host 升级 settlement_tooth_position
 * 后可改成 chain 级精准 — 真没做的牙位仍立链召回。当前是 patient 级粗粒度对齐。
 */
function filterDiscoveredByPatientLevel(chains: ComposedChain[]): ComposedChain[] {
  // 收集每个 category 是否"已启动"(任一桶 status='ongoing'/'closed' 或 'entered')
  // 包括 entered — 患者主动预约了同 cat 也算"已动",不再算潜在新发现
  const catStarted = new Set<string>();
  for (const c of chains) {
    if (c.status === 'ongoing' || c.status === 'closed' || c.status === 'entered') {
      catStarted.add(c.category);
    }
  }
  // filter out:discovered chain 的 DxMap.code.categories 全集中**任一**已启动 → 直接删
  // 跟 scenario SQL.excludeCats 完全对齐(K00 categories=['surgical','prosthodontic','implant','orthodontic']
  // 任一启动即 K00 不召回 / 不立链)
  return chains.filter((c) => {
    if (c.status !== 'discovered') return true;
    const cats = c.allCategories ?? [c.category]; // fallback 兜底
    return !cats.some((cat) => catStarted.has(cat));
  });
}

function markAlternativeClosed(chains: ComposedChain[], byType: FactsByType): void {
  // 定性替代治疗类别 — 同牙做了这些 = 原诊断已被替代方案处置(占位/修复/拔除)。
  // cosmetic(贴面)与 prosthodontic(冠桥)同属定性修复:李梦维 K00 乳牙滞留 1B;4C —
  //   4C 粘结桥(prosthodontic)+ 1B 贴面(cosmetic),两颗都已修复,链应闭环(原缺 cosmetic 漏判 1B)。
  const ALT_CATS = new Set(['implant', 'surgical', 'prosthodontic', 'cosmetic']);
  // ⭐ W4 末改造:扫 fact 而非 chain
  //   原因:surgical(拔除)往往是"actual-only"链 — 无对应 dx sig,bucket.tooth='',
  //   被原版 markAlternativeClosed(看 chain.toothPosition)跳过。
  //   例:季根财 K04@23 + surgical actual @ 23(松动恒牙拔除术)→ 23 已拔不需 RCT,
  //   但 surgical chain 是 actual-only(tooth='') → 老版 K04 chain 一直 ★ discovered。
  //   改成扫 byType.treatment 的 actual,直接用 actual.content.tooth_position 跟 c.toothPosition 比。
  const altActuals = byType.treatment.filter((tx) => {
    if (tx.kind !== FactKind.ACTUAL) return false;
    const cat = String((tx.content as Record<string, unknown>).category ?? '');
    return ALT_CATS.has(cat);
  });
  for (const c of chains) {
    if (c.status !== 'discovered' && c.status !== 'entered') continue;
    if (!c.toothPosition || !c.diagnosedAt) continue;
    // ⭐ W4 末新加豁免 #1:lifelong_maintenance 链(periodontic 牙周等)永不被替代闭环
    //   牙周治疗是慢性病终身维护,做种植 21;41 不代表牙周不需要继续 → 路遥误标 closed 修
    const cLifecycle = lookupTreatmentLifecycleByCategory(c.category);
    if (cLifecycle.maxStage === 4) continue;
    // ⭐ W4 末新加豁免 #2:wholeMouth 链(全口治疗,牙位 ≥ 20 颗)不被 per-tooth 链替代
    //   全口洁治覆盖 26 颗牙 vs 种植 21;41 这 2 颗 — 临床上是"维护 + 局部治疗",不是替代
    if (isWholeMouthTooth(c.toothPosition)) continue;
    // ⭐ 全牙位覆盖才闭环 — 收集所有适用的 alt actuals 求并集
    //   季根财 K08{16,17,25,26,27,44,45} 只被 surgical@44 覆盖 1 颗 → 不闭环(其他 6 颗仍缺口)
    //   季根财 K04@23 被 surgical@23 完全覆盖 → 闭环 ✓
    //   注:范萍莉 K08{41,47} 由同 cat implant actual 覆盖,走的是 s5Eligible 闸(同 cat 算正治不算替代)
    const cToothSet = new Set(c.toothPosition.split(';').map((t) => t.trim()).filter(Boolean));
    if (cToothSet.size === 0) continue;
    const altCovered = new Set<string>();
    const altSources: string[] = [];
    const cDx = new Date(c.diagnosedAt);
    if (isNaN(cDx.getTime())) continue;
    for (const tx of altActuals) {
      const txCat = String((tx.content as Record<string, unknown>).category ?? '');
      if (txCat === c.category) continue;
      if (!tx.occurredAt) continue;
      if (tx.occurredAt.getTime() < cDx.getTime()) continue;
      const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '');
      if (!txTooth) continue;
      let hit = false;
      for (const t of txTooth.split(';')) {
        const trimmed = t.trim();
        if (trimmed && cToothSet.has(trimmed)) {
          altCovered.add(trimmed);
          hit = true;
        }
      }
      if (hit) altSources.push(`${CATEGORY_LABEL[txCat] ?? txCat}·${txTooth}`);
    }
    if (altCovered.size === cToothSet.size && altCovered.size > 0) {
      // 全覆盖 → alt-closed;altSources 去重(同牙多条同类 actual 会重复,如 1B 贴面修复+戴贴面)
      c.status = 'closed';
      c.target = false;
      c.alternativeClosedBy = [...new Set(altSources)].join(' / ');
      c.currentStage = 5;
    }
  }
}

/// W4 末:全口治疗判定(牙位字符串拆分后 ≥ 20 颗 视为全口;26~28 颗是成人全恒牙)
function isWholeMouthTooth(tp: string): boolean {
  if (!tp) return false;
  if (tp === '*whole' || tp.toLowerCase() === 'whole') return true;
  const teeth = tp.split(/[;,，;\s]+/).map((s) => s.trim()).filter(Boolean);
  return teeth.length >= 20;
}

/// W4 末:按 category 反查 lifecycle(给 markAlternativeClosed 用)
function lookupTreatmentLifecycleByCategory(category: string) {
  const ms = lookupTreatmentMilestone(category);
  return ms ? lookupTreatmentLifecycle(ms.lifecycle) : DEFAULT_LIFECYCLE;
}

// ─────────────────────────────────────────────
// 5 阶段引擎核心
// ─────────────────────────────────────────────

/**
 * 单"链桶" 5 阶段判定 —— bucket = (category, code, tooth) 三元组,本桶内 sig 已分好。
 * 返回 null 表示该 bucket 无意义(无信号),不立链。
 */
function inferChainStage(
  bucket: ChainBucket,
  byType: FactsByType,
  doctorMap: Map<string, string>,
): ComposedChain | null {
  const { category, signals: s1Facts } = bucket;
  const milestone = lookupTreatmentMilestone(category);
  const lifecycle = milestone
    ? lookupTreatmentLifecycle(milestone.lifecycle)
    : DEFAULT_LIFECYCLE;
  const maxAllowedStage = lifecycle.maxStage;

  // ─ S1 信号 ─(本 bucket 内 sig,已由 collectChainBuckets 按 code+tooth 分好)
  const actuals0 = getActualTreatments(category, byType);
  if (s1Facts.length === 0 && actuals0.length === 0) {
    // 既没诊断也没治疗 — 不立链(全是其他 category 残留信号场景)
    return null;
  }
  // 弱信号链过滤:有 actual 但完全无诊断 + actual subtype 也不命中 milestone steps → 丢弃
  // 例:罗国标 preventive RVG/CT 被 host 归预防类,但 subtype 不在["洁牙","涂氟","封闭"]字典 →
  // 立链后是 stage=1 discovered + "无诊断信号" 占位 → 客服看到困惑。
  if (s1Facts.length === 0) {
    const matched0 = matchMilestoneSteps(actuals0, milestone);
    if (milestone && matched0.matched.length === 0) {
      return null;
    }
  }
  const s1Earliest = earliest(s1Facts);

  // ─ 先算 S3 actual(后面 S2 需要 s3FirstActual 作 actual-only 桶 anchor)
  // **时间方向** — 只算 s1 之后的 actual(跟 scenario SQL `tx.occurred_at >= sig.occurred_at` 同口径)
  // s1Earliest null(actual-only 桶)→ 全 actual 都算(无 sig 锚点)
  // ⭐ W4 末:**牙位过滤** — bucket.tooth 非空时只算同牙位 actual
  //   蒋卓易 Joey K03@12 bug:原版无牙位过滤,把所有 restorative actuals(16/26/46/...)
  //   都算到 K03@12 桶里 → 误判 s3Reached=true → stage=4 ongoing
  //   实际上 12 一次治疗没做,SQL 召回是对的,chain 状态也该 stage=1 discovered
  //   wholeMouth 桶('*whole')+ actual-only 桶('')不过滤(全口治疗按牙位无意义)
  //   ⭐ 镜像召回 ⑤a(b0b9705):**无牙位 actual 仅 periodontic/orthodontic 视为"全口覆盖"**
  //     (整牙弓治疗无牙位是常态,牙周 134:1 / 正畸 19:1)→ 覆盖该桶具体牙位。
  //     否则曾松柏 K06 牙周脓肿@45;46 全是无牙位"龈上洁治" → 链显示 discovered,但召回已按全口覆盖排除 → 分叉。
  //     其它类目(restorative/surgical/implant 等)的无牙位 actual 不当覆盖(蒋卓易 K03@12 仍按牙位过滤)。
  const allActualsByCategory = getActualTreatments(category, byType);
  const allActuals = bucket.tooth && bucket.tooth !== '*whole'
    ? allActualsByCategory.filter((tx) => {
        const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '').trim();
        if (!txTooth && WHOLE_MOUTH_COVERAGE_CATS.has(category)) return true; // 无牙位 perio/ortho = 全口覆盖
        return toothOverlap(bucket.tooth, txTooth);
      })
    : allActualsByCategory;
  const actuals = s1Earliest?.occurredAt
    ? allActuals.filter((tx) => tx.occurredAt && tx.occurredAt.getTime() >= s1Earliest.occurredAt!.getTime())
    : allActuals;
  const matchedSteps = matchMilestoneSteps(actuals, milestone);
  // W4 末:s3Reached 双口径
  //   ① terminalHit 命中(任一终末术式)→ 即使 minSteps 未满,治疗已收尾
  //      例 endodontic:pulpotomy 单步 = 完整 VPT,跟完整 RCT(canal_filling)等价
  //   ② matched.length >= minSteps(老逻辑,阶梯类)
  const s3Reached = milestone
    ? matchedSteps.terminalHit !== null || matchedSteps.matched.length >= milestone.minSteps
    : actuals.length > 0;
  const s3FirstActual = earliest(actuals);
  const s3LastActual = latest(actuals);

  // ─ S2 信号 ─ "进入治疗链" 事件(预约 complaint_category / planned treatment fallback)
  // anchor 时间:s1AnchorTime(诊断之后);actual-only 桶 → null(任意时间)
  // 时间上界:s3FirstActual — 只对 **预约** 生效(临床:先约后做);planned 不约束(医生随时可开下一步)
  const s1AnchorTime = s1Earliest?.occurredAt ?? null;
  const s2UpperTime = s3FirstActual?.occurredAt ?? null;
  const s2Hits = collectS2Facts(category, byType, s1AnchorTime, bucket.tooth, s2UpperTime);
  const s2Earliest = earliest(s2Hits);

  // ─ S4 信号 ─ S3 之后的复查 / 术后随访
  const s3AnchorTime = s3LastActual?.occurredAt ?? null;
  const s4Hits = s3AnchorTime ? collectS4Facts(category, byType, s3AnchorTime) : [];
  const s4Earliest = earliest(s4Hits);

  // ─ S5 信号 ─ closed 条件(W3 末调整 — 不再要求 allSatisfied;W4 末加 requiresCrownProtection)
  // 1. S3 满足 minSteps(s3Reached) — 例:种植 minSteps=2 要植入+修复都做才可能 closed;
  //    单步类(充填/拔除/洁牙)minSteps=1 做一次即可。**不再要求 milestone 全 steps 都做**
  // 2. S4 至少 1 命中(术后随访 / 复查 encounter / planned review / *_REVIEW_RECOMMENDED)
  // 3. lifecycle.maxStage = 5(lifelong_maintenance 直接卡死 stage 4)
  // 4. 无 S3 之后的 refund 同 patient(简化:patient 级,跨 category 也判)
  // 5. 无 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0 视为反弹)
  // 6. ⭐ W4 末:lifecycle.requiresCrownProtection → 同牙位有 prosthodontic actual(冠/桩核)
  //    临床:根管后牙变脆 ~30% 折裂率,没戴冠不算真闭环
  //    杨光宗 27 K04 根管完成但没冠 → 应该 ongoing 提示客服回访做冠
  // W4 末:requiresCrownProtection 局部 override —
  //   命中 pulpotomy(VPT 终末)= 根髓活,牙不变脆,临床不需要冠保护
  //   命中 canal_filling(完整 RCT)= 根管去髓,牙变脆,~30% 折裂,需冠保护
  //   两者都未命中(仅开髓/根备等)= 治疗进行中,crownOk 判定不影响(s3Reached=false)
  const pulpotomyTerminal = matchedSteps.terminalHit === 'pulpotomy';
  const crownOk = !lifecycle.requiresCrownProtection
    || pulpotomyTerminal
    || hasCrownProtection(bucket.tooth, byType, s3AnchorTime);
  // ⭐ W4 末:多牙位桶的"覆盖闸"——
  //   union-find 把同 (code, category) 的多 sig 合并到一桶,bucket.tooth 是并集
  //   (例:范萍莉 K08 sig {41,47} + implant actual {47} 合一桶,但 41 实际未处置)
  //   如果实际 actual 没覆盖到桶里所有牙位,**不能算 closed**
  //   ⚠️ 召回算法侧暂不动(治疗链内召回属 W5+,先只确保展示层正确)
  //   wholeMouth(全口 K05 等)桶不参与本闸——全口治疗本来就不按颗牙判
  const uncoveredTeeth = computeUncoveredTeeth(bucket.tooth, actuals);
  const fullyCovered = uncoveredTeeth.length === 0;
  const s5Eligible =
    s3Reached &&
    s4Hits.length > 0 &&
    maxAllowedStage === 5 &&
    crownOk &&
    fullyCovered &&
    !hasPostS3Refund(byType, s3AnchorTime) &&
    !hasPostS3Relapse(category, byType, s3AnchorTime);

  // ─ 推导 currentStage + status ─
  let currentStage: 1 | 2 | 3 | 4 | 5;
  let status: ChainStatus;

  if (s5Eligible) {
    currentStage = 5;
    status = 'closed';
  } else if (s4Hits.length > 0 && s3Reached) {
    // S4 命中但不到 S5(可能 lifelong_maintenance 卡 4,或 milestone 未全满足,或反弹)
    currentStage = 4;
    status = 'ongoing';
  } else if (s3Reached) {
    currentStage = 3;
    status = 'ongoing';
  } else if (actuals.length > 0) {
    // ⭐ 桶里有同牙位同类 actual,但 milestone 没匹配上(host 笼统 subtype 如 bare "种植" / 牙位级残缺)。
    //   治疗物理上已发生 —— 召回 ⑤a 正是据此(category 级)排除 → 链至少 ongoing(治疗中),不再 discovered。
    //   例 Ammu 36:K08 缺牙 + actual"种植"(subtype 不含 milestone 关键词)→ 召回已排除,链却 discovered → 分叉。
    currentStage = 3;
    status = 'ongoing';
  } else if (s2Hits.length > 0) {
    currentStage = 2;
    status = 'entered';
  } else {
    currentStage = 1;
    status = 'discovered';
  }

  // ─ 链元信息 ─
  const code = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).code ?? '') : '';
  const rawTooth = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '') : '';
  const dxRule = code ? lookupDxTreatment(code) : undefined;
  const fallback = dxRule?.wholeMouth ? '全口' : '未标注牙位';
  // ⭐ 废用牙/无功能牙(host 映射 K08,但牙还在无功能)— 桶内诊断全是这类 → "无修复必要"。
  //   召回侧已剔除(scenario ④');展示侧 Option B:不当"种植修复·发现机会"潜在链,
  //   改中性标签(诊断名)+ 建议拔除/观察 + 非召回目标(target=false),保留可见性。
  const dxNameZh = s1Earliest ? String((s1Earliest.content as Record<string, unknown>).name_zh ?? '') : '';
  const restorationIneligible =
    s1Facts.length > 0 &&
    s1Facts.every((f) =>
      isRestorationIneligibleDxName(String((f.content as Record<string, unknown>).name_zh ?? '')),
    );
  const chainLabel = restorationIneligible
    ? (dxNameZh || dxRule?.chainLabel || CATEGORY_LABEL[category] || category)
    : (dxRule?.chainLabel ?? CATEGORY_LABEL[category] ?? category);
  // ⭐ tooth 显示用 bucket.tooth(union-find 合并后的并集 normalize 排序),不用 s1 第一条 rawTooth
  //   union-find 把同 (category, code) tooth 有交集的多个桶合并 → bucket.tooth 是完整并集
  //   显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗
  //   例:张超 K08 合并 9 次诊断 → bucket.tooth="11;15;16;17;22;26;27;37" (8 颗) vs 旧版只显示 s1 的 "15;16;17"
  //   wholeMouth(K05/SRP_RECOMMENDED)桶 tooth='*whole' 统一显示"全口"
  // W4 末:actual-only 桶(无 dx sig,bucket.tooth='')回退到 actuals 牙位并集
  //   例 季根财 surgical 链:无 K01/K07 dx,actuals 各拔不同牙位(23;24/15;44/37/22)→
  //   汇总并集"15;22;23;24;37;44" 显示给客服,不再"未标注牙位"
  const actualToothUnion = actualsToothUnion(actuals);
  const tooth = dxRule?.wholeMouth
    ? '全口'
    : (bucket.tooth && bucket.tooth !== '*whole'
        ? bucket.tooth
        : (rawTooth || actualToothUnion));
  const chainName = `${chainLabel} · ${tooth || fallback}`;
  // ⭐ 用 effectiveTime(occurred_at ?? planned_for),跟召回 SQL 的 COALESCE(occurred_at, planned_for) 同口径。
  //   recommendation_record(医生建议,planned)只有 planned_for 没 occurred_at —— 旧版只看 occurredAt →
  //   gapDays=undefined→0 < cooldown → 链被 cooldown 闸误滤掉(return null),但召回照样命中 → 召回有/链没有。
  //   例 Miyabe Masashi:CROWN_RECOMMENDED@36 planned_for 2021-06-02 → 召回 1823 天,链却不显示。
  const s1Time = s1Earliest ? effectiveTime(s1Earliest) : null;
  const diagnosedAt = s1Time ? fmtYearMonthDay(s1Time) : '—';
  const gapDays = s1Time
    ? Math.floor((Date.now() - s1Time.getTime()) / 86400_000)
    : undefined;
  // ⭐ 最近一次诊断(re-trigger 证据)— 多次诊断时即"在管又被诊断"那条;附带其医生真实医嘱
  const s1Latest = latest(s1Facts);
  const s1LatestC = s1Latest?.content as Record<string, unknown> | undefined;
  const latestDxAt = s1Latest && effectiveTime(s1Latest) ? fmtYearMonthDay(effectiveTime(s1Latest)!) : undefined;
  const latestDxName = s1LatestC
    ? (String(s1LatestC.name_zh ?? s1LatestC.name ?? '') || undefined)
    : undefined;
  const latestDxAdvice = findDoctorAdvice(s1Latest, byType.emr) ?? undefined;

  // ★ 潜在新链 = 仅 discovered(纯诊断、零治疗规划 / 付费)。
  // entered(已有 planned 治疗 / 大额付款,但还没 actual)属于"已启动·待治疗",跟 scenario SQL 的
  // "未启动相关治疗" 排除口径对齐 — 已 planned 的不再算"潜在",避免跟 plan_reasons 集合背离。
  //
  // ⭐ cooldown 同步(W3 末):scenario SQL 已过滤 < cooldownDays 的诊断(K02=14/K04=14/K05=30/K08=30),
  //   chain-composer 也尊重此口径 — discovered 状态 + 未过 cooldown → **直接不立链**
  //   (不搞"诊断观察期"灰态 — 真没到召回时机,UI 就别显示;过 cooldown 后才出现)
  //   entered/ongoing/closed 不受 cooldown 影响(已经在治疗链里了)
  const cooldownDays = dxRule?.cooldownDays ?? 0;
  if (status === 'discovered' && (gapDays ?? 0) < cooldownDays) {
    return null;
  }

  return {
    id: `chain_${category}_${s1Earliest?.id ?? s3FirstActual?.id ?? 'unknown'}`,
    name: chainName,
    category,
    // chain 对应诊断码(K0x)— SQL 为准对齐用:plan-aggregate 按 chain.code === reason.signal_code 严格匹配 ★
    // bucket.code 从 s1Earliest 拿;actual-only 桶(无诊断)code='' 不参与 ★ 对齐
    code: bucket.code || (s1Earliest ? String((s1Earliest.content as Record<string, unknown>).code ?? '') : ''),
    // DxMap.code.categories 全集 — 给其他场景(如 alternative-closed)用
    allCategories: dxRule?.categories,
    status,
    currentStage,
    // 废用牙/无功能牙不是召回目标(跟 scenario 剔除口径一致)
    target: status === 'discovered' && !restorationIneligible,
    diagnosedAt,
    gapDays,
    latestDxAt,
    latestDxName,
    latestDxAdvice,
    lifecycleNoteZh: lifecycle.noteZh,
    // 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key),
    // 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后)
    // W4 末:actual-only 桶用 actuals 牙位并集
    toothPosition: bucket.tooth && bucket.tooth !== '*whole'
      ? bucket.tooth
      : (rawTooth || actualToothUnion),
    nodes: buildStageNodes({
      currentStage,
      status,
      category,
      lifecycle,
      milestone,
      s1Earliest,
      s2Earliest,
      s1Facts,
      actuals,
      matchedSteps,
      s3LastActual,
      s4Earliest,
      s4Hits,
      byType,
      doctorMap,
      crownOk,
      uncoveredTeeth,
      restorationIneligible,
    }),
  };
}

// ─────────────────────────────────────────────
// 信号收集 helpers
// ─────────────────────────────────────────────

interface FactsByType {
  diagnosis: ChainComposeInputFact[];
  recommendation: ChainComposeInputFact[];
  treatment: ChainComposeInputFact[];
  appointment: ChainComposeInputFact[];
  payment: ChainComposeInputFact[];
  encounter: ChainComposeInputFact[];
  refund: ChainComposeInputFact[];
  emr: ChainComposeInputFact[];
}

function groupByType(facts: ChainComposeInputFact[]): FactsByType {
  return {
    diagnosis: facts.filter((f) => f.type === FactType.DIAGNOSIS_RECORD),
    recommendation: facts.filter((f) => f.type === FactType.RECOMMENDATION_RECORD),
    treatment: facts.filter((f) => f.type === FactType.TREATMENT_RECORD),
    appointment: facts.filter((f) => f.type === FactType.APPOINTMENT_RECORD),
    payment: facts.filter((f) => f.type === FactType.PAYMENT_RECORD),
    encounter: facts.filter((f) => f.type === FactType.ENCOUNTER_RECORD),
    refund: facts.filter((f) => f.type === FactType.REFUND_RECORD),
    // emr_record 自由文本:**仅用于展示层证据**(把医生真实医嘱 doctor_advice 透给 UI),
    //   不进任何 stage 判定 / 召回决策(遵守"算法不读自由文本"纪律)。
    emr: facts.filter((f) => f.type === FactType.EMR_RECORD),
  };
}

/**
 * 取某次诊断对应的医生真实医嘱(doctor_advice)— **展示层证据用,非算法决策**。
 * 关联口径:同 source 文档(dx.source_encounter_external_id == emr.emr_external_id)优先,
 *   退化到同日期(occurred_at 同天)。命中且 doctor_advice 非空才返回。
 */
function findDoctorAdvice(
  dx: ChainComposeInputFact | null,
  emrFacts: ChainComposeInputFact[],
): string | null {
  if (!dx) return null;
  const dc = dx.content as Record<string, unknown> | null;
  const srcId = dc?.source_encounter_external_id ? String(dc.source_encounter_external_id) : '';
  const dxDay = effectiveTime(dx)?.toISOString().slice(0, 10) ?? '';
  let dateFallback: string | null = null;
  for (const e of emrFacts) {
    const ec = e.content as Record<string, unknown> | null;
    const advice = ec?.doctor_advice ? String(ec.doctor_advice).trim() : '';
    if (!advice) continue;
    // 精确:同 source 文档 id
    if (srcId && String(ec?.emr_external_id ?? '') === srcId) return advice;
    // 兜底:同日期(记下,循环结束没精确命中再用)
    if (dxDay && effectiveTime(e)?.toISOString().slice(0, 10) === dxDay) dateFallback ??= advice;
  }
  return dateFallback;
}

/**
 * doctor_id → doctor_name 解析表(从所有 facts 学习一遍)。
 *
 * 缘由:host 部分 fact 给 doctor_id 但缺 doctor_name(典型:fact_settlement_out 只有 doctor_id;
 * fact_appointment_out 也常缺 name)。同 patient 的 emr_record / diagnosis_record 一般两个都给,
 * 这里用 patient 内 (doctor_id, doctor_name) 学一个 map,渲染时缺 name 的 fact fallback 查表。
 *
 * 同 patient 内一般不会有两位医生同 ID(host id 唯一),冲突时最后写入胜出。
 */
function buildDoctorMap(facts: ChainComposeInputFact[]): Map<string, string> {
  const map = 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 && name) map.set(id, name);
  }
  return map;
}

/// 取 fact 的医生名:有 doctor_name 直接用,否则用 doctor_id 查解析表
function resolveDoctorName(
  f: ChainComposeInputFact | null,
  doctorMap: Map<string, string>,
): string | undefined {
  if (!f) return undefined;
  const c = f.content as Record<string, unknown> | null;
  if (!c) return undefined;
  const name = (c.doctor_name as string | undefined) ?? '';
  if (name) return name;
  const id = c.doctor_id ? String(c.doctor_id) : '';
  return id ? doctorMap.get(id) : undefined;
}

/**
 * 链桶(W3 末新)— 每个 bucket 立一条独立 chain
 *   key = (category, code, tooth) 三元组
 *   signals = 同桶的全部诊断/推荐 fact(同 K 码 + 同牙位的多次诊断合并到一桶)
 *
 * 实例罗国标 K08 多牙位:
 *   bucket A: (implant, K08, 34)               → K08 34 诊断 5-13(actual 12-15 在后 → 闭环)
 *   bucket B: (implant, K08, 21)               → K08 21 诊断 12-25/12-31(无 21 actual → 缺口/discovered)
 *   bucket C: (implant, K08, 14;15;16;17;47)   → K08 14-17;47 诊断 4-23(actual 早于诊断 → 缺口)
 * 旧版按 category 聚合 → 只立"种植修复·34"(K08 最早牙位),漏掉 K08 21 / K08 14-17 → scenario 召回 21 但 chain 不显示
 */
interface ChainBucket {
  category: string;
  code: string; // '' 表示无诊断,纯 actual-only 桶
  tooth: string;
  signals: ChainComposeInputFact[];
}

function collectChainBuckets(byType: FactsByType): ChainBucket[] {
  const map = new Map<string, ChainBucket>();
  const addSignal = (category: string, code: string, tooth: string, sig: ChainComposeInputFact) => {
    const key = `${category}|${code}|${tooth}`;
    let b = map.get(key);
    if (!b) {
      b = { category, code, tooth, signals: [] };
      map.set(key, b);
    }
    b.signals.push(sig);
  };

  // 诊断 / 推荐 — primary category(rule.categories[0])
  // ⭐ wholeMouth=true 的码(K05 牙周等)用统一 bucket key '*whole'(不按精确 tooth 串)
  //    临床上全口治疗就一条链,复诊医生改了几颗牙位描述(如 42→32)不应拆桶
  // ⭐ 非 wholeMouth 也用 **normalizeTooth**(排序+去重)做桶 key — host 多次诊断
  //    顺序可能不同("17;47;37" vs "47;17;37"),同 3 颗牙就该合 1 桶(W3 末)
  //    例:王辉 K08 多次诊断 17;47;37 / 47;17;37 字符串顺序差异 → 旧版拆 2 条种植链,新版合 1 条
  for (const dx of byType.diagnosis) {
    const c = dx.content as Record<string, unknown>;
    const code = String(c.code ?? '');
    const rawTooth = String(c.tooth_position ?? '').trim();
    const rule = lookupDxTreatment(code);
    if (rule && rule.categories.length > 0) {
      const tooth = rule.wholeMouth ? '*whole' : normalizeTooth(rawTooth);
      addSignal(rule.categories[0]!, code, tooth, dx);
    }
  }
  for (const rec of byType.recommendation) {
    const c = rec.content as Record<string, unknown>;
    const code = String(c.code ?? '');
    const rawTooth = String(c.tooth_position ?? '').trim();
    const rule = lookupDxTreatment(code);
    if (rule && rule.categories.length > 0) {
      const tooth = rule.wholeMouth ? '*whole' : normalizeTooth(rawTooth);
      addSignal(rule.categories[0]!, code, tooth, rec);
    }
  }

  // actual-only:有 actual 但 category 没出现在以上 dx 桶里 → 立无诊断空桶,等 inferChainStage 判定
  // ⭐ 排除清单(W3 末):
  //   - review     非治疗动作(复查)
  //   - unknown    yaml 未分类
  //   - preventive 洁牙 / 涂氟 / 封闭 等周期性预防保健,临床上跟"治疗链"语义不符(无诊断 → 不该立链)
  //                 真有诊断 + preventive 治疗的(如 K00 釉质发育不全 → 涂氟)仍立桶(因有 S1 dx 桶,
  //                 这里只过滤 **actual-only** preventive)。K05 牙周诊断的"洁牙"已通过 K05.categories 含
  //                 preventive 算"已启动牙周治疗",不需要独立 preventive 链兜底。
  const EXCLUDED_ACTUAL_ONLY_CATS = new Set(['review', 'unknown', 'preventive']);
  // 同 cat 诊断桶覆盖的牙位集合。
  //   ⚠️ wholeMouth('*whole')或【无牙位诊断】(host 诊断常不填牙位)→ 覆盖范围未知,
  //   视为"该 cat 全覆盖",不再 carve actual-only —— 否则诊断链会借 actual 牙位显示,
  //   跟新 actual-only 链撞同 (category, tooth) 双显(023cbb47:K02 无牙位 + 充填 17;47 即此坑)。
  const dxCatsWithBucket = new Set<string>();
  const dxFullCoverageCats = new Set<string>();
  const dxToothByCat = new Map<string, Set<string>>();
  for (const b of map.values()) {
    dxCatsWithBucket.add(b.category);
    if (b.tooth === '*whole' || !b.tooth) {
      dxFullCoverageCats.add(b.category);
      continue;
    }
    const set = dxToothByCat.get(b.category) ?? new Set<string>();
    for (const t of b.tooth.split(';')) {
      const tt = t.trim();
      if (tt) set.add(tt);
    }
    dxToothByCat.set(b.category, set);
  }
  // 扫 actual 立"无诊断 / 诊断未覆盖"链:
  //   - cat 完全无诊断桶 → tooth='' 全收桶(原行为,如 修复冠桥 / 美容)
  //   - cat 有诊断桶但 actual 落在【同类诊断未覆盖的牙位】→ 立 tooth=未覆盖 的 actual-only 桶
  //     修(李梦维 case):K00 主类目=surgical 占了 surgical 槽,但拔牙在 28/18/15(K00 牙位 1B;4C
  //     不覆盖)→ 老版 category 级判据 `!dxCategories.has(cat)` 把整条外科 actual-only 链 ban,
  //     拔牙隐身、"已被替代:外科手术·28" 指向不存在的链。改牙位级:未被同类诊断覆盖的 actual
  //     牙位单独立链(只收这些牙,不跟诊断链重叠;code='' 不参与召回 ★,纯展示)。
  const noDxActualCats = new Set<string>();
  const uncoveredByCat = new Map<string, Set<string>>();
  for (const tx of byType.treatment) {
    if (tx.kind !== FactKind.ACTUAL) continue;
    const cat = String((tx.content as Record<string, unknown>).category ?? '');
    if (!cat || EXCLUDED_ACTUAL_ONLY_CATS.has(cat)) continue;
    if (!dxCatsWithBucket.has(cat)) {
      noDxActualCats.add(cat); // 原行为:该 cat 无任何诊断
      continue;
    }
    if (dxFullCoverageCats.has(cat)) continue; // 全口 / 无牙位诊断视为全覆盖,不 carve
    const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '').trim();
    if (!txTooth) continue; // 有诊断 + 无牙位 actual → 交给诊断链,不另立
    const covered = dxToothByCat.get(cat);
    const set = uncoveredByCat.get(cat) ?? new Set<string>();
    for (const t of txTooth.split(';')) {
      const tt = t.trim();
      if (tt && !covered?.has(tt)) set.add(tt);
    }
    if (set.size > 0) uncoveredByCat.set(cat, set);
  }
  for (const cat of noDxActualCats) {
    map.set(`${cat}||`, { category: cat, code: '', tooth: '', signals: [] });
  }
  for (const [cat, teeth] of uncoveredByCat) {
    const tooth = normalizeTooth([...teeth].join(';'));
    if (tooth) map.set(`${cat}|_act_|${tooth}`, { category: cat, code: '', tooth, signals: [] });
  }

  // ⭐ tooth overlap union(W3 末)— 同 (category, code) 下,tooth 集合有交集的桶合并为一条链
  //   解决:host 多次诊断牙位漂移(医生陆续完成 47/37,牙位描述跟着变 → {4,37}/{37,47}/{47})
  //         普通 normalizeTooth 严格匹配只合"完全相同 tooth",不合"有重叠 tooth";
  //         临床上多次诊断同 K 码下相邻牙位都是同一治疗链,应合
  //   wholeMouth 桶 tooth='*whole' 已经合并,跳过
  //   actual-only 桶 tooth='' 也跳过(本就是粗粒度兜底)
  return mergeOverlappingBuckets([...map.values()]);
}

/// 同 (category, code) 下,tooth 集合有交集的桶合并 — 用 union-find 处理传递性
/// (A∩B、B∩C 但 A∩C=∅ → A、B、C 应合 1 桶,union-find 自动处理)
function mergeOverlappingBuckets(buckets: ChainBucket[]): ChainBucket[] {
  if (buckets.length <= 1) return buckets;
  // 按 (category, code) 分组
  const groups = new Map<string, number[]>();
  buckets.forEach((b, i) => {
    const key = `${b.category}|${b.code}`;
    const arr = groups.get(key) ?? [];
    arr.push(i);
    groups.set(key, arr);
  });

  // union-find
  const parent = buckets.map((_, i) => i);
  const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
  const union = (a: number, b: number) => {
    const ra = find(a), rb = find(b);
    if (ra !== rb) parent[ra] = rb;
  };

  for (const ids of groups.values()) {
    if (ids.length <= 1) continue;
    for (let i = 0; i < ids.length; i++) {
      const bi = buckets[ids[i]!]!;
      // tooth 空(actual-only)/ '*whole'(wholeMouth)— 不参与 overlap union(各自独立)
      if (!bi.tooth || bi.tooth === '*whole') continue;
      for (let j = i + 1; j < ids.length; j++) {
        const bj = buckets[ids[j]!]!;
        if (!bj.tooth || bj.tooth === '*whole') continue;
        if (toothOverlap(bi.tooth, bj.tooth)) {
          union(ids[i]!, ids[j]!);
        }
      }
    }
  }

  // 合并:root → 合后桶(tooth 取并集 normalizeTooth,signals 合并)
  const merged = new Map<number, ChainBucket>();
  for (let i = 0; i < buckets.length; i++) {
    const root = find(i);
    const m = merged.get(root);
    if (!m) {
      merged.set(root, { ...buckets[i]!, signals: [...buckets[i]!.signals] });
    } else {
      // tooth 取并集再 normalize
      m.tooth = normalizeTooth(`${m.tooth};${buckets[i]!.tooth}`);
      m.signals.push(...buckets[i]!.signals);
    }
  }
  return [...merged.values()];
}

/// S1 = diagnosis/recommendation 中 code 映射到当前 category 的 fact
function collectS1Facts(category: string, byType: FactsByType): ChainComposeInputFact[] {
  const out: ChainComposeInputFact[] = [];
  for (const f of [...byType.diagnosis, ...byType.recommendation]) {
    const code = String((f.content as Record<string, unknown>).code ?? '');
    const rule = lookupDxTreatment(code);
    if (rule?.categories.includes(category as never)) out.push(f);
  }
  return out;
}

/// S2 = S1 之后的"进入治疗链" 强信号 — **必须是患者主动行为**(W4 末严格化)
///
/// 历史演进:
///   - W2 旧版:planned treatment 当 S2 → 王辉 case 证伪("医生开计划 ≠ 患者承诺")
///   - W3 末:加预约 complaint_category 强信号,planned 降为 fallback
///   - W4 末:fallback 完全移除 — 患者真没动作不应误升 status=entered
///     路遥 K01 智齿拔除 / K08 种植 都只有医生 planned,患者未预约/未付款 → 应留 discovered
///
/// 当前 S2 信号(只有 1 个,患者主动):
///   appointment 主诉类别匹配 — "牙周"/"种植"/"正畸" 等 complaint_category 命中 category
///   - 预约是患者主动行为(挂号/约时间/到店),代理"真意向" 比"医生侧计划" 准
///   - 桶 tooth 不约束(挂号阶段无牙位字段),按 category 粗判
///
/// planned treatment 信息没浪费:在 S2 node 渲染时作为"医生已开计划"hint 展示(done=false),
/// 让客服看到"医生计划是 XX 但患者还没动" — 但 status 仍是 discovered,需要被召回。
function collectS2Facts(
  category: string,
  byType: FactsByType,
  s1AnchorTime: Date | null,
  _bucketTooth: string, // 保留参数 — appointment 阶段无牙位粒度,不消费
  apptUpperTime: Date | null, // 预约时间上界(s3FirstActual)— 临床上预约必须早于 actual
): ChainComposeInputFact[] {
  // anchor=null(actual-only 桶)→ 不做时间下界约束(patient 全程都算)
  // anchor 非空 → sig 之后才算(diagnosis 锚点)
  const passesLower = (f: ChainComposeInputFact): boolean => {
    if (!s1AnchorTime) return true;
    const t = effectiveTime(f);
    return !!(t && t.getTime() >= s1AnchorTime.getTime());
  };
  const passesUpper = (f: ChainComposeInputFact): boolean => {
    if (!apptUpperTime) return true;
    const t = effectiveTime(f);
    return !!(t && t.getTime() < apptUpperTime.getTime());
  };
  const out: ChainComposeInputFact[] = [];
  for (const appt of byType.appointment) {
    if (appt.status === 'cancelled') continue;
    if (!passesLower(appt) || !passesUpper(appt)) continue;
    const rawComplaint = String((appt.content as Record<string, unknown>).complaint_category ?? '');
    const cats = parseComplaintCategories(rawComplaint);
    if (cats.includes(category as PACTreatmentCategory)) {
      out.push(appt);
    }
  }
  return out;
}

/// W4 末:S2 node 展示用 — 找同 category 的 planned treatment(s1 之后)
/// status 不升 entered,但客服看到"医生已开计划但未执行"的提示
function findPlannedTreatmentHint(
  category: string,
  byType: FactsByType,
  s1AnchorTime: Date | null,
): ChainComposeInputFact | null {
  let best: ChainComposeInputFact | null = null;
  for (const tx of byType.treatment) {
    if (tx.kind !== FactKind.PLANNED) continue;
    const tc = tx.content as Record<string, unknown>;
    if (String(tc.category ?? '') !== category) continue;
    if (s1AnchorTime) {
      const t = effectiveTime(tx);
      if (!t || t.getTime() < s1AnchorTime.getTime()) continue;
    }
    const t = effectiveTime(tx);
    const bt = best ? effectiveTime(best) : null;
    if (!best || (t && (!bt || t.getTime() < bt.getTime()))) best = tx;
  }
  return best;
}

/// 牙位串规整化 — 用于桶分 key:同 N 颗牙不同顺序("17;47;37" / "47;17;37")合 1 桶。
/// 牙位号去 trim + 去重 + 字典序排序 + ";" 拼接。空 → 返回空串(actual-only 桶兼容)。
/// 桶 name 显示仍用 s1Earliest 的 rawTooth(医生录入原序),保留语义。
function normalizeTooth(s: string): string {
  if (!s.trim()) return '';
  const list = Array.from(
    new Set(s.split(';').map((t) => t.trim()).filter(Boolean)),
  ).sort();
  return list.join(';');
}

/// actuals 牙位并集(去重 + 字典序)— actual-only 桶兜底显示用
/// 例:季根财 surgical 4 次 actual 拔 {23;24, 15;44, 37, 22} → "15;22;23;24;37;44"
function actualsToothUnion(actuals: ChainComposeInputFact[]): string {
  const set = new Set<string>();
  for (const tx of actuals) {
    const tp = String((tx.content as Record<string, unknown>).tooth_position ?? '');
    for (const t of tp.split(';')) {
      const trimmed = t.trim();
      if (trimmed) set.add(trimmed);
    }
  }
  return Array.from(set).sort().join(';');
}

/// 桶 sig 牙位中 actual 没覆盖到的部分(差集)— W4 末加,给"覆盖闸"用
///
/// 例:范萍莉 K08·implant 桶 tooth='41;47',6 次 actual 全在 47 → 返回 ['41']
/// 跳过条件:
///   - 桶 tooth 空 / wholeMouth → 返回 []
///   - sig 单颗牙时,只要任一 actual 牙位包含它就算覆盖
///
/// **注**:本判定 only for chain 展示层 closed 闸,不影响 scenario SQL 排除粒度。
function computeUncoveredTeeth(
  bucketTooth: string,
  actuals: ChainComposeInputFact[],
): string[] {
  if (!bucketTooth || bucketTooth === '*whole') return [];
  const bucketSet = new Set(bucketTooth.split(';').map((t) => t.trim()).filter(Boolean));
  if (bucketSet.size === 0) return [];
  const covered = new Set<string>();
  for (const tx of actuals) {
    const tp = String((tx.content as Record<string, unknown>).tooth_position ?? '');
    for (const t of tp.split(';')) {
      const trimmed = t.trim();
      if (trimmed) covered.add(trimmed);
    }
  }
  const uncovered: string[] = [];
  for (const t of bucketSet) {
    if (!covered.has(t)) uncovered.push(t);
  }
  return uncovered.sort();
}

/// 牙位是否有交集(用 ; 分隔,trim 后比较)
function toothOverlap(a: string, b: string): boolean {
  const setA = new Set(
    a.split(';').map((s) => s.trim()).filter(Boolean),
  );
  if (setA.size === 0) return false;
  for (const t of b.split(';')) {
    if (setA.has(t.trim())) return true;
  }
  return false;
}

/// (W3 末废弃)S2 大额 payment 阈值字典 — payment 无 category/tooth 关联,作为 S2 信号会误归桶
/// 例:罗国标 K04 14;15 桶被"种植定金 ¥4260"误判 entered。改成只用 planned treatment 作 S2 信号。
/// 字典保留(防有 host 接入时按 payment 阈值过滤误算入 LTV / 大额识别),实际不消费。
const _S2_PAYMENT_THRESHOLD_CENTS_DEPRECATED: Record<string, number> = {
  implant: 500000,
  orthodontic: 500000,
  prosthodontic: 200000,
  endodontic: 100000,
  periodontic: 50000,
  restorative: 30000,
  surgical: 30000,
  preventive: 30000,
  cosmetic: 100000,
  pediatric: 30000,
};
void _S2_PAYMENT_THRESHOLD_CENTS_DEPRECATED;

/// S3 = 同 category 的 actual treatment
function getActualTreatments(category: string, byType: FactsByType): ChainComposeInputFact[] {
  return byType.treatment.filter((tx) => {
    if (tx.kind !== FactKind.ACTUAL) return false;
    const cat = String((tx.content as Record<string, unknown>).category ?? '');
    return cat === category;
  });
}

interface MilestoneMatch {
  /// 命中的 milestone step(按字典顺序)
  matched: string[];
  /// 是否全步骤都命中
  allSatisfied: boolean;
  /// 每个 step → 命中的 actual treatment fact(用于 timeline label)
  stepToFact: Map<string, ChainComposeInputFact>;
  /// W4 末:命中的"终末 step"(milestone.terminalSteps 列表中,任一命中即视为治疗收尾)
  /// 例 endodontic:canal_filling(完整 RCT)或 pulpotomy(VPT 单步终末)
  /// inferChainStage 用它判 s3Reached:命中 terminal 即满足,跳过 minSteps
  terminalHit: PACTreatmentStepKey | null;
}

/// actual treatments 按 milestone.steps 匹配 — W4 末改进:
///   ① 优先用 actual.content.treat_stages[](host 给的精确 step,已 yaml 翻译到 PAC enum)
///   ② fallback 用 LegacyStepSubtypeKeywords[step] 词根匹配 subtype.includes()
///      给老数据 / host 没填 treat_stages 时兜底
function matchMilestoneSteps(
  actuals: ChainComposeInputFact[],
  milestone: TreatmentMilestone | undefined,
): MilestoneMatch {
  const stepToFact = new Map<string, ChainComposeInputFact>();
  if (!milestone || milestone.steps.length === 0) {
    // 无字典定义 → 视为 one_shot:有 actual 即满足"step 1"
    if (actuals.length > 0) stepToFact.set('treatment', actuals[0]!);
    return {
      matched: actuals.length > 0 ? ['treatment'] : [],
      allSatisfied: actuals.length > 0,
      stepToFact,
      terminalHit: null,
    };
  }
  const matched: string[] = [];
  for (const step of milestone.steps) {
    // ① 优先 treat_stages 精确匹配(PAC 标准 enum)
    let hit = actuals.find((tx) => {
      const stages = (tx.content as Record<string, unknown>).treat_stages;
      return Array.isArray(stages) && stages.includes(step);
    });
    // ② fallback:host 没填 stages → 用 LegacyStepSubtypeKeywords 词根匹配 subtype
    if (!hit) {
      const keywords = LegacyStepSubtypeKeywords[step] ?? [];
      hit = actuals.find((tx) => {
        const stages = (tx.content as Record<string, unknown>).treat_stages;
        // 如果该 actual 有 stages 但不含本 step,不算 hit(避免 stages 已显式列出但漏的情况)
        if (Array.isArray(stages) && stages.length > 0) return false;
        const sub = String((tx.content as Record<string, unknown>).subtype ?? '');
        return keywords.some((kw) => sub.includes(kw));
      });
    }
    if (hit) {
      matched.push(step);
      stepToFact.set(step, hit);
    }
  }
  // W4 末:terminalHit — milestone.terminalSteps 中任一命中即视为治疗收尾
  // 例 endodontic.terminalSteps=['canal_filling','pulpotomy'] — 任一命中 s3Reached=true
  const terminal = milestone.terminalSteps ?? [];
  const terminalHit = (terminal.find((s) => matched.includes(s)) as PACTreatmentStepKey | undefined) ?? null;
  return {
    matched,
    allSatisfied: matched.length === milestone.steps.length,
    stepToFact,
    terminalHit,
  };
}

/// S4 = S3 之后的复查 / 术后随访信号
function collectS4Facts(
  category: string,
  byType: FactsByType,
  s3AnchorTime: Date,
): ChainComposeInputFact[] {
  const after = (f: ChainComposeInputFact) => f.occurredAt && f.occurredAt.getTime() > s3AnchorTime.getTime();
  const out: ChainComposeInputFact[] = [];

  // review category 的 treatment_record(复查 / 拆线 / 暂观,真实落 fact)
  for (const tx of byType.treatment) {
    const cat = String((tx.content as Record<string, unknown>).category ?? '');
    if (cat === 'review' && after(tx)) out.push(tx);
  }
  // planned 同 category 复查(treat_plan 有 review 类计划)
  for (const tx of byType.treatment) {
    if (tx.kind !== FactKind.PLANNED) continue;
    const cat = String((tx.content as Record<string, unknown>).category ?? '');
    if (cat === category && after(tx)) out.push(tx);
  }
  // S3 之后的 encounter(复诊接诊)— 粗粒度,任何后续接诊都算"复查/术后" 信号
  for (const enc of byType.encounter) {
    if (after(enc)) out.push(enc);
  }
  // recommendation_record 中的 ANNUAL_REVIEW_RECOMMENDED 等(粗粒度,任何 review 类推荐都算)
  for (const rec of byType.recommendation) {
    const code = String((rec.content as Record<string, unknown>).code ?? '');
    if (code.includes('REVIEW') && after(rec)) out.push(rec);
  }
  return out;
}

/// 是否 S3 之后有 refund(同 patient 级,跨 category 也判;后续接 host order_id 后细化)
function hasPostS3Refund(byType: FactsByType, s3AnchorTime: Date | null): boolean {
  if (!s3AnchorTime) return false;
  return byType.refund.some(
    (r) => r.occurredAt && r.occurredAt.getTime() > s3AnchorTime.getTime(),
  );
}

/// 是否 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0)
function hasPostS3Relapse(
  category: string,
  byType: FactsByType,
  s3AnchorTime: Date | null,
): boolean {
  if (!s3AnchorTime) return false;
  return byType.diagnosis.some((dx) => {
    if (!dx.occurredAt || dx.occurredAt.getTime() <= s3AnchorTime.getTime()) return false;
    const code = String((dx.content as Record<string, unknown>).code ?? '');
    const rule = lookupDxTreatment(code);
    return rule?.categories.includes(category as never);
  });
}

/// W4 末:同牙位是否有 prosthodontic actual(冠/桩核)— lifecycle.requiresCrownProtection 用
/// 杨光宗 27 K04 根管完成但没冠 → false → S5 不闭环 → ongoing 提示客服
/// 时间窗:any time(S3 之前还是之后都算,host 偶尔有"先做冠后补根管"反常顺序)
function hasCrownProtection(
  bucketTooth: string,
  byType: FactsByType,
  _s3AnchorTime: Date | null,
): boolean {
  if (!bucketTooth || bucketTooth === '*whole') return true; // 全口/无牙位的(实际很少 endo case)— 跳过这个 check
  for (const tx of byType.treatment) {
    if (tx.kind !== FactKind.ACTUAL) continue;
    const c = tx.content as Record<string, unknown>;
    if (String(c.category ?? '') !== 'prosthodontic') continue;
    const stages = c.treat_stages;
    const subtype = String(c.subtype ?? '');
    // ① 优先 treat_stages:含 crown_restoration / post_core 算冠保护
    const hasCrownStage = Array.isArray(stages)
      && (stages.includes('crown_restoration') || stages.includes('post_core'));
    // ② fallback 词根:subtype 含"冠" 或 "桩核"(prosthodontic category 内的冠相关动作)
    const hasCrownSubtype = !hasCrownStage && (subtype.includes('冠') || subtype.includes('桩核'));
    if (!hasCrownStage && !hasCrownSubtype) continue;
    // 牙位 overlap check(prosthodontic 冠通常带具体牙位)
    const txTooth = String(c.tooth_position ?? '');
    if (!txTooth) continue; // 没牙位的修复不算特定牙位的冠
    if (toothOverlap(bucketTooth, txTooth)) return true;
  }
  return false;
}

// ─────────────────────────────────────────────
// timeline nodes 构造
// ─────────────────────────────────────────────

function buildStageNodes(opts: {
  currentStage: 1 | 2 | 3 | 4 | 5;
  status: ChainStatus;
  category: string;
  lifecycle: { maxStage: 4 | 5; noteZh: string; expectedSpanMonths?: number; requiresCrownProtection?: boolean };
  milestone: TreatmentMilestone | undefined;
  s1Earliest: ChainComposeInputFact | null;
  s2Earliest: ChainComposeInputFact | null;
  s1Facts: ChainComposeInputFact[];
  actuals: ChainComposeInputFact[];
  matchedSteps: MilestoneMatch;
  s3LastActual: ChainComposeInputFact | null;
  s4Earliest: ChainComposeInputFact | null;
  s4Hits: ChainComposeInputFact[];
  byType: FactsByType;
  doctorMap: Map<string, string>;
  crownOk: boolean;   // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过)
  uncoveredTeeth: string[];  // W4 末:多牙位桶里 actual 没覆盖到的牙位(用于 S5 hint)
  restorationIneligible: boolean;  // 废用牙/无功能牙 — 建议拔除/观察而非修复(Option B)
}): ChainNode[] {
  const {
    currentStage,
    status,
    category,
    lifecycle,
    milestone,
    s1Earliest,
    s2Earliest,
    s1Facts,
    actuals,
    matchedSteps,
    s3LastActual,
    s4Earliest,
    s4Hits,
    byType,
    doctorMap,
    crownOk,
    uncoveredTeeth,
    restorationIneligible,
  } = opts;
  const reached = (s: number) => currentStage >= s;
  const cur = (s: number) => currentStage === s;
  const fmt = (d: Date | null | undefined) => (d ? fmtYearMonthDay(d) : '—');
  // 废用牙/无功能牙:不建议种植/桥/义齿,改建议拔除/观察(临床:无功能牙该拔或观察)
  const expectedHint = restorationIneligible
    ? '拔除 / 观察'
    : (CATEGORY_RECOMMEND[category] ?? '相应治疗');

  // ─── S1 发现机会 ───────────────────────────────────
  const s1Node: ChainNode = (() => {
    const n: ChainNode = { stage: 1, at: '—', done: reached(1), current: cur(1) };
    if (!s1Earliest) {
      // 无诊断但有 actual treatment(host 数据缺诊断 case)
      n.title = '—';
      n.detail = '无诊断信号';
      return n;
    }
    const c = s1Earliest.content as Record<string, unknown>;
    const isRec = s1Earliest.type === FactType.RECOMMENDATION_RECORD;
    const code = String(c.code ?? '');
    const codeName =
      String(c.name_zh ?? c.name ?? '') ||
      lookupDxTreatment(code)?.chainLabel ||
      code ||
      (isRec ? '医生建议' : '诊断');
    n.title = isRec ? `建议 ${codeName}` : codeName;
    n.detail = formatToothShort(String(c.tooth_position ?? ''), category);
    n.doctor = resolveDoctorName(s1Earliest, doctorMap);
    n.at = fmt(effectiveTime(s1Earliest));
    return n;
  })();

  // ─── S2 进入治疗链 ─────────────────────────────────
  // W4 末两阶段判定:
  //   1. status=entered/ongoing/closed 的判定看 collectS2Facts(必须患者主动行为)
  //   2. S2 node 的 done 展示标准更宽:**只要 S3 达到 → S2 自动 done**(已治疗 ⇒ 已进入)
  //      因为患者实际接受了治疗,必然"进入"过治疗链(不管经预约还是直接到诊)
  //
  // S2 node 文案优先级(从强到弱):
  //   ① 预约 complaint 匹配 → "预约就诊 · {主诉}"
  //   ② 付款 → "已付款 · ¥X"
  //   ③ 有 plannedHint(医生开了计划)→ "{subtype} · 已开计划"
  //      - 如果 S3 reached:"已开计划"(已执行)
  //      - 如果 status=discovered:"已开计划(待执行)" 给客服暗示
  //   ④ S3 reached 无任何上面信号 → "直接执行 · 未经预约"(急诊场景)
  //   ⑤ status=discovered 无任何信号 → "尚未启动" + hint
  const plannedHint = findPlannedTreatmentHint(category, byType, s1Earliest?.occurredAt ?? null);
  // 跟 inferChainStage 同口径:terminalHit 命中即 s3Reached(无字典则 actual 即可)
  const s3Reached =
    matchedSteps.terminalHit !== null ||
    matchedSteps.matched.length >= (milestone?.minSteps ?? 1) ||
    (!milestone && actuals.length > 0);
  const s2Node: ChainNode = (() => {
    // S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达 OR ③ 桶里有 actual(治疗发生过必经 S2)
    //   ③ 兜底 milestone 没匹配上的笼统 subtype(bare"种植"等)—— 跟 inferChainStage 的 actuals>0→ongoing 一致,
    //   否则 S3 已治疗(✓)但 S2 还"尚未启动"(○)自相矛盾 + 误挂"建议种植"hint。
    const s2Done = !!s2Earliest || s3Reached || actuals.length > 0;
    const n: ChainNode = { stage: 2, at: '—', done: s2Done, current: cur(2), missing: !s2Done };

    // 文案优先级 ① s2Earliest 强信号
    if (s2Earliest) {
      const c = s2Earliest.content as Record<string, unknown>;
      if (s2Earliest.type === FactType.APPOINTMENT_RECORD) {
        const complaint = String(c.complaint_text ?? c.complaint_category ?? '').trim();
        n.title = '预约就诊';
        n.detail = complaint ? shortLabel(complaint) : '已主动预约';
        n.doctor = resolveDoctorName(s2Earliest, doctorMap);
      } else if (s2Earliest.type === FactType.PAYMENT_RECORD) {
        const amount = Number(c.amount_cents ?? 0);
        n.title = '已付款';
        n.detail = formatYuan(amount);
      } else {
        n.title = '已进入';
      }
      n.at = fmt(effectiveTime(s2Earliest));
      return n;
    }

    // ② 没强信号 — 优先用 plannedHint 展示(医生开了计划)
    if (plannedHint) {
      const pc = plannedHint.content as Record<string, unknown>;
      n.title = shortLabel(String(pc.subtype ?? '') || '医生计划');
      n.detail = s3Reached ? '已开计划' : '已开计划(待执行)';
      n.doctor = resolveDoctorName(plannedHint, doctorMap);
      n.at = fmt(effectiveTime(plannedHint));
      return n;
    }

    // ③ 没强信号 也没 planned — 看是否已有治疗(s3Reached 或 有 actual:笼统 subtype 也算治疗发生过)
    if (s3Reached || actuals.length > 0) {
      n.title = '直接执行';
      n.detail = '未经预约';
    } else {
      n.title = '尚未启动';
      n.hint = `建议${expectedHint}`;
    }
    return n;
  })();

  // ─── S3 治疗执行 ───────────────────────────────────
  const s3Node: ChainNode = (() => {
    const n: ChainNode = { stage: 3, at: '—', done: reached(3), current: cur(3), missing: !reached(3) };
    if (!s3LastActual) {
      n.title = '未进行';
      n.hint = status === 'entered' ? `等待${expectedHint}` : undefined;
      return n;
    }
    // 命中的 milestone steps 拼起来(种植"种植体植入 → 种植上部修复(冠)")
    // PAC step enum 渲染中文 label(PACTreatmentStep 字典),fallback 给 step key 本身
    const stepLabel = (step: string): string =>
      PACTreatmentStep[step as PACTreatmentStepKey] ?? step;
    const titleSteps =
      matchedSteps.matched.length > 0
        ? matchedSteps.matched.map(stepLabel).map(shortLabel).join(' → ')
        : shortSubtype(s3LastActual);
    n.title = titleSteps || '治疗';
    // detail:进度展示(W4 末改进 — 优先显示真实次数,字典分子分母对客服不直观)
    //   - s3Reached 满足:显示 "已完成 N 次"(N=actual treatment 次数)— 直观,跟次数对应
    //   - s3Reached 不满足 + multi-step(如种植 minSteps=2 只做了植入):显示"X/Y 步骤(进行中)" 提示缺
    //   - 单 step 类(periodontic/restorative minSteps=1)→ 一律走"次数"分支
    // 杨光宗 27 根管 3 次 actual:旧版"1/3 步骤"误导(像"差 2 步"),新版"已完成 3 次" 准确
    const totalCents = sumAmountCents(actuals);
    // matched>0 才显"X/Y 步骤"(真有部分步骤命中);matched=0 是笼统 subtype 没匹配上(bare"种植"等),
    //   治疗确实发生了,显"N 次治疗"而非"0/Y 步骤(进行中)"(后者误导成"几乎没做")。
    const isMultiStep = !!milestone && milestone.minSteps > 1 && matchedSteps.matched.length > 0;
    const progress = s3Reached
      ? `已完成 ${actuals.length} 次`
      : isMultiStep
        ? `${matchedSteps.matched.length}/${milestone!.steps.length} 步骤(进行中)`
        : `${actuals.length} 次治疗`;
    n.detail = totalCents > 0 ? `${progress} · ${formatYuan(totalCents)}` : progress;
    n.doctor = resolveDoctorName(s3LastActual, doctorMap);
    n.at = fmt(s3LastActual.occurredAt);
    return n;
  })();

  // ─── S4 术后复查 ───────────────────────────────────
  const s4Node: ChainNode = (() => {
    const n: ChainNode = { stage: 4, at: '—', done: reached(4), current: cur(4), missing: !reached(4) };
    if (!s4Earliest) {
      n.title = '未复查';
      n.hint = status === 'ongoing' && currentStage === 3 ? '建议术后复诊' : undefined;
      return n;
    }
    const c = s4Earliest.content as Record<string, unknown>;
    // 若是复评 diagnosis,提示牙位差异
    if (s4Earliest.type === FactType.DIAGNOSIS_RECORD) {
      const code = String(c.code ?? '');
      const codeName = String(c.name_zh ?? c.name ?? code);
      n.title = `${codeName} 复评`;
      const reTooth = String(c.tooth_position ?? '').split(';').filter(Boolean).length;
      const baseTooth = s1Earliest
        ? String((s1Earliest.content as Record<string, unknown>).tooth_position ?? '')
            .split(';').filter(Boolean).length
        : 0;
      if (baseTooth > 0 && reTooth !== baseTooth) {
        const diff = reTooth - baseTooth;
        n.detail = `${reTooth} 牙${diff > 0 ? `(+${diff})` : `(${diff})`}`;
      } else {
        n.detail = reTooth > 0 ? `${reTooth} 牙` : '—';
      }
    } else if (s4Earliest.type === FactType.ENCOUNTER_RECORD) {
      n.title = '复诊接诊';
      n.detail = `共 ${s4Hits.filter((f) => f.type === FactType.ENCOUNTER_RECORD).length} 次`;
    } else if (s4Earliest.type === FactType.TREATMENT_RECORD) {
      const cat = String(c.category ?? '');
      n.title = cat === 'review' ? '复查 / 拆线' : '复查计划';
      n.detail = shortLabel(String(c.subtype ?? '')) || '—';
    } else {
      n.title = '复查';
    }
    n.doctor = resolveDoctorName(s4Earliest, doctorMap);
    n.at = fmt(effectiveTime(s4Earliest));
    return n;
  })();

  // ─── S5 闭环 / 维护期 / 未闭环 ─────────────────────
  const s5Node: ChainNode = (() => {
    const n: ChainNode = { stage: 5, at: '—', done: reached(5), current: cur(5), missing: !reached(5) };

    // lifelong_maintenance:永远卡 stage 4,S5 显示"维护期"
    if (lifecycle.maxStage === 4) {
      n.title = '维护期';
      n.detail = lifecycle.noteZh;
      // 查 active 预约
      const nextApt = findNextActiveAppointment(byType.appointment);
      if (nextApt) {
        n.hint = `已预约 ${fmt(effectiveTime(nextApt))}`;
      } else {
        n.hint = '建议周期复查';
      }
      n.missing = false; // 维护型不能算"missing 未到",改"无终点"
      return n;
    }

    // 已闭环
    if (status === 'closed') {
      n.title = '已闭环';
      // 总周期 = S1 → S4
      if (s1Earliest && s4Earliest) {
        const s1t = effectiveTime(s1Earliest);
        const s4t = effectiveTime(s4Earliest);
        if (s1t && s4t) {
          const days = Math.floor((s4t.getTime() - s1t.getTime()) / 86400_000);
          n.detail = `周期 ${days} 天`;
        }
      }
      if (s4Earliest) n.at = fmt(effectiveTime(s4Earliest));
      return n;
    }

    // 未闭环:提示还差什么
    n.title = '未闭环';
    // W4 末:覆盖闸 — bucket 多牙位但 actual 没覆盖到全部 → 优先提示
    //   例:K08·implant 桶 {41,47},6 次种植全在 47 → 提示"47 已闭环, 41 待处置"
    //   说明患者已在治疗链内但仍有未处置牙位(可视为治疗链内召回的暗示)
    if (uncoveredTeeth.length > 0) {
      const covered = s3LastActual ? '已处置部分牙位' : '';
      n.hint = covered
        ? `${uncoveredTeeth.join(',')} 待处置(其余已处置)`
        : `${uncoveredTeeth.join(',')} 待处置`;
    } else if (lifecycle.requiresCrownProtection && s3Reached && !crownOk) {
      n.hint = '待冠保护(根管后建议戴冠,防牙冠折裂)';
    } else if (matchedSteps.terminalHit) {
      // W4 末:命中了 terminal step(如 pulpotomy / canal_filling)
      // 治疗已收尾,缺的只是 S4 复查 / 时间观察 — 不算"待 missing step"
      n.hint = '待复查 / 观察期(治疗已收尾)';
    } else if (milestone && matchedSteps.matched.length < milestone.steps.length) {
      // 排除 terminalSteps:replacement 路径不算"缺步"(避免提示"待 pulpotomy"等)
      const terminal = new Set<string>(milestone.terminalSteps ?? []);
      const missing = milestone.steps.filter(
        (s) => !matchedSteps.matched.includes(s) && !terminal.has(s),
      );
      if (missing.length > 0) {
        const stepLabelFn = (step: string): string =>
          PACTreatmentStep[step as PACTreatmentStepKey] ?? step;
        n.hint = `待 ${missing.map(stepLabelFn).map(shortLabel).join(' / ')}`;
      }
    } else if (currentStage === 3) {
      n.hint = '待术后复查';
    }
    return n;
  })();

  return [s1Node, s2Node, s3Node, s4Node, s5Node];
}

/// 牙位简化显示:全口 / 22 颗 / 11;12;... / 未标注牙位
function formatToothShort(tooth: string, category: string): string {
  if (!tooth.trim()) {
    const rule = lookupDxTreatment(/* dxCode 无 */ '');
    return rule?.wholeMouth || category === 'periodontic' ? '全口' : '未标注牙位';
  }
  const list = tooth.split(';').map((s) => s.trim()).filter(Boolean);
  if (list.length === 0) return '未标注';
  if (list.length <= 3) return list.join(';');
  if (list.length >= 20) return `全口 ${list.length} 牙`;
  return `${list.slice(0, 3).join(';')}等 ${list.length} 牙`;
}

function formatYuan(cents: number): string {
  if (!cents) return '';
  const y = cents / 100;
  if (y >= 10000) return `¥${(y / 10000).toFixed(1)}万`;
  return `¥${y.toFixed(0)}`;
}

function sumAmountCents(facts: ChainComposeInputFact[]): number {
  let sum = 0;
  for (const f of facts) {
    const c = f.content as Record<string, unknown>;
    const v = Number(c.amount_cents ?? 0);
    if (Number.isFinite(v)) sum += v;
  }
  return sum;
}

/// 找最近一条 active planned 预约(术后/复查 S5 提示用)
function findNextActiveAppointment(
  appointments: ChainComposeInputFact[],
): ChainComposeInputFact | null {
  const now = Date.now();
  let best: ChainComposeInputFact | null = null;
  let bestDelta = Infinity;
  for (const ap of appointments) {
    if (ap.kind !== FactKind.PLANNED) continue;
    const t = effectiveTime(ap);
    if (!t || t.getTime() < now) continue; // 过去的不算
    const delta = t.getTime() - now;
    if (delta < bestDelta) {
      bestDelta = delta;
      best = ap;
    }
  }
  return best;
}

/// 各 category 的"建议什么治疗" 短词(给 discovered/entered hint 用)
const CATEGORY_RECOMMEND: Record<string, string> = {
  implant: '种植/桥/义齿',
  prosthodontic: '冠桥修复',
  restorative: '充填修复',
  endodontic: '根管治疗',
  periodontic: '牙周基础治疗',
  surgical: '拔除',
  orthodontic: '正畸治疗',
  preventive: '预防保健',
  cosmetic: '美学修复',
  pediatric: '儿童牙科',
};

// ─────────────────────────────────────────────
// 工具 / 字典
// ─────────────────────────────────────────────

/// 取 fact 的有效时间:actual 用 occurredAt,planned 用 plannedFor 兜底。
/// planned treatment 的 occurredAt 通常 null(parser 设计),真值在 plannedFor。
function effectiveTime(f: ChainComposeInputFact): Date | null {
  return f.occurredAt ?? f.plannedFor ?? null;
}

function earliest(facts: ChainComposeInputFact[]): ChainComposeInputFact | null {
  let best: ChainComposeInputFact | null = null;
  let bestT: Date | null = null;
  for (const f of facts) {
    const t = effectiveTime(f);
    if (!t) continue;
    if (!bestT || t.getTime() < bestT.getTime()) {
      best = f;
      bestT = t;
    }
  }
  return best;
}

function latest(facts: ChainComposeInputFact[]): ChainComposeInputFact | null {
  let best: ChainComposeInputFact | null = null;
  let bestT: Date | null = null;
  for (const f of facts) {
    const t = effectiveTime(f);
    if (!t) continue;
    if (!bestT || t.getTime() > bestT.getTime()) {
      best = f;
      bestT = t;
    }
  }
  return best;
}

/**
 * subtype 简称:host settlement_project_name 去括号 + 截 6 字。
 * "种植体植入费(颗)" → "种植体植入费";"冠修复(国产全瓷)" → "冠修复"。
 */
function shortSubtype(t: ChainComposeInputFact): string {
  const c = t.content as Record<string, unknown>;
  const sub = String(c.subtype ?? '').trim();
  return shortLabel(sub);
}

function shortLabel(s: string): string {
  if (!s) return '';
  const clean = s.replace(/[(（].+?[)）]/g, '').trim();
  return clean.length > 6 ? clean.slice(0, 6) : clean;
}

const CATEGORY_LABEL: Record<string, string> = {
  endodontic: '根管治疗',
  prosthodontic: '修复(冠 / 桥)',
  orthodontic: '正畸',
  implant: '种植',
  periodontic: '牙周维护',
  restorative: '充填修复',
  surgical: '外科手术',
  preventive: '预防保健',
  cosmetic: '美容修复',
  pediatric: '儿童牙科',
  review: '复查 / 流程',
};

/// 排序优先级:召回候选(discovered/entered)排前,运营关注度依次降低
const STATUS_PRIORITY: Record<ChainStatus, number> = {
  discovered: 1,
  entered: 2,
  ongoing: 3,
  closed: 4,
};

/// 没字典的 category 默认 lifecycle(one_shot 等价)
const DEFAULT_LIFECYCLE = { maxStage: 5 as const, noteZh: '默认一次性', requiresCrownProtection: false };

// ─────────────────────────────────────────────
// Types(对外接口)
// ─────────────────────────────────────────────

export interface ChainComposeInputFact {
  id: string;
  type: string;
  kind: string;
  /// fact 版本流状态('active' / 'superseded' / 'cancelled' / 'fulfilled' / 'expired' / 'invalidated')
  /// chain-composer S2 用此过滤已取消预约;后续阶段也可用做"软删除" 兜底
  status: string;
  occurredAt: Date | null;
  /// planned 类(治疗计划 / 复查计划 / 预约) — occurredAt 通常为 null,真值在 plannedFor。
  /// 时间比较 / 显示 / 排序统一用 effectiveTime() = occurredAt ?? plannedFor。
  plannedFor?: Date | null;
  content: Prisma.JsonValue;
}

/// 5 阶段链状态(替代旧 'closed'|'ongoing'|'uninitiated' 三态)
///
/// - discovered: 仅 S1 命中(诊断/推荐)— 召回算法关注的"潜在新链"
/// - entered:   S2 命中(已挂号/预约/付款但未真正治疗)
/// - ongoing:   S3 或 S4 命中(治疗中 / 复查中)
/// - closed:    S5 全条件满足 — 真正闭环(lifelong_maintenance 类永不到此)
export type ChainStatus = 'discovered' | 'entered' | 'ongoing' | 'closed';

export interface ComposedChain {
  id: string;
  name: string;
  category: string;
  /// 该链对应诊断码(K00 / K01 / ... / K09)— plan-aggregate 按 chain.code === reason.signal_code 对齐 ★
  /// actual-only 桶(纯 actual,无诊断)code=''
  code?: string;
  /// 该链对应诊断码的全部可关联 categories(DxMap.code.categories)— alt-close 等场景用
  allCategories?: readonly string[];
  status: ChainStatus;
  currentStage: number;
  /// 召回算法的关注候选标记(status='discovered'/'entered' 时 true,UI 展示"★"标)
  target?: boolean;
  /// 估值字段保留兼容,实际不再使用(无可信来源)
  estimatedValueCents?: number;
  /// S1 信号(首次诊断 / 推荐)发生日 — UI "诊断于 YYYY.MM.DD" 用
  diagnosedAt?: string;
  /// S1 至今天数 — gap 越大临床越紧迫(召回算法的紧迫度信号)
  gapDays?: number;
  /// ⭐ 最近一次诊断日(re-trigger 证据)— 多次诊断时 = 再诊断日(如牙周在管又被诊断 2023-10-16)
  latestDxAt?: string;
  /// 最近一次诊断的中文名(如"慢性牙龈炎")— banner "又诊断「X」" 用
  latestDxName?: string;
  /// 该次诊断对应的医生真实医嘱(emr_record.doctor_advice,如"两个月复诊")— **展示层落地证据**,
  ///   替代前端硬编码的"医嘱建议 X 个月内";无则前端不显示"医嘱"避免夸大。
  latestDxAdvice?: string;
  /// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
  lifecycleNoteZh?: string;
  /// 桶 tooth_position(用于 cross-chain "alternative-closed" 判定:同 patient 同 tooth overlap)
  toothPosition?: string;
  /// "替代治疗已覆盖"原因 chain.name(被同 tooth 后续 K08+种植 / 拔除等替代时填,UI 显示提示)
  alternativeClosedBy?: string;
  nodes: ChainNode[];
}

/**
 * 5 阶段节点 — 每节点最多 4 信息位 + 视觉状态(W3 末重塑)。
 *
 * 设计:STAGE_LABELS (前端 ① 发现机会/② 进入治疗链/...) 是静态 header,**不在 node 里**。
 * node 装"该阶段具体做了什么 + 关键事实 + 医生 + 时间"4 信息位:
 *
 *   L1 title   = 具体动作(K05 牙周炎 / *全口洁治 / 牙周刮治术 / 复诊接诊 / 维护期)
 *   L2 detail  = 关键事实(全口 26 牙 / ¥58 ✓ / 1/2 步骤 / 牙位 +1 颗)
 *   L3 doctor  = 主治 / 计划 医生名(用于追溯,可空)
 *   L4 at      = 日期 YYYY.MM.DD(planned 用 plannedFor 兜底)
 *
 * 状态字段(done/current/missing)给视觉用:
 *   - done:    该阶段已达成,圆点实心
 *   - current: 当前所处阶段(currentStage),圆点高亮
 *   - missing: 未达到,圆点虚线 / 灰
 *
 * hint = 给 discovered/entered/未闭环 显示的"建议下一步" 友好提示
 *        (例:S2 hint = "尚未启动 · 建议种植/桥/义齿";S5 hint = "维护型 · 下次 3-6 个月内")
 */
export interface ChainNode {
  stage: number;
  /// 兼容旧字段(若 title/detail 都不存在前端可回退到 label,但优先用结构化字段)
  label?: string;
  title?: string;
  detail?: string;
  doctor?: string;
  at: string;
  done?: boolean;
  current?: boolean;
  missing?: boolean;
  hint?: string;
  /// 旧字段保留兼容(未使用)
  note?: string;
}

/** 兼容旧 ComposeInputEncounter 类型(给老调用方过渡) */
export type ComposeInputEncounter = ChainComposeInputFact;
