import {
  Temperature,
  TEMPERATURE_META,
  TEMPERATURE_ORDER,
  TEMPERATURE_TOOL_DESC,
  TEMPERATURE_TOOL_VALUES,
  classifyTemperature,
  gapTemperatureBounds,
  hottestBounds,
  lookupDxTreatment,
  POTENTIAL_TREATMENT_SOURCE_CODES,
  temperatureWindowHint,
} from '@pac/types';
import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff';

/**
 * 窗口温度口径回归(教条 T6 / 开发规划 Q-6 的落地)。
 *
 * 这里锁的是三件**做错了不会报错、只会让主管看到一个假分布**的事:
 *   ① 逐条 gap 按自己 K 码的窗判档 —— 而不是先 max(daysSince) 再判
 *   ② 边界时刻是事实不是时钟 —— 换个"现在"重算,值必须一模一样
 *   ③ 老数据没有边界时 → 「未知」,⛔ 不许当成「冷」
 */

const DAY = 86_400_000;
const NOW = new Date('2026-08-02T00:00:00.000Z');
const daysAgo = (n: number) => new Date(NOW.getTime() - n * DAY);

describe('温度口径 —— 逐条 gap 用自己的窗', () => {
  test('⭐ 各码的边界严格来自 DiagnosisTreatmentMap,不是自己拍的常量', () => {
    for (const code of ['K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08']) {
      const rule = lookupDxTreatment(code)!;
      const b = gapTemperatureBounds(code, daysAgo(0))!;
      expect(new Date(b.hotUntil).getTime() - NOW.getTime()).toBe(rule.urgencyDayThreshold * DAY);
      expect(new Date(b.warmUntil).getTime() - NOW.getTime()).toBe(rule.windowDays * DAY);
    }
  });

  test('⭐⭐ Q-6「一对多无解」其实有解:extraction ← K01(180/90) + K03(90/60) 各归各的', () => {
    // 同一个「潜在拔牙」标签下的两条 gap,天数一样(70 天),但两码的黄金期不同:
    //   K01 urgency=90 → 70 天仍在黄金期 = 热
    //   K03 urgency=60 → 70 天已过黄金期 = 温
    // 取最热 → 热。⛔ 不需要为「潜在拔牙」另定一套标签级窗口(那才是新增口径)。
    const k01 = gapTemperatureBounds('K01', daysAgo(70));
    const k03 = gapTemperatureBounds('K03', daysAgo(70));
    expect(classifyTemperature(k01, NOW)).toBe(Temperature.HOT);
    expect(classifyTemperature(k03, NOW)).toBe(Temperature.WARM);
    expect(classifyTemperature(hottestBounds([k01, k03]), NOW)).toBe(Temperature.HOT);
  });

  test('⭐ perio ← K05(120/90) + K06(120/60) 同理', () => {
    const k05 = gapTemperatureBounds('K05', daysAgo(75));
    const k06 = gapTemperatureBounds('K06', daysAgo(75));
    expect(classifyTemperature(k05, NOW)).toBe(Temperature.HOT); // 75 ≤ 90
    expect(classifyTemperature(k06, NOW)).toBe(Temperature.WARM); // 75 > 60
    expect(classifyTemperature(hottestBounds([k05, k06]), NOW)).toBe(Temperature.HOT);
  });

  test('三档边界是闭区间左闭右闭:恰好落在窗上的那天仍算窗内', () => {
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(60)), NOW)).toBe(Temperature.HOT); // urgency=60
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(61)), NOW)).toBe(Temperature.WARM);
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.WARM); // window=90
    // 91 天前 = 出窗但不到 1 年 → 冷端第一档
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD_1Y);
  });

  test('未知码 / 无锚点 → null,⛔ 不猜一个默认窗口', () => {
    expect(gapTemperatureBounds('K99', daysAgo(1))).toBeNull();
    expect(gapTemperatureBounds('K02', null)).toBeNull();
    expect(gapTemperatureBounds('K02', 'not-a-date')).toBeNull();
  });
});

