import { Test } from '@nestjs/testing';
import { CohortAttributesService, COHORT_DIM_CATALOG } from '../src/modules/plan/cohort-attributes.service';
import { PrismaService } from '../src/prisma/prisma.service';

/**
 * 画像圈人(T9-B)的行为回归。
 *
 * 🔴 这里最贵的一条是 **noTag ≠ 反面**:
 * 「32 人有商保标签」剩下的**不是自费**,是**没证据**。模型极容易顺口说成
 * 「其余 68 人自费」—— 那是凭空造事实(T14),而且主管会拿它去定价。
 */

const SCOPE = {
  hostId: '00000000-0000-0000-0000-000000000001',
  tenantId: 't1',
  sourceUnits: [] as string[],
  clinicIds: ['c1'],
  userId: 'u1',
} as never;

/**
 * ⚠️ **按 SQL 形态发牌,不按调用顺序** —— 四个查询里有两个是条件发出的
 * (温度未知计数只在指定治疗项时查、上次到诊只在点名该维度时查),
 * 按序号取牌会在换一组 keys 时整体错位,而且错得很像"逻辑 bug"。
 */
function makePrisma(opts: {
  patientIds: string[];
  features?: Array<{ patientId: string; key: string; data: unknown }>;
  dedicated?: string[];
}) {
  return {
    $queryRaw: jest.fn(async (q: { strings: string[] }) => {
      const sql = q.strings.join(' ');
      if (sql.includes('count(DISTINCT fp.patient_id)')) return [{ n: BigInt(0) }];
      if (sql.includes('FROM followup_plans')) return opts.patientIds.map((patientId) => ({ patientId }));
      if (sql.includes('FROM persona_features') || sql.includes('JOIN persona_features'))
        return opts.features ?? [];
      if (sql.includes('patient_profiles')) return [];
      throw new Error(`未预期的查询:${sql.slice(0, 80)}`);
    }),
    patient: {
      findMany: jest.fn(async () =>
        opts.patientIds.map((id) => ({
          id,
          preferences: opts.dedicated?.includes(id) ? { dedicatedCs: { id: 'cs-1' } } : null,
        })),
      ),
    },
  } as unknown as PrismaService;
}

async function build(prisma: PrismaService): Promise<CohortAttributesService> {
  const mod = await Test.createTestingModule({
    providers: [CohortAttributesService, { provide: PrismaService, useValue: prisma }],
  })
    .useMocker(() => ({}))
    .compile();
  return mod.get(CohortAttributesService);
}

const ENT = (patientId: string, types: string[]) => ({
  patientId,
  key: 'entitlement_status',
  data: { types },
});

describe('画像圈人 —— noTag 的语义', () => {
  test('⭐⭐ 有商保 2 / 共 10 人 → noTag=7,且⛔ 不产生任何"其余=反面"的字段', async () => {
    const ids = Array.from({ length: 10 }, (_, i) => `p${i}`);
    const prisma = makePrisma({
      patientIds: ids,
      features: [ENT('p0', ['high_insurance']), ENT('p1', ['high_insurance']), ENT('p2', ['medical'])],
    });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });

    const dim = r.dimensions.find((d) => d.id === 'entitlement_status')!;
    expect(r.cohortSize).toBe(10);
    expect(dim.options.find((o) => o.value === 'high_insurance')?.count).toBe(2);
    expect(dim.options.find((o) => o.value === 'medical')?.count).toBe(1);
    expect(dim.noTag).toBe(7); // p0/p1/p2 有标签,其余 7 人**没证据**
    // ⛔ 结构里不许出现任何暗示"反面"的键
    expect(Object.keys(dim)).toEqual(expect.not.arrayContaining(['without', 'others', 'negative']));
  });

  test('⭐ note 里必须写死「不是反面」这句 —— 提示词与工具返回双保险', async () => {
    const prisma = makePrisma({ patientIds: ['p0'] });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' });
    expect(r.note).toContain('没有这条画像证据');
    expect(r.note).toContain('不要说成反面');
  });
});

describe('画像圈人 —— 多标签维度不能拿来算百分比', () => {
  test('⭐ 一个人命中两项 → 各计一次,合计 > 人数,且必须标 multi', async () => {
    const prisma = makePrisma({
      patientIds: ['p0', 'p1'],
      features: [ENT('p0', ['high_insurance', 'stored_value']), ENT('p1', ['high_insurance'])],
    });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });
    const dim = r.dimensions[0]!;
    const sum = dim.options.reduce((a, o) => a + o.count, 0);
    expect(sum).toBe(3); // > cohortSize=2
    expect(dim.multi).toBe(true);
    expect(dim.noTag).toBe(0);
    expect(r.note).toContain('别拿它算百分比');
  });
});

