import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { ClickHouseSourceService } from '../src/modules/sync/cold-import/clickhouse-source.service';
import {
  ColdImportManifestSchema,
  DEFAULT_REVERSE_PULL_FROM,
} from '../src/modules/sync/cold-import/manifest.schema';

/**
 * jvs-dw 宿主跟进闸(DW「潜在治疗池」= FRIDAY 侧「复杂病例」)的 SQL 改写回归。
 *
 * 【为什么这组测试必须存在】
 * 接这个字段时,主档 query 头一次出现**SELECT 列里的标量子查询**:
 *   SELECT *, (patient_id,brand) IN (SELECT … FROM … WHERE is_del=1 …) AS has_active_complex_case
 *   FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
 * 而增量链路的两个 SQL 改写器原本都假设"第一个 FROM / WHERE 就是本表的":
 *   - injectIncrementalCursor:/SELECT ([\s\S]+?) FROM ([\w.]+)/ 非贪婪 → 撞上子查询的 FROM,
 *     切出半截列表 + 错误表名,增量直接拉错表
 *   - extractBusinessFilters:/\bWHERE\b/ 抓第一个 → 把子查询的 is_del=1 当成主档的业务过滤
 *     搬到外层 → 主档没有 is_del 列 → CH 报错(且探针 SQL 一并错)
 * 两者都改成按**括号深度**定位顶层关键字。这些是静默错法(不崩就是拉错数据),必须锁死。
 *
 * 另锁两条与"闸"本身的正确性直接相关的纪律:
 *   - 反向拉主档必须复用主档 query 的 SELECT 列(写死 `SELECT *` 会丢派生列 → 正在跟进的
 *     患者被 full upsert 洗回 null → 静默回到召回池)
 *   - manifest 真文件能过 schema,且 reverse_pull_from 确实带上了复杂病例表
 */

const svc = new ClickHouseSourceService();
type Priv = {
  extractBusinessFilters(s: string): string[];
  splitSelectFrom(s: string): { selectCols: string; fromTable: string } | null;
  topLevelIndexOf(s: string, kw: string): number;
  injectIncrementalCursor(s: string, col: string, val: string | null): string;
  tableKeyOf(s: string): string;
};
const priv = svc as unknown as Priv;

/// 与 data/jvs-dw/manifest.yaml 的主档 query 同形(带派生列的标量子查询)
const MASTER_SQL = `
  SELECT *,
         (patient_id, brand) IN (
           SELECT customer_id, brand FROM dw_group.fact_complex_cases_out
           WHERE is_del = 1 AND case_stage IN (1, 2, 3, 5)
         ) AS has_active_complex_case
  FROM dw_group.fact_client_out
  WHERE last_visit_time IS NOT NULL`;

describe('顶层关键字定位 — 子查询里的 FROM/WHERE 不参与', () => {
  test('splitSelectFrom 取顶层 FROM,不是子查询的', () => {
    const parsed = priv.splitSelectFrom(MASTER_SQL);
    expect(parsed).not.toBeNull();
    // ⭐ 关键:表名是主档,不是子查询里的 fact_complex_cases_out
    expect(parsed!.fromTable).toBe('dw_group.fact_client_out');
    // 派生列完整保留(含整个子查询和别名),否则增量拉回来就少这一列
    expect(parsed!.selectCols).toContain('has_active_complex_case');
    expect(parsed!.selectCols).toContain('fact_complex_cases_out');
    // 括号必须配平 —— 半截子查询是老正则最典型的产物
    const open = (parsed!.selectCols.match(/\(/g) ?? []).length;
    const close = (parsed!.selectCols.match(/\)/g) ?? []).length;
    expect(open).toBe(close);
  });

  test('topLevelIndexOf 跳过括号内的同名关键字', () => {
    const at = priv.topLevelIndexOf(MASTER_SQL, 'WHERE');
    // 顶层 WHERE 后面跟的是 last_visit_time,不是子查询的 is_del
    expect(MASTER_SQL.slice(at)).toMatch(/^WHERE\s+last_visit_time/);
  });

  test('无顶层 FROM / 非 SELECT → null(调用方自行报错,不硬崩)', () => {
    expect(priv.splitSelectFrom('SHOW TABLES')).toBeNull();
    expect(priv.splitSelectFrom('SELECT 1')).toBeNull();
  });
});

