import { reasonNeedsRefresh } from '../src/modules/plan/engine/reason-refresh';

/**
 * unchanged 分支「reason 就地刷新」判定回归。
 *
 * 背景:引擎变化判定只比 (scenario, subKey) 集合,集合没变就走 unchanged 分支、plan_reasons 一字不改。
 * 于是"只改 signals 内容"的算法升级对存量 plan 永远不生效 —— 生产 3,170 条高龄缺牙即使
 * 上线了按年龄排治疗,仍显示「目标·种植」。
 *
 * 修复引入的**新风险**(本文件主要防这个):reason 文案与 signals 都内嵌天数,天天变。
 * 若无条件刷新,每日重算会把全部 plan_reasons(生产 23 万+ plan)重写一遍 —— 纯废写。
 * 所以判据必须**忽略纯时间漂移**,只认语义变化。
 */

const BASE = {
  subKey: 'missing_tooth',
  triggers: [{ type: 'diagnosis', code: 'K08' }],
  toothPosition: '16;17',
  daysSince: 118,
  signalOccurredAt: '2026-03-28T00:00:00.000Z',
  expectedCategories: ['implant', 'prosthodontic'],
};
const EV = { factIds: ['f1', 'f2'] };

const row = (signals: unknown, evidence: unknown = EV) => ({ signals, evidence });

describe('reasonNeedsRefresh — 只在语义变化时才写', () => {
  test('⭐ 完全相同 → 不刷新', () => {
    expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE }))).toBe(false);
  });

  test('⭐ 只有 daysSince 变(天天都会发生)→ **不刷新**,否则每日重算全量重写', () => {
    expect(reasonNeedsRefresh(row({ ...BASE, daysSince: 118 }), row({ ...BASE, daysSince: 119 }))).toBe(
      false,
    );
    // 跨很多天也一样
    expect(reasonNeedsRefresh(row({ ...BASE, daysSince: 1 }), row({ ...BASE, daysSince: 999 }))).toBe(
      false,
    );
  });

  test('⭐ 新增 focusCategory / patientAge(本次年龄适配)→ 刷新', () => {
    const next = { ...BASE, focusCategory: 'prosthodontic', patientAge: 90 };
    expect(reasonNeedsRefresh(row({ ...BASE }), row(next))).toBe(true);
  });

  test('⭐ focusCategory 改变(如患者过生日跨过 70 岁线)→ 刷新', () => {
    const before = { ...BASE, focusCategory: 'implant', patientAge: 70 };
    const after = { ...BASE, focusCategory: 'prosthodontic', patientAge: 71 };
    expect(reasonNeedsRefresh(row(before), row(after))).toBe(true);
  });

  test('牙位变化 → 刷新(新长出缺牙,文案要跟着变)', () => {
    expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, toothPosition: '16;17;27' }))).toBe(true);
  });

  test('expectedCategories 变化 → 刷新(排除闸口径变了)', () => {
    expect(
      reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, expectedCategories: ['prosthodontic'] })),
    ).toBe(true);
  });

  test('触发信号锚点变化 → 刷新(换了更早/更晚的诊断)', () => {
    expect(
      reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE, signalOccurredAt: '2026-01-01T00:00:00.000Z' })),
    ).toBe(true);
  });

  test('⭐ 旧 plan 无 signals(历史数据)→ 刷新,让它补上', () => {
    expect(reasonNeedsRefresh(row(null), row({ ...BASE }))).toBe(true);
  });

  test('新旧都无 signals → 不刷新(没东西可补)', () => {
    expect(reasonNeedsRefresh(row(null), row(null))).toBe(false);
  });
});

describe('reasonNeedsRefresh — evidence 维度', () => {
  test('证据集变化(命中新 fact)→ 刷新', () => {
    expect(reasonNeedsRefresh(row(BASE, { factIds: ['f1'] }), row(BASE, { factIds: ['f1', 'f3'] }))).toBe(
      true,
    );
  });

  test('⭐ factIds 只是顺序不同 → **不刷新**(批次间顺序会抖,不能当变化)', () => {
    expect(
      reasonNeedsRefresh(row(BASE, { factIds: ['f2', 'f1'] }), row(BASE, { factIds: ['f1', 'f2'] })),
    ).toBe(false);
  });

  test('evidence 缺失 / 形状异常不炸,按无证据处理', () => {
    expect(reasonNeedsRefresh(row(BASE, null), row(BASE, null))).toBe(false);
    expect(reasonNeedsRefresh(row(BASE, {}), row(BASE, null))).toBe(false);
  });
});

