import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  PlanScenario,
  DiagnosisTreatmentMap,
  APPT_COMPLAINT_TO_CATEGORY,
  RESTORATION_INELIGIBLE_DX_NAMES,
  lookupDxTreatment,
  resolverCategoriesFor,
  STRUCTURAL_DX_CODE_LIST,
  diagnosisCodeNameZh,
  treatmentCategoryNameZh,
  treatmentCategoryNameZhForTeeth,
} from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service';
import type {
  PlanScenarioPlugin,
  ScenarioHit,
  ScenarioScope,
} from '../scenario.interface';
import { calcPriority } from '../priority-scorer';
import { toothSet } from '../../../sync/pipeline/parsers/tooth-position.util';

/**
 * 潜在治疗新链召回(treatment_initiation_recall)— v2.1 重写
 *
 * **v2.1 关键变化**(对齐 canonical-fact-layer.md §四 + potential-treatment-recall.md §八):
 *   - 读独立 fact_type:diagnosis_record / treatment_record / recommendation_record
 *     不再 LIKE 自由文本,直接 `content->>'code' = 'K08'` 索引友好
 *   - 算分用 6 因子 priority-scorer(临床基线 × 时间窗 + 价值/转化/紧迫 + 信号置信)
 *   - 排除条件改读 treatment_record(独立)而非嵌套 encounter.treatments[]
 *
 * 3 个主子场景(单价由高到低):
 *
 *   #A 缺失牙 → 种植/桥/义齿(黄金窗 30-180 天,基线 60)
 *      触发:diagnosis_record.code='K08' 或 recommendation_record.code='IMPLANT_RECOMMENDED'
 *      排除:诊断后启动的 treatment_record(actual).category IN ('implant','prosthodontic')
 *
 *   #B 龋齿 → 充填(黄金窗 14-90 天,基线 45)
 *      触发:diagnosis_record.code='K02' 或 recommendation_record.code='FILLING_RECOMMENDED'
 *      排除:诊断后启动的 treatment_record(actual).category='restorative'
 *
 *   #C 牙周诊断 → SRP/翻瓣(黄金窗 30-120 天,基线 50)
 *      触发:diagnosis_record.code='K05' 或 recommendation_record.code='SRP_RECOMMENDED'
 *      排除:诊断后启动的 treatment_record(actual).category='periodontic'
 *
 *   #D 牙髓/根尖周炎 → 根管(黄金窗 14-90 天,基线 52)
 *      触发:diagnosis_record.code='K04'
 *      排除:诊断后启动的 treatment_record(actual).category='endodontic'
 *      (K04 拖久 → 根尖脓肿/拔牙,故窗口比龋齿略紧、紧迫临界 45 天)
 *
 * ⭐ 入池窗口 vs 黄金窗(W3 末修正,别再混用):
 *   - goldenRange = 算分用(priority-scorer.computeTimeWindowFactor):窗内 1.0,过早 0.6→1.0,
 *     过晚从 end 到 2×end 衰减到 0.4。**只影响优先级,不当入池硬边界**。
 *   - 入池窗口 = [start, INTAKE_MAX_DAYS]:下界 start(刚诊断 <start 天还在考虑期,不急召);
 *     上界放宽到 INTAKE_MAX_DAYS(缺牙拖 1 年比拖 3 个月更该召!超 golden 上界仍入池,交 scorer 衰减)。
 *   - 旧 bug:入池上界 = golden end → 缺牙 >180 天直接踢出池,scorer 的"过晚"分支成死代码。
 *
 * ⭐ 触发时间 = COALESCE(occurred_at, planned_for):
 *   diagnosis 用 occurred_at;recommendation 是 planned kind,occurred_at 为 null、时间在 planned_for。
 *   旧 bug:SQL 写 `occurred_at IS NOT NULL` → recommendation 触发(recCodes)全部命不中,死代码。
 *
 * ⚠️ 牙位无法做"按牙排除"(数据源限制,非 bug):
 *   "已做治疗"的真凭据是结算表 fact_settlement_out(花钱才算做)→ 落 actual treatment,
 *   而结算按收费项记账,**结构上无牙位列**。故排除只能 patient 级(同 patient 做过同类治疗即排)。
 *   treat_plan 里 46% 有牙位,但落的是 planned(≠已做),不能用于排除。反馈 DW 在结算补牙位再升级。
 *
 * 通用过滤(每个子场景 SQL 自己写,共用片段后续抽):
 *   active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
 */

/// 临床语义剔除:这些 name_zh 被 host 映射到 K08(yaml 标注"临床功能性缺失"),但牙其实
/// 还在、只是无咬合功能(对颌缺失/过长)→ 临床该拔除或观察,**不是修复(种植/桥/义齿)对象**
/// → 不进 K08 种植召回。host 原文 name_zh 在 diagnosis_record.content 留底,据此精确剔除。
/// 案例:韩雷 38;48 / 826790 18;28;38;48 都是"废用牙"被误当缺牙召回种植(91 分误召)。
/// 注:这跟"真缺失"(缺失/牙列缺损 → 该修复)区分开;拔牙本身不做泛召回(详见 docs 讨论)。
/// ⭐ 单一真理源(canonical-codes):chain-composer 同表 → 不立"种植修复·发现机会"潜在链,口径一致。
const RESTORATION_INELIGIBLE_NAMES = [...RESTORATION_INELIGIBLE_DX_NAMES];

/// 就诊冷静(防打扰,⑤f):患者近 N 天到过诊(encounter/emr)→ 本轮不召,别催刚来过的人。
/// 跟诊断 cooldown 不同锚点 — cooldown 锚"诊断日"按 K 码定长;本项锚"最近到诊"统一长度。
/// 二者并存,各补各的洞:cooldown 给新诊断缓冲;本项拦"旧诊断 + 近期又来过"。
/// 患者级、与具体信号无关;将来复购 scenario 应抽成共享 fragment 复用。
const POST_VISIT_COOLDOWN_DAYS = 14;

