import { HOT_BUCKET_DAYS, Temperature, WARM_BUCKET_DAYS } from '@pac/types';
import { assertCohortCriteria, cohortWhereSql } from '../src/modules/plan/cohort-filter';

/**
 * 人群取数口径回归。
 *
 * 这一层的错误全都是**静默**的:SQL 照跑、不报错,只是圈出来的人不对。
 * 所以这里逐条锁"生成的 SQL 里必须/不许出现什么" —— 便宜,且拦得住最贵的那几种改法。
 */

const SCOPE = {
  hostId: '00000000-0000-0000-0000-000000000001',
  tenantId: 't1',
  sourceUnits: [] as string[],
  clinicIds: ['c1'],
  userId: 'u1',
} as never;
const NOW = new Date('2026-08-02T00:00:00.000Z');

/** Prisma.Sql 的 strings 拼起来 = 去掉参数占位的 SQL 骨架 */
const sqlOf = (...args: Parameters<typeof cohortWhereSql>) => cohortWhereSql(...args).strings.join('?');

describe('人群取数 —— 池子基线', () => {
  test('⭐ 只圈**未被认领**的活跃 plan,且冷静期已过', () => {
    const s = sqlOf(SCOPE, { clinicId: 'c1' }, NOW);
    expect(s).toContain("fp.status = 'active'");
    expect(s).toContain('fp.assignee_user_id IS NULL');
    expect(s).toContain('fp.superseded_at IS NULL');
    // ⚠️ 约好 6/10 回访的患者不能被卷进批次
    expect(s).toContain('fp.snoozed_until');
  });

  test('⭐ 批次不跨诊所:诊所是硬条件不是可选过滤', () => {
    expect(sqlOf(SCOPE, { clinicId: 'c1' }, NOW)).toContain('fp.target_clinic_id');
  });
});

describe('人群取数 —— 画像必须按患者取当前版', () => {
  const all = sqlOf(
    SCOPE,
    { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT, personaTags: 'rfm:important_value' },
    NOW,
  );

  test('⭐⭐ ⛔ 一处都不许出现 `pe.id = fp.persona_id`(升一次画像版本就静默归零)', () => {
    // 教条 §4.38:plan.persona_id 指向建 plan 那刻的版本,画像升版本后该条件恒为假。
    expect(all).not.toMatch(/pe\.id\s*=\s*fp\.persona_id/);
  });

  test('⭐ 仍走画像的**只剩画像标签**一处 —— 两根轴 2026-08 已换成召回单证据', () => {
    // 换源前是三处(治疗项/温度/画像标签);现在治疗项和温度都不再碰 personas。
    expect(all.match(/pe\.patient_id = fp\.patient_id/g)?.length).toBe(1);
    expect(all.match(/pe\.superseded_at IS NULL/g)?.length).toBe(1);
  });
});

