import { Test } from '@nestjs/testing';
import { PersonaService } from '../src/modules/persona/persona.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { FeatureRegistry } from '../src/modules/persona/features/feature.registry';
import { PotentialTreatmentSelector } from '../src/modules/clinical-gap/potential-treatment.selector';
import type {
  FeatureExtractor,
  PersonaFeatureDraft,
} from '../src/modules/persona/features/feature.interface';

/**
 * 画像水位闸回归 —— 守住 2026-07 那次事故。
 *
 * 事故:画像算的是 patient_facts,stale 判定却只看 patient_transactions 的 event_seq。
 * reparse(「amount_cents 应收→实付」)重写事实、**不产新事务**,于是紧随其后的全量重算
 * 被这道闸 noop 掉 325,979 人(生产日志 manual:cli noop=325979 / success=79010),
 * 74% 存量画像的累计消费永久停在旧口径,中位高估 91%;这个数还要跟实时算出的 M 分位阈值
 * 比大小 → mScore 系统性偏高 → RFM 分群整体偏「重要」→ 直接抬高召回优先级。
 *
 * 所以本文件第一条测试就是「事务水位没动、事实水位动了,必须重算」。
 *
 * 另一半是闸放宽后的**写入侧节流**(见 persona-diff.spec.ts 判定本身):
 * 算了不等于要写 —— unchanged 只推水位,refreshed 就地刷新,只有 semantic 才升版本。
 */

const PATIENT = {
  id: 'pat-1',
  hostId: 'h1',
  tenantId: 't1',
  externalId: 'E1',
  name: '张三',
  phone: null,
  gender: 'F',
  birthDate: new Date('1968-01-01'),
  active: true,
  profile: null,
};

const T0 = new Date('2026-07-15T00:00:00Z');
const T1 = new Date('2026-07-21T15:37:00Z'); // reparse 时刻

/** 只产一个特征的桩 extractor,输出由测试注入 */
function stubRegistry(draft: PersonaFeatureDraft | null): FeatureRegistry {
  const ex: FeatureExtractor = { key: 'rfm' as never, extract: () => draft };
  return { extractors: [ex] } as FeatureRegistry;
}

const RFM = (over: Partial<PersonaFeatureDraft> = {}): PersonaFeatureDraft =>
  ({
    key: 'rfm',
    description: '重要价值 · 距上次 140 天 · 累计¥154,350',
    score: null,
    data: { segment: 'important_value', recencyDays: 140, monetaryCents: 15435000 },
    evidence: { factIds: ['f1'] },
    ...over,
  }) as PersonaFeatureDraft;

interface Opts {
  /** 库里 active persona 的水位 + 到期锚点;null = 从没算过 */
  persona: {
    eventWatermark: bigint | null;
    factWatermark: Date | null;
    nextBoundaryAt?: Date | null;
  } | null;
  /** 当前实时水位 */
  now: { eventSeq: bigint | null; factAt: Date | null };
  /** 当前 active 版本已存的 feature 行 */
  storedFeatures?: Array<Record<string, unknown>>;
  draft?: PersonaFeatureDraft | null;
}

function makeHarness(o: Opts) {
  const calls = {
    personaCreate: 0,
    personaUpdate: [] as Record<string, unknown>[],
    featureUpdate: [] as Record<string, unknown>[],
    watermarkQueries: 0,
    txnFindFirst: 0,
    /// 事务内拿的患者级 advisory 锁(防 CLI 与 stale-scan cron 抢版本号,见 persona.service)
    advisoryLocks: [] as string[],
    /// 事务内重读最高版本的次数 —— 版本号必须锁内重读,不能用事务外那次
    versionRereads: 0,
  };
  const tx = {
    $executeRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
      calls.advisoryLocks.push(strings.join('?'));
      return Promise.resolve(1);
    }),
    persona: {
      findFirst: jest.fn().mockImplementation(() => {
        calls.versionRereads++;
        return Promise.resolve(
          o.persona
            ? { id: 'persona-old', version: 3, supersededAt: null, ...o.persona }
            : null,
        );
      }),
      update: jest.fn().mockImplementation(({ data }) => {
        calls.personaUpdate.push(data);
        return Promise.resolve({});
      }),
      create: jest.fn().mockImplementation(() => {
        calls.personaCreate++;
        return Promise.resolve({ id: 'persona-new' });
      }),
    },
    personaFeature: {
      update: jest.fn().mockImplementation(({ data }) => {
        calls.featureUpdate.push(data);
        return Promise.resolve({});
      }),
    },
  };

  const prisma = {
    patient: { findUnique: jest.fn().mockResolvedValue(PATIENT) },
    // 到诊派生字段(上次到诊 / 医生)写在 profile 上 —— 不是画像,只是借这趟遍历顺路刷新
    patientProfile: {
      findUnique: jest.fn().mockResolvedValue(null),
      upsert: jest.fn().mockResolvedValue({}),
    },
    // readWatermarks(patient_facts / patient_transactions)与 M 分位查询共用 $queryRaw,靠 SQL 文本分流
    $queryRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
      const sql = strings.join('');
      if (sql.includes('patient_facts') && sql.includes('MAX(updated_at)')) {
        calls.watermarkQueries++;
        return Promise.resolve([{ event_seq: o.now.eventSeq, fact_at: o.now.factAt }]);
      }
      return Promise.resolve([{ q: [] }]); // getMonetaryQuantiles
    }),
    persona: {
      findFirst: jest.fn().mockResolvedValue(
        o.persona
          ? { id: 'persona-old', version: 3, supersededAt: null, nextBoundaryAt: null, ...o.persona }
          : null,
      ),
    },
    personaFeature: { findMany: jest.fn().mockResolvedValue(o.storedFeatures ?? []) },
    personaRecomputeLog: {
      create: jest.fn().mockResolvedValue({ id: 'log-1' }),
      update: jest.fn().mockResolvedValue({}),
    },
    patientFact: { findMany: jest.fn().mockResolvedValue([]) },
    patientRelation: { findMany: jest.fn().mockResolvedValue([]) },
    patientTransaction: {
      findFirst: jest.fn().mockImplementation(() => {
        calls.txnFindFirst++;
        return Promise.resolve(null);
      }),
    },
    $transaction: jest.fn().mockImplementation(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
  };

  return { prisma: prisma as unknown as PrismaService, calls };
}