describe('extractBusinessFilters — 不把子查询的过滤搬到外层', () => {
  test('⭐ 主档 SQL:只留 last_visit_time,绝不带出 is_del / case_stage', () => {
    const f = priv.extractBusinessFilters(MASTER_SQL);
    // is_del / case_stage 是**子查询**里的条件,搬到主档外层会因列不存在直接报错
    expect(f.some((c) => /is_del/i.test(c))).toBe(false);
    expect(f.some((c) => /case_stage/i.test(c))).toBe(false);
    // last_visit_time IS NOT NULL 属于被显式剔除的一类(增量不需要)→ 结果应为空
    expect(f).toHaveLength(0);
  });

  test('回归:结算表的业务过滤照旧保留(老行为不变)', () => {
    const sql = `
      SELECT id, patient_id FROM dw_group.fact_settlement_out
      WHERE (is_refund IS NULL OR is_refund = 0)
        AND settlement_status = 1
        AND (patient_id, brand) IN (
          SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
        )`;
    const f = priv.extractBusinessFilters(sql);
    expect(f).toContain('settlement_status = 1');
    expect(f.some((c) => /\(patient_id\s*,\s*brand\)\s+IN/i.test(c))).toBe(false);
  });
});

describe('injectIncrementalCursor — 带派生列的主档 SQL', () => {
  test('⭐ 表名正确 + 派生列保留 + 子查询条件不外泄', () => {
    const out = priv.injectIncrementalCursor(MASTER_SQL, 'last_visit_time', '2026-07-01 00:00:00');
    expect(out).toContain('FROM dw_group.fact_client_out');
    expect(out).not.toMatch(/FROM\s+dw_group\.fact_complex_cases_out\s+WHERE\s+last_visit_time/);
    expect(out).toContain('has_active_complex_case');
    expect(out).toContain("last_visit_time > '2026-07-01 00:00:00'");
    // 子查询的条件不能出现在**顶层** WHERE(它仍应留在 SELECT 列的括号里)
    const topWhere = out.slice(priv.topLevelIndexOf(out, 'WHERE'));
    expect(topWhere).not.toMatch(/\bis_del\b/);
  });

  test('复杂病例表:cursor 用 SELECT 别名 changed_at(源列 44% NULL 不能直接当 cursor)', () => {
    const sql = `
      SELECT customer_id AS patient_id, brand,
             coalesce(updated_gmt_at, toDateTime(created_gmt_at)) AS changed_at
      FROM dw_group.fact_complex_cases_out
      WHERE (customer_id, brand) IN (
        SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
      )`;
    const out = priv.injectIncrementalCursor(sql, 'changed_at', '2026-07-01 00:00:00');
    expect(out).toContain('FROM dw_group.fact_complex_cases_out');
    // 别名定义必须还在(WHERE 引用 SELECT 别名是 CH 的能力,别名被切掉就失效)
    expect(out).toContain('AS changed_at');
    expect(out).toContain("changed_at > '2026-07-01 00:00:00'");
    expect(out).toContain('ORDER BY changed_at');
  });
});

