import { lookupDxTreatment } from './canonical-codes';

/**
 * 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。
 *
 * ═══ 口径(2026-08-11 六档统一成一条**绝对时间轴**)═══════════════════
 * 档位 = **距上次到诊多久**,不是客户价值、不是意愿、不是诊断距今。
 *
 * ```
 * [0, 90d]      三个月内
 * (90d, 180d]   三个月到半年
 * (180d, 1y]    1 年内
 * (1y, 2y] (2y, 3y] (3y, ∞)
 * ```
 *
 * ═══ 锚点 = 末诊,只此一版(2026-08-13 产品定)═══════════════════════
 * 此前默认锚在「医生最后一次写下这条诊断」,并存过一个 `AnchorMode` 开关。整套删了。
 *
 * 🔴 ⛔ **别把「按诊断」加回来。** 它有一个**已量化**的系统偏差:复诊时医生常不重复写
 *   同一条诊断。**测试服朝阳全量** 45,190 个机会:诊断后又来过的 19,605 个里
 *   **49.1% 一次都没再写**,期间平均又来 4.7 次;诊断日与末诊日**中位相差 490 天**
 *   = 矩阵上差 1–2 档。各医生记录习惯还差一倍 ⇒ 那个档位里含着"这个医生爱不爱重复写"
 *   的成分,⛔ 读不成临床新鲜度。而召回问的是「这人多久没露面」,末诊直接回答它。
 * ⚠️⚠️ 复量这类比例**必须按日期比,⛔ 不能按时间戳**:同次就诊里 emr 与 diagnosis 的
 *   `occurred_at` 差几毫秒,用 `>` 会把"本次就诊"算成"又来过一次"(测试服上算出 99.98%,
 *   真值 43%),而这个错不报错。
 * ⚠️ **拿本地库下结论前先看一眼末诊的跨度**。2026-08-11 那次就栽在这:当时本地只有
 *   5,825 人、最早末诊 2024-06,「3 年以上」恒为空,差点被读成"沉睡患者其实一直在来"——
 *   其实是那批人**根本不在库里**。补摄之后本地已是 30,000 人、末诊 2020-01~2026-06、
 *   其中 9,559 人超 3 年(2026-08-13 实测),这一档才有东西。⛔ 空列先怀疑数据,别怀疑口径。
 * ⚠️ 末诊是**患者级**的:同一个人的所有治疗项必然落同一档。所以矩阵一行里
 *   一个患者只会出现在一列 —— ⛔ 别把这当成取数漏了。
 *   ⭐ 顺带修掉了诊断锚点的一个毛病:那时同一个人的两条 plan 可能落在**两列**,
 *     各列去重人数相加会大于池子人数(2026-08-13 实测拔牙:19,269 → 18,872)。
 * ⚠️ 「三个月内」这一列**本来就常年是空的**,⛔ 别当成锚点改坏了:
 *   入池下界(`cooldownDays` 考虑期)已经把刚来过的人挡在召回池外了 —— 两版锚点下都是 0。
 * ⚠️ 末诊为 NULL → 落 `unknown`,⛔ 不许默认塞进某一档。
 *
 * 🔴 **前两档不再按治疗项自己的临床周期算**(2026-08-11 前的旧口径:
 *   `[0, urgencyDayThreshold]` = 黄金期、`(urgencyDayThreshold, windowDays]` = 窗口内,
 *   每个 K 码一套天数)。改成固定 90/180 天,理由是主管那一侧的可读性:
 *   同一列里「种植·黄金期」是 90 天、「根管·黄金期」是 45 天,列头写的却是同一个词,
 *   主管按列比较人数时比的其实不是同一段时间。⛔ 别为了"临床更精确"改回去 ——
 *   要表达临床紧迫,那是 priority-scorer 的活(它仍按各码自己的窗口衰减),不是矩阵列头的活。
 * ⚠️ 六档现在**共用一个锚点、一把尺子**,所以列头可以直接写时间(见 TEMPERATURE_META)。
 * ⚠️ 枚举值仍是 hot/warm/cold_* —— ⛔ 别跟着改(API 参数、SQL 谓词、已落库 JSON 路径都用它)。
 *
 * ═══ 两条关键设计,都是被数据打出来的 ═══════════════════════════════
 *
 * ① **在「温度」上聚合,不在「天数」上聚合** —— 一个患者的同一业务标签常有多条 gap
 *    (实测本地库:41% 的 K02 患者有多条诊断,时间跨度均值 452 天,而 K02 窗口只有 90 天)。
 *
 *    ⛔ 旧做法是先 `Math.max(daysSince)` 再判档(potential-treatment.feature 的聚合口径)。
 *       那是**反单调**的:上周刚查出的龋齿,因为身上还有一颗两年前的旧龋,整个人被判成「冷」。
 *       **多一条未满足需求,反而更冷** —— 这在召回池里是说不通的。
 *
 *    ✅ 正解:**逐条 gap 判档,再取最热**(2026-08-11 前是"按自己 K 码的窗口"判,
 *       现在六档统一按绝对天数 —— 聚合口径没变,变的只是每条 gap 的判据)。
 *
 *    ⚠️ **实测量级要说准**(2026-08-02 本地 5,825 患者):在**召回池**上(矩阵真正展示的人群,
 *       3,880 个「患者×标签」格位)只有 **1.31% 翻档,43 条变热 / 8 条变冷** —— 不是什么大数目。
 *       那 8 条里 6 条是"恰好压线"(ds 正好等于阈值,按毫秒比 vs 按整天截断的差),
 *       另 2 条是 K06(60 天黄金期)被标签级近似当成 K05(90 天)误判成热,**新口径改对了**。
 *       量级小是因为池子已经过了 gap 闸;拿**未过闸的原始诊断事实**去量会得到 40-70% 的
 *       夸张差值 —— 那是错的人群,别拿它当收益。
 *       这条改动真正的价值在 ② 的解冻和 Q-6 的收口,不在今天这 1.31%。
 *       ⚠️ 上面这段实测是**旧口径**(按 K 码窗口)下量的,2026-08-11 改成绝对天数后
 *         这些数字只有考古价值 —— 别拿它当现在的分布。
 *       (Q-6 说的「一对多无解」现在自然消失:拔牙 ← K01+K03 两条 gap 用的是同一把尺子。)
 *
 * ② **存边界时刻,不存天数/不存档位** —— 与 `visitRecencyRange`(存日期读时分档)、
 *    `applyLiveDays`(存锚点读时算天数)同一套路,本仓已两次为同一个坑付过学费。
 *
 *    ⛔ 存 `daysSince`:它在 `VOLATILE_DATA_KEYS` 里被 `stripVolatile` 剔掉 →
 *       **时间流逝永远不触发重算** → 温度档永久冻结在画像算出来那一刻。
 *       本地库刚重建所以看着正常,生产跑几个月会整体往「冷」漂,且无人察觉。
 *    ⛔ 存档位('hot'/'warm'/'cold'):同样会过期,还得配一整套「到期重算」机械
 *       (persona 的 nextBoundaryAt 能做,但那是每人每次翻档都要写一遍行的代价)。
 *    ✅ 存 `hotUntil` / `warmUntil` 两个**时刻**:它们由「事实日期 + 静态窗口配置」推出,
 *       同一批事实换个时刻重算值不变 → **不是易变键**(⛔ 别加进 VOLATILE_DATA_KEYS),
 *       语义变了才升版本;而档位在**读时**跟 now 一比就出来,永远准,零维护。
 *
 * ═══ 刻意不做的事 ═════════════════════════════════════════════════
 * ⛔ **不在温度层重复 `cooldownDays`(治疗考虑期)闸**。
 *    入池下界是召回引擎的职责(`treatment-initiation-recall.scenario`),矩阵是在**池子上**切分,
 *    池子已经过闸。两层各判一次 = 两套口径,正是 T6a「同源保证」要防的
 *    「矩阵上有人、点进去列表没有」。要调整"刚诊断的先别打",去改池子那层的单一真理源。
 */

