import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  PlanScenario,
  lookupDxTreatment,
  resolverCategoriesFor,
  diagnosisCodeNameZh,
  treatmentCategoryNameZh,
  treatmentCategoryNameZhForTeeth,
  treatmentCategoryNameZhFor,
  recommendedCategoriesForAge,
  refineCategoriesForDiagnosis,
} 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';
// ⭐ gap 核心单一真理源(召回 + 潜在治疗画像共用;SQL 逻辑搬此,本文件只组装)
import { buildGapCore, GAP_FLAGS_BY_PRIMARY, GAP_PRIMARY_GROUPS } from '../../../clinical-gap/potential-treatment-gap.sql';

/**
 * 潜在治疗新链召回(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)全部命不中,死代码。
 *
 * 牙位级排除(W4 末已升级 —— 旧注释说"做不到"已废,实现见 ⑤a):
 *   "已做治疗"的凭据 = actual treatment(花钱结算落地)。排除按牙位 overlap 判:
 *   - 信号有牙位 + actual 有牙位 → tooth array overlap 命中即排除(牙位来自 EMR.treat_plan,~48.7% 带牙位)
 *   - 信号无牙位(如 K05 全口诊断)→ 退化到 patient / category 级
 *   - actual 无牙位(如全口洁治)→ 视为全口覆盖 → 排除该 category 全部牙位
 *   注:结算表本身无牙位列;牙位取自 EMR.treat_plan(planned,不当"已做",仅供与 actual 对齐做 overlap)。
 *   反馈 DW:在结算补牙位可进一步提精度。
 *
 * 通用过滤(每个子场景 SQL 自己写,共用片段后续抽):
 *   active + 非 DNC + 非 deceased(phone 缺失暂不强制,DW 不提供)
 */

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

/// v3.0 打分上下文(每患者 active persona 投影)
interface PersonaScoreCtx {
  rfmSegment: string | null;
  urgencyLevel: 'urgent' | 'high' | 'mid' | 'low' | null;
  lifecycleStage: string | null;
  recencyDays: number | null; // 末诊天数(lifecycle)— 近1年到诊判定
  treatmentHistory: string[]; // treatment_history.types
  isReferralChampion: boolean;
  frequentNoShow: boolean; // special_attention 屡次爽约 → 履约差
}

/// 召回 primaryCode → treatment_history code(信任迁移"同类治疗史 +1"判定)。
/// 仅 4 个主治疗有历史标签;基础治疗(龋/根管/拔)无 → 不加分。
const HISTORY_CODE_BY_PRIMARY: Record<string, string> = {
  K08: 'implant_history',
  K07: 'ortho_history',
  K03: 'prostho_history',
  K05: 'perio_history',
};

