import { AssignStrategy, 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']));
    // 只有 a 一个人在岗 → 目标水位 = 3,三条全归他
    const { placed } = placeAgents(chosen, [agent('a', 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);
  });

  /**
   * 🔴🔴 **专属那一趟也受目标水位约束**(2026-08-03)。
   *
   * 容量删掉之后没有任何东西约束专属了,本地实测一次:100 条里 76 条是同一个人的专属,
   * 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
   * 目标水位 =(团队在手 + 本批人数)/ 在岗人数,不是新旋钮,由 N 推出来。
   */
  test('🔴 3 条全是 a 的专属、两人在岗 → a 只拿应得的 2 条,第 3 条溢出给 b', () => {
    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.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    // ⚠️ 溢出的那条**仍然标 spread_overflow**:关系还在,只是这轮份额满了
    const spilled = placed.find((p) => p.assigneeUserId === 'b')!;
    expect(spilled.assignStrategy).toBe(AssignStrategy.SPREAD_OVERFLOW);
  });

  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', 0), agent('b', 0)], dedicated, (u) =>
      u === 'a' ? 1 : Infinity,
    );
    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]);
  });

  /**
   * 🔴🔴 水位法的本体:**从手上最少的人开始填**,不是"每人加一样多"。
   *
   * 真实场景(2026-08-03 走查):第一批把有专属的人灌高了,第二批如果按人头均分,
   * 起点不齐终点还是不齐 —— 主管要的是"最后保持齐平"。
   */
  test('🔴 在手 10/0/0,分 6 条 → 全给空的两位并拉平(⛔ 不是每人各 2)', () => {
    const { placed } = placeAgents(pick(6), [agent('busy', 10), agent('x', 0), agent('y', 0)], new Map());
    expect(placed.filter((p) => p.assigneeUserId === 'busy')).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'x')).toHaveLength(3);
    expect(placed.filter((p) => p.assigneeUserId === 'y')).toHaveLength(3);
  });

  test('🔴 水位追平之后才开始雨露均沾(在手 2/0,分 6 条 → 2 / 4)', () => {
    const { placed } = placeAgents(pick(6), [agent('a', 2), agent('b', 0)], new Map());
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
  });

  test('🔴 专属吃到目标水位就转铺平,最终两人齐平(6 条 → 3 / 3)', () => {
    // 6 条里前 4 条是 a 的专属,但目标水位 = 6/2 = 3 → a 拿 3,第 4 条溢出
    const chosen = pick(6);
    const dedicated = new Map(chosen.slice(0, 4).map((c) => [c.patientId, 'a']));
    const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
    expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
    expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
  });

  test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
    const chosen = pick(4);
    const { placed } = placeAgents(chosen, [agent('off', 0), agent('free', 0)], new Map(), (u) =>
      u === 'off' ? 0 : Infinity,
    );
    expect(placed.filter((p) => p.assigneeUserId === 'off')).toHaveLength(0);
    expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4);
  });

  test('⭐⭐ 所有人名额都用完 → 如实报 unplaced(⛔ 不硬塞)', () => {
    const { placed, unplaced } = placeAgents(pick(5), [agent('a', 0)], new Map(), () => 2);
    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', 0), agent('b', 0)], dedicated, (u) =>
      u === 'a' ? 2 : Infinity,
    );
    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' };
  /// 首次默认批次人数(BATCH_SIZE_DEFAULT)
  const N = 100;

  test('⭐⭐ 候选 44 < 本批 100 → 说「一共就 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 > 本批 100 → 照实说「从 500 位候选里取前 100 人」+ 剩下的还在排队', async () => {
    const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
    expect(r.target).toBe(N);
    expect(r.selectionNote).toContain(`从 500 位候选里取前 ${N} 人`);
    // ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
    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 r = await svcWith(1080, 9).propose(SCOPE, {
      clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold',
    });
    expect(r.candidateTotal).toBe(1080);
    expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${N} 人`);
    expect(r.placed).toBe(N);
  });
});

/**
 * 两个基数(本批人数 + 时效)的**沿用**,以及"连着分第二批"。
 *
 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
 */
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);
  };

  /**
   * 🔴🔴 **连着分第二批必须还能分**。
   *
   * 这是 2026-08-03 走查抓到的死路:批次规模曾经 = Σ(容量−在手),
   * 于是第一批把所有人填到水位之后,第二批恒为 0 —— 主管只能靠往上棚容量来绕开,
   * 而容量会被记住,下周的"习惯"就是个虚高的数。
   */
  test('🔴 第一批分完(人人在手 25)→ 第二批照样 100 人,⛔ 不是 0', async () => {
    const r = await mk(1000, 9, { inHand: 25, lastCriteria: { batchSize: 100, expiresInDays: 3 } })
      .propose(SCOPE, { clinicId: 'c1' });
    expect(r.target).toBe(100);
    expect(r.placed).toBe(100);
    expect(r.unplaced).toBe(0);
    expect(r.skippedAgents).toHaveLength(0);
  });

  test('⭐⭐ 上次用了 300 人 / 5 天 → 本次沿用,并说出沿用哪一次', async () => {
    const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.target).toBe(300);
    expect(r.expiresInDays).toBe(5);
    expect(r.basis).toBe('inherited');
    expect(r.basisNote).toContain('沿用 2026-07-28');
  });

  /**
   * ⚠️ 老快照里这个数叫 `target`(那时它是推出来的结果)。不认的话,改版后所有人的
   * "沿用"都会静默退回默认值,而界面只显示"首次默认" —— 看不出丢了东西。
   */
  test('⭐ 老快照只有 target(没有 batchSize)→ 一样认', async () => {
    const r = await mk(1000, 9, { lastCriteria: { target: 250, expiresInDays: 7 } }).propose(SCOPE, {
      clinicId: 'c1',
    });
    expect(r.target).toBe(250);
    expect(r.basis).toBe('inherited');
  });

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

  test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => {
    const r = await mk(1000, 9, { lastCriteria: { batchSize: 100, expiresInDays: 3 } }).propose(SCOPE, {
      clinicId: 'c1',
      targetCount: 200,
    });
    expect(r.target).toBe(200);
    expect(r.basis).toBe('explicit');
    expect(r.basisNote).toContain('下次自动沿用');
  });

  /**
   * 🔴 按客服精调必须**穿到落人那一层**。
   * 「李莉这周带教,这批最多 5 条」—— 只改展示不改落人的话,她照样被水位法灌满,
   * 而卡片上写着 5。这种错不报错,要到她抱怨时才发现。
   */
  test('🔴 李莉本批最多 5 条 → 她只拿 5 条,其余照样分掉(总数不变)', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { batchSize: 100, expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3');
    expect(lily.count).toBe(5);
    expect(lily.overridden).toBe(true);
    // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
    expect(r.placed).toBe(100);
    expect(r.basisNote).toContain('本批最多 5 条');
  });

  test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { batchSize: 100, agentOverrides: { a3: { maxThisBatch: 0 } } },
    }).propose(SCOPE, { clinicId: 'c1' });
    expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3')).toBeUndefined();
    expect(r.skippedAgents.map((x: { userId: string }) => x.userId)).toContain('a3');
    expect(r.placed).toBe(100);
  });

  test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => {
    const r = await mk(1000, 9, {
      lastCriteria: { batchSize: 100, 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);
  });

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

  test('⭐ 分配后的水位要写进 basisNote(没有阈值判断,只报事实)', async () => {
    const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
      .propose(SCOPE, { clinicId: 'c1' });
    expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
    expect(r.basisNote).toContain('分完后每人在手 3 条');
  });
});
