import { Test } from '@nestjs/testing';
import { Permission, PlanEventType, REVOKE_WINDOW_MINUTES } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';

/**
 * 撤销整批回归。
 *
 * ⚠️ **撤销 ≠ 退回**(T21):主管收整批 vs 客服退单条。
 * 三条红线错一条都会造成静默损失:
 *   ① 已被打开过的单被收走 → 客服刚打完的电话永久蒸发,他第二天才发现单子没了
 *   ② 写了 release_reason  → 退回率的分子分母一起虚高
 *   ③ 清了批次归因         → 这次误操作影响了多少人,事后再也说不清
 */

/**
 * ⚠️ 必须是**真 uuid**,不能再用 'b1' 这种假 id ——
 * 服务端现在会先解析批次引用(完整 uuid 走快路,短号走前缀查询),
 * 假 id 两条路都不匹配。这不是测试凑合,是被 2026-08-03 的线上失败逼出来的:
 * 假 id 的测试**永远绿**,而真实调用里模型手上只有 8 位短号,必然失败。
 */
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
const SHORT = BATCH.slice(0, 8);

const SCOPE = {
  hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'leader-1',
} as never;
const LEADER = { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH] };

function makePrisma(opts: {
  createdBy?: string;
  ageMinutes?: number;
  status?: string;
  plans?: Array<{ id: string; status: string; assigneeUserId: string | null }>;
  viewedPlanIds?: string[];
  /** 短号前缀查询命中的批次(默认恰好一个 = 正常情况) */
  prefixHits?: Array<{ id: string }>;
}) {
  const captured: { data?: Record<string, unknown>; ids?: string[] } = {};
  const events: Array<Record<string, unknown>> = [];
  const headUpdates: Array<Record<string, unknown>> = [];
  const plans = opts.plans ?? [
    { id: 'p1', status: 'assigned', assigneeUserId: 'a' },
    { id: 'p2', status: 'assigned', assigneeUserId: 'a' },
  ];
  const tx = {
    followupPlan: {
      updateMany: jest.fn(async (a: { where: { id: { in: string[] } }; data: Record<string, unknown> }) => {
        captured.data = a.data;
        captured.ids = a.where.id.in;
        return { count: a.where.id.in.length };
      }),
    },
    planEventLog: {
      createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
        events.push(...data);
        return { count: data.length };
      }),
    },
    planAssignment: {
      update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
        headUpdates.push(data);
        return {};
      }),
    },
  };
  const prisma = {
    planAssignment: {
      findFirst: jest.fn(async () => ({
        id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1',
        createdBy: opts.createdBy ?? 'leader-1',
        status: opts.status ?? 'confirmed',
        createdAt: new Date(Date.now() - (opts.ageMinutes ?? 5) * 60_000),
      })),
    },
    followupPlan: {
      findMany: jest.fn(async () =>
        plans.map((p) => ({
          ...p, hostId: 'h1', tenantId: 't1', patientId: `pat-${p.id}`,
          assignedAt: new Date(Date.now() - 3600_000),
        })),
      ),
    },
    planEventLog: {
      groupBy: jest.fn(async () => (opts.viewedPlanIds ?? []).map((planId) => ({ planId }))),
    },
    // 短号前缀解析(id::text LIKE 'xxxxxxxx%')
    $queryRaw: jest.fn(async () =>
      (opts.prefixHits ?? [{ id: BATCH }]).map((h) => ({ ...h, created_at: new Date() })),
    ),
    $transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
  } as unknown as PrismaService;
  return { prisma, captured, events, headUpdates, tx };
}

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('撤销整批 —— 「已动过」的判据', () => {
  test('⭐⭐ 红线①:客服**打开过**的单不收,并如实回报', async () => {
    // 判据只能用 view 事件:plan_executions 回写率仅 11%,拿它判会把 89%
    // 已经打过电话的单当成"没动过"收走,那通电话永久蒸发。
    const { prisma, captured } = makePrisma({ viewedPlanIds: ['p1'] });
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, BATCH);
    expect(r.revoked).toBe(1);
    expect(r.skippedTouched).toBe(1);
    expect(captured.ids).toEqual(['p2']); // p1 被跳过
    expect(r.note).toContain('已经打开过');
  });

  test('全都没打开过 → 全收', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, BATCH);
    expect(r.revoked).toBe(2);
    expect(r.skippedTouched).toBe(0);
  });

  test('已退回 / 已到期的不计入(本就不在人手上)', async () => {
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'assigned', assigneeUserId: 'a' },
        { id: 'p2', status: 'active', assigneeUserId: null },
      ],
    });
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, BATCH);
    expect(r.revoked).toBe(1);
    expect(r.alreadyReleased).toBe(1);
  });
});

