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',
  COLD: 'cold',
} as const;
export type TemperatureValue = (typeof Temperature)[keyof typeof Temperature];

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

export const TEMPERATURE_META: Record<
  TemperatureValue,
  { zh: string; icon: string; hint: string }
> = {
  [Temperature.HOT]: {
    zh: '热',
    icon: '🔥',
    hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得',
  },
  [Temperature.WARM]: {
    zh: '温',
    icon: '🌡',
    hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由',
  },
  [Temperature.COLD]: {
    zh: '冷',
    icon: '❄️',
    hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况再谈方案',
  },
};

/**
 * 一条 gap 的温度边界。存 ISO 串(persona_features.data 是 JSON)。
 *
 * ⚠️ **必须是 `toISOString()` 的 UTC 形态** —— 全仓统一后 ISO 串的字典序 == 时间序,
 *   SQL 侧(jsonb 路径比较)与 JS 侧才能用同一个比较,不需要来回 parse。
 *   带偏移量的写法(`+08:00`)会破坏这个性质。
 */
export interface TemperatureBounds {
  /** 黄金期终点 = 锚点 + urgencyDayThreshold */
  hotUntil: string;
  /** 临床周期终点 = 锚点 + windowDays */
  warmUntil: 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(),
  };
}

/**
 * 多条 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;
  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;
  }
  return hot !== null && warm !== null ? { hotUntil: hot, warmUntil: warm } : null;
}

/**
 * 读时定档 —— 这是温度**唯一**的判定入口,前后端/SQL 想自己写一遍的时候请回来看一眼。
 *
 * @returns 边界缺失(本次改动之前算出来的老画像)→ **null,不是 'cold'**。
 *   把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
 *   调用方应把 null 显式呈现为「温度未知」或排除出矩阵,并说明原因。
 */
export function classifyTemperature(
  bounds: { hotUntil?: string | null; warmUntil?: 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;
  return Temperature.COLD;
}