describe('画像圈人 —— 维度点名', () => {
  // ⚠️ 2026-08-14：工具描述里那句举例（「排掉怕疼的」）已删 —— 举例会被模型念给主管听。
  //   但**能力本身要留**：hidden 的语义是"前端面板不展示"，⛔ 不是"不能点名"。
  test('⭐⭐ 隐藏维度可以点名:treatment_sensitivity 不在面板上,但 keys 点得到', async () => {
    // hidden 的语义是"前端面板不展示",不是"不能用"(见 persona-tag-filters 的 hidden 注释)。
    const prisma = makePrisma({
      patientIds: ['p0', 'p1'],
      features: [{ patientId: 'p0', key: 'treatment_sensitivity', data: { types: ['dental_fear'] } }],
    });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['treatment_sensitivity'] });
    expect(r.dimensions.map((d) => d.id)).toEqual(['treatment_sensitivity']);
    expect(r.dimensions[0]!.options.find((o) => o.value === 'dental_fear')?.count).toBe(1);
  });

  test('⭐ 但**默认不给**隐藏维度 —— 一次推 16 个维度,模型会挑个不相干的开始发挥', async () => {
    const prisma = makePrisma({ patientIds: ['p0'] });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' });
    expect(r.dimensions.map((d) => d.id)).not.toContain('treatment_sensitivity');
  });

  test('⭐ 工具描述里的维度清单**含**隐藏维度 —— 模型得能把「怕疼」映射到 id 上', () => {
    expect(COHORT_DIM_CATALOG).toContain('treatment_sensitivity');
    expect(COHORT_DIM_CATALOG).toContain('entitlement_status');
    // 开集维度(医生姓名)不进清单:几百个取值,列出来只会撑爆
    expect(COHORT_DIM_CATALOG).not.toContain('visit_doctor_last');
  });

  test('⭐ 库里的野值不往主管面前放(只出闭集里声明过的取值)', async () => {
    const prisma = makePrisma({
      patientIds: ['p0'],
      features: [ENT('p0', ['high_insurance', '某天冒出来的新值'])],
    });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' }, { keys: ['entitlement_status'] });
    expect(r.dimensions[0]!.options.map((o) => o.value)).toEqual(['high_insurance']);
  });
});