describe('人群取数 —— 两根轴走召回单证据(2026-08 换源)', () => {
  const hot = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW);

  test('⭐⭐ 温度必须挂在治疗项上:光给温度直接拒', () => {
    // 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的"热"是无意义的。
    // ⛔ 尤其不许"贴心地"给个默认治疗项 —— 那会让主管拿到一批他没要的人。
    expect(() => assertCohortCriteria({ clinicId: 'c1', temperature: Temperature.HOT })).toThrow(/必须与潜在治疗项/);
    expect(() =>
      assertCohortCriteria({ clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }),
    ).not.toThrow();
  });

  test('⭐⭐ 两根轴从 plan_reasons → patient_facts 取,⛔ 不再读画像的 types / temperature', () => {
    // 画像回答「这个人有哪些潜在治疗」,矩阵要回答「引擎为什么召回他」。实测 161 个格位
    // 画像有而召回单没有 —— 主管点那一格捞到的人,plan 讲的是别的病。
    expect(hot).toContain('plan_reasons');
    expect(hot).toContain('patient_facts');
    expect(hot).not.toContain("pf.data #> '{types}'");
    expect(hot).not.toContain('pf.data #>>');
  });

  test('⭐⭐ 只认**未治疗**的证据 —— 治完是 fulfilled,拿它当锚就是念一个做完的诊断', () => {
    expect(hot).toContain("f.status = 'active'");
  });

  test('🔴 关联片段⛔ 不许自己 SELECT followup_plans —— 内层 fp 会遮蔽外层,关联条件恒真且不报错', () => {
    // 这是本次改造最贵的一个坑:SQL 合法、跑得通,只是筛选完全失效。
    expect(hot).not.toMatch(/FROM\s+followup_plans/);
    expect(hot).toContain('pr.plan_id = fp.id');
  });

  test('⭐⭐ 先聚合再判档(GROUP/HAVING)—— 逐条判再 OR 会把"有一条还热"错读成整格热', () => {
    expect(hot).toContain('HAVING');
    expect(hot).toMatch(/max\(/);
  });

  test('⭐⭐ 旧取值 `cold` 展开成四个冷档的并集 —— API 契约不能一夜之间全变非法', () => {
    const legacy = cohortWhereSql(
      SCOPE,
      { clinicId: 'c1', potentialTreatment: 'implant', temperature: 'cold' as never },
      NOW,
    );
    const vals = legacy.values.flat();
    for (const t of ['cold_1y', 'cold_2y', 'cold_3y', 'cold_over']) expect(vals).toContain(t);
  });

  test('⭐ 冷端按**自然年**推(interval years),⛔ 不用 365*n —— 闰年会让两边对不上数', () => {
    const cold = sqlOf(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: 'cold_2y' as never }, NOW);
    expect(cold).toMatch(/interval '\d+ years'/);
    expect(cold).not.toMatch(/365/);
  });

  test('⭐ 前两档天数来自 @pac/types 常量,不是手写字面量', () => {
    // 🔴 2026-08-11 换口径:原来这里锁的是"按码取 DiagnosisTreatmentMap"
    //   (K08 种植 黄金 120 / 窗 180);现在六档同一把尺子,固定 90/180,与码无关。
    const sql = cohortWhereSql(
      SCOPE,
      { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
      NOW,
    );
    expect(sql.values.flat()).toContain(HOT_BUCKET_DAYS);
    expect(sql.values.flat()).toContain(WARM_BUCKET_DAYS);
    // ⛔ 天数不许出现在 SQL 文本里(那就是第二份真理源)
    expect(sql.strings.join('?')).not.toMatch(/\b(90|180)\b/);
    // 按码的窗口配置**不该**再被注进来(120 = K08 的旧黄金期)
    expect(sql.values.flat()).not.toContain(120);
  });

  test('⛔ 判档用 SQL 的 NOW(),不掺 JS 时刻 —— 两个时钟会让边界人群漂', () => {
    expect(hot).toContain('NOW()');
    expect(cohortWhereSql(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT }, NOW)
      .values.flat()
      .some((v) => v instanceof Date)).toBe(false);
  });
});

/**
 * 🔴 **圈人侧的锚点** —— 这一层错了,后果是「主管看到 87 人、确认单给另一批」,
 *   而且两个数字都"对"、全程不报错(T14 口径对数;矩阵 67→324 那次事故的同构版本)。
 * ⚠️ 2026-08-13 起只剩末诊一版(`AnchorMode` 开关已删),所以这里锁的是**唯一那一版**。
 */
describe('人群取数 —— 锚点 = 末诊,只此一版', () => {
  const sql = () =>
    cohortWhereSql(
      SCOPE,
      { clinicId: 'c1', potentialTreatment: 'implant', temperature: Temperature.HOT },
      NOW,
    );

  test('⭐⭐ **必须** join patient_profiles 并用 last_visit_at 当锚点', () => {
    const t = sql().strings.join('?');
    expect(t).toContain('patient_profiles');
    expect(t).toContain('last_visit_at');
  });

  test('⭐⭐ ⛔ 不许再拿诊断日当锚点 —— 但 anc.at 的非空守卫要留着(它决定行集)', () => {
    const t = sql().strings.join('?');
    expect(t).toContain('anc.at IS NOT NULL');
    // 锚点表达式只能是末诊:⛔ 不许出现 `max(anc.at`(那是旧口径的指纹)
    expect(t).not.toMatch(/max\(\s*anc\.at/);
  });

  test('⭐⭐ 档位切分仍由 @pac/types 的常量生成 —— ⛔ 不许手写天数', () => {
    const vals = sql().values.flat();
    expect(vals).toContain(HOT_BUCKET_DAYS);
    expect(vals).toContain(WARM_BUCKET_DAYS);
    // 自然年推法(⛔ 不许换成 365 天)
    expect(sql().strings.join('?')).toMatch(/interval '\d+ years'/);
  });
});
