import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Test } from '@nestjs/testing';
import { PlanEventReason, PlanEventType, ReleaseReason } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';

/**
 * 批次跟踪的两条不变量 —— **都是 seed 数据当场抓出来的真 bug**,不是想出来的。
 *
 * 这两条错了都**不报任何错**,只是报表上的数字悄悄不对:
 *   ① agentStats 各人之和 ≠ planned  → 已退回/已到期的单从所有人名下蒸发
 *   ② 退回原因分布混进系统原因      → 退回率几乎翻倍
 * 主管看到的是一个看起来合理、实际错了的数,而"某人退了几条"恰恰是他最想看的列。
 */

const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1' } as never;
const BATCH = 'b1a55555-1111-4222-8333-444455556666'; // ⚠️ 真 uuid:detail 现在先解析批次引用(短号也认),假 id 两条路都不匹配

function makePrisma(opts: {
  plans: Array<{
    id: string; status: string; assigneeUserId: string | null; releaseReason: string | null;
    patientId?: string; version?: number; snoozedUntil?: Date | null;
  }>;
  events: Array<{ planId: string; event: string; assigneeUserId: string | null; createdAt: Date }>;
}) {
  const eventFindMany = jest.fn(async (args: { where: Record<string, unknown>; orderBy?: unknown }) => {
    // ⚠️ 本 spec 模拟的是**老批次**(账本没记批次号)—— 所以按 assignmentId 取一律为空,
    //   detail 会回落到按 planId 取事件 + 读 followup_plans.release_reason,
    //   而那条回落路径正是本 spec 要锁的。新口径见 assignment-ledger-attribution.spec。
    if (args.where.assignmentId) return [];
    const w = args.where as { event?: string };
    let rows = opts.events.filter((e) => (w.event ? e.event === w.event : true));
    rows = [...rows].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // desc
    return rows;
  });
  const prisma = {
    planAssignment: {
      findFirst: jest.fn(async () => ({
        id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
        requestId: 'r1', criteria: {}, attributes: null,
        // ⭐ 批次头**晚于**事件 —— 正是当初那个时间窗过滤会踩的情形
        expiresAt: new Date('2026-08-05'), status: 'confirmed',
        revokedAt: null, revokedBy: null,
        createdAt: new Date('2026-08-10'), updatedAt: new Date('2026-08-10'),
      })),
    },
    followupPlan: {
      findMany: jest.fn(async () =>
        opts.plans.map((p, i) => ({
          assignmentExpiresAt: null,
          patientId: `pat-${p.id}`,
          version: 1,
          snoozedUntil: null,
          ...p,
        })),
      ),
    },
    planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
    // 「已处置」按人拆走**执行口径**(有通话结果才算)——空 = 这批一条结果都没回写,
    // ⚠️ 那正是生产的常态(回写率约 11%),⛔ 别为了让数字好看在 stub 里编几条
    planExecution: { groupBy: jest.fn(async () => []) },
    // 批次名要按**宿主时区**格式化(主管说的"今天下午那批"是他墙上的时间)
    host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
    // ⚠️ 账本口径的计数走 $queryRaw。这里返回空 = 模拟「**老批次**」(账本里没有批次号),
    //   于是回落到 followup_plans 现算 —— 本 spec 锁的正是那条回落路径的口径。
    //   新口径(账本有数)另有 assignment-ledger-attribution.spec 覆盖。
    $queryRaw: jest.fn(async () => []),
  } as unknown as PrismaService;
  return { prisma, eventFindMany };
}

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