@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 + 慢窗口 → 排到后面)
  // public(原 private):召回归因调试 RecallDebugService 需读 primaryCode↔subKey 映射做交叉核对。
  static readonly SUB_SCENARIOS = {
    missing_tooth: {
      base: 60,
      primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
      label: '缺失牙未启动修复',
      goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
      // 高龄版目标(>DENTURE_FIRST_AGE):义齿在前、种植并行 —— 高龄**不是种植禁忌**,
      //   能否种植取决于骨量骨质/全身状况/患者意愿,由医生评估;PAC 只调整介绍顺序,不下结论。
      //   ⚠️ 只改"建议做什么",不改要不要召回 —— 缺牙影响咀嚼营养,高龄同样该管。
      goalElderly:
        '邀约评估缺牙修复方案(活动义齿为主、种植可并行考虑,由医生按身体条件评估),改善咀嚼、避免邻牙倾斜 / 对颌伸长',
      // ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
      //   (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
    },
    ortho_no_consult: {
      base: 55,
      primaryCode: 'K07',
      label: '错颌畸形未启动正畸',
      goal: '邀约做正畸初诊评估,把握矫治窗口(尤其混合牙列期 8-12 岁 / 成人骨健全期)',
    },
    endo_no_rct: {
      base: 52,
      primaryCode: 'K04',
      label: '牙髓炎/根尖周炎未做根管',
      goal: '邀约尽快做根管治疗,避免发展为根尖脓肿 / 拔牙',
    },
    perio_no_srp: {
      base: 50,
      primaryCode: 'K05',
      label: '牙周炎未做基础治疗',
      goal: '邀约做牙周基础治疗(SRP / 翻瓣),控制炎症发展',
    },
    caries_no_filling: {
      base: 45,
      primaryCode: 'K02',
      label: '龋齿未做充填',
      goal: '邀约尽快做龋齿充填,避免发展为牙髓炎',
    },
    hard_tissue_damage: {
      base: 35,
      primaryCode: 'K03',
      // K03 复合:残根残冠→拔(surgical)/ 楔缺→充填(restorative)/ 大缺损→冠桥(prosthodontic)
      // 三类 actual 任一存在即排除(见 DxMap K03.categories)— 数据噪音可控
      label: '牙体损伤未修复',
      goal: '邀约处理牙体硬组织损伤(楔状缺损/残根/残冠 → 充填/嵌体/拔除修复)',
    },
    gum_alveolar_lesion: {
      base: 35,
      primaryCode: 'K06',
      // K06 病种保留为 K06,不归 K05(不改诊断码原则)— 临床路径同 periodontic+surgical
      label: '牙龈/牙槽嵴疾患未处置',
      goal: '邀约处理牙龈/牙槽嵴问题(牙龈萎缩/增生/瘤/根分叉病变/系带异常 → 牙周治疗或手术)',
    },
    impacted_tooth: {
      base: 30,
      primaryCode: 'K01',
      // 智齿阻生临床不强制拔除(无症状可观察);base 低让分数沉底,真急的患者靠 urgencyBonus 升上来
      label: '阻生牙未拔除',
      goal: '邀约评估阻生牙是否需拔除(智齿冠周炎反复 / 顶坏邻牙 / 正畸需要)',
    },
    jaw_cyst: {
      base: 50,
      primaryCode: 'K09',
      // 颌骨囊肿临床必处理(不摘除会继续扩大压迫邻牙/神经),base 中等偏高
      label: '颌骨囊肿未处理',
      goal: '邀约尽快做颌骨囊肿摘除术,避免囊肿扩大压迫邻牙 / 神经',
    },
    development_eruption: {
      base: 25,
      primaryCode: 'K00',
      // K00 真病种(乳牙滞留 / 多生牙 / 先天缺失 / 釉质发育不全 / 萌出障碍)— 不含"乳牙列""混合牙列"等正常态(yaml 不映射)
      // 儿童居多,base 最低;windowDays 365 给足观察期
      label: '牙发育/萌出异常未处置',
      goal: '邀约评估处置(乳牙滞留/多生牙拔除 · 先天缺失修复 · 釉质发育不全美容修复 · 萌出障碍助萌)',
    },
    // 「建议拔除」独立场景 —— 跨病种治疗动作(阻生/残根/松动牙/龋坏…都可能拔),不绑病种。
    //   旧:EXTRACTION_RECOMMENDED 硬归 K01 → 任何建议拔除都误标"阻生牙未拔除"(毕强松动牙案例)。
    //   显示中性的「牙未拔除(医生建议)」;有同牙编码诊断时让位给病种场景(GAP_FLAGS deferToToothDx)。
    extraction_recommended: {
      base: 30,
      primaryCode: 'EXTRACTION_RECOMMENDED',
      label: '牙未拔除',
      goal: '邀约完成医生建议的拔除(残根 / 阻生 / 无保留价值牙等),避免感染或影响邻牙及后续修复',
    },
  } as const;

  constructor(private readonly prisma: PrismaService) {}

  async selectHits(scope: ScenarioScope): Promise<ScenarioHit[]> {
    const entries = Object.entries(TreatmentInitiationRecallScenario.SUB_SCENARIOS);
    const hits: ScenarioHit[] = [];
    // 10 个子场景查询彼此独立(各自 SQL + union-find 合并 + 打分,无共享状态);
    //   合并/去重(下游 hitsByPatient Map + max 分 + Set 比对)与顺序无关 → 并行 = 结果逐字节相同。
    //   PAC_RECALL_SUBSCENARIO_CONCURRENCY=N(>1)开并行(分块 Promise.all);默认串行,行为不变。
    const conc = Math.max(1, Number(process.env.PAC_RECALL_SUBSCENARIO_CONCURRENCY) || 1);
    // ⚠️ 不能 hits.push(...subHits):spread 把每个元素当实参压栈,V8 实参上限 ~6.5万;
    //    host 患者到 ~28 万后单子场景命中可超限 → RangeError: Maximum call stack size exceeded
    //    (2026-07-17 华北全量后线上实炸)。改逐个 push,容量无上限。
    if (conc > 1) {
      for (let i = 0; i < entries.length; i += conc) {
        const chunk = await Promise.all(
          entries.slice(i, i + conc).map(([subKey, cfg]) => this.runSubScenario(scope, subKey, cfg)),
        );
        for (const subHits of chunk) for (const h of subHits) hits.push(h);
      }
    } else {
      for (const [subKey, cfg] of entries) {
        const subHits = await this.runSubScenario(scope, subKey, cfg);
        for (const h of subHits) hits.push(h);
      }
    }
    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];
    // 码分组 → 单一真理源 GAP_PRIMARY_GROUPS(召回 + 潜在治疗画像共用,不在 SUB_SCENARIOS 内联)
    const grp = GAP_PRIMARY_GROUPS[cfg.primaryCode] ?? { dxCodes: [], recCodes: [] };
    const dxCodes = grp.dxCodes as readonly string[];
    const recCodes = grp.recCodes as readonly string[];
    const allCodes = [...dxCodes, ...recCodes];
    // §E gap 修正 flag → 单一真理源 GAP_FLAGS_BY_PRIMARY(召回 + 潜在治疗画像共用)
    const cfgFlags = GAP_FLAGS_BY_PRIMARY[cfg.primaryCode] ?? {};
    // ⭐ 两个口径分开(单一真理源 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[];

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

    // ⭐ gap 核心(sig 牙位 / resolved / remaining + ⑤a 判定 + 废用牙/先天剔除)抽到共享模块
    //   potential-treatment-gap.sql —— 召回与潜在治疗画像【单一真理源】,SQL 逻辑零改动只搬家。
    //   召回在此基础上再加时间门(④ cooldown / ⑤b 预约 / ⑤d entered / ⑤f 到诊)+ 6 因子打分。
    const gap = buildGapCore({ rule, cfgFlags, allCodes, resolverCats });

    // ╔═════════════════════════════════════════════════════════════════════╗
    // ║ 召回 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          (没标"已故")                       │ ║
    // ║ │ pp.host_follow_up_active IS NOT TRUE (宿主没在自己跟进)         │ ║
    // ║ │ → 法务 / 投诉风险硬过滤,任一不满足直接踢                       │ ║
    // ║ │ 注:跟进闸性质不同于前两者 —— 合规是永久/法务,跟进是临时/协作,  │ ║
    // ║ │   宿主停手下次推主档即回池。可空,NULL=未接入该信号,不启用。    │ ║
    // ║ └────────────────────────────────────────────────────────────────────┘ ║
    // ║                                                                      ║
    // ║ ┌─ ③ 触发信号(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 排除:患者已有未来预约 ────────────────────────────────┐ ║
    // ║ ┌─ ⑤g 排除:诊所已排未来回访(2026-07 加)───────────────────┐ ║
    // ║ │ 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,
        -- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
        date_part('year', age(${scope.now}::timestamptz, p.birth_date))::int AS patient_age,
        sig.id                      AS signal_fact_id,
        sig.type                    AS signal_type,
        sig.content->>'code'        AS signal_code,
        -- 诊断原文词(K00 主类目细分用:滞留→外科 / 早失→正畸 / 先天缺→修复…)。
        --   纯投影,不进任何 WHERE —— 返回行集与加它之前逐行相同。
        sig.content->>'name_zh'     AS signal_name_zh,
        -- ⭐ 牙位级相减:有牙位信号 → 剩余未治牙位;全口信号 → 原样 NULL(gap 核心,共享模块)
        ${gap.toothOutput} AS tooth,
        sig.content->>'extracted_by' AS extracted_by,
        sig.content->>'confidence'  AS confidence,
        sig.content->>'code_source' AS code_source,  -- 置信度因子:std_code/name_map=医生 / image_ai / null
        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 牙位 / 已解决 / 剩余未治)— gap 核心,共享模块单一真理源
      ${gap.lateralJoin}
      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                            -- ② 合规闸
        -- ② 宿主跟进闸:宿主自己正在跟进的患者不召回(避免两边同时联系同一个人)。
        --   ⚠️ 必须 IS NOT TRUE 而非 = false:该列可空,NULL = 宿主未提供该信号
        --   (jvs-dw 未接入),= false 会因 NULL != false 把这些患者全挡在池外。
        AND pp.host_follow_up_active IS NOT TRUE            -- ② 宿主跟进闸
        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
        -- ④' 废用牙/无功能牙剔除 + ④' §E 先天缺失剔除 + ⑤a 牙位级 gap 判定(全口 NOT EXISTS / 有牙位剩余非空)
        --     —— 全部抽到 gap 核心(共享模块),召回与潜在治疗画像口径一致
        ${gap.restorationIneligibleFrag}
        ${gap.congenitalFrag}
        ${gap.gapWhere}
        -- (⑤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
        )
        -- ⑤d(预约科目排除)已移除(W7):
        --   数据验证 → 全口场景 resolvedTeeth(看治疗)已覆盖在治患者(K05 挡669/K07 挡73,⑤d 独占仅0~1
        --   且那1个无近期预约=stalled本就该召);牙位场景 ⑤d 按科目(非牙位)误排"同科目别牙在治、这颗没治"
        --   (沈静芳 34;43 修复 被 25;26 修复预约连带排除)。⑤d 既冗余又有害 → 去掉,全口/牙位统一靠
        --   resolvedTeeth(治疗) + ⑤b(未来预约) + ⑤f(近期到诊) + ⑤g(未来回访)。"已进入链"的细粒度留 W5+ 治疗链内召回。
        -- (⑤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
        )
        AND NOT EXISTS (                                   -- ⑤g 排除:诊所已排未来回访
          -- 同 ⑤b 的道理:召回的产物就是"让客服去联系一次";诊所已经排好了这次联系,
          -- PAC 再产一条就是重复触达(一线反馈里 duplicate_contact 本就是既有反馈项)。
          -- 患者级、不看牙位/类目 —— 回访任务本身就是患者级的,宿主不按牙位排。
          --
          -- 到期自愈:条件是 task_date > 今天,任务日一过本闸自动失效,下一轮照常召回。
          -- 所以"排了不做"(全库到期任务执行率 74.3%)不需要额外兜底 —— 烂尾的到点就放行。
          -- 也不设窗口上限:在池患者的未来排程 99.65% 在一年内(2028 年以后仅 50 条),
          -- 拍一个 N 天窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
          --
          -- ⚠️ 位置刻意放在这里(scenario)而不是 gapWhere:gapWhere 是召回与画像**共享**的
          -- 【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口真的没了);本闸是【运营时机】层
          -- (缺口还在,只是现在不该打电话),同 ⑤b/⑤f。放进 gapWhere 会让画像因为"排了个回访"
          -- 就说这人没有潜在治疗 —— 那是两套口径。
          SELECT 1 FROM patient_return_visits rv
          WHERE rv.patient_id = p.id
            AND rv.task_date > ${scope.now}::date
        )
    `;

    // ⭐ 同 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);

    // v3.0 优先级模型:一次性查命中 patient 的 persona 打分上下文 + 咨询意向
    const patientIds = [...new Set(mergedHits.map((r) => r.patient_id))];
    const personaCtx = await this.fetchPersonaContext(patientIds);
    const consultCtx = await this.fetchConsultIntents(patientIds);
    const expectedCatSet = new Set(expectedCats);
    const historyCode = HISTORY_CODE_BY_PRIMARY[cfg.primaryCode];

    const hits: ScenarioHit[] = [];
    for (const r of mergedHits) {
      const patientId = r.patient_id;
      const persona = personaCtx.get(patientId);
      // 主诉行为:咨询过本 gap 类别(consultation 意向 ∩ expectedCats)
      const consultMatch = [...(consultCtx.get(patientId) ?? [])].some((c) => expectedCatSet.has(c));
      // 信任迁移:同类治疗史
      const hasHist = historyCode ? (persona?.treatmentHistory.includes(historyCode) ?? false) : false;
      // 信任 +1:近1年到诊(末诊<365)且 履约良好(非屡次爽约)
      const recentVisitGood =
        persona?.recencyDays != null && persona.recencyDays < 365 && !persona.frequentNoShow;
      const toothCount = r.tooth ? r.tooth.split(';').filter(Boolean).length : 0;

      const { score, breakdown } = calcPriority({
        urgencyLevel: persona?.urgencyLevel ?? null,
        daysSince: r.days_since,
        windowDays: rule.windowDays,
        primaryCode: cfg.primaryCode,
        toothCount,
        rfmSegment: persona?.rfmSegment ?? null,
        consultIntentMatch: consultMatch,
        lifecycleStage: persona?.lifecycleStage ?? null,
        hasTreatmentHistory: hasHist,
        isReferralChampion: persona?.isReferralChampion ?? false,
        recentVisitGood,
        confidence: r.cluster_confidence ?? sourceConfidence(r),
      });

      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');
      // 影像AI 诊断(code_source=image_ai)单独标注 → 话术更谨慎(影像所见 ≠ 医生确诊)
      const isImg = hasDx && !hasRec && r.code_source === 'image_ai';
      const sourceStr = hasDx && hasRec ? '(诊断+医生建议)' : hasRec ? '(医生建议)' : isImg ? '(影像AI)' : '(诊断)';
      // 触发信号类型(原 enum,不语义化;前端用 triggerTypeLabelZh 翻译)
      // lead 单一类型 — 想精准列出所有 sig 的话需要把 triggers[] 改成 cluster 全量,目前 lead 代表
      const triggerType = triggerTypeOf(r);
      // 标签分流(同前端 ReasonLine):lead = 优先级最高来源(诊断>建议>影像)。
      //   lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
      const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code };
      const isRecLead = leadTrig.type === 'recommendation';
      // ⭐ 建议治疗:走**源头**的两道重排,此处不做任何本地分支。
      //   ① refineCategoriesForDiagnosis(临床语义)—— K00 同码内按诊断词定方向:
      //      乳牙滞留→外科(有牙可拔) / 乳牙早失→正畸(牙没了,管间隙) / 先天缺→修复 / 萌出障碍→正畸。
      //   ② recommendedCategoriesForAge(年龄)—— >70 岁缺牙义齿前置、<18 岁种植降末位(均并行保留)。
      //   ⚠️ 两道都只挪位不增删;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
      const age = r.patient_age ?? null;
      const dxNameZh = r.signal_name_zh ?? null;
      const recCats = recommendedCategoriesForAge(
        refineCategoriesForDiagnosis(r.signal_code, dxNameZh, expectedCats),
        age,
      );
      const focusCategory = recCats[0] ?? null;
      const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / ');
      hits.push({
        patientId,
        patientExternalId: r.patient_external_id,
        // reason 文本兜底(AI prompt / 调试用,前端不依赖此字段)
        // 乳牙 restorative 只显「充填」(去嵌体),跟前端 ReasonLine/目标 tag 同口径(treatmentCategoryNameZhForTeeth)
        reason: isRecLead
          ? `${diagnosisCodeNameZh(leadTrig.code)}${toothStr}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`
          : `${cfg.label}${toothStr} — ${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`,
        priorityScore: score,
        // 高龄目标文案(义齿在前、种植并行);判据同源头 —— recCats 首项被换成义齿即为高龄路径
        goal:
          (focusCategory === 'prosthodontic' && expectedCats[0] === 'implant'
            ? (cfg as { goalElderly?: string }).goalElderly
            : null) ?? cfg.goal,
        recommendedRole: 'staff',
        recommendedChannel: 'phone',
        // recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
        // 前端已移除推荐时间展示/排序。待有真实择时逻辑再启用。
        // 目标诊所兜底 = 诊断出该未治疗需求的诊所;plan 顶层正常口径由 plan-engine 覆盖为
        //   "患者最后到诊诊所"(运营跟进归属),仅当患者无任何到诊记录时才回退到此值。
        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'}`,
        // 时间锚抑制逃逸:cluster 内最新信号时间(单 sig cluster = 自身时间)
        latestSignalOccurredAt: r.cluster_latest_occurred_at ?? r.signal_occurred_at,
        // 结构化召回信号(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],
          // ⭐ 建议治疗主类目(已按诊断词 + 年龄适配)—— 前端「目标 · X」标签据此渲染,
          //   不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
          focusCategory,
          // 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
          patientAge: age,
          // 诊断原文词快照 —— 前端 ReasonLine 要用同一套 refineCategoriesForDiagnosis 复算
          //   "未启动 X / Y" 的类目串,没这个字段前端算不出跟后端一致的顺序。
          dxNameZh,
        },
        priorityBreakdown: breakdown,
      });
    }
    return hits;
  }

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

  /// 拉每个 patient 当前 active persona 的打分上下文(v3.0 优先级模型用):
  ///   rfm.segment(RFM依从)/ urgency_level.level(急迫)/ lifecycle_stage.stage(信任)/
  ///   treatment_history.types(信任迁移)/ referral_champion(信任 +1)。
  private async fetchPersonaContext(
    patientIds: string[],
  ): Promise<Map<string, PersonaScoreCtx>> {
    if (patientIds.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<
      Array<{ patient_id: string; key: string; data: unknown }>
    >`
      SELECT p.patient_id, pf.key, pf.data
      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 ('rfm', 'urgency_level', 'lifecycle_stage', 'treatment_history', 'referral_champion', 'special_attention')
    `;
    const ctx = new Map<string, PersonaScoreCtx>();
    const get = (id: string) =>
      ctx.get(id) ??
      ({ rfmSegment: null, urgencyLevel: null, lifecycleStage: null, recencyDays: null, treatmentHistory: [], isReferralChampion: false, frequentNoShow: false } as PersonaScoreCtx);
    for (const r of rows) {
      const cur = get(r.patient_id);
      const d = (r.data ?? {}) as Record<string, unknown>;
      if (r.key === 'rfm' && typeof d.segment === 'string') cur.rfmSegment = d.segment;
      else if (r.key === 'urgency_level' && typeof d.level === 'string') cur.urgencyLevel = d.level as PersonaScoreCtx['urgencyLevel'];
      else if (r.key === 'lifecycle_stage') {
        if (typeof d.stage === 'string') cur.lifecycleStage = d.stage;
        if (typeof d.recencyDays === 'number') cur.recencyDays = d.recencyDays;
      } else if (r.key === 'treatment_history' && Array.isArray(d.types)) cur.treatmentHistory = d.types as string[];
      else if (r.key === 'referral_champion') cur.isReferralChampion = true;
      else if (r.key === 'special_attention' && Array.isArray(d.types)) cur.frequentNoShow = (d.types as string[]).includes('frequent_no_show');
      ctx.set(r.patient_id, cur);
    }
    return ctx;
  }

  /// 拉每个 patient 咨询过的治疗类别(consultation_record.intent_categories)— 主诉行为(意愿)用。
  private async fetchConsultIntents(patientIds: string[]): Promise<Map<string, Set<string>>> {
    if (patientIds.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<Array<{ patient_id: string; cat: string }>>`
      SELECT pf.patient_id, jsonb_array_elements_text(pf.content->'intent_categories') AS cat
      FROM patient_facts pf
      WHERE pf.patient_id = ANY(${patientIds}::uuid[])
        AND pf.type = 'consultation_record' AND pf.status = 'active'
    `;
    const ctx = new Map<string, Set<string>>();
    for (const r of rows) {
      const s = ctx.get(r.patient_id) ?? new Set<string>();
      if (r.cat) s.add(r.cat);
      ctx.set(r.patient_id, s);
    }
    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;
  patient_age: number | null; // 无生日 → null(不做年龄适配)
  signal_fact_id: string;
  signal_type: string; // 'diagnosis_record' | 'recommendation_record'
  signal_code: string;
  signal_name_zh: string | null; // 诊断原文词(K00 细分用);历史 fact 可能没写 → null 走默认序
  tooth: string | null;
  extracted_by: string | null;
  confidence: string | null; // pg 转 string,JS Number 转换
  code_source: string | null; // std_code/name_map=医生 / image_ai / null — 置信度因子用
  clinic_id: string | null; // 触发诊断 fact 的诊所 → hit.targetClinicId(仅作兜底:患者无到诊记录时用;
  //   plan.targetClinicId 正常口径 = 患者最后到诊诊所,由 plan-engine 覆盖,见 upsertPlan)
  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_confidence?: number;     // cluster 最优来源置信度(诊断1.0/建议0.8/影像0.5,取 max)
  cluster_triggers?: Array<{ type: string; code: string }>; // cluster 内 unique (type, code),给 signals.triggers
  /// cluster 内**最新**信号时间(lead 锚最早=daysSince 口径;此字段锚最新=终态抑制时间锚逃逸口径)
  cluster_latest_occurred_at?: Date;
}

/// 单 sig 来源置信度(v3.1 三源标定):诊断 1.0 / 医生建议 0.8 / 影像AI 0.5。
///   优先级即置信度高低:**诊断 > 建议 > 影像AI**。cluster 取 max → "有诊断以诊断为准"。
///   影像AI = code_source='image_ai' 的诊断(AI 读片,非医生确诊 → 降到 0.5)。
function sourceConfidence(row: HitRow): number {
  if (row.signal_type === 'recommendation_record') return 0.8;
  if (row.code_source === 'image_ai') return 0.5;
  return 1.0; // 医生诊断(std_code / name_map / 未标来源)
}

/// 牙位字符串 → 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_confidence = Math.max(...wholeMouth.map(sourceConfidence));
      lead.cluster_triggers = uniqueTriggers(wholeMouth);
      lead.cluster_latest_occurred_at = latestOccurredAt(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_confidence = Math.max(...c.rows.map(sourceConfidence));
      lead.cluster_triggers = uniqueTriggers(c.rows);
      lead.cluster_latest_occurred_at = latestOccurredAt(c.rows);
      merged.push(lead);
    }
  }
  return merged;
}