describe('温度口径 —— 反单调性(这是旧口径最贵的那个 bug)', () => {
  test('⭐⭐ 多一条**更旧**的同类需求,绝不能让人变冷', () => {
    // 上周刚查出的龋齿(K02,5 天)+ 两年前的旧龋(K02,700 天)。
    // 旧口径:max(daysSince)=700 → 冷,这个刚被医生说过的人在矩阵上永远看不见。
    const fresh = gapTemperatureBounds('K02', daysAgo(5));
    const stale = gapTemperatureBounds('K02', daysAgo(700));
    expect(classifyTemperature(fresh, NOW)).toBe(Temperature.HOT);
    expect(classifyTemperature(stale, NOW)).toBe(Temperature.COLD_2Y); // 700 天 ≈ 1.9 年
    expect(classifyTemperature(hottestBounds([fresh, stale]), NOW)).toBe(Temperature.HOT);
    expect(classifyTemperature(hottestBounds([stale, fresh]), NOW)).toBe(Temperature.HOT); // 与顺序无关
  });

  test('⭐ 单调性通则:往集合里加任何一条 gap,档位只可能变热或不变', () => {
    const base = [gapTemperatureBounds('K02', daysAgo(700))];
    // 六档由热到冷的名次 —— ⛔ 别再手写,从 TEMPERATURE_ORDER 派生(加档位时自动跟上)
    const rank = Object.fromEntries(TEMPERATURE_ORDER.map((t, i) => [t, i])) as Record<string, number>;
    const before = rank[classifyTemperature(hottestBounds(base), NOW)!]!;
    for (const d of [1, 30, 59, 60, 61, 89, 90, 91, 365, 3000]) {
      const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!;
      expect(after).toBeLessThanOrEqual(before);
    }
  });

  test('⭐ 不变式 hotUntil < warmUntil 在跨码取 max 之后仍成立', () => {
    // 两个 max 可以来自不同 gap,这里证明那不会把区间弄反(弄反 = 温档凭空消失)
    const mixed = hottestBounds([
      gapTemperatureBounds('K01', daysAgo(10)), // hot 到 +80d, warm 到 +170d
      gapTemperatureBounds('K04', daysAgo(1)), // hot 到 +44d, warm 到 +59d
    ])!;
    expect(mixed.hotUntil < mixed.warmUntil).toBe(true);
  });
});