export const Temperature = {
  HOT: 'hot',
  WARM: 'warm',
  /// 超周期后按**绝对年数**再分四档(2026-08 产品定)。冷端占池子 90%+,不拆等于没分。
  COLD_1Y: 'cold_1y',
  COLD_2Y: 'cold_2y',
  COLD_3Y: 'cold_3y',
  COLD_OVER: 'cold_over',
} as const;
export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature];

/// 由热到冷 —— 矩阵列序、以及「取最热」的比较序都用它,别在别处另写一份
export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [
  Temperature.HOT,
  Temperature.WARM,
  Temperature.COLD_1Y,
  Temperature.COLD_2Y,
  Temperature.COLD_3Y,
  Temperature.COLD_OVER,
];

/**
 * 四个冷档 —— 给"整个冷端"做筛选/统计时用,⛔ 别在调用处手写这个数组。
 *
 * ⚠️ 旧 API 参数 `temperature=cold` 仍然合法,语义 = 这四档的并集(见 expandTemperatureFilter)。
 *   ⛔ 不要把 'cold' 加回 Temperature 枚举 —— 它不是一个**展示档**,矩阵里没有这一列;
 *   加回去会让 TEMPERATURE_ORDER / TEMPERATURE_META 多出一个永远渲染不出来的成员。
 */
