import { Test } from '@nestjs/testing';
import { ReleaseReason } from '@pac/types';
import { PlanService } from '../src/modules/plan/plan.service';
import { PrismaService } from '../src/prisma/prisma.service';

/**
 * 召回分配 P0.6 / P0.7 回归 —— 退回原因落库 + 写路径的诊所硬边界。
 *
 * 两条都属于「不修就会静默出错」的类型:
 *   · 退回原因原本在 controller 就被丢掉(`@Body() _dto`),客服填了等于没填,
 *     而退回原因分布是主管调整分配策略的唯一输入(T7)。
 *   · assign/recycle 的 findFirst 不校验 targetClinicId,A 诊所 leader 拿到 B 诊所的
 *     planId 就能跨诊所写入 —— 读路径挡着(他看不到),写路径不挡。
 */

const HOST = 'h1';
const TENANT = 't1';

function scopeWith(clinicIds: string[]) {
  return { hostId: HOST, tenantId: TENANT, sourceUnits: [] as string[], clinicIds } as never;
}

const BASE_PLAN = {
  id: 'p1',
  hostId: HOST,
  tenantId: TENANT,
  patientId: 'pat1',
  status: 'assigned',
  assigneeUserId: 'u-me',
  assignedAt: new Date(Date.now() - 3600_000),
  targetClinicId: 'clinic-a',
};

/**
 * findFirst 按 where 真实过滤 —— 只建模本用例关心的两维:id 与 clinicBoundary 的 AND/OR。
 * (不建模就等于把"边界有没有生效"这件事假设掉了,而那正是要测的东西。)
 */
function makePrisma(plan: Record<string, unknown> | null = BASE_PLAN) {
  const updates: Record<string, unknown>[] = [];
  const events: Record<string, unknown>[] = [];
  const tx = {
    followupPlan: {
      update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
        updates.push(data);
        return {};
      }),
    },
    planEventLog: {
      create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
        events.push(data);
        return data;
      }),
    },
  };
  const findFirst = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
    if (!plan) return null;
    const and = where.AND as Array<{ OR: Array<Record<string, unknown>> }> | undefined;
    if (and?.length) {
      const allowed = and[0]!.OR.flatMap((c) => {
        const t = c.targetClinicId as { in?: string[] } | null;
        return t === null ? [null] : (t?.in ?? []);
      });
      if (!allowed.includes(plan.targetClinicId as string | null)) return null;
    }
    return plan;
  });
  const prisma = {
    followupPlan: { findFirst, findMany: jest.fn(async () => []) },
    $transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
  };
  return { prisma: prisma as unknown as PrismaService, updates, events, findFirst };
}

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

describe('recycle —— 退回原因必须落到账本(T7)', () => {
  test('⭐ 带结构化原因 → 落进 plan_event_logs.reason,事件类型是 release', async () => {
    const { prisma, events } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.ALREADY_IN_PROGRESS);
    expect(events).toHaveLength(1);
    expect(events[0]).toMatchObject({
      event: 'release',
      reason: 'already_in_progress',
      assigneeUserId: null,
    });
  });

  test('文字说明进 details,不占 reason 列(reason 要能直接 GROUP BY)', async () => {
    const { prisma, events } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, '  号码是空号  ');
    expect(events[0]).toMatchObject({ reason: 'other', details: { note: '号码是空号' } });
  });

  test('⭐ other 不写说明 → **服务端**拒绝(不能只信前端)', async () => {
    const { prisma } = makePrisma();
    const svc = await buildService(prisma);
    await expect(
      svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER),
    ).rejects.toThrow(/必须填写说明/);
    // 空白串也算没填
    await expect(
      svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, '   '),
    ).rejects.toThrow(/必须填写说明/);
  });

  test('不填原因仍可退(自助认领的单不强制)—— reason 落 null', async () => {
    const { prisma, events } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
    expect(events[0]).toMatchObject({ event: 'release', reason: null });
  });

  test('⭐⭐ 红线:退回**绝不动 snoozedUntil**(动了池子里会凭空少一批人)', async () => {
    const { prisma, updates } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.TOO_SOON);
    expect(updates).toHaveLength(1);
    // 「最近刚联系过」最像该压一压的原因 —— 正因为像,才要在这里钉死
    expect(updates[0]).not.toHaveProperty('snoozedUntil');
    expect(updates[0]).toMatchObject({ status: 'active', assigneeUserId: null });
  });

  test('⭐ 退回原因同时落主表当前值(列表页要直接显示,不必回查事件流)', async () => {
    const { prisma, updates } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.NEEDS_OTHER_ROLE);
    expect(updates[0]).toMatchObject({ releaseReason: 'needs_other_role', releaseNote: null });
  });

  test('⭐⭐ 红线:退回**不清 assignment_id / assigned_by / assign_strategy** —— 那是退回率的分母', async () => {
    const { prisma, updates } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.ALREADY_IN_PROGRESS);
    // 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在;清了就只剩分子
    expect(updates[0]).not.toHaveProperty('assignmentId');
    expect(updates[0]).not.toHaveProperty('assignedBy');
    expect(updates[0]).not.toHaveProperty('assignStrategy');
    // 但「在办期限」要随归属一起结束(列的自洽:非空 ⟺ 有一次在办的分配)
    expect(updates[0]).toMatchObject({ assignmentExpiresAt: null });
  });
});

