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,
  };
}

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');

  /**
   * ⚠️ 这个假 prisma 必须**照 LIMIT 截断**。
   *   原来的写法不管 LIMIT、一律吐 candidateCount 行 —— 于是"候选数被取数上限截断"
   *   这个真 bug 在测试里**根本不可能出现**,500 那条用例一直是假绿。
   *   count 查询与明细查询靠 SQL 文本区分,与 service 里的两条查询一一对应。
   */
  function svcWith(
    candidateCount: number,
    agentCount: number,
    opts: { inHand?: number; lastCriteria?: Record<string, unknown> | null } = {},
  ) {
    const prisma = {
      $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
        const text = (sql?.strings ?? []).join(' ');
        if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
        // 明细:LIMIT 是模板里最后一个参数
        const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
        return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
          planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
        }));
      }),
      patient: { findMany: jest.fn(async () => []) },
      // 基数沿用:上一次分配的 criteria 快照(null = 从来没分过)
      planAssignment: {
        findFirst: jest.fn(async () =>
          opts.lastCriteria
            ? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
            : null,
        ),
      },
    };
    const roster = {
      list: jest.fn(async () => ({
        agents: Array.from({ length: agentCount }, (_, i) => ({
          userId: `a${i}`, name: `客服${i}`, inHand: opts.inHand ?? 0,
        })),
        rosterNote: '名册说明',
      })),
    };
    return new AssignmentProposalService(prisma, roster);
  }
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
  /// 9 位客服 × 首次默认容量 20 = 180。⚠️ 批次规模现在是**推出来的**,不是默认常量
  const DEFAULT_TARGET = 9 * 20;

  test('⭐⭐ 候选 44 < 可分 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', 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('取前');
  });

  test('⭐ 候选 500 > 可分 180 → 照实说「从 500 位候选里取前 180 人」', async () => {
    const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.target).toBe(DEFAULT_TARGET);
    expect(r.selectionNote).toContain(`从 500 位候选里取前 ${DEFAULT_TARGET} 人`);
    // ⚠️ 「只分这么多」必须说清是**容量吃不下**而不是**候选不够** —— 主管的下一步动作相反
    expect(r.selectionNote).toContain('按容量算出来的');
  });

  /**
   * 🔴🔴 取数上限**不许**冒充候选数。
   *
   * 真实场景(2026-08-03 走查发现):主管在矩阵上点「充填 · 窗口外 1,080」,
   * 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(target*1.5)+20` 这个 LIMIT。
   * 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。
   */
  test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => {
    const svc = svcWith(1080, 9);
    const r = await svc.propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' });
    expect(r.candidateTotal).toBe(1080);
    expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${DEFAULT_TARGET} 人`);
    expect(r.placed).toBe(DEFAULT_TARGET);
  });
});

/**
 * 两个基数(容量 + 时效)的**沿用**。
 *
 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
 */
describe('基数沿用 —— 容量与时效', () => {
  const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
  const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
  // 复用上面的构造器(同文件内提升不了,直接再拿一次)
  const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
    const prisma = {
      $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
        const text = (sql?.strings ?? []).join(' ');
        if (text.includes('count(DISTINCT')) return [{ n: BigInt(candidateCount) }];
        const limit = Number((sql?.values ?? []).at(-1) ?? candidateCount);
        return Array.from({ length: Math.min(candidateCount, limit) }, (_, i) => ({
          planId: `p${i}`, patientId: `pat${i}`, priorityScore: 50 - i,
        }));
      }),
      patient: { findMany: jest.fn(async () => []) },
      planAssignment: {
        findFirst: jest.fn(async () =>
          opts.lastCriteria
            ? { criteria: opts.lastCriteria, createdAt: new Date('2026-07-28T02:00:00.000Z') }
            : null,
        ),
      },
    };
    const roster = {
      list: jest.fn(async () => ({
        agents: Array.from({ length: agentCount }, (_, i) => ({
          userId: `a${i}`, name: `客服${i}`, inHand: (opts.inHand as number) ?? 0,
        })),
        rosterNote: '名册说明',
      })),
    };
    return new AssignmentProposalService(prisma, roster);
  };

  test('⭐⭐ 上次用了容量 30 / 时效 5 天 → 本次沿用,批次规模 = 9 × 30', async () => {
    const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
      potentialTreatment: 'implant',
    });
    expect(r.capacity).toBe(30);
    expect(r.expiresInDays).toBe(5);
    expect(r.basis).toBe('inherited');
    expect(r.target).toBe(270);
    // 出处必须说出来 —— 「这 30 哪来的」和这个数本身一样重要
    expect(r.capacityNote).toContain('沿用 2026-07-28');
  });

  test('⭐ 从来没分过 → 首次默认(容量 20 / 3 天),且明说是默认', async () => {
    const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.capacity).toBe(20);
    expect(r.expiresInDays).toBe(3);
    expect(r.basis).toBe('default');
    expect(r.capacityNote).toContain('首次默认值');
  });

  test('⭐ 主管本次说「每人 40 条」→ explicit,批次 = 9 × 40', async () => {
    const r = await mk(1000, 9, { lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(SCOPE, {
      clinicId: 'c1',
      capacity: 40,
    });
    expect(r.capacity).toBe(40);
    expect(r.basis).toBe('explicit');
    expect(r.target).toBe(360);
  });

  /**
   * 🔴 一次性人数**不许**回写成基数。
   * 拿「这批只要 60 人」反推出「以后每人 6.7 条」= 把临时决定固化成长期参数,
   * 而下次没人记得为什么变了。
   */
  test('🔴 主管说「这批只要 60 人」→ 人数 60,但容量基数原样不动', async () => {
    const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
      targetCount: 60,
    });
    expect(r.target).toBe(60);
    expect(r.capacity).toBe(30); // ⛔ 不是 60/9
    expect(r.basis).toBe('inherited');
    expect(r.selectionNote).toContain('不改容量基数');
  });

  /**
   * 🔴🔴 按客服精调必须**穿到落人那一层**。
   *
   * 「李莉这周带教,只给 5 条」—— 如果精调只改了展示、没进 room 计算,
   * 她照样会被分满 20 条,而卡片上写着 5。这种错不会报错,要到她抱怨时才发现。
   */
  test('🔴 李莉容量精调 5 → 她只拿 5 条,总人数也跟着变(8×20 + 5)', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { capacity: 5 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    expect(r.target).toBe(8 * 20 + 5);
    const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3');
    expect(lily.capacity).toBe(5);
    expect(lily.overridden).toBe(true);
    expect(lily.count).toBeLessThanOrEqual(5);
    // 精调必须**点名**,否则推导链里的「9 × 20」自己就对不上账
    expect(r.capacityNote).toContain('容量 5');
  });

  test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    expect(r.expiresInDays).toBe(3); // 整体不变
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3').expiresInDays).toBe(7);
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a0').expiresInDays).toBe(3);
    expect(r.target).toBe(180); // 时效不改人数
  });

  test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: {
        capacity: 20,
        agentOverrides: { a1: { capacity: 0 }, a2: { capacity: '很多' }, a3: { expiresInDays: 999 } },
      },
    }).propose(SCOPE, { clinicId: 'c1' });
    expect(r.agentOverrides).toEqual({});
    expect(r.target).toBe(180);
  });

  test('⭐ 在手 ≥ 容量的人本批不参与,但要列进 skippedAgents', async () => {
    const r = await mk(1000, 9, { inHand: 25, lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(
      SCOPE,
      { clinicId: 'c1' },
    );
    expect(r.target).toBe(0);
    expect(r.placed).toBe(0);
    expect(r.skippedAgents).toHaveLength(9);
    // 「分不了」必须同时说出旋钮在哪,否则主管只看到死路
    expect(r.selectionNote).toContain('每人 30 条');
  });
});