describe('批次详情 —— agentStats 必须与 planned 对得上', () => {
  test('⭐⭐ 已退回的单(assignee 已清空)要按账本回捞原始承接人,不能蒸发', async () => {
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
        { id: 'p2', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
        // 已退回:assignee 为 null,只能靠 assign 事件找回是谁的
        { id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
      ],
      events: [
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
        { planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
        { planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    const sum = d.agentStats.reduce((acc, x) => acc + x.planned, 0);
    expect(sum).toBe(d.planned); // 3 = 3
    expect(d.agentStats.find((a) => a.userId === 'a')!.released).toBe(1);
  });

  test('⭐⭐ 事件时间**早于**批次头也要能回捞 —— 不许用时间窗过滤', async () => {
    // 这就是 seed 抓到的那一幕:事件写在 5 天前、批次头是刚建的,
    // 原实现 `createdAt >= head.createdAt` 把全部 assign 事件排掉,
    // 199 条里 50 条已退回/已到期的当场从 agentStats 消失,而**不报错**。
    const { prisma } = makePrisma({
      plans: [{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' }],
      events: [
        // 批次头是 2026-08-10,事件在 08-01
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.agentStats.reduce((acc, x) => acc + x.planned, 0)).toBe(1);
    expect(d.agentStats[0]!.userId).toBe('a');
  });

  test('同一 plan 进过多个批次 → 取**最近一次** assign(当前 assignment_id 就是本批)', async () => {
    const { prisma } = makePrisma({
      plans: [{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' }],
      events: [
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'old', createdAt: new Date('2026-07-01') },
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'now', createdAt: new Date('2026-08-01') },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.agentStats[0]!.userId).toBe('now');
  });
});

describe('退回原因分布 —— 不得混进系统原因', () => {
  test('⭐⭐ 到期(auto_release)**不进**退回原因分布', async () => {
    // seed 实测:199 条里 22 条到期。若把它们算进退回,
    // 退回数从 28 变 50、退回率几乎翻倍,而报表看起来完全正常。
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
        // 到期回收:release_reason **为空**(那一列只属于客服的处置)
        { id: 'p2', status: 'active', assigneeUserId: null, releaseReason: null },
        { id: 'p3', status: 'active', assigneeUserId: null, releaseReason: null },
      ],
      events: [
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
        { planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
        { planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.released).toBe(1); // 只有真退回那一条
    expect(d.releaseReasons).toHaveLength(1);
    expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.ALREADY_IN_PROGRESS);
    // 到期的原因码绝不能出现在退回原因分布里
    expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED);
  });
});

/**
 * 🔴 第三条不变量:**处理得最好的那批人不许从批次里消失**。
 *
 * 引擎发现某患者已无活信号(治疗真做了 / 诊断没了)时走 `closeStaleActivePlan`:
 * `status='superseded' + supersededAt=now`,且**不建后继版本**。
 * detail/list 若照着直觉写 `where: { supersededAt: null }`,这些行就整个消失 ——
 * 「分了 100 人,30 个真的来把治疗做了」显示成 `planned=70`,
 * 批次凭空缩水 30 人、不报错不告警,而缩掉的恰恰是唯一算得上成功的那 30 个。
 *
 * (D-12 的无条件继承只保住了**升版本**那条路 —— 后继行继承 assignment_id;
 *  关闭这条路压根没有后继行,继承救不了。R12 当时只想到了前者。)
 */
describe('批次详情 —— 出池的人必须留在批次里', () => {
  test('⭐⭐ superseded 的行照样计入 planned,并记进「已处理」', async () => {
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'superseded', assigneeUserId: 'a1', releaseReason: null },
        { id: 'p2', status: 'superseded', assigneeUserId: null, releaseReason: null },
        { id: 'p3', status: 'assigned', assigneeUserId: 'a1', releaseReason: null },
      ],
      events: [
        { planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') },
        { planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a2', createdAt: new Date('2026-08-11') },
        { planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);

    expect(d.planned).toBe(3); // ⛔ 不是 1
    expect(d.progress.resolved).toBe(2);
    expect(d.progress.done).toBe(2);
    expect(d.progress.inHandPending).toBe(1);
    // 五桶穷尽:加起来必须还是 planned
    expect(d.progress.done + d.progress.inHandPending + d.progress.backToPool).toBe(d.planned);
  });

  test('⭐ 取数**不许**带 supersededAt 过滤(带了就是把成功的人删掉)', async () => {
    const { prisma } = makePrisma({ plans: [], events: [] });
    const svc = await build(prisma);
    await svc.detail(SCOPE, BATCH);
    const where = (prisma.followupPlan.findMany as jest.Mock).mock.calls[0][0].where;
    expect(where).not.toHaveProperty('supersededAt');
    expect(where).toMatchObject({ assignmentId: BATCH });
  });

  test('⭐ 同患者多版本(升版本时新旧行都带 assignment_id)→ 按患者取最新版,不重复计数', async () => {
    const { prisma } = makePrisma({
      plans: [
        { id: 'v2', patientId: 'pat-x', version: 2, status: 'assigned', assigneeUserId: 'a1', releaseReason: null },
        { id: 'v1', patientId: 'pat-x', version: 1, status: 'superseded', assigneeUserId: 'a1', releaseReason: null },
      ],
      events: [{ planId: 'v2', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') }],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.planned).toBe(1); // 一个患者算一个人
    expect(d.progress.inHandPending).toBe(1); // 取的是最新版(v2 assigned),不是旧的 superseded
    expect(d.progress.resolved).toBe(0);
  });

  test('⭐ note 必须写明「处理率不是成功率」+ 批次年龄警告', async () => {
    const { prisma } = makePrisma({
      plans: [{ id: 'p1', status: 'superseded', assigneeUserId: 'a1', releaseReason: null }],
      events: [{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') }],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.progress.note).toContain('处理率不是成功率');
    // 跑了三个月的批次天然比跑了三天的好看,不提醒就会被拿去横向比
    expect(d.progress.note).toContain('先看年龄');
  });
});

/**
 * 「团队现在什么状态」里那句**分到多少 / 一条都没动**的口径(2026-08-10 产品发现)。
 *
 * ── 实测 ────────────────────────────────────────────────────────
 * 姜茜「这 7 天分到 53 条,一条都还没动」。拆开是 20 + 20 + 13,
 * 其中头一个 20 来自一批**已撤销**的分配 —— 主管自己在撤销窗口里收回去的,
 * 她根本没机会碰。摆在「分到…一条都没动」里,等于**拿主管自己的操作去指责客服**。
 *
 * ⚠️ 这条只能锁 SQL 文本:口径写在原生查询里,而 `$queryRaw` 一 mock 就是"喂什么返什么",
 *   行为测试证明不了任何事(与 mcp-clinic-scope 那几条同一类)。
 */
describe('团队负载 —— 「分到」要排掉被主管撤回去的', () => {
  const SVC = readFileSync(
    join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
    'utf8',
  );
  // 只看 workload() 里那段 assign 统计,⛔ 别误伤 detail 那边同名的查询
  const ASSIGNED_SQL =
    /窗口内「分到了多少」[\s\S]*?GROUP BY e\.assignee_user_id`\)/.exec(SVC)?.[0] ?? '';

  test('⭐⭐ 撤销回收的那些必须排掉', () => {
    expect(ASSIGNED_SQL).toMatch(/NOT EXISTS/);
    expect(ASSIGNED_SQL).toMatch(/PlanEventReason\.REVOKED/);
  });

  test('🔴 判据是**逐条 + 同批次**,⛔ 不是"整批 status=revoked 就全扣"', () => {
    // 撤销刻意不收已被客服打开过的单 —— 那些仍在他手上、仍该算他的
    expect(ASSIGNED_SQL).toMatch(/r\.plan_id = e\.plan_id/);
    // 同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣
    expect(ASSIGNED_SQL).toMatch(/r\.assignment_id IS NOT DISTINCT FROM e\.assignment_id/);
    expect(ASSIGNED_SQL).not.toMatch(/plan_assignments/); // ⛔ 别去 join 批次状态
  });

  test('⛔ 到期回池照样算 —— 那正是「没动」要表达的东西', () => {
    expect(ASSIGNED_SQL).not.toMatch(/ASSIGNMENT_EXPIRED/);
  });
});
