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

/**
 * 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);

    // ⭐ 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[]): void {
  const ALT_CATS = new Set(['implant', 'surgical', 'prosthodontic']);
  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;
    for (const other of chains) {
      if (other === c) continue;
      if (!ALT_CATS.has(other.category)) continue;
      if (!other.toothPosition || !other.diagnosedAt) continue;
      // ⭐ 同 category 不交叉关闭(W3 末)— alt-close 语义是 K04 根管→K08 种植 这种"不同方案"替代,
      //    同病种(都 implant / 都 surgical)多次诊断不是"替代"是"重复",该在桶分阶段合并而非这里互判
      //    例:王辉 K08 多次诊断 tooth 顺序差异 → 旧版同 implant 互判 alt-closed → 误标
      if (other.category === c.category) continue;
      if (other.diagnosedAt < c.diagnosedAt) continue;
      if (!toothOverlap(c.toothPosition, other.toothPosition)) continue;
      // 命中 — 标记替代闭环
      c.status = 'closed';
      c.target = false;
      c.alternativeClosedBy = other.name;
      c.currentStage = 5;
      break;
    }
  }
}

/// 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 锚点)
  const allActuals = getActualTreatments(category, byType);
  const actuals = s1Earliest?.occurredAt
    ? allActuals.filter((tx) => tx.occurredAt && tx.occurredAt.getTime() >= s1Earliest.occurredAt!.getTime())
    : allActuals;
  const matchedSteps = matchMilestoneSteps(actuals, milestone);
  const s3Reached = milestone ? 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)
  // 1. S3 满足 minSteps(s3Reached) — 例:种植 minSteps=2 要植入+修复都做才可能 closed;
  //    单步类(充填/拔除/洁牙)minSteps=1 做一次即可。**不再要求 milestone 全 steps 都做**
  //    (preventive 字典 ['洁牙','涂氟','封闭'] 只做洁牙也算 closed,涂氟/封闭非必做)
  // 2. S4 至少 1 命中(术后随访 / 复查 encounter / planned review / *_REVIEW_RECOMMENDED)
  // 3. lifecycle.maxStage = 5(lifelong_maintenance 直接卡死 stage 4)
  // 4. 无 S3 之后的 refund 同 patient(简化:patient 级,跨 category 也判 — 后续接 host order_id 后细化)
  // 5. 无 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0 视为反弹)
  const s5Eligible =
    s3Reached &&
    s4Hits.length > 0 &&
    maxAllowedStage === 5 &&
    !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 (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 ? '全口' : '未标注牙位';
  const chainLabel = 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' 统一显示"全口"
  const tooth = dxRule?.wholeMouth
    ? '全口'
    : (bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth);
  const chainName = `${chainLabel} · ${tooth || fallback}`;
  const diagnosedAt = s1Earliest?.occurredAt ? fmtYearMonthDay(s1Earliest.occurredAt) : '—';
  const gapDays = s1Earliest?.occurredAt
    ? Math.floor((Date.now() - s1Earliest.occurredAt.getTime()) / 86400_000)
    : 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,
    target: status === 'discovered',
    diagnosedAt,
    gapDays,
    lifecycleNoteZh: lifecycle.noteZh,
    // 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key),
    // 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后)
    toothPosition: bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth,
    nodes: buildStageNodes({
      currentStage,
      status,
      category,
      lifecycle,
      milestone,
      s1Earliest,
      s2Earliest,
      s1Facts,
      actuals,
      matchedSteps,
      s3LastActual,
      s4Earliest,
      s4Hits,
      byType,
      doctorMap,
    }),
  };
}

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

interface FactsByType {
  diagnosis: ChainComposeInputFact[];
  recommendation: ChainComposeInputFact[];
  treatment: ChainComposeInputFact[];
  appointment: ChainComposeInputFact[];
  payment: ChainComposeInputFact[];
  encounter: ChainComposeInputFact[];
  refund: 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),
  };
}

/**
 * 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']);
  const dxCategories = new Set([...map.values()].map((b) => b.category));
  const actualCategories = new 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)) actualCategories.add(cat);
  }
  for (const cat of actualCategories) {
    if (!dxCategories.has(cat)) {
      map.set(`${cat}||`, { 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(';');
}

/// 牙位是否有交集(用 ; 分隔,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>;
}

/// 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,
    };
  }
  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);
    }
  }
  return {
    matched,
    allSatisfied: matched.length === milestone.steps.length,
    stepToFact,
  };
}

/// 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);
  });
}

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

function buildStageNodes(opts: {
  currentStage: 1 | 2 | 3 | 4 | 5;
  status: ChainStatus;
  category: string;
  lifecycle: { maxStage: 4 | 5; noteZh: string; expectedSpanMonths?: number };
  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>;
}): ChainNode[] {
  const {
    currentStage,
    status,
    category,
    lifecycle,
    milestone,
    s1Earliest,
    s2Earliest,
    s1Facts,
    actuals,
    matchedSteps,
    s3LastActual,
    s4Earliest,
    s4Hits,
    byType,
    doctorMap,
  } = opts;
  const reached = (s: number) => currentStage >= s;
  const cur = (s: number) => currentStage === s;
  const fmt = (d: Date | null | undefined) => (d ? fmtYearMonthDay(d) : '—');
  const expectedHint = 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);
  const s3Reached = matchedSteps.matched.length >= (milestone?.minSteps ?? 1) || actuals.length > 0;
  const s2Node: ChainNode = (() => {
    // S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达(治疗都做了一定经过 S2)
    const s2Done = !!s2Earliest || s3Reached;
    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 — 看 status
    if (s3Reached) {
      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);
    const isMultiStep = !!milestone && milestone.minSteps > 1;
    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 = '未闭环';
    if (milestone && matchedSteps.matched.length < milestone.steps.length) {
      const missing = milestone.steps.filter((s) => !matchedSteps.matched.includes(s));
      if (missing.length > 0) {
        n.hint = `待 ${missing.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: '默认一次性' };

// ─────────────────────────────────────────────
// 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;
  /// 生命周期提示("终身维护(永不闭环)" 等)— 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;
