import { Prisma } from '@prisma/client';
import { lookupDxTreatment } from '@pac/types';
import { TreatmentInitiationRecallScenario } from '../src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../src/modules/clinical-gap/potential-treatment.selector';
import {
  buildGapCore,
  GAP_FLAGS_BY_PRIMARY,
  GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
import type { PrismaService } from '../src/prisma/prisma.service';

/**
 * ⑤g 排除闸:诊所已排未来回访 → 本轮不召。
 *
 * 由来(2026-07 业务可用性测试):
 *   「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」
 * 生产量级:在池 236,508 条 plan 里,30,426(12.9%)患者已有未到期回访排程;
 * 其中急迫档 urgent 占 17,263 —— 正是一线说的"明明排了回访还催我"。
 *
 * 口径三条,本文件逐条锁住:
 *   ① 患者级 + 只认未到期(task_date > 今天)—— 到期自愈,不需要解除逻辑;
 *      "排了不做"(全库到期任务执行率 74.3%)靠这条自然放行,不另设兜底。
 *   ② 不设窗口上限 —— 在池未来排程 99.65% 在一年内,拍 N 天窗口反而会在第 N+1 天
 *      把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
 *   ③ ⭐ 必须在 scenario,**不能**进 gapWhere。
 *      gapWhere 是召回与画像共享的【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口没了);
 *      本闸是【运营时机】层(缺口还在,只是现在不该打电话),同 ⑤b 未来预约 / ⑤f 就诊冷静。
 *      放错层会让画像因为"排了个回访"就说这人没有潜在治疗 —— 两套口径,正是要避免的分叉。
 */

/// 鸭子类型判 Prisma.Sql —— `Prisma.Sql` 在运行时不是可 instanceof 的构造器
type SqlLike = { strings: string[]; values: unknown[] };
const isSql = (v: unknown): v is SqlLike =>
  !!v && typeof v === 'object' && Array.isArray((v as SqlLike).strings) && Array.isArray((v as SqlLike).values);

/// 把 Prisma.Sql 片段(含嵌套)摊平成可断言的文本。
/// 必须递归展开:嵌套片段(gapWhere / lateralJoin …)在外层 tagged template 里只是一个
/// 绑定参数,不摊开就看不见内容,断言会假通过。
function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
  if (!isSql(frag)) return ' ? ';
  return frag.strings
    .map((s, i) => s + (i < frag.values.length ? flatten(frag.values[i]) : ''))
    .join('');
}

/// 捕获 $queryRaw 的完整 SQL 形状(静态段 + 递归摊平的嵌套片段;绑定值占位成 ` ? `)。
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
  const captured: string[] = [];
  const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => {
    const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)];
    captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
    return Promise.resolve([]);
  };
  const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
  return { prisma, sql: () => captured.join('\n/* --- next query --- */\n') };
}