export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
  Temperature.COLD_1Y,
  Temperature.COLD_2Y,
  Temperature.COLD_3Y,
  Temperature.COLD_OVER,
];

/**
 * 矩阵 Y 轴的**轴名** —— 列头上方那句「按 X 距今多久分档」用它。
 *
 * ⚠️ 这不是装饰:列头写的是「三个月内」,而"什么的三个月"决定了它是哪批人。
 *   ⛔ 别把这句话从界面上拿掉。
 */
export const TEMPERATURE_AXIS_ZH = '患者最后一次到诊';

/**
 * 档位标签的**前缀** —— 「3 年以上」补全成「距上次到诊 3 年以上」。
 *
 * 🔴 2026-08-14 产品走查：`modelFacts` 给模型的是 `"时间档": "3 年以上"`，
 *   而**「3 年以上」自己说不清是什么的 3 年**。界面上不缺这半句（列头上方那行
 *   `TEMPERATURE_AXIS_ZH` 就在旁边，位置本身提供了上下文）；JSON 里没有位置，
 *   模型拿到的是一个孤零零的字符串 —— 同一个标签在界面上够用、在 JSON 里有歧义。
 * ⚠️ 修法是把**值补全**，⛔ 不是另加一个「口径」键：只剩末诊一版锚点，
 *   单独一个口径字段读起来像"还有别的口径"，那正是 2026-08-13 删掉的东西。
 * ⛔ 别在别处再手写「距上次到诊」——`temperatureWindowHint` 和 facts 都取这一份。
 */
export const TEMPERATURE_SINCE_ZH = '距上次到诊';

/**
 * 前两档的**天数上界**(距锚点)—— 2026-08-11 产品定,固定值,与治疗项无关。
 *
 * 🔴 这两个数是**唯一真理源**:TS 判档(`gapTemperatureBounds`)、SQL 判档
 *   (`reason-temperature.sql.ts`)、列头 hover 的天数区间全从这里取。
 *   ⛔ 任何一处再手写 90 / 180 都是第二份真理源。
 * ⚠️ 与 `DiagnosisTreatmentMap` 的 `urgencyDayThreshold` / `windowDays` **已经脱钩** ——
 *   那两个仍然管着召回入池下界(cooldown)与 priority-scorer 的衰减,⛔ 别顺手统一。
 */
export const HOT_BUCKET_DAYS = 90;
export const WARM_BUCKET_DAYS = 180;

