import { AssignStrategy, AGENT_CAPACITY_DEFAULT, type AgentInfo } from '@pac/types';
import { placeAgents } from '../src/modules/plan/assignment-proposal.service';

/**
 * 「专属优先 / 溢出铺平」落人算法回归。
 *
 * 这是主管每天都要看的那张确认单背后的算法 —— 它错了不会报错,
 * 只会让主管把单分给不该分的人,而他要到客服抱怨时才知道。
 */

function agent(userId: string, inHand: number, name = userId): AgentInfo {
  return {
    userId,
    name,
    inHand,
    inHandThisClinic: inHand,
    recentVisits: 10,
    lastVisitAt: '2026-07-01T00:00:00.000Z',
    inRoster: true,
    capacityRange: [20, 50],
    capacityBasis: 'default',
  };
}

const pick = (n: number, prefix = 'p') =>
  Array.from({ length: n }, (_, i) => ({
    planId: `${prefix}-plan-${i}`,
    patientId: `${prefix}-pat-${i}`,
    selectionMode: 'rank' as const,
  }));

describe('placeAgents —— 专属优先', () => {
  test('⭐ 专属客服在名册内且有余量 → 命中 dedicated', () => {
    const chosen = pick(3);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    expect(placed).toHaveLength(3);
    expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true);
  });

  test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
    const chosen = pick(2);
    // 专属是 'ghost'(已离职,名册里查不到)
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'ghost']));
    const { placed } = placeAgents(chosen, [agent('a', 0)], dedicated);
    expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
    // 「从头没有可用专属」≠「专属满了溢出」—— 混成一个值,T20 就分不清
    // "铺平完成率低"是策略问题还是人群问题
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
  });

  test('⭐ 无专属客服 → spread_no_dedicated', () => {
    const chosen = pick(2);
    const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
    expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
  });

  test('⭐⭐ 专属**已满** → 溢出给别人,且标 spread_overflow(与"无专属"区分开)', () => {
    const chosen = pick(3);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    // a 只剩 1 个余量
    const { placed } = placeAgents(
      chosen,
      [agent('a', AGENT_CAPACITY_DEFAULT - 1), agent('b', 0)],
      dedicated,
    );
    const byStrategy = placed.reduce<Record<string, number>>((m, p) => {
      m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
      return m;
    }, {});
    expect(byStrategy[AssignStrategy.DEDICATED]).toBe(1);
    // 关系还在,只是这次没轮到他 —— 与 spread_no_dedicated 是两群人
    expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBe(2);
    expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBeUndefined();
  });
});

describe('placeAgents —— 溢出均分 + 在手硬护栏', () => {
  test('⭐ 均分:9 条给 3 个空闲客服 → 各 3 条', () => {
    const chosen = pick(9);
    const { placed, unplaced } = placeAgents(
      chosen,
      [agent('a', 0), agent('b', 0), agent('c', 0)],
      new Map(),
    );
    expect(unplaced).toBe(0);
    const counts = ['a', 'b', 'c'].map((u) => placed.filter((p) => p.assigneeUserId === u).length);
    expect(counts).toEqual([3, 3, 3]);
  });

  test('⭐⭐ 已满的人**本批一条都不给**(不是少给)', () => {
    const chosen = pick(4);
    const { placed } = placeAgents(
      chosen,
      [agent('full', AGENT_CAPACITY_DEFAULT), agent('free', 0)],
      new Map(),
    );
    // 硬塞给已满的人是 T5 的反面,而且会立刻造出 over_capacity 退回 ——
    // 那正是要用来反推容量默认值的信号,自己造出来就没法反推了
    expect(placed.filter((p) => p.assigneeUserId === 'full')).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4);
  });

  test('⭐⭐ 容量不够 → **截断不摊派**,如实报 unplaced', () => {
    const chosen = pick(5);
    // 只剩 2 个余量
    const { placed, unplaced } = placeAgents(
      chosen,
      [agent('a', AGENT_CAPACITY_DEFAULT - 2)],
      new Map(),
    );
    expect(placed).toHaveLength(2);
    expect(unplaced).toBe(3);
  });

  test('⭐⭐ 专属段与铺平段**共用同一个余量累加器**(否则专属大户会被再灌一轮)', () => {
    // 6 条全是 a 的专属;a 只剩 2 个余量 → 2 条 dedicated,4 条该溢出给 b
    const chosen = pick(6);
    const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
    const { placed, unplaced } = placeAgents(
      chosen,
      [agent('a', AGENT_CAPACITY_DEFAULT - 2), agent('b', 0)],
      dedicated,
    );
    expect(unplaced).toBe(0);
    // ⚠️ 若两段各自算余量,a 会在铺平段又被当成"还有余量"再吃一轮 —— 这一点在代码上完全不显眼
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
  });

  test('名册为空 → 一条都落不上,不报错', () => {
    const { placed, unplaced } = placeAgents(pick(3), [], new Map());
    expect(placed).toHaveLength(0);
    expect(unplaced).toBe(3);
  });
});