describe('assign —— 归属列与批次列的分工', () => {
  test('⭐ 记 assignedBy;⛔ 不碰 assignmentId / assignStrategy(那是批量写入的活)', async () => {
    const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
    const svc = await buildService(prisma);
    await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-leader');
    expect(updates[0]).toMatchObject({ assigneeUserId: 'u-staff', assignedBy: 'u-leader' });
    // 单条认领/指派不属于任何批次 —— 瞎写会造出归属不到任何批次的"孤儿归因"
    expect(updates[0]).not.toHaveProperty('assignmentId');
    expect(updates[0]).not.toHaveProperty('assignStrategy');
  });

  test('⭐ 重新有人接手 → 清掉上一次的退回原因(否则主表自相矛盾)', async () => {
    const { prisma, updates } = makePrisma({
      ...BASE_PLAN,
      status: 'active',
      assigneeUserId: null,
      releaseReason: 'over_capacity',
    });
    const svc = await buildService(prisma);
    await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-staff');
    // 不清的话会同时显示「已分配给 u-staff」和「因手上排满被退回」
    expect(updates[0]).toMatchObject({ releaseReason: null, releaseNote: null });
  });

  test('自认领时 assignedBy == assignee(主表上就能分出"自己领的"与"主管派的")', async () => {
    const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
    const svc = await buildService(prisma);
    await svc.assign(scopeWith([]), 'p1', 'u-me', 'u-me');
    expect(updates[0]).toMatchObject({ assigneeUserId: 'u-me', assignedBy: 'u-me' });
  });
});

describe('写路径诊所硬边界(F4)', () => {
  test('⭐ 跨诊所 assign → 找不到,不是"分成功了"', async () => {
    // scope 只有 clinic-b,plan 属于 clinic-a
    const { prisma } = makePrisma();
    const svc = await buildService(prisma);
    await expect(svc.assign(scopeWith(['clinic-b']), 'p1', 'u-other', 'u-leader')).rejects.toThrow(
      /not found/i,
    );
  });

  test('⭐ 跨诊所 recycle → 同样拒绝', async () => {
    const { prisma } = makePrisma();
    const svc = await buildService(prisma);
    await expect(svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-leader', true)).rejects.toThrow(
      /not found/i,
    );
  });

  test('本诊所的单照常放行(边界不能误伤)', async () => {
    const { prisma, events } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith(['clinic-a']), 'p1', 'u-me', true);
    expect(events).toHaveLength(1);
  });

  test('⭐ 集团池(targetClinicId=null)窄 scope 也能操作 —— 它不属于任何诊所', async () => {
    const { prisma, events } = makePrisma({ ...BASE_PLAN, targetClinicId: null });
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-me', true);
    expect(events).toHaveLength(1);
  });

  test('集团级 scope(clinicIds 为空)不加任何过滤', async () => {
    const { prisma, findFirst } = makePrisma();
    const svc = await buildService(prisma);
    await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
    expect((findFirst.mock.calls[0]![0] as { where: Record<string, unknown> }).where.AND)
      .toBeUndefined();
  });
});