/** 冷档的**年数上界**(距锚点);COLD_OVER 无上界 = null。SQL 与 TS 判档共用这一份。 */
export const COLD_BUCKET_YEARS: Readonly<Record<string, number | null>> = {
  [Temperature.COLD_1Y]: 1,
  [Temperature.COLD_2Y]: 2,
  [Temperature.COLD_3Y]: 3,
  [Temperature.COLD_OVER]: null,
};

/**
 * 兼容旧取值:`cold` → 四个冷档。其余原样返回单元素数组。
 * 用在 API 入参解析(list / matrix / MCP 工具)—— ⛔ 别在每个消费方各写一遍。
 */
export function expandTemperatureFilter(v: string): readonly TemperatureValue[] {
  if (v === 'cold') return COLD_TEMPERATURES;
  return [v as TemperatureValue];
}

/**
 * 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。
 *
 * ⚠️ **代码名与显示名刻意不一致**,不是遗漏:
 *   代码里是 `hot` / `warm` / `cold_*`(枚举值、API 参数 `temperature=`、
 *   已落库 JSON 路径都是它),显示名是六段中文时间。
 *   ⛔ **不要顺手把枚举也改成 d90/d180** —— 那要同时改 API 契约、SQL 谓词、
 *   已落库的 JSON 路径和前端 query,收益只是"看着顺眼"。
 *
 * ⭐ 2026-08-11:前两档显示名从「黄金期 / 窗口内」改成「三个月内 / 三个月到半年」。
 *   ⚠️ 这**不只是改文案** —— 判据同时从"该治疗自己的临床周期"改成了固定 90/180 天
 *   (见文件头)。⛔ 别只改回文案而留着新判据,或反过来:两者必须同进同退,
 *   否则列头写着「黄金期」而里面装的是"三个月内",主管拿它当临床窗口用就会做错决定。
 */
export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = {
  [Temperature.HOT]: {
    zh: '三个月内',
    hint: '上次到诊不到三个月 —— 人还热乎,当年那句话大概率还记得',
  },
  [Temperature.WARM]: {
    zh: '三个月到半年',
    hint: '上次到诊三到六个月 —— 还来得及,话术要给个理由',
  },
  [Temperature.COLD_1Y]: {
    zh: '半年到 1 年',
    hint: '上次到诊半年到 1 年 —— 情况变化不大,先问近况再谈方案',
  },
  [Temperature.COLD_2Y]: {
    zh: '1–2 年',
    hint: '上次到诊 1-2 年 —— 当年的方案多半要重评,别照着老诊断讲',
  },
  [Temperature.COLD_3Y]: {
    zh: '2–3 年',
    hint: '上次到诊 2-3 年 —— 按"重新了解情况"开口,不要假设需求还在',
  },
  [Temperature.COLD_OVER]: {
    zh: '3 年以上',
    hint: '上次到诊 3 年以上 —— 长尾,期望值放低;⛔ 别用紧迫话术',
  },
};

/**
 * 给 **LLM 工具入参** 用的档位清单 —— 助手 / MCP 的 `temperature` 参数一律取这里。
 *
 * ⚠️ **必须从 TEMPERATURE_ORDER 推**,⛔ 别在工具定义里手写数组。
 *   2026-08-05 实测的事故:冷端从 1 档拆成 4 档之后,两处工具定义里的
 *   `enum: ['hot','warm','cold']` **没人跟着改**。主管点了矩阵的「牙周 · 2–3 年」(67 人),
 *   模型在只有三个取值的枚举里找不到对应档,退而选了 `cold` ——
 *   而 `cold` 按兼容规则展开成**四个冷档的并集**,确认单于是报出 324 人(70+174+67+13)。
 *   两个数都"对",口径差了 4.8 倍,而且**全程零报错**:枚举合法、SQL 合法、count 正确。
 *   这正是 T14「口径对数」要防的 —— 主管刚在矩阵上看过 67,下一句话就变成 324。
 *
 * ⚠️ 带上中文名是**必需的**,不是注释:矩阵格子交给助手时只传了一句自然语言
 *   (「帮我给『牙周 · 2–3 年』这批患者出一份分配方案」),模型得自己把「2–3 年」
 *   翻回 `cold_3y`。不给对照表它只能猜。
 */
