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

/**
 * 窗口温度 —— 初选矩阵的 Y 轴(X 轴见 persona-tag-filters 的 potential_treatment 8 类)。
 *
 * ═══ 口径(教条 T6,2026-08-02 定稿)═════════════════════════════════
 * 温度 = **该治疗项目自身的临床时间周期**走到哪一段了,不是客户价值、不是意愿、不是末诊天数。
 *
 * ```
 * [0, urgencyDayThreshold]     🔥 热   黄金期内
 * (urgencyDayThreshold, windowDays]  🌡 温   周期内
 * (windowDays, ∞)              ❄️ 冷   超周期
 * ```
 * 每个治疗项目**用自己的周期尺度**(`DiagnosisTreatmentMap`),归一化后横向可比 ——
 * 「潜在根管·热」(45 天内)与「潜在正畸·热」(180 天内)天数差 4 倍,但业务含义同级。
 *
 * ═══ 两条关键设计,都是被数据打出来的 ═══════════════════════════════
 *
 * ① **在「温度」上聚合,不在「天数」上聚合** —— 一个患者的同一业务标签常有多条 gap
 *    (实测本地库:41% 的 K02 患者有多条诊断,时间跨度均值 452 天,而 K02 窗口只有 90 天)。
 *
 *    ⛔ 旧做法是先 `Math.max(daysSince)` 再判档(potential-treatment.feature 的聚合口径)。
 *       那是**反单调**的:上周刚查出的龋齿,因为身上还有一颗两年前的旧龋,整个人被判成「冷」。
 *       **多一条未满足需求,反而更冷** —— 这在召回池里是说不通的。
 *
 *    ✅ 正解:**逐条 gap 用自己 K 码的窗口判档,再取最热**。
 *
 *    ⚠️ **实测量级要说准**(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%。
 *       顺带把开发规划 Q-6 说的「一对多无解」也解掉了 —— `extraction ← K01(180/90) + K03(90/60)`
 *       根本不需要为标签另定一套窗口:K01 那条按 K01 判、K03 那条按 K03 判,各归各的。
 *       于是 T6「阈值复用 DiagnosisTreatmentMap,不新增口径」真的做到了,
 *       `canonical-codes.ts` 那句「不允许任一处再硬编码窗口/临界」也没被违反。
 *
 * ② **存边界时刻,不存天数/不存档位** —— 与 `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,
];

/** 冷档的**年数上界**(距锚点);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=`、
 *   `persona_features.data.temperature` 的路径都是它),显示名是「黄金期 / 窗口内 / 窗口外」。
 *   ⛔ **不要顺手把枚举也改成 golden/inWindow/outWindow** —— 那要同时改
 *   API 契约、SQL 谓词、已落库的 JSON 路径和前端 query,收益只是"看着顺眼"。
 *
 * ⭐ 显示名从「热/温/冷」改成临床说法(2026-08 产品定):
 *   「热」是**比喻**,而这三档本来就有精确的临床语义(黄金期 / 临床周期内 / 超周期)。
 *   名字精确之后,温度渐变那套颜色比喻就成了第二套更弱的编码 —— 一并撤掉,见 pool-matrix。
 */
export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = {
  [Temperature.HOT]: {
    zh: '黄金期',
    hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得',
  },
  [Temperature.WARM]: {
    zh: '窗口内',
    hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由',
  },
  [Temperature.COLD_1Y]: {
    zh: '1 年内',
    hint: '刚出临床周期 —— 情况变化不大,先问近况再谈方案',
  },
  [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` = 后四档**合计**(窗口外全体),' +
  '⛔ 只有主管明确说「窗口外 / 超周期」整体时才用 —— ' +
  '他点的是矩阵里某一格时一律给**具体档位**,给成 cold 会把人数放大好几倍。' +
  '\n🔴 ⛔ **这些取值码只用于调工具,一个字都不许说给主管**(他界面上没见过)。' +
  '回话时一律用中文档名:「2–3 年」「1–2 年」「窗口内」「黄金期」。' +
  '⛔ 也不要说「温度」这个词 —— 界面上没有,那就是一档一档的时间;' +
  '要说「往后放一档」而不是「放宽温度」。';

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

const DAY_MS = 86_400_000;

/**
 * 单条 gap 的温度边界 = 锚点日期 + **该诊断码自己**的窗口配置。
 *
 * @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;
  const rule = lookupDxTreatment(primaryCode);
  if (!rule) return null;
  return {
    hotUntil: new Date(t + rule.urgencyDayThreshold * DAY_MS).toISOString(),
    warmUntil: new Date(t + rule.windowDays * 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 用,只给区间,不给解释。
 *
 * ⚠️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是各码区间的**并集**:
 *   每条 gap 按**自己那个码**的窗口判档(见文件头 ①),不存在"标签级的那一个天数"。
 *   并集是对该档人群天数范围的**如实**描述 —— 比挑一个码的数字诚实,也比塞一堆解释短。
 *   例:拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
 *       热 [0,90]∪[0,60] = ≤90 天 · 温 (90,180]∪(60,90] = 60–180 天 · 冷 >180∪>90 = >90 天
 */
export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
  const years = COLD_BUCKET_YEARS[temp];
  // 冷四档是**绝对年数**,与治疗项无关 —— 不查窗口配置
  if (years !== undefined) {
    if (years === null) return '诊断距今 > 3 年';
    return years === 1 ? '出周期后 1 年内' : `诊断距今 ${years - 1}–${years} 年`;
  }
  const codes = POTENTIAL_TREATMENT_SOURCE_CODES[label];
  const rules = (codes ?? []).map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r);
  if (!rules.length) return '';
  const urg = rules.map((r) => r.urgencyDayThreshold);
  const win = rules.map((r) => r.windowDays);
  if (temp === Temperature.HOT) return `≤ ${Math.max(...urg)} 天`;
  return `${Math.min(...urg)}–${Math.max(...win)} 天`;
}

/**
 * 读时定档 —— 温度**唯一**的判定入口。
 *
 * ⭐ 一个锚点走到底(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;
}