describe('⑤g 未来回访排除闸 —— 召回 SQL', () => {
  let sqlText: string;
  /// ⑤g 闸自身的子查询文本(整条 SQL 里还有别的 rv.* 条件,断言必须收窄到闸内)
  let gate: string;

  beforeAll(async () => {
    const { prisma, sql } = makeSqlCapturingPrisma();
    const scenario = new TreatmentInitiationRecallScenario(prisma);
    await scenario.selectHits({
      hostId: '00000000-0000-0000-0000-000000000001',
      tenantId: 't1',
      now: new Date('2026-07-26T00:00:00Z'),
    });
    sqlText = sql();
    // 同一条 SQL 里有两个 `SELECT 1 FROM patient_return_visits rv` 子查询:
    //   外院已治疗闸(gapWhere,读 rv.result)和 ⑤g(读 rv.task_date 未来)。
    // 取后者 —— 判据是它比的是 `::date`(跟今天比),外院那条比的是诊断日。
    const subs = [...sqlText.matchAll(/SELECT 1 FROM patient_return_visits rv[\s\S]{0,240}?\)/g)].map((m) => m[0]);
    gate = subs.find((s) => /task_date\s*>\s*\?\s*::date/.test(s)) ?? '';
    expect(subs.length).toBeGreaterThanOrEqual(2); // 外院闸 + ⑤g 都在
    expect(gate).not.toBe(''); // 抓不到 ⑤g 就别往下断言了,免得后面全部假通过
  });

  test('⭐ 闸存在,且是 NOT EXISTS 子查询(不是 JOIN,别把行数放大)', () => {
    expect(sqlText).toContain('patient_return_visits');
    expect(sqlText).toMatch(/NOT EXISTS \([\s\S]*?SELECT 1 FROM patient_return_visits rv/);
    expect(sqlText).not.toMatch(/JOIN patient_return_visits/);
  });

  test('⭐ 只认未到期 —— 必须是 task_date > now::date,不能是 >= 或 <', () => {
    // 绑定参数被占位成 ` ? `,故断言写成 `rv.task_date >  ? ::date`。
    // ⚠️ 只在 ⑤g 闸内断言:同一条 SQL 里外院闸也有 `rv.task_date >= 诊断日`,那是另一回事。
    expect(gate).toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
    expect(gate).not.toMatch(/rv\.task_date\s*>=/);
    expect(gate).not.toMatch(/rv\.task_date\s*<\s*\?/);
  });

  test('患者级:按 patient_id 关联,不掺牙位 / 类目条件', () => {
    expect(gate).toContain('rv.patient_id = p.id');
    expect(gate).not.toContain('tooth');
    expect(gate).not.toContain('category');
  });

  test('⭐ 不设窗口上限(闸内只有一个 task_date 比较,没有 now + N 天的第二个条件)', () => {
    expect(gate.match(/task_date/g)?.length).toBe(1);
    expect(gate).not.toMatch(/interval|days'/i);
  });

  test('⑤b(未来预约)/ ⑤f(就诊冷静)仍在 —— ⑤g 是新增不是替换', () => {
    expect(sqlText).toContain('appointment_record');
    expect(sqlText).toMatch(/vis\.type IN \('encounter_record', 'emr_record'\)/);
  });
});

describe('⭐ 分层纪律:⑤g 属于「运营时机」层,不得下沉到召回+画像共享的 gapWhere', () => {
  const gap = buildGapCore({
    rule: lookupDxTreatment('K08')!,
    cfgFlags: GAP_FLAGS_BY_PRIMARY.K08 ?? {},
    allCodes: [...GAP_PRIMARY_GROUPS.K08!.dxCodes, ...GAP_PRIMARY_GROUPS.K08!.recCodes],
    resolverCats: ['implant', 'prosthodontic'],
  });
  const gapWhereText = flatten(gap.gapWhere);

  test('⭐ gapWhere 里没有「与今天比的未来 task_date」—— 那是 ⑤g,不属于这一层', () => {
    // ⚠️ 注意 gapWhere 里**本来就有** task_date:外院已治疗闸拿它跟**诊断日**比方向
    //   (rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)),那是临床事实判定。
    //   要禁的是 ⑤g 那种跟**今天**比的未来判定(rv.task_date > $now::date)。
    expect(gapWhereText).not.toMatch(/task_date\s*>\s*\?\s*::date/);
    expect(gapWhereText.match(/task_date/g)?.length).toBe(1);
  });

  test('gapWhere 里**保留**外院已治疗闸 —— 它读 rv.result(临床事实),跟 ⑤g 分工不同', () => {
    expect(gapWhereText).toContain('patient_return_visits');
    expect(gapWhereText).toContain('rv.result');
    expect(gapWhereText).toMatch(/rv\.task_date\s*>=\s*COALESCE\(sig\.occurred_at/);
  });

  test('画像 selector 跑出来的 SQL 不含未来回访闸', async () => {
    const { prisma, sql } = makeSqlCapturingPrisma();
    const selector = new PotentialTreatmentSelector(prisma);
    await selector.selectForPatient({
      hostId: '00000000-0000-0000-0000-000000000001',
      tenantId: 't1',
      patientId: '00000000-0000-0000-0000-0000000000aa',
      now: new Date('2026-07-26T00:00:00Z'),
      activeCodes: new Set(['K08', 'K07', 'K02']), // 不给码会被剪枝跳过,SQL 都不生成
    });
    const text = sql();
    expect(text.length).toBeGreaterThan(0); // 确认真跑了 SQL,不是被剪枝跳过
    // 外院闸的 task_date(跟诊断日比)允许出现;⑤g 那种跟今天比的未来判定不允许
    expect(text).not.toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
  });
});