export const TEMPERATURE_TOOL_VALUES: readonly string[] = [...TEMPERATURE_ORDER, 'cold'];

/**
 * 工具参数说明(含中文名对照)—— 与 TEMPERATURE_TOOL_VALUES 同源,⛔ 别各写各的。
 *
 * 🔴 **这些取值码是给模型调工具用的,不是给人看的**(2026-08-06 产品走查)。
 *   实测助手对主管说过「「2–3 年」对应的是 cold_3y 档」「放宽温度改成 cold_2y」——
 *   主管眼里界面上从来没有 `cold_3y`,也没有「温度」这个词,那一列就叫「2–3 年」。
 *   看到不认识的英文词,他第一反应是系统出错了。所以说明里必须**同时**给两件事:
 *   ① 中文→取值码 的翻译表(模型调工具要用);② 一条硬约束:⛔ 别把码说出口。
 */
export const TEMPERATURE_TOOL_DESC: string =
  '矩阵的一档(界面上就是列头那几个中文,主管说的也是它们)。⚠️ 必须与 potentialTreatment 同时给。' +
  '按此表把主管的话翻成取值:' +
  TEMPERATURE_ORDER.map((t) => `${TEMPERATURE_META[t].zh}=${t}`).join(' · ') +
  /**
   * 🔴 **命名陷阱**：`cold_Ny` 是「N−1 到 N 年」，⛔ 不是「N 年以上」。
   *
   * 2026-08-14 实测（DeepSeek，思维链原话）：主管说「拔牙 · 3 年以上」，
   *   模型照数字把 `3` 匹配成 `cold_3y` 出了一版（947 人），拿到返回值里的
   *   「距上次到诊 **2−3 年**」才发现对不上，重调 `cold_over` 又出了一版 ——
   *   白跑一次取数。
   * ⚠️ 对照表本来就在下面且是对的，它**没查表、凭数字直觉填的** ⇒ 只给表不够，
   *   要把**最容易撞的那一对**单独点出来（同一份描述里已有的两条硬约束也是这么写的）。
   * ⛔ 枚举值本身不能改（API 参数 / SQL 谓词 / 已落库 JSON 路径都用它，见本文件头）。
   */
  '\n🔴 ⛔ **别照数字直接匹配**:每个 `cold_Ny` 表示「N−1 到 N 年」——' +
  '`cold_3y` 是「2–3 年」,主管说「3 年以上」要用 `cold_over`。' +
  '\n⚠️ `cold` = 后四档**合计**(上次到诊半年以上全体),' +
  '⛔ 只有主管明确说「半年以上 / 老单子」整体时才用 —— ' +
  '他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' +
  '\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' +
  '回话时一律用中文档名(就是上面那张表左边那些)。' +
  '⛔ 也不要说「温度」这个词 —— 界面上没有,那就是一档一档的时间;' +
  '要说「往后放一档」而不是「放宽温度」。';

/**
 * 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
 *
 * ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
 *   SQL 侧(jsonb 路径比较)与 JS 侧才能用同一个比较,不需要来回 parse。
 *   带偏移量的写法(`+08:00`)会破坏这个性质。
 */
export interface TemperatureBounds {
  /** 第一档终点 = 锚点 + HOT_BUCKET_DAYS(90 天) */
  hotUntil: string;
  /** 第二档终点 = 锚点 + WARM_BUCKET_DAYS(180 天) */
  warmUntil: string;
  /**
   * 锚点本身(该标签**最新的未治疗**诊断)。冷端四档按距它的绝对年数分。
   * ⚠️ 与两个边界取同一条 gap 的 max —— 三者同源,⛔ 别各取各的。
   */
  anchorAt: string;
}

const DAY_MS = 86_400_000;