async function build(o: Opts) {
  const { prisma, calls } = makeHarness(o);
  const mod = await Test.createTestingModule({
    providers: [
      PersonaService,
      { provide: PrismaService, useValue: prisma },
      { provide: FeatureRegistry, useValue: stubRegistry(o.draft === undefined ? RFM() : o.draft) },
      { provide: PotentialTreatmentSelector, useValue: { selectForPatient: jest.fn().mockResolvedValue([]) } },
    ],
  }).compile();
  return { svc: mod.get(PersonaService), calls, prisma };
}

const storedFrom = (d: PersonaFeatureDraft) => [
  { key: d.key, description: d.description, score: d.score ?? null, data: d.data, evidence: d.evidence },
];

describe('水位闸 —— 该不该算', () => {
  test('⭐⭐ 事故回归:事务水位没动、**事实水位前进**(reparse)→ 必须重算,不能 noop', async () => {
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 }, // event 一样,fact 变新
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).not.toBe('noop');
  });

  test('两个水位都没动 → noop(闸的本职:少算)', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T1 },
      now: { eventSeq: 100n, factAt: T1 },
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).toBe('noop');
    expect(calls.personaCreate).toBe(0);
  });

  test('⭐ 存量版本 factWatermark=NULL(迁移前写的)→ 判 stale,借下次重算自然回填', async () => {
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: null },
      now: { eventSeq: 100n, factAt: T1 },
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).not.toBe('noop');
  });

  test('事务水位前进(正常增量)→ 重算', async () => {
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: T1 },
      now: { eventSeq: 101n, factAt: T1 },
    });
    expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).not.toBe('noop');
  });

  test('从没算过 → 必算', async () => {
    const { svc } = await build({ persona: null, now: { eventSeq: 1n, factAt: T1 } });
    expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).toBe('success');
  });

  test('force=true 直接穿闸', async () => {
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: T1 },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM()),
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test', force: true });
    expect(r.status).toBe('unchanged'); // 算了,但内容确实没变
  });

  test('⭐ 到期改档:两个水位都没动,但 next_boundary_at 已过 → 必须重算', async () => {
    // 事实一个字没变、时间跨过了档界(如急迫等级过 90 天线、年龄跨段)。
    // 没有这一条,夜扫把到期患者捞出来入队也会被水位当场 noop 掉 —— 白捞。
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: T1, nextBoundaryAt: new Date('2026-07-01T00:00:00Z') },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM()),
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test', now: new Date('2026-07-26T00:00:00Z') });
    expect(r.status).not.toBe('noop');
  });

  test('到期锚点还在未来 → 照常 noop', async () => {
    const { svc } = await build({
      persona: { eventWatermark: 100n, factWatermark: T1, nextBoundaryAt: new Date('2027-01-01T00:00:00Z') },
      now: { eventSeq: 100n, factAt: T1 },
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test', now: new Date('2026-07-26T00:00:00Z') });
    expect(r.status).toBe('noop');
  });

  test('该患者既无事务也无事实、且已有 baseline → noop(没有输入可变)', async () => {
    const { svc } = await build({
      persona: { eventWatermark: null, factWatermark: null },
      now: { eventSeq: null, factAt: null },
    });
    expect((await svc.recompute({ patientId: 'pat-1', source: 'test' })).status).toBe('noop');
  });
});

