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

/**
 * 窗口档位口径回归(教条 T6)。
 *
 * 这里锁的是三件**做错了不会报错、只会让主管看到一个假分布**的事:
 *   ① 逐条 gap 判档再取最热 —— 而不是先 max(daysSince) 再判
 *   ② 边界时刻是事实不是时钟 —— 换个"现在"重算,值必须一模一样
 *   ③ 老数据没有边界时 → 「未知」,⛔ 不许当成「冷」
 *
 * 🔴 2026-08-11:前两档从「该 K 码自己的 urgencyDayThreshold / windowDays」改成
 *   **固定 90 / 180 天**。本文件里原有三条锁"各码各判"的用例(K01 vs K03、K05 vs K06、
 *   K02 的 60/90 压线)随之改写 —— ⚠️ 它们不是失效了,是**口径换了**:
 *   现在要锁的恰恰相反 —— 同一天数的两条 gap,不管什么码,必须落同一档。
 */

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('档位口径 —— 六档同一把尺子(绝对天数)', () => {
  test('⭐ 边界 = 锚点 + 固定 90/180 天,**与诊断码无关**', () => {
    for (const code of ['K01', 'K02', 'K03', 'K04', 'K05', 'K06', 'K07', 'K08']) {
      const b = gapTemperatureBounds(code, daysAgo(0))!;
      expect(new Date(b.hotUntil).getTime() - NOW.getTime()).toBe(HOT_BUCKET_DAYS * DAY);
      expect(new Date(b.warmUntil).getTime() - NOW.getTime()).toBe(WARM_BUCKET_DAYS * DAY);
    }
  });

  test('⭐⭐ 同一天数的两条 gap,码不同也必须同档(旧口径下 K01 热 / K03 温)', () => {
    // 这是改口径的**全部意义**:列头写「三个月内」,那一列里就不能混着
    // 「K01 的 90 天」和「K03 的 60 天」两种"三个月内"。
    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.HOT);
    expect(classifyTemperature(hottestBounds([k01, k03]), NOW)).toBe(Temperature.HOT);
  });

  test('⭐ perio ← K05 + K06 同理(旧口径 75 天时一热一温)', () => {
    const k05 = gapTemperatureBounds('K05', daysAgo(75));
    const k06 = gapTemperatureBounds('K06', daysAgo(75));
    expect(classifyTemperature(k05, NOW)).toBe(Temperature.HOT);
    expect(classifyTemperature(k06, NOW)).toBe(Temperature.HOT);
  });

  test('档位边界左闭右闭:恰好落在边界上的那天算前一档', () => {
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(90)), NOW)).toBe(Temperature.HOT);
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.WARM);
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(180)), NOW)).toBe(Temperature.WARM);
    // 181 天 = 过半年但不到 1 年 → 冷端第一档
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(181)), 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, 89, 90, 91, 179, 180, 181, 365, 3000]) {
      const after = rank[classifyTemperature(hottestBounds([...base, gapTemperatureBounds('K02', daysAgo(d))]), NOW)!]!;
      expect(after).toBeLessThanOrEqual(before);
    }
  });

  test('⭐ 不变式 hotUntil < warmUntil 在跨 gap 取 max 之后仍成立', () => {
    // 两个 max 可以来自不同 gap,这里证明那不会把区间弄反(弄反 = 第二档凭空消失)
    const mixed = hottestBounds([
      gapTemperatureBounds('K01', daysAgo(10)),
      gapTemperatureBounds('K04', daysAgo(1)),
    ])!;
    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'))!;
    expect(classifyTemperature(b, new Date('2026-10-01T00:00:00.000Z'))).toBe(Temperature.HOT); // +60d
    expect(classifyTemperature(b, new Date('2026-12-01T00:00:00.000Z'))).toBe(Temperature.WARM); // +121d
    expect(classifyTemperature(b, new Date('2027-03-01T00:00:00.000Z'))).toBe(Temperature.COLD_1Y); // +211d
  });

  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('⭐ 六档区间与治疗项无关,同一档在哪一行都是同一句', () => {
    // 🔴 主语是"距上次到诊"(2026-08-13 锚点定为末诊,「医生最后一次提到」那版已删)
    expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('距上次到诊 ≤ 90 天');
    expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('距上次到诊 90–180 天');
    expect(temperatureWindowHint('implant', Temperature.COLD_1Y)).toBe('距上次到诊 180 天–1 年');
    expect(temperatureWindowHint('implant', Temperature.COLD_OVER)).toBe('距上次到诊 > 3 年');
  });

  test('⭐⭐ 多码标签不再有"并集"这回事 —— 拔牙(K01+K03)与单码标签逐字相同', () => {
    // 旧口径下这里是 '≤ 90 天' / '60–180 天'(两码窗口的并集),主管在同一列
    // 看到两种天数。现在同一档只有一句话。
    for (const t of TEMPERATURE_ORDER) {
      expect(temperatureWindowHint('extraction', t)).toBe(temperatureWindowHint('implant', t));
    }
  });

  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'))!;
    const seen: string[] = [];
    for (const d of [0, 90, 91, 180, 181, 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('标签不参与计算 —— 传个不存在的标签也给同一句(⛔ 别再回到"未知标签返空串")', () => {
    // 旧口径要查该标签对应的码才能算天数,查不到只能返空串。现在天数是固定的,
    // 空串反而是错的(格子会失去 hover 提示)。
    expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('距上次到诊 ≤ 90 天');
  });
});

/**
 * ⭐ 工具入参的档位清单必须跟着矩阵走 —— 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(/合计|并集|全体/);
  });
});

/**
 * ⭐ 锚点 = 末诊,**只此一版**(2026-08-13;此前有个 `AnchorMode` 开关,整套已删)。
 *
 * 🔴 这里锁的是「⛔ 别把第二版口径加回来」:两版并存时圈人侧与矩阵侧一旦不同步,
 *   就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
 */
describe('档位口径 —— 只剩一版', () => {
  test('⛔ AnchorMode / parseAnchorMode 不许复活', () => {
    const mod = types as Record<string, unknown>;
    for (const k of ['AnchorMode', 'ANCHOR_MODE_META', 'parseAnchorMode', 'ANCHOR_MODE_TOOL_DESC'])
      expect(mod[k]).toBeUndefined();
  });

  test('⭐ 六档的主语统一是「距上次到诊」—— ⛔ 没有第二种措辞', () => {
    for (const t of TEMPERATURE_ORDER)
      expect(temperatureWindowHint('implant', t)).toMatch(/^距上次到诊 /);
  });

  test('⭐ 列头说明的轴名与 hover 主语同源 —— ⛔ 别各写各的', () => {
    expect(TEMPERATURE_AXIS_ZH).toBe('患者最后一次到诊');
    for (const t of TEMPERATURE_ORDER) expect(TEMPERATURE_META[t].hint).toContain('上次到诊');
  });
});