/**
 * 单条 gap 的档位边界 = 锚点日期 + 固定 90 / 180 天。
 *
 * ⚠️ 2026-08-11 起**与诊断码无关**(旧口径是 `rule.urgencyDayThreshold / windowDays`)。
 *   `primaryCode` 仍然要传、也仍然要查表 —— 它现在只当**有效性校验**:
 *   码不在 `DiagnosisTreatmentMap` 里 = 这条 gap 根本不该参与矩阵,返回 null 让调用方丢掉。
 *   ⛔ 别因为"不用它算天数了"就把参数删掉,那会让未知码静默混进某一档。
 *
 * @param primaryCode gap 的主码(K01/K02/…),与挖 gap 时用的 `lookupDxTreatment(primaryCode)` 同一条规则
 * @param anchorAt    信号发生时刻(诊断 occurred_at / 推荐 planned_for)—— **不可变锚点**
 * @returns 码不在表里 / 锚点非法 → null
 */
export function gapTemperatureBounds(
  primaryCode: string,
  anchorAt: Date | string | null | undefined,
): TemperatureBounds | null {
  if (!anchorAt) return null;
  const t = anchorAt instanceof Date ? anchorAt.getTime() : new Date(anchorAt).getTime();
  if (Number.isNaN(t)) return null;
  if (!lookupDxTreatment(primaryCode)) return null;
  return {
    hotUntil: new Date(t + HOT_BUCKET_DAYS * DAY_MS).toISOString(),
    warmUntil: new Date(t + WARM_BUCKET_DAYS * DAY_MS).toISOString(),
    anchorAt: new Date(t).toISOString(),
  };
}

/**
 * 多条 gap 合成一个业务标签的边界 —— **取最热**(两个上界各自取 max)。
 *
 * 两个 max 可以来自不同的 gap,这是对的,不是 bug:
 *   `now ≤ max(hotUntil)`  ⟺ ∃ 某条 gap 还在自己的黄金期 → 该标签就是热
 *   `now ≤ max(warmUntil)` ⟺ ∃ 某条 gap 还在自己的周期内 → 至少是温
 * 恰好等于「逐条判档后取最热」,而且不用先把档位算出来。
 *
 * 顺带保证了 `hotUntil < warmUntil` 恒成立:设 max(hotUntil) 来自第 j 条,
 * 则 max(warmUntil) ≥ warmUntil_j > hotUntil_j = max(hotUntil)(每条都有 urgency < window)。
 */
export function hottestBounds(
  list: ReadonlyArray<TemperatureBounds | null | undefined>,
): TemperatureBounds | null {
  let hot: string | null = null;
  let warm: string | null = null;
  let anchor: string | null = null;
  for (const b of list) {
    if (!b) continue;
    if (hot === null || b.hotUntil > hot) hot = b.hotUntil;
    if (warm === null || b.warmUntil > warm) warm = b.warmUntil;
    // ⭐ 锚点也取 max(最新)—— 与两个边界同向;取最早会让冷端与前两档用两套锚点(见文件头)
    if (anchor === null || b.anchorAt > anchor) anchor = b.anchorAt;
  }
  return hot !== null && warm !== null && anchor !== null
    ? { hotUntil: hot, warmUntil: warm, anchorAt: anchor }
    : null;
}

/**
 * 读时定档 —— 这是温度**唯一**的判定入口,前后端/SQL 想自己写一遍的时候请回来看一眼。
 *
 * @returns 边界缺失(本次改动之前算出来的老画像)→ **null,不是 'cold'**。
 *   把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
 *   调用方应把 null 显式呈现为「温度未知」或排除出矩阵,并说明原因。
 */
/**
 * 业务标签 ← 触发它的诊断码(**只是投影,不是真理源**)。
 *
 * ⚠️ 真理源是 `potential-treatment.feature` 的 `classifyGapToLabel` —— 那里还带年龄闸
 * (种植要 >18、早矫 3-12)和名称判断(K03 含「残根/残冠」才归拔牙),不是纯码映射。
 * 这里只取它在**码**这一维上的投影,供展示层算「窗口期是多少天」用。
 * 两者一致性由 `tests/temperature.spec.ts` 锁住 —— 改那边必须同步改这里。
 */