describe('初选矩阵', () => {
  /**
   * ⚠️ `label: null` 的行 = SQL 里 `GROUPING SETS` 多出的**跨标签去重汇总**行。
   *   `unknownTotal` 只认这一行,⛔ 不再把各标签的 unknown 相加(见下面那条测试的由来)。
   */
  const matrixPrisma = (rows: Array<{ label: string | null; temp: string | null; n: number }>) =>
    ({
      $queryRaw: jest.fn(async () => rows.map((r) => ({ ...r, n: BigInt(r.n) }))),
    }) as unknown as PrismaService;

  test('⭐⭐ 「算不出温度」单独一列,⛔ 不许并进任何一个冷档', async () => {
    // 并进去行合计好看,但那是**假分布** —— 主管会以为那些人真的超窗了。
    // ⚠️ SQL 侧 temp 为 NULL 时这里收到的是 null,不是字符串 'unknown'。
    const svc = await build(
      matrixPrisma([
        { label: 'implant', temp: 'hot', n: 10 },
        { label: 'implant', temp: 'cold_1y', n: 5 },
        { label: 'implant', temp: null, n: 3 },
        { label: null, temp: null, n: 3 }, // 汇总行
      ]),
    );
    const m = await svc.matrix(SCOPE, 'c1');
    const implant = m.rows.find((r) => r.key === 'implant')!;
    expect(implant.counts.cold_1y).toBe(5); // ⛔ 不是 8
    expect(implant.unknown).toBe(3);
    expect(m.unknownTotal).toBe(3);
    // ⚠️ 2026-08-11 措辞从「算不出温度」改成「算不出档位」(界面上早就没有"温度"这个词了);
    //   2026-08-13 锚点定为末诊后,原因只有一个:没有末诊记录。
    expect(m.note).toContain('算不出档位');
    expect(m.note).toContain('没有并进');
  });

  /**
   * 🔴 2026-08-16 实测(测试服上海世纪公园):界面写「另有 **50 人**算不出档位」,
   *   而去重只有 42 —— 50 是把 8 个治疗行的 unknown 竖着加出来的,
   *   一个患者有几个治疗项就被数几次。矩阵开头那条口径(「按患者去重」)自己被这行汇总踩了。
   * ⇒ 汇总必须来自 SQL 的跨标签去重行,⛔ 不许在 TS 里相加。
   */
  test('🔴 「另有 N 人算不出档位」取去重行,⛔ 不是各行相加(一个人有几个治疗项就被数几次)', async () => {
    const svc = await build(
      matrixPrisma([
        { label: 'implant', temp: null, n: 3 },
        { label: 'extraction', temp: null, n: 4 },
        { label: null, temp: null, n: 5 }, // 去重后只有 5 人(有 2 人同时占两行)
      ]),
    );
    const m = await svc.matrix(SCOPE, 'c1');
    expect(m.unknownTotal).toBe(5); // ⛔ 不是 3 + 4 = 7
    // 各行照旧是本行的去重人数 —— ⛔ 别为了"加起来等于 5"去改行内的数
    expect(m.rows.find((r) => r.key === 'implant')!.unknown).toBe(3);
    expect(m.rows.find((r) => r.key === 'extraction')!.unknown).toBe(4);
    // ⚠️ 行合计(7)本来就会大于去重数(5),这一点必须写在话里,
    //   否则主管一相加就发现对不上 —— 那是换了一种方式让他不信任这个数。
    expect(m.note).toContain('竖着加会大于这个数');
  });

  test('⭐⭐ 六档都要在 counts 里出现(没人的给 0)—— ⛔ 缺键会让前端渲染成空白而不是 0', async () => {
    const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 1 }]));
    const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
    expect(Object.keys(implant.counts).sort()).toEqual(
      ['cold_1y', 'cold_2y', 'cold_3y', 'cold_over', 'hot', 'warm'].sort(),
    );
    expect(implant.counts.cold_over).toBe(0);
  });

  test('⭐⭐ total 含 unknown —— 它就是「点这一行能拿到多少人」', async () => {
    // 少算了主管会以为系统丢了人;逐行对数(矩阵行合计 vs 圈人结果)靠的就是这条。
    const svc = await build(
      matrixPrisma([
        { label: 'implant', temp: 'hot', n: 10 },
        { label: 'implant', temp: 'warm', n: 2 },
        { label: 'implant', temp: 'cold_1y', n: 5 },
        { label: 'implant', temp: null, n: 3 },
      ]),
    );
    const implant = (await svc.matrix(SCOPE, 'c1')).rows.find((r) => r.key === 'implant')!;
    expect(implant.total).toBe(20);
  });

  test('⭐ 8 行恒定输出(没人的格子给 0),⛔ 不按人数排序', async () => {
    // 空行也要在:主管需要看到"这一格是 0",而不是这一行凭空消失。
    // 按人数排会让矩阵每天换个样子,肌肉记忆全废 —— 行序照业务声明序固定。
    const svc = await build(matrixPrisma([{ label: 'perio', temp: 'hot', n: 1 }]));
    const m = await svc.matrix(SCOPE, 'c1');
    expect(m.rows).toHaveLength(8);
    expect(m.rows.map((r) => r.key)).toEqual([
      'implant', 'ortho', 'early_ortho', 'endo', 'perio', 'filling', 'restoration', 'extraction',
    ]);
    expect(m.rows.find((r) => r.key === 'implant')).toMatchObject({
      counts: { hot: 0, warm: 0, cold_1y: 0, cold_2y: 0, cold_3y: 0, cold_over: 0 },
      total: 0,
    });
  });

  test('⭐ 没有待重算的人时 note 为空 —— 不制造无谓的告警噪音', async () => {
    const svc = await build(matrixPrisma([{ label: 'implant', temp: 'hot', n: 4 }]));
    const m = await svc.matrix(SCOPE, 'c1');
    expect(m.unknownTotal).toBe(0);
    expect(m.note).toBe('');
  });
});

describe('画像圈人 —— 边界', () => {
  test('⭐ 空人群直接短路,不去查特征,并给一句能落地的话', async () => {
    const prisma = makePrisma({ patientIds: [] });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' });
    expect(r.cohortSize).toBe(0);
    expect(r.dimensions).toEqual([]);
    expect(r.note).toContain('先放宽条件');
    expect((prisma.patient.findMany as jest.Mock)).not.toHaveBeenCalled();
  });

  test('⭐ 专属客服只报有/无,⛔ 不返回在岗判断(六·已定取舍:助手不校验在岗)', async () => {
    const prisma = makePrisma({ patientIds: ['p0', 'p1', 'p2'], dedicated: ['p0', 'p1'] });
    const svc = await build(prisma);
    const r = await svc.describe(SCOPE, { clinicId: 'c1' });
    expect(r.dedicatedCs).toEqual({ withDedicated: 2, none: 1 });
    expect(Object.keys(r.dedicatedCs)).not.toContain('stillActive');
    expect(r.note).toContain('不判在岗');
  });
});
