import {
  Temperature,
  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
    expect(classifyTemperature(gapTemperatureBounds('K02', daysAgo(91)), NOW)).toBe(Temperature.COLD);
  });

  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);
    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))];
    const rank = { hot: 0, warm: 1, cold: 2 } as const;
    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); // +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)).toBe('> 180 天');
  });

  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)).toBe('> 90 天');
  });

  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);
      const cold = Number(temperatureWindowHint(label, Temperature.COLD).match(/\d+/)![0]);
      // 单码时三档首尾**恰好相接**(温上界 == 冷下界),多码时并集会让温更宽 —— 都不许留缝
      expect(warmLo).toBeLessThanOrEqual(hot);
      expect(warmHi).toBeGreaterThanOrEqual(cold);
    }
  });

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