export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> = {
  implant: ['K08'],
  ortho: ['K07'],
  early_ortho: ['K07'],
  endo: ['K04'],
  perio: ['K05', 'K06'],
  filling: ['K02'],
  restoration: ['K03'],
  extraction: ['K01', 'K03'],
};

/**
 * 这一档对应的**天数区间** —— 矩阵格子 hover 用,只给区间,不给解释。
 *
 * ⚠️ 2026-08-11 起六档都是绝对时间,**与治疗项无关** —— `label` 因此不再参与计算。
 *   ⛔ 参数没删是为了调用点稳定(而且真要按项目分档时,这里是唯一入口)。
 *   旧口径下这里要拼各码窗口的并集(拔牙 = K01 ∪ K03),现在整段没有了。
 */
export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
  void label;
  const from = TEMPERATURE_SINCE_ZH;
  const years = COLD_BUCKET_YEARS[temp];
  if (years !== undefined) {
    if (years === null) return `${from} > 3 年`;
    return years === 1 ? `${from} ${WARM_BUCKET_DAYS} 天–1 年` : `${from} ${years - 1}–${years} 年`;
  }
  if (temp === Temperature.HOT) return `${from} ≤ ${HOT_BUCKET_DAYS} 天`;
  return `${from} ${HOT_BUCKET_DAYS}–${WARM_BUCKET_DAYS} 天`;
}

/**
 * 读时定档 —— 温度**唯一**的判定入口。
 *
 * ⭐ 一个锚点走到底(2026-08 统一):`anchorAt` = 该标签**最新的未治疗**诊断
 *   (口径 `COALESCE(occurred_at, planned_for)` + `status='active'`)。
 *   前两档按该治疗项**自己的临床窗口**、后四档按**绝对年数**,但量的都是「距同一个锚点多久」,
 *   所以六档是一条单调的时间轴,不存在交叉。
 *
 * ⛔ **不要**把冷端改成「距最早那条诊断多久」。那会让 hot/warm 也必须跟着改口径,
 *   而 hot/warm 用最早锚点是**反单调**的:上周刚查出的龋齿,因为身上有颗两年前的旧龋,
 *   整个人被判成冷 —— 多一条未满足需求反而更冷,在召回池里说不通(见文件头 ①)。
 *   「这机会最早挂了多久」另有出口:`potential_treatment.detail[].daysSince`(取 max),
 *   那是**话术勾子**的活,⛔ 别让矩阵档位去兼任。
 *
 * @returns 边界或锚点缺失 → **null,不是任何一个冷档**。
 *   把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
 */
export function classifyTemperature(
  bounds:
    | { hotUntil?: string | null; warmUntil?: string | null; anchorAt?: string | null }
    | null
    | undefined,
  now: Date = new Date(),
): TemperatureValue | null {
  if (!bounds?.hotUntil || !bounds.warmUntil) return null;
  const iso = now.toISOString();
  if (iso <= bounds.hotUntil) return Temperature.HOT;
  if (iso <= bounds.warmUntil) return Temperature.WARM;
  // ── 冷端:按距锚点的绝对年数再分四档 ──
  // ⚠️ 锚点缺失时**不许**默认塞进某一档 —— 返回 null,由调用方显式呈现"未知"
  if (!bounds.anchorAt) return null;
  const anchor = new Date(bounds.anchorAt);
  if (Number.isNaN(anchor.getTime())) return null;
  for (const t of COLD_TEMPERATURES) {
    const y = COLD_BUCKET_YEARS[t];
    if (y === null || y === undefined) return t; // COLD_OVER 兜底(表里缺键也走这条,不静默漏档)
    const edge = new Date(anchor);
    edge.setFullYear(edge.getFullYear() + y); // ⚠️ 按自然年推,别用 365*n(闰年会漂)
    if (now <= edge) return t;
  }
  return Temperature.COLD_OVER;
}
