import { refreshLabelsSql, countMissingLabelsSql, LABEL_REFRESH_BATCH } from '../src/modules/plan/plan-label.sql';
import {
  planLabelAnchorsSql,
  labelExistsSql,
  labelTemperatureExistsSql,
} 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('|')})$`)) }),
        );
      }
    }
  });
});

/**
 * 🔴 **看侧与圈人侧必须同源** —— `reason-temperature.sql.ts` 里那段注释写着：
 *   「曾经它们各自带一个 mode 参数，一旦调用方漏传就是主管看到 87 人、
 *     确认单捞出另一批，而且全程不报错(T14)」。
 *
 * ⚠️ 2026-08-17 差点重演：看侧(矩阵)改用了预存的 `potential_labels`，
 *   而圈人侧仍在实时回查 `patient_facts` 算标签 ⇒ 过生日跨年龄档的人会两边不一致。
 *   （标签规则里有三条带年龄：K08>18 / K07 3~12 / K07 13~40。）
 * ⇒ 这组测试把"两侧读同一个来源"钉死。
 */
describe('看侧与圈人侧同源', () => {
  const view = planLabelAnchorsSql(Prisma.sql`fp.status = 'active'`).sql;
  const pick = labelExistsSql('implant').sql;
  const pickTemp = labelTemperatureExistsSql('implant', ['cold_3y']).sql;

  it('🔴 三处都读 potential_labels', () => {
    expect(view).toMatch(/potential_labels/);
    expect(pick).toMatch(/potential_labels/);
    expect(pickTemp).toMatch(/potential_labels/);
  });

  it('🔴 ⛔ 三处都不再回查 patient_facts —— 那是两边会漂的根源', () => {
    for (const sql of [view, pick, pickTemp]) {
      expect(sql).not.toMatch(/patient_facts/);
      expect(sql).not.toMatch(/jsonb_array_elements_text/);
    }
  });

  it('⛔ 圈人侧也不再按年龄现算标签（年龄已烘进那一列）', () => {
    for (const sql of [pick, pickTemp]) {
      expect(sql).not.toMatch(/date_part\('year'/);
    }
  });

  it('温度那支仍保留末诊锚点与 HAVING 分档语义', () => {
    expect(pickTemp).toMatch(/patient_profiles/);
    expect(pickTemp).toMatch(/HAVING/);
  });
});

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\[\]/);
  });

  /**
   * 🔴 这两条原本长在 `cohort-filter.spec.ts` 的查询断言里，2026-08-17 随标签预计算搬到这儿。
   *   ⛔ 别因为"查询里看不到了"就当它们没了 —— 删掉之后，谁把「治完的诊断」或
   *   「没有日期的证据」算进标签都不会有人发现，而那会直接改变召回谁。
   */
  it('⭐⭐ 只认**未治疗**的证据 —— 治完是 fulfilled，拿它当锚就是念一个做完的诊断', () => {
    const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
    expect(s).toMatch(/f\.status = 'active'/);
  });

  it('⭐⭐ 证据必须有日期 —— 没有 occurred_at / planned_for 的不算进标签', () => {
    const s = text(refreshLabelsSql({ afterId: null, onlyMissing: true }));
    expect(s).toMatch(/COALESCE\(f\.occurred_at, f\.planned_for\) IS NOT NULL/);
  });

  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);
  });
});