describe('placeAgents —— 确定性', () => {
  test('⭐ 同样的输入两次算出**同样的分法**(主管微调后会重算,跳动他就不敢按确认)', () => {
    const chosen = pick(7);
    const agents = [agent('a', 3), agent('b', 1), agent('c', 5)];
    const r1 = placeAgents(chosen, agents, new Map());
    const r2 = placeAgents(chosen, agents, new Map());
    expect(r1.placed.map((p) => `${p.planId}:${p.assigneeUserId}`)).toEqual(
      r2.placed.map((p) => `${p.planId}:${p.assigneeUserId}`),
    );
  });

  test('selectionMode 原样带过去(探索配额标记不能在落人这步丢)', () => {
    const chosen = [
      { planId: 'p1', patientId: 'x1', selectionMode: 'rank' as const },
      { planId: 'p2', patientId: 'x2', selectionMode: 'explore' as const },
    ];
    const { placed } = placeAgents(chosen, [agent('a', 0)], new Map());
    expect(placed.find((p) => p.planId === 'p2')!.selectionMode).toBe('explore');
    // 丢了就等于探索配额白留 —— 事后无法把两组分开
    expect(placed.find((p) => p.planId === 'p1')!.selectionMode).toBe('rank');
  });
});

/**
 * 「取前 N 人」这句话的准确性。
 *
 * 🔴 它是**要求助手原话转述**的句子(T14 双保险的工具返回值那一半)——
 * 措辞错了等于让助手替系统撒谎,而且主管没法自己发现。
 * 真实场景:主管从矩阵点了一个 44 人的格子进来,句子却说「排序取前 100 人」,
 * 他会以为有 56 个人被系统悄悄丢了。
 */
describe('selectionNote —— 候选不够时的措辞', () => {
  const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');

  function svcWith(candidateCount: number, agentCount: number) {
    const prisma = {
      $queryRaw: jest.fn(async () =>
        Array.from({ length: candidateCount }, (_, i) => ({
          planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
        })),
      ),
      patient: { findMany: jest.fn(async () => []) },
    };
    const roster = {
      list: jest.fn(async () => ({
        agents: Array.from({ length: agentCount }, (_, i) => ({ userId: `a${i}`, name: `客服${i}`, inHand: 0 })),
        rosterNote: '名册说明',
      })),
    };
    return new AssignmentProposalService(prisma, roster);
  }
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };

  test('⭐⭐ 候选 44 < 默认批次 100 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 100 人」', async () => {
    const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.selectionNote).toContain('一共就 44 人');
    expect(r.selectionNote).toContain('全部纳入');
    expect(r.selectionNote).not.toContain('取前 100 人');
    // ⛔ 候选不够时不该再吹「批次规模 100 为默认值」—— 那个默认值根本没起作用
    expect(r.selectionNote).not.toContain('批次规模 100');
  });

  test('⭐ 候选 500 > 默认批次 100 → 照实说「从 500 位候选里取前 100 人」+ 标默认值', async () => {
    const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.selectionNote).toContain('从 500 位候选里取前 100 人');
    expect(r.selectionNote).toContain('默认值');
  });
});