@Injectable()
export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
  readonly key = PlanScenario.TREATMENT_INITIATION_RECALL;
  private readonly logger = new Logger(TreatmentInitiationRecallScenario.name);

  /// W3 末决策:**移除入池时间上界硬过滤**(原 730 天)
  /// - 临床缺口不会自愈(K08 缺牙/K07 错颌/K00 先天缺失 拖 5 年仍是缺口,该召还是该召)
  /// - 算法层已有衰减:priority-scorer.computeTimeWindowFactor 对超 goldenRange.end 的诊断按 [end → 2×end] 衰减到 0.4
  /// - 真正"该止损"的判断让 scorer 给低分 + UI 排到尾部 + 客服自选,**不在 SQL 层硬切**
  /// - 副作用:召回池可能膨胀(老 K05 牙周诊断永远在),但 scorer 衰减 + priority 排序自然处理
  private static readonly INTAKE_MAX_DAYS = null;

  // ─ 子场景配置 ─
  //
  // 临床窗口 / 类别 / 紧迫临界 **统一从 canonical-codes.DiagnosisTreatmentMap 读**(单一真理源),
  // 本表只装跟"召回话术 / 业务话目标"相关的 UI 文案 + base 基线分。
  // 改窗口/类别去改字典,不要在这里硬编码,否则 scenario / chain-composer / scorer 会跟字典背离。
  // W3 末扩 9 子场景(K00-K08 全覆盖,跟 DiagnosisTreatmentMap 一对一)
  // base 分级:K08 缺牙 60(单价 1.5-3 万)/ K07 正畸 55(单价 3-5 万)/ K04 牙髓 52(防恶化)/
  //          K05 牙周 50 / K02 龋齿 45 / K03 牙体 35 / K06 牙龈 35 / K01 阻生 30 / K00 萌出 25
  // 不漏码原则:host 数据出现的诊断都进召回池,priority 自然衰减(低 base + 慢窗口 → 排到后面)
  private static readonly SUB_SCENARIOS = {
    missing_tooth: {
      base: 60,
      primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
      dxCodes: ['K08'],
      recCodes: ['IMPLANT_RECOMMENDED'],
      label: '缺失牙未启动修复',
      goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
      // ⭐ 乳牙不进种植/修复召回:乳牙缺失/晚萌是生理现象,恒牙会替换,不可能种植/做冠桥。
      //   只对本子场景(缺牙→成人修复)生效;龋齿(乳牙也补)/正畸/萌出 不受影响。
      //   乳牙判定:FDI 51-85 + 宿主象限记法 1A-4E(见 toothArrSql dropDeciduous)。
      excludeDeciduous: true,
    },
    ortho_no_consult: {
      base: 55,
      primaryCode: 'K07',
      dxCodes: ['K07'],
      recCodes: ['ORTHO_CONSULT_RECOMMENDED'],
      label: '错颌畸形未启动正畸',
      goal: '邀约做正畸初诊评估,把握矫治窗口(尤其混合牙列期 8-12 岁 / 成人骨健全期)',
    },
    endo_no_rct: {
      base: 52,
      primaryCode: 'K04',
      dxCodes: ['K04'],
      recCodes: ['RCT_RECOMMENDED'],
      label: '牙髓炎/根尖周炎未做根管',
      goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙',
    },
    perio_no_srp: {
      base: 50,
      primaryCode: 'K05',
      dxCodes: ['K05'],
      recCodes: ['SRP_RECOMMENDED'],
      label: '牙周炎未做基础治疗',
      goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展',
    },
    caries_no_filling: {
      base: 45,
      primaryCode: 'K02',
      dxCodes: ['K02'],
      recCodes: ['FILLING_RECOMMENDED'],
      label: '龋齿未做充填',
      goal: '邀约尽快做龋齿充填,避免发展为牙髓炎',
    },
    hard_tissue_damage: {
      base: 35,
      primaryCode: 'K03',
      dxCodes: ['K03'],
      recCodes: ['HARD_TISSUE_REPAIR_RECOMMENDED', 'CROWN_RECOMMENDED'],
      // K03 复合:残根残冠→拔(surgical)/ 楔缺→充填(restorative)/ 大缺损→冠桥(prosthodontic)
      // 三类 actual 任一存在即排除(见 DxMap K03.categories)— 数据噪音可控
      label: '牙体损伤未修复',
      goal: '邀约处理牙体硬组织损伤(楔状缺损/残根/残冠 → 充填/嵌体/拔除修复)',
    },
    gum_alveolar_lesion: {
      base: 35,
      primaryCode: 'K06',
      // K06 病种保留为 K06,不归 K05(不改诊断码原则)— 临床路径同 periodontic+surgical
      dxCodes: ['K06'],
      recCodes: ['GUM_TREATMENT_RECOMMENDED'],
      label: '牙龈/牙槽嵴疾患未处置',
      goal: '邀约处理牙龈/牙槽嵴问题(牙龈萎缩/增生/瘤/根分叉病变/系带异常 → 牙周治疗或手术)',
    },
    impacted_tooth: {
      base: 30,
      primaryCode: 'K01',
      // 智齿阻生临床不强制拔除(无症状可观察);base 低让分数沉底,真急的患者靠 urgencyBonus 升上来
      dxCodes: ['K01'],
      recCodes: ['EXTRACTION_RECOMMENDED'],
      label: '阻生牙未拔除',
      goal: '邀约评估阻生牙是否需拔除(智齿冠周炎反复 / 顶坏邻牙 / 正畸需要)',
    },
    jaw_cyst: {
      base: 50,
      primaryCode: 'K09',
      dxCodes: ['K09'],
      recCodes: ['JAW_CYST_REMOVAL_RECOMMENDED'],
      // 颌骨囊肿临床必处理(不摘除会继续扩大压迫邻牙/神经),base 中等偏高
      label: '颌骨囊肿未处理',
      goal: '邀约尽快做颌骨囊肿摘除术,避免囊肿扩大压迫邻牙 / 神经',
    },
    development_eruption: {
      base: 25,
      primaryCode: 'K00',
      // K00 真病种(乳牙滞留 / 多生牙 / 先天缺失 / 釉质发育不全 / 萌出障碍)— 不含"乳牙列""混合牙列"等正常态(yaml 不映射)
      // 儿童居多,base 最低;windowDays 365 给足观察期
      dxCodes: ['K00'],
      recCodes: ['ERUPTION_INTERVENTION_RECOMMENDED'],
      label: '牙发育/萌出异常未处置',
      goal: '邀约评估处置(乳牙滞留/多生牙拔除 · 先天缺失修复 · 釉质发育不全美容修复 · 萌出障碍助萌)',
    },
  } as const;

  constructor(private readonly prisma: PrismaService) {}

  async selectHits(scope: ScenarioScope): Promise<ScenarioHit[]> {
    const hits: ScenarioHit[] = [];
    for (const [subKey, cfg] of Object.entries(
      TreatmentInitiationRecallScenario.SUB_SCENARIOS,
    )) {
      const subHits = await this.runSubScenario(scope, subKey, cfg);
      hits.push(...subHits);
    }
    this.logger.debug(
      `initiation total hits: ${hits.length}(missing_tooth/caries/perio 合计)`,
    );
    return hits;
  }

  // ─────────────────────────────────────────────────────────
  // 子场景跑 SQL + 算 6 因子分
  // ─────────────────────────────────────────────────────────

  private async runSubScenario(
    scope: ScenarioScope,
    subKey: string,
    cfg: (typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS)[keyof typeof TreatmentInitiationRecallScenario.SUB_SCENARIOS],
  ): Promise<ScenarioHit[]> {
    // 临床窗口 / 类别 / 紧迫临界从 canonical-codes.DiagnosisTreatmentMap 单一真理源读
    const rule = lookupDxTreatment(cfg.primaryCode);
    if (!rule) {
      throw new Error(
        `SUB_SCENARIOS[${subKey}].primaryCode=${cfg.primaryCode} 在 DiagnosisTreatmentMap 中找不到 — ` +
          `检查 canonical-codes.ts(单一真理源)`,
      );
    }
    const start = rule.cooldownDays;
    const goldenRange: [number, number] = [rule.cooldownDays, rule.windowDays];
    const dxCodes = cfg.dxCodes as readonly string[];
    const recCodes = cfg.recCodes as readonly string[];
    const allCodes = [...dxCodes, ...recCodes];
    // ⭐ 两个口径分开(单一真理源 canonical-codes):
    //   expectedCats = rule.categories(窄,主治疗)→ 展示"未启动 X" + 触发预期 + ⑤d 主诉匹配
    //   resolverCats = resolverCategoriesFor(宽,治疗家族)→ ⑤a "已解决" 判定
    //     结构码(K02/K03/K08…)= 任何局部结构治疗都算(充填/根管/冠桥/种植/外科/美学/儿牙);
    //     牙周/正畸(K05/K06/K07)沿用各自 categories。见 canonical-codes.resolverCategoriesFor。
    const expectedCats = rule.categories as readonly string[];
    const resolverCats = resolverCategoriesFor(cfg.primaryCode) as readonly string[];

    // W4 末:按 expectedCats 算出对应的预约 complaint 文本(host appointment.complaint_category 字段值)
    //   APPT_COMPLAINT_TO_CATEGORY 反查:filter complaint where category ∈ expectedCats
    //   例 K07 expectedCats=['orthodontic'] → complaintTexts=['正畸','早矫']
    //       K08 expectedCats=['implant','prosthodontic'] → ['种植','修复']
    //   用于 ⑤d:sig 之后有 complaint 匹配的 appointment → 患者已 entered 治疗链,不召回
    const complaintTexts = Object.entries(APPT_COMPLAINT_TO_CATEGORY)
      .filter(([, cat]) => expectedCats.includes(cat))
      .map(([text]) => text);

    // 单 patient 收窄(详情页"刷新"):设了 scope.patientId → 只扫该患者,O(全租户)→O(1)
    const patientFilter = scope.patientId
      ? Prisma.sql`AND p.id = ${scope.patientId}::uuid`
      : Prisma.empty;

    // ⑤a 时间方向开关:excludeIfEverTreated 的码(全口长疗程,正畸 K07 / 牙周 K05)忽略时间方向 —
    //   "曾做过同类治疗"即排除(复诊反复重记诊断,不能要求治疗晚于诊断,否则误召"未启动")。
    //   按牙位的码保留"治疗晚于诊断",靠牙位区分新旧病灶。
    const afterDxFrag = rule.excludeIfEverTreated
      ? Prisma.empty
      : Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;

    // ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。
    //   把"信号的牙位"统一替换成 NULL → 下游一律按"无牙位"处理:
    //     - 主 SELECT:tooth=null → 聚类归 'whole' cluster(sub_key=...@whole),展示不带单颗牙
    //     - ⑤a:第一分支"信号无牙位"恒真 → category 级排除(只要做过同类治疗即排,不比牙位)
    //     - ⑤c / ⑤e:"信号有牙位"守卫恒假 → 不按单牙误排(单颗拔除/单颗冠桥不终结全口病)
    //   原因:全口病诊断偶带某颗牙(录入习惯),治疗可能在别的牙,单牙重叠判断会误召
    //        (709686:K05@38 牙周治疗在 36 → 单牙不重叠 → 误召 perio_no_srp@38)。
    //   非 wholeMouth 码(K02 龋/K04 根管/K08 缺牙等)保持按牙位精确匹配,不受影响。
    const sigToothExpr = rule.wholeMouth
      ? Prisma.sql`NULL::text`
      : Prisma.sql`sig.content->>'tooth_position'`;
    // 是否全口码(K05/K07)— 编译期已知,显式传入。
    //   ⚠️ 不能再用 `cardinality(sig_teeth)=0` 推断全口:非全口码若牙位全是非法(裸象限数字
    //   "1;2" 笔误,被 toothArrSql 过滤成空)也会 cardinality=0 → 误入全口分支 → 输出原始裸数字
    //   (phantom 召回 endo_no_rct@1;)。改用 rule.wholeMouth:非全口码牙位全非法 → remaining 空 → 不召。
    const wholeMouthFlag = rule.wholeMouth ? Prisma.sql`TRUE` : Prisma.sql`FALSE`;

    // ⭐ 牙位级"按牙相减"(W5:修多牙诊断被部分治疗整体误抑制)
    //   牙位字符串 → 牙位数组,**只剥"空格+牙面字母"后缀,保留 FDI 数字 & Palmer 乳牙字母**。
    //   口径必须跟单一真理源 toothSet(tooth-position.util.ts)严格一致 —— 否则乳牙塌缩。
    //   ⚠️ 旧实现 `regexp_replace(expr,'[^0-9;]+',';')` 把所有字母无差别剥掉,
    //      乳牙 "1D;1E" 全塌成 "1"(象限)→ 错标 + 整象限过度相减漏召(差分测试 26 患者命中)。
    //   现:① 剥 "牙位base + 空格 + 牙面字母"(同 normalizeToothPosition 的 SURFACE_SUFFIX_RE)
    //       ② 去残留空格 ③ 按 ';' 拆。例:
    //      "1D;1E;2D"  → {1D,1E,2D}(乳牙保留)   "1D OD;17 D" → {1D,17}(剥面)   "16;46" → {16,46}
    //   注:JS 模板里反向引用要写 '\\1'(传到 SQL 为 '\1');用 POSIX [[:space:]] 避开 \s 转义坑。
    //   ⭐ 合法牙过滤(口径同 util.isValidToothToken):只留【数字开头 + ≥2 字符】的 token。
    //      裸单数字 1-8(象限号,医生把 "16" 笔误成 "1")非法 → 丢,否则造幽灵召回 caries@1。
    //      `x ~ '^[0-9].'`:数字开头且其后还有字符(裸 "1" 仅 1 字符 → 不匹配 → 滤掉)。
    //   ⭐ dropDeciduous(仅 missing_tooth 信号牙位用):剔除乳牙 —— 乳牙缺失/损伤不进种植/修复召回。
    //      乳牙 = FDI 51-85(`^[5-8][1-5]$`)+ 宿主象限记法 1A-4E(`^[1-4][A-Ea-e]$`)。
    //      恒牙 FDI 11-48(象限 1-4 + 1-8 数字)不匹配这两条,不误伤。全乳牙 → st 空 → 不召(同空数组闸)。
    const toothArrSql = (expr: Prisma.Sql, dropDeciduous = false) => {
      const deciduousFilter = dropDeciduous
        ? Prisma.sql`AND x !~ '^[5-8][1-5]$' AND x !~ '^[1-4][A-Ea-e]$'`
        : Prisma.empty;
      return Prisma.sql`(SELECT COALESCE(array_agg(x), ARRAY[]::text[]) FROM unnest(array_remove(string_to_array(
        regexp_replace(
          regexp_replace(${expr}, '([0-9]+[A-Ea-e]?)[[:space:]]+[DMOBLPIdmoblpi]+', '\\1', 'g'),
          '[[:space:]]+', '', 'g'),
        ';'), '')) AS x WHERE x ~ '^[0-9].' ${deciduousFilter})`;
    };
    // 该信号"已被解决"的牙位集合 = 诊断后同牙做了 resolverCats 家族里任一治疗(afterDx)。
    //   ⭐ 治疗家族 resolver(单一真理源 canonical-codes.resolverCategoriesFor):
    //     结构码 resolverCats = 局部结构治疗全集(充填/嵌体/冠桥/种植/牙髓/外科/美学/儿牙)—
    //       已涵盖旧 ⑤a同类 + ⑤c拔除(surgical)+ ⑤e替代(implant/prostho),三分支收成一条。
    //       新增:贴面(cosmetic)/ 根管(endodontic)/ 充填(restorative)治龋等过去漏判的也算已治(李梦维 1B)。
    //       刻意不含 periodontic/orthodontic/preventive/review → 不被洗牙/刮治/流程跨病误销。
    //     牙周/正畸码 resolverCats = rule.categories(牙周只认牙周/外科,不被结构治疗误销)。
    //   ⚠️ afterDx(治疗 ≥ 诊断 才终结):拔除只终结它之前/同时的病;诊断在拔除【之后】=新信号(不压)。
    //     excludeIfEverTreated 码(K05/K07 全口长疗程)忽略时间方向 → 曾做过即解决。
    const afterDxFragRtx = rule.excludeIfEverTreated
      ? Prisma.empty
      : Prisma.sql`AND rtx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
    const resolvedTeethSql = Prisma.sql`
      (SELECT COALESCE(array_agg(DISTINCT t), ARRAY[]::text[]) FROM (
        -- (a) 治疗家族 resolver(afterDx):同牙诊断后做了 resolverCats 家族里任一治疗
        SELECT rtt AS t
          FROM patient_facts rtx
          CROSS JOIN unnest(${toothArrSql(Prisma.sql`rtx.content->>'tooth_position'`)}) AS rtt
         WHERE rtx.patient_id = p.id
           AND rtx.type = 'treatment_record' AND rtx.kind = 'actual'
           AND rtx.status IN ('active', 'fulfilled')
           AND COALESCE(NULLIF(trim(rtx.content->>'tooth_position'), ''), '') != ''
           AND rtx.content->>'category' = ANY(${resolverCats}::text[])
           ${afterDxFragRtx}
        UNION
        -- (b) ⭐ 同牙位以【最新诊断】为准:某牙存在比本信号更晚的真实诊断 → 旧诊断对该牙失效。
        --   解:深龋(2023)→ 缺失(2024)缺失取代龋(牙没了,补龋/拔牙都 moot);龋→牙髓炎(进展)只召根管;
        --       复发同病只认最新那次(daysSince 从最新算)。证据是【诊断】本身,无需治疗记录。
        --   只算"真实码"(非空码,排除深窝沟等 null 噪音);严格更晚(> 不含同刻,避免自我取代)。
        --   wholeMouth 码(K05/K07)走 category 级、sig_teeth 空 → 不经此路径,不受影响。
        SELECT ldt AS t
          FROM patient_facts ldx
          CROSS JOIN unnest(${toothArrSql(Prisma.sql`ldx.content->>'tooth_position'`)}) AS ldt
         WHERE ldx.patient_id = p.id
           AND ldx.type = 'diagnosis_record' AND ldx.status = 'active'
           AND ldx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])  -- 仅结构码取代(牙周/正畸不 moot 结构病)
           AND ldx.occurred_at > COALESCE(sig.occurred_at, sig.planned_for)
        UNION
        -- (c) ⭐ 诊断 vs 建议冲突,以【建议】为准(医生治疗决定 > 诊断):
        --   某牙存在 ≥ 本诊断时间的真实建议(建议拔除/种植…)→ 该诊断对该牙失效,听医生的决定。
        --   18:深龋(诊断)+ 建议拔除(同日)→ 龋失效,只留拔除。只作用于本 sig 是【诊断】时
        --   (建议本身不被同牙建议取代;建议被更晚诊断取代已由 (b) 覆盖)。
        SELECT rdt AS t
          FROM patient_facts rdx
          CROSS JOIN unnest(${toothArrSql(Prisma.sql`rdx.content->>'tooth_position'`)}) AS rdt
         WHERE rdx.patient_id = p.id
           AND rdx.type = 'recommendation_record' AND rdx.status = 'active'
           AND rdx.content->>'code' = ANY(${[...STRUCTURAL_DX_CODE_LIST]}::text[])  -- 仅结构建议(拔除/种植/充填…)取代;牙周/正畸建议不 moot 结构病
           AND COALESCE(rdx.occurred_at, rdx.planned_for) >= COALESCE(sig.occurred_at, sig.planned_for)
           AND sig.type = 'diagnosis_record'
      ) u)`;

    // ╔═════════════════════════════════════════════════════════════════════╗
    // ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回)                  ║
    // ║                                                                      ║
    // ║ 一句话:"找诊断了 / 医生推荐了,但**没启动**对应治疗的患者"          ║
    // ║                                                                      ║
    // ║ ┌─ 主查询(找触发信号 sig + 算 daysSince) ─────────────────────────┐ ║
    // ║ │ 从 patients 表起,JOIN patient_profiles + patient_facts(sig)     │ ║
    // ║ │ 每命中一条 (patient × signal_fact) 组合返回一行                   │ ║
    // ║ │ daysSince = 今天 - 触发时间(单位:天,给 scorer 用)              │ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ① 隔离闸(强制基线) ──────────────────────────────────────────┐ ║
    // ║ │ p.host_id = scope.hostId    + p.tenant_id = scope.tenantId       │ ║
    // ║ │ → PAC 三层模型的 platform+tenant 硬隔离,跨租户不漏              │ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ② 合规闸 ────────────────────────────────────────────────────┐ ║
    // ║ │ p.active = true              (患者主档活跃)                     │ ║
    // ║ │ pp.do_not_contact = false    (没勾"勿打扰")                    │ ║
    // ║ │ pp.deceased = false          (没标"已故")                       │ ║
    // ║ │ → 法务 / 投诉风险硬过滤,任一不满足直接踢                       │ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ③ 触发信号(sig 这条 fact 必须是"该召回理由") ───────────────┐ ║
    // ║ │ sig.status = 'active'                  (当前版本,非历史)        │ ║
    // ║ │ sig.type IN ('diagnosis_record',       (诊断)                  │ ║
    // ║ │              'recommendation_record')  (医生 / LLM 推荐)         │ ║
    // ║ │ sig.content->>'code' = ANY(allCodes)   (K08/K07/.. 或 *_RECOMMENDED)│ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ④ 触发时间下界(冷静期 cooldown) ────────────────────────────┐ ║
    // ║ │ COALESCE(sig.occurred_at, sig.planned_for) <= now - cooldownDays │ ║
    // ║ │ → 刚诊断 < cooldown 天(K08=30天/K04=14天/...)还在医生考虑期,    │ ║
    // ║ │   客服过早打扰不合适。从字典 DiagnosisTreatmentMap[code] 读     │ ║
    // ║ │ 注:旧版还有上界(730天),W3 末已废 — 缺口不会自愈,scorer 自然衰减│ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ⑤a 排除闸:NOT EXISTS 同类 actual 治疗(牙位级 W4 末升级)─┐ ║
    // ║ ┌─ ⑤c 排除:同牙位拔除(W4 末加,临床:拔了就终结)────────────┐ ║
    // ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║
    // ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual'              │ ║
    // ║ │ tx.status IN ('active','fulfilled')   (完成的 actual 是 fulfilled)│ ║
    // ║ │ tx.content->>'category' = ANY(resolverCats)                      │ ║
    // ║ │     resolverCats = 治疗家族(结构码=局部结构治疗全集;牙周/正畸沿用)│ ║
    // ║ │ tx.occurred_at >= sig.occurred_at  ⭐ 时间方向                  │ ║
    // ║ │     只算"诊断之后才启动"的治疗,历史旧治疗(可能是另一颗牙)不算  │ ║
    // ║ │                                                                  │ ║
    // ║ │ W4 末升级:**牙位级 overlap**(EMR.treat_plan 带 48.7% 牙位)    │ ║
    // ║ │   信号有牙位 + actual 有牙位 → tooth array overlap 判排除         │ ║
    // ║ │   信号无牙位(K05 全口诊断)→ 不算 tooth,patient/category 级       │ ║
    // ║ │   actual 无牙位(全口洁治)→ 视为"全口覆盖"→ 仍排除 category 全位 │ ║
    // ║ │   ⇒ 罗国标 K04 14;15 之前误排(actual endo 在 36),现在正确召回   │ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ COALESCE(occurred_at, planned_for):                                  ║
    // ║   diagnosis_record 是 actual(已发生)→ 用 occurred_at                ║
    // ║   recommendation_record 是 planned(未发生)→ 用 planned_for          ║
    // ║                                                                      ║
    // ║ 输出:每个命中 (patient × sig) 一行,后段 byPatient Map 去重         ║
    // ║      只留 daysSince 最大那条(最早诊断 = 最有召回价值)              ║
    // ╚═════════════════════════════════════════════════════════════════════╝
    const rows: HitRow[] = await this.prisma.$queryRaw`
      SELECT
        p.id                        AS patient_id,
        p.external_id               AS patient_external_id,
        sig.id                      AS signal_fact_id,
        sig.type                    AS signal_type,
        sig.content->>'code'        AS signal_code,
        -- ⭐ 牙位级相减:有牙位信号 → 输出"剩余未治牙位"(诊断牙位 − 已治牙位);
        --    全口信号(sig_teeth 空)→ 原样(NULL,下游归 whole cluster)
        CASE WHEN ${wholeMouthFlag}
             THEN ${sigToothExpr}
             ELSE array_to_string(lat.remaining_teeth, ';') END AS tooth,
        sig.content->>'extracted_by' AS extracted_by,
        sig.content->>'confidence'  AS confidence,
        sig.clinic_id               AS clinic_id,
        COALESCE(sig.occurred_at, sig.planned_for) AS signal_occurred_at,
        EXTRACT(DAY FROM ${scope.now}::timestamptz - COALESCE(sig.occurred_at, sig.planned_for))::int AS days_since
      FROM patients p
      JOIN patient_profiles pp ON pp.patient_id = p.id
      JOIN patient_facts sig ON sig.patient_id = p.id
      -- ⭐ 按牙相减:算 sig 牙位 / 已解决牙位 / 剩余未治牙位(供 tooth 输出 + ⑤a 闸)
      LEFT JOIN LATERAL (
        SELECT st AS sig_teeth, rt AS resolved_teeth,
          (SELECT COALESCE(array_agg(x), ARRAY[]::text[])
             FROM unnest(st) AS x WHERE x <> ALL(rt)) AS remaining_teeth
        FROM (
          SELECT COALESCE(${toothArrSql(sigToothExpr, (cfg as { excludeDeciduous?: boolean }).excludeDeciduous === true)}, ARRAY[]::text[]) AS st,
                 ${resolvedTeethSql} AS rt
        ) base
      ) lat ON true
      WHERE p.host_id = ${scope.hostId}::uuid              -- ① 隔离闸
        AND p.tenant_id = ${scope.tenantId}                -- ① 隔离闸
        ${patientFilter}                                   -- 单刷收窄(可空)
        AND p.active = true                                -- ② 合规闸
        AND pp.do_not_contact = false                      -- ② 合规闸
        AND pp.deceased = false                            -- ② 合规闸
        AND sig.status = 'active'                          -- ③ 触发信号 active 版本
        AND sig.type IN ('diagnosis_record', 'recommendation_record')  -- ③ 信号类型
        AND sig.content->>'code' = ANY(${allCodes}::text[])            -- ③ 信号 code 命中
        AND COALESCE(sig.occurred_at, sig.planned_for) IS NOT NULL     -- ④ 时间不为空
        AND COALESCE(sig.occurred_at, sig.planned_for) <= ${this.daysAgo(scope.now, start)}::timestamptz  -- ④ 过 cooldown
        -- ④' 临床语义剔除:废用牙/无功能牙(host 映射到 K08,但牙还在无功能 → 该拔/观察,非修复对象)
        --     不进种植召回。仅这些 name_zh 受影响(它们只在 K08),其余子场景诊断不含此名 → 无副作用。
        AND COALESCE(sig.content->>'name_zh', '') <> ALL(${RESTORATION_INELIGIBLE_NAMES}::text[])
        -- ⑤a 牙位级排除(W5 按牙相减,修"多牙诊断被部分治疗整体误抑制"):
        --   全口信号(sig_teeth 空,如 K05/K07)→ 沿用 category 级 NOT EXISTS(做过同类治疗即排)
        --   有牙位信号 → "剩余未治牙位"非空才召(⑤a 同类 / ⑤c 拔除 / ⑤e 替代 已折进 resolved_teeth 按牙相减)
        --   例 龚靖舜 浅龋@16;26;46;36 只补了 16 → remaining={26,36,46} 非空 → 仍召这三颗
        AND (
          CASE WHEN ${wholeMouthFlag} THEN
            NOT EXISTS (
              SELECT 1 FROM patient_facts tx
              WHERE tx.patient_id = p.id
                AND tx.type = 'treatment_record'
                AND tx.kind = 'actual'
                AND tx.status IN ('active', 'fulfilled')
                AND tx.content->>'category' = ANY(${resolverCats}::text[])  -- 治疗家族 resolver(全口码 = rule.categories)
                ${afterDxFrag}
                -- 全口信号 sigToothExpr=NULL → 下条恒真 → category 级排除(忽略 dx 自带牙位)
                AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
            )
          ELSE
            cardinality(lat.remaining_teeth) > 0
          END
        )
        -- (⑤c 同牙位拔除 已折进上面 resolved_teeth 的 surgical 分支 — 拔了的牙从 remaining 减掉)
        AND NOT EXISTS (                                   -- ⑤b 排除:患者已有未来预约
          -- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
          -- 不按 complaint_category 匹配(陆伟根:K07 诊断 + 修复预约 → 反正会来诊)
          -- 只看 status='active' + future
          SELECT 1 FROM patient_facts appt
          WHERE appt.patient_id = p.id
            AND appt.type = 'appointment_record'
            AND appt.status = 'active'
            AND COALESCE(appt.planned_for, appt.occurred_at) > ${scope.now}::timestamptz
        )
        AND NOT EXISTS (                                   -- ⑤d 排除:sig 之后 complaint 匹配的预约(W4 末加)
          -- 跟 chain-composer.collectS2Facts 同口径:complaint_category 命中 expectedCategories → 患者已 entered
          -- 例 林菲菲:K07@2026-04-14 诊断 + appointment(complaint=正畸)@2026-04-26 → 已进入正畸链 → 排除
          -- "现在召回只做新链" 原则:entered/ongoing 患者不属于新链召回,等 W5+ 治疗链内召回 scenario
          -- active 未到诊 + fulfilled 已到诊都算(只要有 complaint 匹配)
          SELECT 1 FROM patient_facts appt
          WHERE appt.patient_id = p.id
            AND appt.type = 'appointment_record'
            AND appt.status IN ('active', 'fulfilled')
            AND COALESCE(appt.planned_for, appt.occurred_at) >= COALESCE(sig.occurred_at, sig.planned_for)
            AND EXISTS (
              SELECT 1
              FROM unnest(string_to_array(COALESCE(appt.content->>'complaint_category', ''), ',')) AS c
              WHERE trim(c) = ANY(${complaintTexts}::text[])
            )
        )
        -- (⑤e 同牙位替代治疗 种植/冠桥 已折进上面 resolved_teeth 的 implant/prosthodontic 分支 — 诊断后做了的从 remaining 减掉)
        AND NOT EXISTS (                                   -- ⑤f 就诊冷静:近 N 天到过诊 → 别催刚来过的人(防打扰)
          -- 患者级、与具体信号无关:最近一次到诊(encounter/emr)在 N 天内 → 本轮不召。
          -- 锚"最近到诊"而非"诊断日" → 补 cooldown 漏的"旧诊断 + 近期又来过"场景。
          -- 不含 appointment(那是预约不一定到诊;未来预约已由 ⑤b 单独排除)。
          SELECT 1 FROM patient_facts vis
          WHERE vis.patient_id = p.id
            AND vis.type IN ('encounter_record', 'emr_record')
            AND vis.occurred_at IS NOT NULL
            AND vis.occurred_at > ${this.daysAgo(scope.now, POST_VISIT_COOLDOWN_DAYS)}::timestamptz
        )
    `;

    // ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
    //   跟 chain-composer 的 bucket 合并口径一致 → reason 与 chain 1:1 对齐
    //   场景:王燕桦 K01 3 个 sig(38 / 48;38 / 18;28;38)牙位重叠 → 1 个临床 episode
    //   合并后 sub_key = sub_scenario@<union(tooth)>;daysSince 取 cluster 内最大(最早诊断)
    //   全口诊断(K05 等空牙位)→ 全部归入 'whole' cluster(1 个 / patient,跟 chain.tooth='*whole' 一致)
    const mergedHits = mergeRowsByToothOverlap(rows);

    // 为算 6 因子,一次性查所有命中 patient 的 persona + recent execution
    const patientIds = [...new Set(mergedHits.map((r) => r.patient_id))];
    const personaCtx = await this.fetchPersonaContext(patientIds);
    const execCtx = await this.fetchRecentExecutions(patientIds, scope.now);

    const hits: ScenarioHit[] = [];
    for (const r of mergedHits) {
      const patientId = r.patient_id;
      const persona = personaCtx.get(patientId);
      const confidence = r.confidence
        ? Number(r.confidence)
        : r.signal_type === 'recommendation_record'
          ? 0.8 // 默认 LLM/规则抽出的 0.8
          : 1.0; // 结构化诊断 1.0

      const { score, breakdown } = calcPriority({
        base: cfg.base,
        daysSince: r.days_since,
        goldenRange,
        valueScore: persona?.valueScore ?? null,
        riskScore: persona?.riskScore ?? null,
        recentExecutions: execCtx.get(patientId) ?? [],
        signalConfidences: [confidence],
        urgencyDayThreshold: rule.urgencyDayThreshold,
      });

      const toothStr = r.tooth ? ` · 牙位 ${r.tooth}` : '';
      // cluster 内可能含两种 sig(诊断 + 医生建议)→ 文案合并显示;只 1 种则保留单一来源
      const hasDx = r.cluster_has_diagnosis ?? (r.signal_type === 'diagnosis_record');
      const hasRec = r.cluster_has_recommendation ?? (r.signal_type === 'recommendation_record');
      const sourceStr = hasDx && hasRec ? '(诊断+医生建议)' : hasRec ? '(医生建议)' : '(诊断)';
      // 触发信号类型(原 enum,不语义化;前端用 triggerTypeLabelZh 翻译)
      // lead 单一类型 — 想精准列出所有 sig 的话需要把 triggers[] 改成 cluster 全量,目前 lead 代表
      const triggerType =
        r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis';
      hits.push({
        patientId,
        patientExternalId: r.patient_external_id,
        // reason 文本兜底(AI prompt / 调试用,前端不依赖此字段)
        // 乳牙 restorative 只显「充填」(去嵌体),跟前端 ReasonLine/目标 tag 同口径(treatmentCategoryNameZhForTeeth)
        reason: `${cfg.label}${toothStr} — ${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${expectedCats.map((c) => treatmentCategoryNameZhForTeeth(c, r.tooth)).join(' / ')}`,
        priorityScore: score,
        goal: cfg.goal,
        recommendedRole: 'staff',
        recommendedChannel: 'phone',
        // recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
        // 前端已移除推荐时间展示/排序。待有真实择时逻辑再启用。
        // 目标诊所 = 诊断出该未治疗需求的诊所(患者最可能回访的地方)
        targetClinicId: r.clinic_id ?? null,
        evidence: {
          // cluster 内全部 sig 的 fact_id(lead 在 [0])— 让审计/追溯能看到所有触发依据
          factIds: r.cluster_fact_ids ?? [r.signal_fact_id],
        },
        // sub_key 牙位级粒度:plan_reasons UNIQUE(plan, scenario, sub_key) 允许同 patient
        //   同 sub_scenario 多牙位各 1 行(36/46 都需充填都进库)。
        //   全口诊断(K05 等无牙位)→ '@whole';前端 / plan-aggregate 用 signals.toothPosition 区分语义。
        subKey: `${subKey}@${(r.tooth ?? '').trim() || 'whole'}`,
        // 结构化召回信号(DB 存 raw enum / canonical code,前端字典翻译富文本)
        // triggers 是 cluster 内全量(去重),前端按 type set 大小渲染 (诊断) / (医生建议) / (诊断+医生建议)
        signals: {
          subKey,
          triggers: r.cluster_triggers ?? [{ type: triggerType, code: r.signal_code }],
          toothPosition: r.tooth ?? null,
          daysSince: r.days_since,
          // 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
          signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
          expectedCategories: [...expectedCats],
        },
        priorityBreakdown: breakdown,
      });
    }
    return hits;
  }

  // ─────────────────────────────────────────────────────────
  // 算分需要的辅助数据查询
  // ─────────────────────────────────────────────────────────

  /// 拉每个 patient 当前 active persona 的 value / recall_risk 两个 score
  private async fetchPersonaContext(
    patientIds: string[],
  ): Promise<Map<string, { valueScore: number | null; riskScore: number | null }>> {
    if (patientIds.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<
      Array<{ patient_id: string; key: string; score: number | null }>
    >`
      SELECT p.patient_id, pf.key, pf.score
      FROM personas p
      JOIN persona_features pf ON pf.persona_id = p.id
      WHERE p.patient_id = ANY(${patientIds}::uuid[])
        AND p.superseded_at IS NULL
        AND pf.key IN ('value', 'recall_risk')
    `;
    const ctx = new Map<string, { valueScore: number | null; riskScore: number | null }>();
    for (const r of rows) {
      const cur = ctx.get(r.patient_id) ?? { valueScore: null, riskScore: null };
      if (r.key === 'value') cur.valueScore = r.score;
      else if (r.key === 'recall_risk') cur.riskScore = r.score;
      ctx.set(r.patient_id, cur);
    }
    return ctx;
  }

  /// 拉每个 patient 最近 30 天的 plan_executions outcome(用于 likelihoodBonus)
  private async fetchRecentExecutions(
    patientIds: string[],
    now: Date,
  ): Promise<Map<string, Array<{ outcome: string }>>> {
    if (patientIds.length === 0) return new Map();
    const cutoff = new Date(now.getTime() - 30 * 86400_000);
    const rows = await this.prisma.$queryRaw<
      Array<{ patient_id: string; outcome: string }>
    >`
      SELECT fp.patient_id, pe.outcome
      FROM plan_executions pe
      JOIN followup_plans fp ON fp.id = pe.plan_id
      WHERE fp.patient_id = ANY(${patientIds}::uuid[])
        AND pe.created_at >= ${cutoff}::timestamptz
    `;
    const ctx = new Map<string, Array<{ outcome: string }>>();
    for (const r of rows) {
      const arr = ctx.get(r.patient_id) ?? [];
      arr.push({ outcome: r.outcome });
      ctx.set(r.patient_id, arr);
    }
    return ctx;
  }

  private daysAgo(now: Date, n: number): Date {
    return new Date(now.getTime() - n * 86400_000);
  }
}

interface HitRow {
  patient_id: string;
  patient_external_id: string;
  signal_fact_id: string;
  signal_type: string; // 'diagnosis_record' | 'recommendation_record'
  signal_code: string;
  tooth: string | null;
  extracted_by: string | null;
  confidence: string | null; // pg 转 string,JS Number 转换
  clinic_id: string | null; // 触发诊断 fact 的诊所 → plan.targetClinicId
  signal_occurred_at: Date;
  days_since: number;
  // ↓ mergeRowsByToothOverlap 合并后注入(单 sig 时跟 signal_fact_id / signal_type 同步)
  cluster_fact_ids?: string[];     // cluster 内所有 sig 的 fact_id(给 evidence.factIds)
  cluster_has_diagnosis?: boolean; // cluster 含至少 1 个 diagnosis_record
  cluster_has_recommendation?: boolean; // cluster 含至少 1 个 recommendation_record
  cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers
}

/// 牙位字符串 → set,**直接委托共享 toothSet**(单一真理源)。
/// ⚠️ 必须跟 plan-aggregate.service.ts 的 target 匹配口径一致 —— 都用同一个 toothSet。
///   历史 bug:本函数曾自己用 /^\d+/ 剥成 base 数字("1B"→"1"),而 plan-aggregate 的
///   toothSet 保留 Palmer 乳牙字母("1B"→"1B")→ 乳牙(K00 萌出/发育 等)reason 牙位"1;4"
///   跟 chain 牙位"1B;4C"对不上 → target=false → UI 把召回中的链显示成"暂不召回"(李梦维案例)。
///   恒牙(FDI 36/46)两边一致无碍;只乳牙分叉。统一到 toothSet 彻底消除。
function parseToothSet(s: string | null | undefined): Set<string> {
  return toothSet(s);
}

/// union-find 把同 patient 多 sig 按 tooth-overlap 合并成 cluster
/// 全口诊断(tooth 空)→ 每 patient 1 个 'whole' cluster(全口治疗按牙位无意义,合并到一起)
/// 返回:每个 cluster 1 个 mergedHit,tooth = union of all teeth,daysSince = max(最早诊断)
function mergeRowsByToothOverlap(rows: HitRow[]): HitRow[] {
  // 按 patient_id 分组(scenario 已 per sub_scenario,这里只需 patient 级合并)
  const byPatient = new Map<string, HitRow[]>();
  for (const r of rows) {
    const arr = byPatient.get(r.patient_id) ?? [];
    arr.push(r);
    byPatient.set(r.patient_id, arr);
  }

  const merged: HitRow[] = [];
  for (const [, patientRows] of byPatient.entries()) {
    // 分两组:有牙位 vs 全口(空牙位)
    const wholeMouth: HitRow[] = [];
    const withTooth: { row: HitRow; teeth: Set<string> }[] = [];
    for (const r of patientRows) {
      const teeth = parseToothSet(r.tooth);
      if (teeth.size === 0) wholeMouth.push(r);
      else withTooth.push({ row: r, teeth });
    }

    // 全口 sig:全 patient 1 个 cluster(K05 等全口诊断不按牙位拆)
    if (wholeMouth.length > 0) {
      wholeMouth.sort((a, b) => b.days_since - a.days_since);
      const lead = { ...wholeMouth[0]! }; // 最早诊断 = 主 hit,tooth 保持 null
      lead.cluster_fact_ids = wholeMouth.map((x) => x.signal_fact_id);
      lead.cluster_has_diagnosis = wholeMouth.some((x) => x.signal_type === 'diagnosis_record');
      lead.cluster_has_recommendation = wholeMouth.some((x) => x.signal_type === 'recommendation_record');
      lead.cluster_triggers = uniqueTriggers(wholeMouth);
      merged.push(lead);
    }

    // 有牙位 sig:union-find 按 tooth overlap 合并
    // 简化版:O(n²) 邻接合并(n 通常 < 10,够用)
    const parent: number[] = withTooth.map((_, i) => i);
    const find = (x: number): number => (parent[x] === x ? x : (parent[x] = find(parent[x]!)));
    const union = (a: number, b: number) => {
      const ra = find(a), rb = find(b);
      if (ra !== rb) parent[ra] = rb;
    };
    for (let i = 0; i < withTooth.length; i++) {
      for (let j = i + 1; j < withTooth.length; j++) {
        const A = withTooth[i]!.teeth;
        const B = withTooth[j]!.teeth;
        for (const t of A) {
          if (B.has(t)) { union(i, j); break; }
        }
      }
    }
    // 收集 cluster
    const clusters = new Map<number, { teeth: Set<string>; rows: HitRow[] }>();
    for (let i = 0; i < withTooth.length; i++) {
      const root = find(i);
      const c = clusters.get(root) ?? { teeth: new Set<string>(), rows: [] };
      for (const t of withTooth[i]!.teeth) c.teeth.add(t);
      c.rows.push(withTooth[i]!.row);
      clusters.set(root, c);
    }
    for (const c of clusters.values()) {
      // 代表 hit = cluster 内 days_since 最大那条;tooth 改为 union 后字符串(字典序)
      c.rows.sort((a, b) => b.days_since - a.days_since);
      const lead = { ...c.rows[0]! };
      lead.tooth = Array.from(c.teeth).sort().join(';');
      lead.cluster_fact_ids = c.rows.map((x) => x.signal_fact_id);
      lead.cluster_has_diagnosis = c.rows.some((x) => x.signal_type === 'diagnosis_record');
      lead.cluster_has_recommendation = c.rows.some((x) => x.signal_type === 'recommendation_record');
      lead.cluster_triggers = uniqueTriggers(c.rows);
      merged.push(lead);
    }
  }
  return merged;
}

/// 提 cluster 内 unique (type, code) 触发,给 signals.triggers
/// type='diagnosis' 排在前(给前端首选展示),其次 'recommendation'
function uniqueTriggers(rows: HitRow[]): Array<{ type: string; code: string }> {
  const seen = new Set<string>();
  const out: Array<{ type: string; code: string }> = [];
  for (const r of rows) {
    const type = r.signal_type === 'recommendation_record' ? 'recommendation' : 'diagnosis';
    const key = `${type}|${r.signal_code}`;
    if (!seen.has(key)) {
      seen.add(key);
      out.push({ type, code: r.signal_code });
    }
  }
  // diagnosis 排前 → 前端 triggers[0] 兜底也是诊断
  out.sort((a, b) => (a.type === 'diagnosis' ? -1 : 1) - (b.type === 'diagnosis' ? -1 : 1));
  return out;
}