describe('增量列患者的 UNION 分支 — 别名列 + 顶层表名', () => {
  /// 复刻 listPatientPairs 里 incBranches 的构造(见 clickhouse-source.service.ts)
  const branchOf = (q: string, cursorCol: string, cursorVal: string) =>
    `SELECT patient_id, brand FROM (${priv.injectIncrementalCursor(q, cursorCol, cursorVal)})`;

  test('⭐ 主档分支查的是主档表,不是 SELECT 列里子查询的那张', () => {
    const b = branchOf(MASTER_SQL, 'last_visit_time', '2026-07-30 08:15:00');
    // 2026-08-01 测试服 dry-run 真实翻车过:分支被拼成
    //   SELECT patient_id, brand FROM dw_group.fact_complex_cases_out WHERE last_visit_time > …
    // → 表名取自子查询、cursor 列取自主档,CH 报 Missing columns: 'last_visit_time' 'patient_id'
    expect(b).not.toMatch(/FROM\s+dw_group\.fact_complex_cases_out\s+WHERE\s+last_visit_time/);
    expect(b).toContain('FROM dw_group.fact_client_out');
  });

  test('⭐ 复杂病例分支能读到别名列 —— 物理表只有 customer_id,没有 patient_id', () => {
    const q = `
      SELECT customer_id AS patient_id, brand,
             coalesce(updated_gmt_at, toDateTime(created_gmt_at)) AS changed_at
      FROM dw_group.fact_complex_cases_out
      WHERE (customer_id, brand) IN (
        SELECT patient_id, brand FROM dw_group.fact_client_out WHERE last_visit_time IS NOT NULL
      )`;
    const b = branchOf(q, 'changed_at', '2026-07-30 08:15:00');
    // 外层 SELECT patient_id 必须落在**子查询之上**(别名在那里才存在)
    expect(b).toMatch(/^SELECT patient_id, brand FROM \(/);
    expect(b).toContain('customer_id AS patient_id');
  });
});

describe('定向名单分片 — ClickHouse max_query_size 256KiB', () => {
  /// CH 服务端默认上限;超过即 `Syntax error: failed at position 262142`
  const MAX_QUERY_SIZE = 256 * 1024;
  const ID_CHUNK = 10_000;
  const mkIds = (n: number) => Array.from({ length: n }, (_, i) => String(1_000_000 + i));

  test('⭐ 7 万 id 单条 SQL 会超 256KiB —— 这正是 2026-08-01 定向补数 fatal 的原因', () => {
    const oneShot = `${mkIds(70_283).map((id) => `'${id}'`).join(', ')}`;
    expect(oneShot.length).toBeGreaterThan(MAX_QUERY_SIZE);
  });

  test('⭐ 按 10k 分片后每片都远低于上限', () => {
    const ids = mkIds(70_283);
    const chunks = Array.from({ length: Math.ceil(ids.length / ID_CHUNK) }, (_, i) =>
      ids.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK),
    );
    expect(chunks).toHaveLength(8);
    for (const c of chunks) {
      const clause = c.map((id) => `'${id}'`).join(', ');
      expect(clause.length).toBeLessThan(MAX_QUERY_SIZE / 2); // 留一半余量给 SQL 其余部分
    }
    // 分片是**无损**切分:并集 = 原名单,不重不漏
    expect(chunks.flat()).toEqual(ids);
  });

  test('名单不超过阈值时不分片(保持单条 SQL,行为不变)', () => {
    const ids = mkIds(500);
    expect(ids.length > ID_CHUNK).toBe(false);
  });
});

describe('manifest 契约', () => {
  const raw = readFileSync(join(__dirname, '../data/jvs-dw/manifest.yaml'), 'utf-8');
  const manifest = ColdImportManifestSchema.parse(yaml.load(raw));

  test('jvs-dw manifest 过 schema', () => {
    expect(manifest.host_name).toBe('jvs-dw');
  });

  test('⭐ reverse_pull_from 带上复杂病例表 —— 否则病例开/关时人不来诊就永远感知不到', () => {
    const list = manifest.sql_source?.cohort?.reverse_pull_from ?? [];
    expect(list).toContain('fact_complex_cases_out');
    // 历史四张不能丢(丢了 = EMR 编辑等既有场景静默退化)
    for (const t of DEFAULT_REVERSE_PULL_FROM) expect(list).toContain(t);
  });

  test('复杂病例表既在 queries 里、也配了 cursor(否则增量拉不到变化)', () => {
    expect(manifest.sql_source?.queries?.['fact_complex_cases_out']).toBeDefined();
    expect(
      manifest.sql_source?.incremental?.per_query?.['fact_complex_cases_out']?.cursor_column,
    ).toBe('changed_at');
  });

  test('主档 query 产出 has_active_complex_case,且用复合键 (patient_id, brand)', () => {
    const sql = manifest.sql_source?.queries?.['fact_client_out'] ?? '';
    expect(sql).toContain('has_active_complex_case');
    // ⚠️ 单键 join 会把瑞泰同号患者误标成"在跟"(集团内同号跨品牌是两个人)
    expect(sql).toMatch(/\(patient_id,\s*brand\)\s+IN/);
    // 口径:未删除 + 在跟四阶段
    expect(sql).toMatch(/is_del\s*=\s*1/);
    expect(sql).toMatch(/case_stage\s+IN\s*\(1,\s*2,\s*3,\s*5\)/);
  });

  test('patient assembler 映射到 canonical hostFollowUpActive', () => {
    const y = readFileSync(join(__dirname, '../data/jvs-dw/assemblers/patient.yaml'), 'utf-8');
    const cfg = yaml.load(y) as { field_mapping: Record<string, string> };
    expect(cfg.field_mapping.hostFollowUpActive).toBe('has_active_complex_case');
  });

  test('tableKeyOf 去库前缀(cohort 写全名,queries 用短名)', () => {
    expect(priv.tableKeyOf('dw_group.fact_client_out')).toBe('fact_client_out');
    expect(priv.tableKeyOf('fact_client_out')).toBe('fact_client_out');
  });
});