describe('温度口径 —— 边界时刻是事实,不是时钟', () => {
  test('⭐⭐ 换个"现在"重算,边界一字不变(否则每天都白升一个画像版本)', () => {
    const anchor = new Date('2026-01-15T03:00:00.000Z');
    const a = gapTemperatureBounds('K08', anchor);
    const b = gapTemperatureBounds('K08', anchor); // 同样的事实,隔了半年再算
    expect(a).toEqual(b);
  });

  test('⭐⭐ hotUntil / warmUntil ⛔ 不许进 VOLATILE_DATA_KEYS', () => {
    // 进了 = stripVolatile 会把它们从语义指纹里剔掉 → 新来一条诊断把温度顶热了也不升版本,
    // 等于把这次要修的"冻结"原样搬到另一层,而且更隐蔽。
    expect(VOLATILE_DATA_KEYS.has('hotUntil')).toBe(false);
    expect(VOLATILE_DATA_KEYS.has('warmUntil')).toBe(false);
    // 对照:daysSince 是真·易变键,必须在里面(它是时钟)
    expect(VOLATILE_DATA_KEYS.has('daysSince')).toBe(true);
  });

  test('⭐ 时间推移会自然翻档,不需要任何重算', () => {
    const b = gapTemperatureBounds('K04', new Date('2026-08-02T00:00:00.000Z'))!; // 60/45
    expect(classifyTemperature(b, new Date('2026-09-10T00:00:00.000Z'))).toBe(Temperature.HOT); // +39d
    expect(classifyTemperature(b, new Date('2026-09-20T00:00:00.000Z'))).toBe(Temperature.WARM); // +49d
    expect(classifyTemperature(b, new Date('2026-11-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +91d
  });

  test('⭐ 边界存的是 UTC ISO —— 字典序必须等于时间序(SQL 侧靠这个直接比较)', () => {
    const early = gapTemperatureBounds('K02', new Date('2026-01-01T00:00:00.000Z'))!;
    const late = gapTemperatureBounds('K02', new Date('2026-06-01T00:00:00.000Z'))!;
    expect(early.hotUntil < late.hotUntil).toBe(true);
    expect(early.hotUntil).toMatch(/Z$/); // 带 +08:00 偏移的写法会破坏字典序
  });
});

describe('温度口径 —— 「不知道」不等于「冷」', () => {
  test('⭐⭐ 老画像没有边界 → null,⛔ 不许静默判成 cold', () => {
    // 判成 cold 会让本次改动之前算出来的画像全部塞进冷格子,
    // 主管看到的是一个**假的**分布,而且看不出哪里假 —— 违 T14。
    expect(classifyTemperature(null, NOW)).toBeNull();
    expect(classifyTemperature(undefined, NOW)).toBeNull();
    expect(classifyTemperature({}, NOW)).toBeNull();
    expect(classifyTemperature({ hotUntil: '2026-09-01T00:00:00.000Z' }, NOW)).toBeNull(); // 只有一半也不算
  });

  test('一条 gap 都算不出边界时,聚合结果也是 null', () => {
    expect(hottestBounds([])).toBeNull();
    expect(hottestBounds([null, undefined])).toBeNull();
  });
});

describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', () => {
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const { classifyGapToLabel } = require('../src/modules/persona/features/potential-treatment.feature');
  const gap = (primaryCode: string, nameZh = '') =>
    ({ primaryCode, nameZh, tooth: null, daysSince: 1, confidence: 1, signalType: 'diagnosis', factId: 'f', anchorAt: NOW });

  test('⭐⭐ 投影表里的每个码,真的会被 classifyGapToLabel 归到那个标签', () => {
    // 展示层拿投影表算「窗口期是多少天」;它跟真理源漂了,hover 上的天数就是错的,
    // 而错了不会报错 —— 主管照着一个假天数理解为什么这格算热。
    const ADULT = 30; // 过掉种植 >18 / 正畸 ≤40 的年龄闸
    for (const [label, codes] of Object.entries(POTENTIAL_TREATMENT_SOURCE_CODES)) {
      for (const code of codes) {
        const age = label === 'early_ortho' ? 8 : ADULT;
        // K03 一码两标签,靠诊断名分流(残根类 → 拔牙,否则 → 修复)
        const nameZh = code === 'K03' && label === 'extraction' ? '残根' : '';
        expect(classifyGapToLabel(gap(code, nameZh), age)?.key).toBe(label);
      }
    }
  });

  test('⭐ 8 个业务标签一个都不能少', () => {
    expect(Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES).sort()).toEqual(
      ['early_ortho', 'endo', 'extraction', 'filling', 'implant', 'ortho', 'perio', 'restoration'],
    );
  });
});

describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
  test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => {
    expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天');
    expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天');
    // 冷端四档是**绝对年数**,与治疗项无关 —— 不再是"> 窗口天数"
    expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('出周期后 1 年内');
    expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('诊断距今 > 3 年');
  });

  test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => {
    // 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
    expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天');
    expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天');
    // 冷端与码无关,多码标签也是同一套年数
    expect(temperatureWindowHint('extraction', Temperature.COLD_2Y)).toBe('诊断距今 1–2 年');
  });

  test('⭐ 前两档区间必须**首尾相接**,不留缝也不写反', () => {
    // 缝 = 有人哪一档都不属于;写反 = 温的上界跑到热的上界左边,主管一看就知道在乱说。
    // ⚠️ 冷端四档已改成绝对年数(与治疗项无关),不参与这条"按天数接龙"的校验。
    for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) {
      const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]);
      const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number);
      expect(warmLo).toBeLessThanOrEqual(hot);
      expect(warmHi).toBeGreaterThan(hot);
    }
  });

  test('⭐⭐ 六档必须两两互斥且覆盖整条时间轴 —— 任何一天都恰好属于一档', () => {
    // 这条比"文案接龙"硬:直接拿判定函数在时间轴上扫,漏一天或重一天都会红。
    const b = gapTemperatureBounds('K02', new Date('2020-01-01T00:00:00.000Z'))!; // 60/90
    const seen: string[] = [];
    for (const d of [0, 60, 61, 90, 91, 364, 365, 366, 730, 731, 1095, 1096, 5000]) {
      const t = classifyTemperature(b, new Date(Date.parse(b.anchorAt) + d * 86_400_000));
      expect(t).not.toBeNull(); // ⛔ 任何一天都不许落进"未知"
      if (!seen.length || seen[seen.length - 1] !== t) seen.push(t!);
    }
    // 走过的档位序列必须是 TEMPERATURE_ORDER 的**前缀连续子序列**(单调变冷,不回头、不跳档)
    const idx = seen.map((t) => TEMPERATURE_ORDER.indexOf(t as never));
    expect(idx).toEqual([...idx].sort((a, z) => a - z));
    expect(new Set(idx).size).toBe(idx.length);
  });

  test('未知标签 → 空串(不编)', () => {
    expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('');
  });
});

/**
 * ⭐ 工具入参的档位清单必须跟着矩阵走 —— 2026-08-05 事故的回归。
 *
 * 事故经过:冷端从 1 档拆成 4 档,`Temperature` / `TEMPERATURE_ORDER` / 矩阵 / SQL 全改了,
 * 唯独两处 LLM 工具定义里的 `enum: ['hot','warm','cold']` 是**手写**的,没人跟着改。
 * 主管点了矩阵的「牙周 · 2–3 年」(67 人),模型在三个取值里找不到对应档 → 退而选 `cold`,
 * 而 `cold` 展开成四个冷档的并集 → 确认单报出 324 人。
 *
 * ⚠️ 这个 bug **没有任何报错**:枚举合法、SQL 合法、count 也是对的,
 *   只有把矩阵和确认单摆在一起看才会发现口径差了 4.8 倍(违 T14「口径对数」)。
 *   所以只能靠这条测试锁 —— tsc 抓不到手写字符串数组和枚举脱节。
 */
describe('LLM 工具的温度取值 —— 必须与矩阵同源', () => {
  test('⭐⭐ 每个展示档都能被工具表达(少一个 = 模型只能退而求其次,静默放大人数)', () => {
    for (const t of TEMPERATURE_ORDER) {
      expect(TEMPERATURE_TOOL_VALUES).toContain(t);
    }
  });

  test('除了兼容值 cold,不许多出矩阵里没有的档(否则模型会给出渲染不出来的格子)', () => {
    expect([...TEMPERATURE_TOOL_VALUES].sort()).toEqual([...TEMPERATURE_ORDER, 'cold'].sort());
  });

  test('说明里必须带**中文档名对照** —— 主管说的是「2–3 年」,模型得自己翻回 cold_3y', () => {
    for (const t of TEMPERATURE_ORDER) {
      expect(TEMPERATURE_TOOL_DESC).toContain(`${TEMPERATURE_META[t].zh}=${t}`);
    }
  });

  test('cold 必须被标注成"合计",否则模型会拿它当某一档用', () => {
    expect(TEMPERATURE_TOOL_DESC).toMatch(/cold/);
    expect(TEMPERATURE_TOOL_DESC).toMatch(/合计|并集|全体/);
  });
});
