import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql';
import { planLabelAnchorsSql } from '../src/modules/plan/reason-temperature.sql';
import { poolBaseSql } from '../src/modules/plan/cohort-filter';
import { Prisma } from '@prisma/client';

/**
 * `plan_reasons.potential_labels` —— 初选矩阵提速的那一列。
 *
 * 本地实测（15,884 条 plan 的诊所）：958 ms → 110 ms，磁盘读 119,861 → 13,500，
 * 而 24 格的数**逐字未变**。这组测试守的是让它继续成立的几条结构性前提。
 */

const text = (q: Prisma.Sql) => q.sql;

describe('矩阵查询不再碰 patient_facts', () => {
  const sql = text(planLabelAnchorsSql(Prisma.sql`fp.status = 'active'`));

  it('🔴 ⛔ 不再 JOIN patient_facts —— 那是三万多次随机查、372 MB I/O 的来源', () => {
    expect(sql).not.toMatch(/patient_facts/);
  });

  it('🔴 ⛔ 不再展开 evidence.factIds', () => {
    expect(sql).not.toMatch(/jsonb_array_elements_text/);
  });

  /**
   * ⚠️ 这里原来断言的是「⛔ 不再 JOIN patients」—— **那条断言本身是错的**，
   *   它把 2026-08-17 那个线上 500 固化成了"期望行为"：`poolBaseSql` 在
   *   sourceUnits 非空时要引用 `p.source_unit`，删了 join 就 `missing FROM-clause`。
   * ⇒ 现在改成断言**真正变了的那件事**：查询里不再出现按年龄推标签的 CASE
   *   （年龄已烘进 potential_labels），而 patients 这张表因为品牌隔离仍要在场。
   */
  it('不再在查询里按年龄推标签 —— 年龄已烘进 potential_labels', () => {
    expect(sql).not.toMatch(/date_part\('year'/);
    expect(sql).not.toMatch(/birth_date/);
  });

  it('改用预存的 potential_labels', () => {
    expect(sql).toMatch(/unnest\(pr\.potential_labels\)/);
  });

  it('末诊锚点仍来自 patient_profiles —— 温度那根轴没动', () => {
    expect(sql).toMatch(/patient_profiles/);
    expect(sql).toMatch(/last_visit_at/);
  });
});

/**
 * 🔴 2026-08-17 线上 500 的回归守卫。
 *
 * 当时把 `JOIN patients p` 从矩阵查询里删掉了（标签预计算后"用不到年龄"），
 * 但 `poolBaseSql` 在 **scope.sourceUnits 非空**时会拼 `AND p.source_unit IN (...)`
 * ⇒ PG 报 `missing FROM-clause entry for table "p"`，矩阵整页 500。
 *
 * ⚠️ 本地基准脚本当时硬编 `sourceUnits: []`，永远走不到那一支 —— **测了个假场景**。
 * ⇒ 这里不连库也能守住：把两种 scope 拼出来的 SQL 做**别名闭合检查**。
 */
describe('poolBaseSql 用到的别名，查询里必须都在 FROM 里', () => {
  const scope = (sourceUnits: string[]) =>
    ({ hostId: 'h', tenantId: 't', clinicIds: [], sourceUnits }) as never;

  it('🔴 sourceUnits 非空时会引用 p. —— 那么查询必须 JOIN patients p', () => {
    const filter = poolBaseSql(scope(['瑞尔']), 'c1');
    expect(filter.sql).toMatch(/p\.source_unit/); // 前提成立：它确实引用了 p
    const q = planLabelAnchorsSql(filter).sql;
    expect(q).toMatch(/JOIN patients p\b/);
  });

  it('sourceUnits 为空时也不能少 —— ⛔ 别按"这一支用不到"就删', () => {
    const q = planLabelAnchorsSql(poolBaseSql(scope([]), 'c1')).sql;
    expect(q).toMatch(/JOIN patients p\b/);
  });

  it('⭐ 别名闭合：过滤片段里出现的每个表别名，都要能在 FROM/JOIN 里找到', () => {
    for (const units of [[], ['瑞尔', '瑞泰']]) {
      const q = planLabelAnchorsSql(poolBaseSql(scope(units), 'c1')).sql;
      const used = new Set([...q.matchAll(/\b([a-z]{1,4})\.[a-z_]+/g)].map((m) => m[1]));
      const declared = new Set([
        ...[...q.matchAll(/\b(?:FROM|JOIN)\s+[a-z_]+\s+([a-z]{1,4})\b/g)].map((m) => m[1]),
        ...[...q.matchAll(/AS\s+([a-z]{1,4})\(/g)].map((m) => m[1]),
      ]);
      for (const a of used) {
        if (a === 'max' || a === 'now') continue; // 函数名不是别名
        expect({ units, alias: a, declared: [...declared] }).toEqual(
          expect.objectContaining({ alias: expect.stringMatching(new RegExp(`^(${[...declared].join('|')})$`)) }),
        );
      }
    }
  });
});

describe('刷新 SQL 的几条结构前提', () => {
  it('🔴 带 id 游标 —— 否则全量重算每次取同一批，原地打转且不报错', () => {
    const first = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
    const next = text(refreshLabelsSql({ afterId: 'x', onlyMissing: false }));
    expect(first).not.toMatch(/pr\.id >/);
    expect(next).toMatch(/pr\.id >/);
  });

  it('🔴 seen / written / maxId 分开报 —— written=0 ⛔ 不等于做完了', () => {
    const s = text(refreshLabelsSql({ afterId: null, onlyMissing: false }));
    expect(s).toMatch(/AS seen/);
    expect(s).toMatch(/AS written/);
    expect(s).toMatch(/"maxId"/);
  });

  it('onlyMissing 才加 IS NULL 闸；全量重算⛔ 不加', () => {
    expect(text(refreshLabelsSql({ afterId: null, onlyMissing: true }))).toMatch(
      /potential_labels IS NULL/,
    );
    expect(text(refreshLabelsSql({ afterId: null, onlyMissing: false }))).not.toMatch(
      /potential_labels IS NULL/,
    );
  });

  it('🔴 用 LEFT JOIN 取 fact —— 推不出标签的也要写 {}，否则永远停在 NULL 被反复捞', () => {
    const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
    expect(s).toMatch(/LEFT JOIN patient_facts/);
    expect(s).toMatch(/'\{\}'::text\[\]/);
  });

  it('⛔ 不在这里另写一份标签 CASE —— 复用矩阵那份（规则表是单一真源）', () => {
    const q = refreshLabelsSql({ afterId: null, onlyMissing: true });
    // ⚠️ 标签值走参数化占位，在 `values` 里而不在 `sql` 里 —— 断言写在 sql 上会假绿。
    const vals = q.values.map(String);
    for (const lbl of ['implant', 'early_ortho', 'restoration']) {
      expect(vals).toContain(lbl);
    }
    // 且确实是一段 CASE（来自 labelCaseSql），⛔ 不是这里手写的映射
    expect(q.sql).toMatch(/CASE\s+WHEN/);
  });

  it('只处理未作废的 plan', () => {
    expect(text(refreshLabelsSql({ afterId: null, onlyMissing: true }))).toMatch(
      /fp\.superseded_at IS NULL/,
    );
    expect(text(countMissingLabelsSql)).toMatch(/fp\.superseded_at IS NULL/);
  });

  it('批量有上限 —— ⛔ 别一条 UPDATE 扫全表', () => {
    expect(LABEL_REFRESH_BATCH).toBeGreaterThan(0);
    expect(LABEL_REFRESH_BATCH).toBeLessThanOrEqual(20_000);
  });
});