describe('撤销整批 —— 写入纪律', () => {
  test('⭐⭐ 红线②:**不写 release_reason**(那是客服的处置,不是主管的收回)', async () => {
    const { prisma, captured } = makePrisma({});
    const svc = await build(prisma);
    await svc.revoke(SCOPE, LEADER, BATCH);
    // 混进去会让退回率的分子分母一起虚高
    expect(captured.data).not.toHaveProperty('releaseReason');
    expect(captured.data).not.toHaveProperty('releaseNote');
  });

  test('⭐⭐ 红线③:**不清批次归因三列**(否则这次误操作影响了多少人再也说不清)', async () => {
    const { prisma, captured } = makePrisma({});
    const svc = await build(prisma);
    await svc.revoke(SCOPE, LEADER, BATCH);
    expect(captured.data).not.toHaveProperty('assignmentId');
    expect(captured.data).not.toHaveProperty('assignedBy');
    expect(captured.data).not.toHaveProperty('assignStrategy');
    expect(captured.data).toMatchObject({ status: 'active', assigneeUserId: null, assignmentExpiresAt: null });
  });

  test('⭐ 红线④:绝不动 snoozedUntil', async () => {
    const { prisma, captured } = makePrisma({});
    const svc = await build(prisma);
    await svc.revoke(SCOPE, LEADER, BATCH);
    expect(captured.data).not.toHaveProperty('snoozedUntil');
  });

  test('⭐ 账本记 actor = **主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责', async () => {
    const { prisma, events } = makePrisma({});
    const svc = await build(prisma);
    await svc.revoke(SCOPE, LEADER, BATCH);
    expect(events[0]).toMatchObject({
      event: PlanEventType.AUTO_RELEASE,
      reason: 'revoked',
      actorUserId: 'leader-1',
      assigneeUserId: null,
    });
  });

  test('批次头标 revoked + 记录撤销人与时刻', async () => {
    const { prisma, headUpdates } = makePrisma({});
    const svc = await build(prisma);
    await svc.revoke(SCOPE, LEADER, BATCH);
    expect(headUpdates[0]).toMatchObject({ status: 'revoked', revokedBy: 'leader-1' });
  });
});

/**
 * 🔴 2026-08-03 线上实测:助手撤销**必然失败**,报「批次 c02e1b80 不存在」。
 *
 * 根因不在撤销,在界面注入给模型的那句话:`已确认分配:批次 #${id.slice(0,8)}`。
 * 模型手里只有 8 位短号,拿短号查主键当然查不到 —— 而卡片上的撤销按钮握着完整 id,
 * 所以那条路一直是通的,**测一条就漏了另一条**。
 * 修法两层:注入给模型的那份带完整 id(前端),这里再兜底认短号。
 */
describe('撤销整批 —— 批次引用解析(短号)', () => {
  test('⭐⭐ 只有 8 位短号也能撤销(模型手上常常只有这个)', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, SHORT);
    expect(r.revoked).toBe(2);
    expect(r.assignmentId).toBe(BATCH); // 回的是**完整 id**,不是短号
  });

  test('界面上带 # 的写法一并认(主管会直接把 `#c02e1b80` 念给助手)', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, `#${SHORT}`)).resolves.toMatchObject({ revoked: 2 });
  });

  test('⭐⭐ 短号撞到多个 → **报错不猜**(猜错就是撤了另一批人的单)', async () => {
    const { prisma } = makePrisma({ prefixHits: [{ id: BATCH }, { id: `${SHORT}-9999-4000-8000-000000000000` }] });
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, SHORT)).rejects.toThrow(/匹配到 2 个批次/);
  });

  test('⭐ 查不到 → 报错必须说「id 没匹配上」,⛔ 不许给出窗口/状态之类的假原因', async () => {
    // 模型实测会照着报错编原因:服务端只说"不存在",它却告诉主管
    // 「可能是已经过了可撤销窗口,或者状态已变更」—— 主管照这个去排查是白跑一趟。
    const { prisma } = makePrisma({ prefixHits: [] });
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, SHORT)).rejects.toThrow(/不是.*撤销窗口超时/);
  });

  test('⭐ 整句话当参数传进来也能撤 —— 实测模型会传「批次 #c02e1b80」而不是裸 id', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, `批次 #${SHORT}`)).resolves.toMatchObject({
      revoked: 2,
      assignmentId: BATCH,
    });
  });

  test('整句里夹着完整 uuid → 直接用它,不走前缀查询', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, `撤销批次 ${BATCH} 谢谢`);
    expect(r.assignmentId).toBe(BATCH);
    expect((prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw).not.toHaveBeenCalled();
  });

  test('太短的前缀直接拒(在几百个批次里撞得到,撞上才报错就晚了)', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, 'c02e')).rejects.toThrow(/找不到批次 id/);
  });
});

describe('撤销整批 —— 授权与时限', () => {
  test('⭐ 别人分的批次 → 拒(除非有 PLAN_VIEW_ALL)', async () => {
    const { prisma } = makePrisma({ createdBy: 'other-leader' });
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, BATCH)).rejects.toThrow(/只能撤销自己发起的批次/);
  });

  test('有 PLAN_VIEW_ALL → 可撤别人的批次', async () => {
    const { prisma } = makePrisma({ createdBy: 'other-leader' });
    const svc = await build(prisma);
    const r = await svc.revoke(
      SCOPE,
      { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] },
      BATCH,
    );
    expect(r.revoked).toBe(2);
  });

  test('⭐ 超出撤销窗口 → 拒,并指路到逐条退回(那条路会留下原因)', async () => {
    const { prisma } = makePrisma({ ageMinutes: REVOKE_WINDOW_MINUTES + 1 });
    const svc = await build(prisma);
    await expect(svc.revoke(SCOPE, LEADER, BATCH)).rejects.toThrow(/撤销窗口/);
  });

  test('⭐ 幂等:重复撤销不报错(主管手抖点两下很正常)', async () => {
    const { prisma, tx } = makePrisma({ status: 'revoked' });
    const svc = await build(prisma);
    const r = await svc.revoke(SCOPE, LEADER, BATCH);
    expect(r.revoked).toBe(0);
    expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
  });

  test('⭐ 合成身份(企微)不能撤销', async () => {
    const { prisma } = makePrisma({});
    const svc = await build(prisma);
    await expect(
      svc.revoke(SCOPE, { userId: 'wx:someone', permissions: [Permission.PLAN_DISPATCH] }, BATCH),
    ).rejects.toThrow(/合成身份/);
  });
});