/// 提 cluster 内 unique (type, code) 触发,给 signals.triggers
/// type='diagnosis' 排在前(给前端首选展示),其次 'recommendation'
/// 触发信号类型:recommendation_record→recommendation / code_source=image_ai→image_finding / 否则 diagnosis
function triggerTypeOf(row: { signal_type: string; code_source: string | null }): string {
  return row.signal_type === 'recommendation_record'
    ? 'recommendation'
    : row.code_source === 'image_ai'
      ? 'image_finding'
      : 'diagnosis';
}

/// cluster 内最新信号时间 —— 时间锚抑制逃逸用(lead 锚最早,此处锚最新,两口径并存各司其职)。
/// pg timestamptz 经 $queryRaw 已是 Date;防御性过滤无效值(理论上 ④ 闸已保证非空)。
function latestOccurredAt(rows: HitRow[]): Date | undefined {
  let max: Date | undefined;
  for (const r of rows) {
    const d = r.signal_occurred_at;
    if (d instanceof Date && !Number.isNaN(d.getTime()) && (!max || d > max)) max = d;
  }
  return max;
}

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) {
    // 影像AI 诊断 → image_finding(前端渲染"影像AI"),区别于医生诊断 diagnosis
    const type = triggerTypeOf(r);
    const key = `${type}|${r.signal_code}`;
    if (!seen.has(key)) {
      seen.add(key);
      out.push({ type, code: r.signal_code });
    }
  }
  // v3.1 优先级排序:**诊断(0) > 医生建议(1) > 影像AI(2)**。
  //   triggers[0] = 最高优先来源 → 驱动展示标签("有诊断以诊断为准,以此类推")。
  //   (原口径把影像AI 和诊断并列排前;现按置信度梯度把影像AI 降到最后。)
  const order = (t: string) => (t === 'recommendation' ? 1 : t === 'image_finding' ? 2 : 0);
  out.sort((a, b) => order(a.type) - order(b.type));
  return out;
}