describe('写入侧节流 —— 算了不等于要写', () => {
  test('⭐ 内容一字未变 → unchanged:不建新版本,只推水位', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM()),
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).toBe('unchanged');
    expect(calls.personaCreate).toBe(0);
    expect(calls.featureUpdate).toHaveLength(0);
    // 水位必须推进,否则闸永远开着、下次还白算一遍
    expect(calls.personaUpdate[0]).toMatchObject({ factWatermark: T1 });
    expect(calls.personaUpdate[0]).not.toHaveProperty('refreshedAt');
  });

  test('⭐ 只有天数漂移 → refreshed:就地改 feature 行,**不升版本**,记 refreshedAt', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM()),
      draft: RFM({
        description: '重要价值 · 距上次 151 天 · 累计¥154,350',
        data: { segment: 'important_value', recencyDays: 151, monetaryCents: 15435000 },
      }),
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).toBe('refreshed');
    expect(r.version).toBe(3); // 版本号不动
    expect(calls.personaCreate).toBe(0);
    expect(calls.featureUpdate).toHaveLength(1);
    expect(calls.featureUpdate[0]).toMatchObject({ description: '重要价值 · 距上次 151 天 · 累计¥154,350' });
    expect(calls.personaUpdate[0]).toHaveProperty('refreshedAt');
  });

  test('⭐⭐ 累计消费被 reparse 订正(应收→实付)→ semantic:升版本', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM({ data: { segment: 'important_value', recencyDays: 140, monetaryCents: 19575000 } })),
      draft: RFM(), // 新算出来是实付 15435000
    });
    const r = await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(r.status).toBe('success');
    expect(r.version).toBe(4);
    expect(calls.personaCreate).toBe(1);
  });
});

describe('查询瘦身', () => {
  test('⭐ 两个水位一次查完,且正算路径不再重复查 transaction', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 },
      storedFeatures: storedFrom(RFM()),
    });
    await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(calls.watermarkQueries).toBe(1);
    expect(calls.txnFindFirst).toBe(0); // 旧实现这里会再查一次 latest event_seq
  });
});

/**
 * 并发写版本号 —— 2026-07-27 测试服实测事故的回归。
 *
 * CLI 全量回填(concurrency=8)跨过了 02:00 的 stale-scan cron,两个**独立进程**同时给
 * 同一批患者建新版本:各自在事务外读到同一个 latest.version、算出同一个 nextVersion →
 *   `Unique constraint failed on the fields: (patient_id, version)`
 * 那一轮 CLI 挂 688 个、定时任务自己挂 1,604 个(它伤得更重)。
 *
 * BullMQ processor 内部按 patient_id 串行,挡得住自己人,挡不住外面另起的 CLI 进程。
 * 修法:两条写路径进事务先拿**按患者的 advisory 事务锁**(pg_advisory_xact_lock,
 * 随事务自动释放、跨进程有效),建版本那支再**在锁内重读**最高版本 —— 迟到者看到
 * 已经变大的版本号,顺序叠上去,不再撞唯一约束。
 */
describe('并发安全 —— CLI 与 cron 抢版本号', () => {
  test('⭐ 建新版本前必须拿患者级 advisory 锁', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      storedFeatures: [{ key: 'gender', description: '男性', score: null, data: null, evidence: {} }],
      now: { eventSeq: 200n, factAt: T1 }, // 水位前进 → 走建版本那支
    });
    await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(calls.personaCreate).toBe(1);
    expect(calls.advisoryLocks).toHaveLength(1);
    expect(calls.advisoryLocks[0]).toContain('pg_advisory_xact_lock');
  });

  test('⭐ 版本号在**锁内重读**,不能用事务外那次查询的结果', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      storedFeatures: [{ key: 'gender', description: '男性', score: null, data: null, evidence: {} }],
      now: { eventSeq: 200n, factAt: T1 },
    });
    await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(calls.versionRereads).toBe(1); // 锁拿到之后又查了一次最高版本
  });

  test('⭐ 就地刷新那支也要拿同一把锁(否则会写到刚被 supersede 的版本上)', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 100n, factWatermark: T0 },
      now: { eventSeq: 100n, factAt: T1 },
      // 只有天数漂移 → volatile → 就地刷新,不升版本(口径同上面 refreshed 那条)
      storedFeatures: storedFrom(RFM()),
      draft: RFM({
        description: '重要价值 · 距上次 151 天 · 累计¥154,350',
        data: { segment: 'important_value', recencyDays: 151, monetaryCents: 15435000 },
      }),
    });
    await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(calls.personaCreate).toBe(0); // 确认走的是就地刷新
    expect(calls.advisoryLocks).toHaveLength(1);
    expect(calls.advisoryLocks[0]).toContain('pg_advisory_xact_lock');
  });

  test('noop(闸拦下)不该白拿锁 —— 压根没进事务', async () => {
    const { svc, calls } = await build({
      persona: { eventWatermark: 200n, factWatermark: T1 },
      now: { eventSeq: 200n, factAt: T1 }, // 水位没动 → noop
    });
    await svc.recompute({ patientId: 'pat-1', source: 'test' });
    expect(calls.advisoryLocks).toHaveLength(0);
  });
});