describe('⭐ 红线:jsonb 键序不能被当成变化', () => {
  // Postgres jsonb 不保留键序(按键长+字节序重排),库里读出来的对象键序 ≠ 代码构造顺序。
  // 不做规范化的话每次比较都判"变了" → 每日重算全量重写(本地实测连跑三次每次都刷新)。
  test('顶层键序不同 → 不刷新', () => {
    const asWritten = {
      subKey: 'missing_tooth', triggers: [{ type: 'diagnosis', code: 'K08' }],
      toothPosition: '27', daysSince: 119, signalOccurredAt: '2026-03-27T00:00:00.000Z',
      expectedCategories: ['implant', 'prosthodontic'], focusCategory: 'prosthodontic', patientAge: 90,
    };
    // 库里 jsonb 实际存回来的顺序(生产实测形态)
    const asStoredByJsonb = {
      subKey: 'missing_tooth', triggers: [{ code: 'K08', type: 'diagnosis' }],
      daysSince: 119, patientAge: 90, focusCategory: 'prosthodontic', toothPosition: '27',
      signalOccurredAt: '2026-03-27T00:00:00.000Z', expectedCategories: ['implant', 'prosthodontic'],
    };
    expect(reasonNeedsRefresh(row(asStoredByJsonb), row(asWritten))).toBe(false);
  });

  test('嵌套对象(triggers 元素)键序不同 → 不刷新', () => {
    const a = { ...BASE, triggers: [{ type: 'diagnosis', code: 'K08' }] };
    const b = { ...BASE, triggers: [{ code: 'K08', type: 'diagnosis' }] };
    expect(reasonNeedsRefresh(row(a), row(b))).toBe(false);
  });

  test('数组**顺序**仍然算变化(expectedCategories 是有序的:首项=主推)', () => {
    const a = { ...BASE, expectedCategories: ['implant', 'prosthodontic'] };
    const b = { ...BASE, expectedCategories: ['prosthodontic', 'implant'] };
    expect(reasonNeedsRefresh(row(a), row(b))).toBe(true);
  });
});

describe('红线:刷新判据不依赖文案格式', () => {
  test('⭐ signals 相同 → 判不刷新,与 reason 文案无关', () => {
    // reason 文案由 signals 派生(诊断名←triggers、牙位←toothPosition、
    // 治疗类目←expectedCategories+patientAge),所以比 signals 就够;
    // 不解析「N 天前」这类文本 —— 将来 scenario 改文案模板不会引发误刷。
    expect(reasonNeedsRefresh(row({ ...BASE }), row({ ...BASE }))).toBe(false);
  });
});

describe('breakdown 维度 —— 打分明细也要能就地刷新', () => {
  // hover 靠 breakdown 把分数讲成人话。「算法给 breakdown 加了字段」和「急迫档位跨了 90 天线」
  // 都不动 signals —— 只比 signals 的话,存量 plan 永远拿不到(同高龄缺牙那次的形态)。
  const bd = (over: Record<string, unknown> = {}) => ({
    priority: { urgency: 10, value: 8, willingness: 7, base: 8.5, freshness: 1, raw: 8.5, ...over },
    subKey: 'missing_tooth',
  });
  const row = (breakdown: unknown) => ({ signals: { ...BASE }, evidence: EV, breakdown });

  test('⭐ breakdown 新增字段(rfmSegment / lifecycleStage)→ 刷新', () => {
    const next = bd({ rfmSegment: 'important_value', lifecycleStage: 'mature' });
    expect(reasonNeedsRefresh(row(bd()), row(next))).toBe(true);
  });

  test('⭐ 急迫档位跨线(7→10)→ 刷新;这不动 signals,以前刷不到', () => {
    expect(reasonNeedsRefresh(row(bd({ urgency: 7 })), row(bd({ urgency: 10 })))).toBe(true);
  });

  test('⭐⭐ 红线:只有 freshness / raw 随天数衰减 → **不刷新**(否则每日全量重写)', () => {
    const before = bd({ freshness: 1, raw: 8.5 });
    const after = bd({ freshness: 0.82, raw: 6.97 });
    expect(reasonNeedsRefresh(row(before), row(after))).toBe(false);
  });

  test('breakdown 完全相同 → 不刷新', () => {
    expect(reasonNeedsRefresh(row(bd()), row(bd()))).toBe(false);
  });

  test('breakdown 缺失 / 形状异常不炸', () => {
    expect(reasonNeedsRefresh(row(null), row(null))).toBe(false);
    expect(reasonNeedsRefresh(row(undefined), row(null))).toBe(false);
    expect(reasonNeedsRefresh(row(bd()), row(null))).toBe(true); // 从有到无算变化
  });

  test('不传 breakdown 的老调用方行为不变', () => {
    expect(reasonNeedsRefresh({ signals: { ...BASE }, evidence: EV }, { signals: { ...BASE }, evidence: EV })).toBe(false);
  });
});
