import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  AGENT_CAPACITY_DEFAULT,
  ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
  AssignStrategy,
  type AgentInfo,
  type AssignmentProposal,
  type ProposedItem,
  type TemperatureValue,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service';
import { assertCohortCriteria, cohortWhereSql, type CohortCriteria } from './cohort-filter';

/**
 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
 *
 * 这是助手出确认单的取数与算法层。**纯只读**(T8:唯一的写动作是主管点确认)。
 *
 * ═══ 两个正交的决策,别混成一件事 ═══════════════════════════
 *   ① 选谁进这批(收敛)   ← 排序键的事
 *   ② 这批怎么落到客服头上 ← 专属优先 / 溢出铺平的事
 * 「优先级排序」只解决 ①,不解决 ②。
 */

/**
 * ① 收敛排序键 —— 三层,每层都能用一句话向主管解释。
 *
 * ```
 * (assignment_id IS NULL) DESC   从没进过任何批次的优先
 * priority_score          DESC   层内沿用现成的分,不自研公式
 * patient_id              ASC    全序兜底
 * ```
 *
 * **首键为什么必须有**:退回的高分单回池后分数几乎不变(freshness 降,但 daysSince 增大
 * 反而可能推高急迫性),它会立刻回到下一批的头部**反复插队**,300 名开外的人永远轮不到。
 * 这不是"还没轮到",是被插队插到死。生产实证:现存 57 个认领单里 79% 已过时限 ——
 * "打了没结果然后挂着"是常态不是例外,这批一旦回池就是稳定的插队源。
 * `assignment_id` 在退回时**不清空**(见 plan.service.recycle),所以它天然就是
 * 「这条从没进过任何批次」的标记 —— **零新列**。
 * 一句话解释:「先给还没联系过的人排队;联系过一轮还没成的,等下一圈。」
 *
 * **第三层不是装饰**:filling 格 1,189 人只有 306 个不同取值(均值 3.9 人同分),
 * 第 100 名撞并列是**必然事件**;而 PG 不承诺没有完整 ORDER BY 时的行序。
 * 被问「她俩分一样,凭什么她进我不进」时必须答得上来。
 * (现有列表排序 `plan.service` 也已经是复合键,同一个问题在分页上踩过。)
 *
 * ⚠️ **退化点**:跑满一整圈后(1,189 / 100 ≈ 12 批)所有人 assignment_id 都非空,
 * 首键恒 false,排序退化成纯 score,插队问题原样回来。届时需要第二圈机制
 * (按 `plan_assignments.created_at` 最早优先);⛔ 不要用 `assignedAt` —— 退回和到期都会清空它。
 * 那是 12 批之后的事,那时已有足够样本用数据决定,现在设计属于过度设计。
 *
 * ⚠️ **不加「专属客服可用性」分层**(重要,且违直觉):85.1% 的池子有在岗专属,
 * 一旦拿它当首键,批次会近乎 100% 是 dedicated,于是 T20 点名要反推的
 * 「专属 vs 铺平完成率差」**永远凑不满样本** —— 用一个未验证的假设去排序,
 * 而这个排序保证了该假设无法被验证。第一批必须混合,才有东西可反推。
 * 这条已经产品确认,不要"顺手优化"掉。
 */
const SELECTION_ORDER = Prisma.sql`
  ORDER BY (fp.assignment_id IS NULL) DESC, fp.priority_score DESC, fp.patient_id ASC
`;

@Injectable()
export class AssignmentProposalService {
  private readonly logger = new Logger(AssignmentProposalService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly roster: AgentRosterService,
  ) {}

  async propose(
    scope: TenantScopeContext,
    input: {
      clinicId: string;
      /** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
      potentialTreatment?: string;
      /** 窗口温度(矩阵 Y 轴)。⚠️ 必须与 potentialTreatment 同时给 */
      temperature?: TemperatureValue;
      /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
      personaTags?: string;
      /** 每个客服的在手容量(基数①,**整体默认**);不传 = 沿用上一次分配的值 */
      capacity?: number;
      /** 批次时效天数(基数②,**整体默认**);不传 = 沿用上一次分配的值 */
      expiresInDays?: number;
      /**
       * 按客服的**精调**(userId → 覆盖值),压过整体基数。
       * 「李莉这周带教只给 5 条」「王强那批给 7 天」。
       * ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
       *   让一条三个月前的临时精调不至于永远静默生效。
       */
      agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>;
      /**
       * 拟分人数 —— **一次性覆盖**,主管说「这批只要 60 人」时才给。
       * ⛔ 它**不回写基数**:批次规模是一次性的运营选择,容量是人的属性,
       *   拿 60 反推出「以后每人 3.5 条」等于把临时决定固化成长期参数,而且没人记得为什么。
       *   他真想改容量会直接说「每人 30 条」—— 那是另一句话,走 capacity。
       */
      targetCount?: number;
      /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
      exploreRatio?: number;
    },
    now: Date = new Date(),
  ): Promise<AssignmentProposal> {
    const { clinicId, potentialTreatment } = input;
    const criteria: CohortCriteria = {
      clinicId,
      ...(potentialTreatment ? { potentialTreatment } : {}),
      ...(input.temperature ? { temperature: input.temperature } : {}),
      ...(input.personaTags ? { personaTags: input.personaTags } : {}),
    };
    assertCohortCriteria(criteria);
    const [roster, baseline] = await Promise.all([
      this.roster.list(scope, clinicId),
      this.resolveBaseline(scope, clinicId),
    ]);
    const agents = roster.agents;

    // ── 两个基数:容量 + 时效 ────────────────────────────────
    // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
    // ⭐ `basis` 要跟着值一起下发 —— 主管看到「容量 20」时,"这 20 哪来的"和这个数一样重要。
    const capacity =
      input.capacity != null && input.capacity > 0 ? Math.floor(input.capacity) : baseline.capacity;
    const expiresInDays =
      input.expiresInDays != null && input.expiresInDays > 0
        ? Math.floor(input.expiresInDays)
        : baseline.expiresInDays;
    const basis: 'inherited' | 'default' | 'explicit' =
      input.capacity != null || input.expiresInDays != null
        ? 'explicit'
        : baseline.from
          ? 'inherited'
          : 'default';
    // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢)
    const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides });
    /// 某人本批实际生效的两个值 —— ⛔ 只此一处合并,别在 placeAgents / byAgent / 前端各合一遍
    const capOf = (userId: string) => agentOverrides[userId]?.capacity ?? capacity;
    const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;

    // ── 拟分人数 = 把在岗客服**填满到容量水位** ────────────────
    // ⚠️ 这是 2026-08-03 的改判:原来批次规模是一个独立默认值(100),容量只当上限。
    //   现在反过来 —— **容量是唯一旋钮,人数是推出来的**。
    //   理由:主管在确认单上调的是容量和时效(会被记住),再给他第三个"批次规模"旋钮,
    //   三者矛盾时听谁的就说不清了(改容量还是改人数?)。
    // ⚠️ 在手 ≥ 容量的人 room=0 → **本批不参与**,但仍进 skippedAgents 列出来
    //   ("他不是被漏了,是已经满了")。
    const totalRoom = agents.reduce((a, g) => a + Math.max(0, capOf(g.userId) - g.inHand), 0);
    const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
    // targetCount 是一次性覆盖(⛔ 不回写基数,见入参注释)
    const target = input.targetCount != null ? Math.max(0, input.targetCount) : totalRoom;
    const sizeBasis: 'explicit' | 'capacity' = input.targetCount != null ? 'explicit' : 'capacity';
    if (target === 0 || agents.length === 0) {
      return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, {
        target,
        capacity,
        expiresInDays,
        basis,
        basisFrom: baseline.from,
        agentOverrides,
        capOf,
      });
    }

    // ── ① 收敛:按三层键取前 N ────────────────────────────────
    // ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
    //
    // 🔴 **候选总数必须单独 count,不能拿这次取回的行数充数**。
    //   取回的行数上限就是这个 LIMIT(默认 170),于是 1,080 人的格子会被报成
    //   「从 170 位候选里取前 100 人」—— 而这句是**要求助手原话转述**的话(T14),
    //   主管刚在矩阵上看过 1,080,当场就对不上。这个数**只能来自 count**。
    //   ⚠️ 用 `count(DISTINCT patient_id)`,与矩阵格子同一种数法 —— 换成 count(*)
    //   就变成「按 plan 数」,同一个人两条召回会被数两次,又对不上了。
    const fetchLimit = Math.ceil(target * 1.5) + 20;
    const [candidateTotal, pool] = await Promise.all([
      this.countCandidates(scope, criteria, now),
      this.selectCandidates(scope, criteria, fetchLimit, now),
    ]);

    // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
    const seenPatient = new Set<string>();
    const ranked = pool.filter((c) => {
      if (seenPatient.has(c.patientId)) return false;
      seenPatient.add(c.patientId);
      return true;
    });

    // ── 探索配额 ────────────────────────────────────────────
    // 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。
    // ⚠️ 「排名之外」的范围只有**取回的这 1.5 倍窗口**(第 rankN…fetchLimit 名),
    //   不是整个候选池 —— 1,080 人的格子里第 500 名永远轮不到被探索。
    //   要全池探索得另取一次样本;现在这样至少是确定性的,先不扩。
    const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0));
    const exploreN = Math.floor(target * ratio);
    const rankN = Math.max(0, target - exploreN);
    const head = ranked.slice(0, rankN).map((c) => ({ ...c, selectionMode: 'rank' as const }));
    const tailPool = ranked.slice(rankN);
    // ⚠️ 确定性抽样:按 patientId 的字典序等距取,不用随机数 ——
    //   主管微调后会重算,随机会让人数和名单无故跳动,他会以为系统在乱来。
    const explore = pickEvenly(tailPool, exploreN).map((c) => ({
      ...c,
      selectionMode: 'explore' as const,
    }));
    const chosen = [...head, ...explore];

    // ── ② 落人:专属优先 → 溢出均分 ──────────────────────────
    // ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
    //   两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
    //   (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
    const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId));
    const items = placeAgents(chosen, agents, dedicated, capOf);

    const byAgent = new Map<string, ProposedItem[]>();
    for (const it of items.placed) {
      const arr = byAgent.get(it.assigneeUserId) ?? [];
      arr.push(it);
      byAgent.set(it.assigneeUserId, arr);
    }
    const agentById = new Map(agents.map((a) => [a.userId, a]));

    return {
      clinicId,
      potentialTreatment: potentialTreatment ?? null,
      /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
      candidateTotal,
      target,
      capacity,
      expiresInDays,
      basis,
      basisFrom: baseline.from,
      placed: items.placed.length,
      unplaced: items.unplaced,
      items: items.placed,
      byAgent: [...byAgent].map(([userId, list]) => ({
        userId,
        name: agentById.get(userId)?.name ?? null,
        inHandBefore: agentById.get(userId)?.inHand ?? 0,
        count: list.length,
        dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
        spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
        /// 逐人下发生效值 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
        capacity: capOf(userId),
        expiresInDays: expOf(userId),
        overridden: agentOverrides[userId] != null,
      })),
      agentOverrides,
      // 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了"
      skippedAgents: agents
        .filter((a) => a.inHand >= capOf(a.userId))
        .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
      rosterNote: roster.rosterNote,
      // ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
      capacityNote: capacityNote({
        agents: agents.length,
        capacity,
        inHandTotal,
        totalRoom,
        expiresInDays,
        basis,
        basisFrom: baseline.from,
        sizeBasis,
        target,
        // 精调过的人要点名 —— 「为什么李莉只有 5 条」必须答得上来
        overrides: agents
          .filter((a) => agentOverrides[a.userId] != null)
          .map((a) => ({ name: a.name ?? a.userId.slice(0, 8), ...agentOverrides[a.userId]! })),
      }),
      selectionNote:
        // ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来,
        //   句子却说「排序取前 100 人」,他会以为有 56 个人被系统丢了。
        //   这是一句**要求助手原话转述**的话,措辞错了等于让助手替系统撒谎(T14)。
        (candidateTotal <= target
          ? `这批候选一共就 ${candidateTotal} 人,**全部纳入**(未做取舍)。`
          : `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${candidateTotal} 位候选里取前 ${target} 人;`) +
        // ⚠️ 「只取到 N 人」必须说清是**容量吃不下**而不是**候选不够** ——
        //   两者主管的下一步动作完全相反(加容量 vs 换人群)。
        (candidateTotal > target && sizeBasis === 'capacity'
          ? `本批 ${target} 人是**按容量算出来的**(不是候选不够,池子里还有 ${candidateTotal - target} 人排队);要多分就说「每人 ${capacity + 10} 条」。`
          : candidateTotal > target && sizeBasis === 'explicit'
            ? `本批 ${target} 人是您本次指定的(⛔ 不改容量基数,下次仍按每人 ${capacity} 条算)。`
            : '') +
        (exploreN > 0
          ? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
          : '') +
        (items.unplaced > 0
          ? `⚠️ 有 ${items.unplaced} 人按当前容量分不下去,建议本批缩到 ${items.placed.length} 人。`
          : ''),
    };
  }

  /**
   * 候选取数 —— 走原生 SQL 是因为排序键的首层 `(assignment_id IS NULL)` 是**表达式**,
   * Prisma 的 orderBy 表达不了。
   *
   * ⚠️ 过滤条件必须与召回池列表同 scope,否则"矩阵上说有 383 人、点进去列表 350 条"
   * 这种对不上的事一发生,主管对整个功能的信任就没了。
   */
  private async selectCandidates(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    limit: number,
    now: Date,
  ): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> {
    // ⭐ 人群条件走**共用**片段(cohort-filter),与 get_cohort_attributes 出的分布同源。
    //   两处各写一遍就会出现「属性分布说商保 32 人,提案只有 28 人」,主管当场不信。
    return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>(
      Prisma.sql`
        SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore"
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
        ${SELECTION_ORDER}
        LIMIT ${limit}
      `,
    );
  }

  /**
   * 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时效。
   *
   * ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点:
   *   他第一次把容量/时效调顺手,系统记住,**第二次起零输入**。
   * ⚠️ 先找**他自己**的上一次,再退到**本诊所**任何人的上一次 ——
   *   容量是团队吞吐的判断,但不同主管的手感不同;先私后公,两级都没有才用默认。
   * ⚠️ 值存在 `criteria` 这个 Json 快照里(**零新列**);读不到就当没有,⛔ 不抛错 ——
   *   老批次根本没有这两个字段,为它们报错等于历史数据一有就出确认单出不来。
   */
  private async resolveBaseline(
    scope: TenantScopeContext,
    clinicId: string,
  ): Promise<{
    capacity: number;
    expiresInDays: number;
    from: string | null;
    agentOverrides: Record<string, { capacity?: number; expiresInDays?: number }>;
  }> {
    const base = {
      hostId: scope.hostId,
      tenantId: scope.tenantId,
      clinicId,
      status: 'confirmed',
    } as const;
    const pick = async (where: Record<string, unknown>) =>
      this.prisma.planAssignment.findFirst({
        where,
        orderBy: { createdAt: 'desc' },
        select: { criteria: true, createdAt: true },
      });
    const last = (await pick({ ...base, createdBy: scope.userId })) ?? (await pick({ ...base }));
    const c = (last?.criteria ?? null) as {
      capacity?: unknown;
      expiresInDays?: unknown;
      agentOverrides?: unknown;
    } | null;
    const capacity = posInt(c?.capacity);
    const expiresInDays = posInt(c?.expiresInDays);
    // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
    return {
      capacity: capacity ?? AGENT_CAPACITY_DEFAULT,
      expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
      from: capacity != null || expiresInDays != null ? last!.createdAt.toISOString() : null,
      agentOverrides: sanitizeOverrides(c?.agentOverrides),
    };
  }

  /**
   * 候选**总数** —— 与取明细分开的一次 count。
   *
   * 🔴 存在的唯一理由:`selectCandidates` 带 LIMIT,它返回的行数**不是**候选数。
   *   拿它当候选数,大格子就会被报成「从 170 位候选里取前 100 人」(170 正是那个 LIMIT),
   *   而主管刚在矩阵上看的是 1,080。
   * ⚠️ `count(DISTINCT fp.patient_id)` —— 与矩阵格子(cohort-attributes)**同一种数法**。
   *   ⛔ 别写成 `count(*)`:同一患者的两条召回会被数两次,又对不上矩阵了。
   */
  private async countCandidates(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    now: Date,
  ): Promise<number> {
    const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number }>>(
      Prisma.sql`
        SELECT count(DISTINCT fp.patient_id) AS n
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
      `,
    );
    return Number(rows[0]?.n ?? 0);
  }

  private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
    const ids = [...new Set(patientIds)];
    if (ids.length === 0) return new Map();
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: ids } },
      select: { id: true, preferences: true },
    });
    const out = new Map<string, string>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) out.set(r.id, id);
    }
    return out;
  }
}

/**
 * ② 落人:**专属优先 → 溢出均分 + 在手硬护栏**。
 *
 * ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
 *   而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
 *
 * ── 为什么是均分,不是「水位拉平」 ──────────────────────────
 * 水位拉平(按在手量补到同一水位)看着更聪明,实际站不住,三条:
 *   1. 它的输入是 `remaining = 容量 − 在手`,而**容量是拍的默认值** ——
 *      拿默认值做完减法再当事实说出口("李莉还能吃 38 个"),一句免责声明救不回来。
 *   2. 在手量当前**不可信**:自动回收长期关闭、执行回写率仅 11%、
 *      现存认领单 79% 已过时限。以它为决策依据 = 把噪声固化成政策。
 *   3. **自毁 T20**:要反推的第一项就是"各客服实际能吃多少",那需要负载**方差**作自变量;
 *      而水位拉平把消灭方差当目标函数。
 * 另外它是全局耦合的:主管改一条指定客服,所有人的数字都要跳 —— 违 T13「一次确认」。
 * 均分是局部的:改一条只影响那一条。
 *
 * ⚠️ 切换到贪心「最少在手优先」将来只是一行排序的改动、不需要新列、不影响任何历史归因 ——
 * 所以这个决定**应该等在手量可信之后再用数据做**,现在不预支。
 */
export function placeAgents(
  chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
  agents: AgentInfo[],
  dedicatedByPatient: Map<string, string>,
  /**
   * **逐人**生效容量(整体基数 + 该人的精调)。⛔ 别在这里读常量或只收一个数 ——
   * 「李莉这周只给 5 条」正是靠这一层生效的,收单值等于精调对落人无效(而且不会报错)。
   */
  capacityOf: (userId: string) => number = () => AGENT_CAPACITY_DEFAULT,
): { placed: ProposedItem[]; unplaced: number } {
    const room = new Map<string, number>();
    for (const a of agents) room.set(a.userId, Math.max(0, capacityOf(a.userId) - a.inHand));
    const rosterIds = new Set(agents.map((a) => a.userId));

    const placed: ProposedItem[] = [];
    const overflow: typeof chosen = [];

    // ── 第一趟:专属命中 ──────────────────────────────────────
    for (const c of chosen) {
      const dcs = dedicatedByPatient.get(c.patientId);
      if (dcs && rosterIds.has(dcs) && (room.get(dcs) ?? 0) > 0) {
        room.set(dcs, room.get(dcs)! - 1);
        placed.push({
          planId: c.planId,
          patientId: c.patientId,
          assigneeUserId: dcs,
          assignStrategy: AssignStrategy.DEDICATED,
          selectionMode: c.selectionMode,
        });
      } else {
        overflow.push(c);
      }
    }

    // ── 第二趟:溢出均分 ──────────────────────────────────────
    // ⚠️ 剩余容量必须**接着第一趟的结果**算(room 是同一个累加器)——
    //   分开算的话专属大户会被再灌一轮,而这一点在代码上完全不显眼。
    const takers = agents.filter((a) => (room.get(a.userId) ?? 0) > 0).map((a) => a.userId);
    let cursor = 0;
    let unplaced = 0;
    for (const c of overflow) {
      // 找下一个还有余量的人;一圈都没有 → 分不下去
      let hops = 0;
      while (hops < takers.length && (room.get(takers[cursor % takers.length]!) ?? 0) <= 0) {
        cursor++;
        hops++;
      }
      if (takers.length === 0 || hops >= takers.length) {
        // ⛔ **截断不摊派**:硬塞给已经满的人是 T5「宁缺毋滥」的反面,
        //   而且会立刻制造 over_capacity 退回 —— 那正是要反推容量默认值的那个信号,
        //   自己造出来就没法反推了。
        unplaced++;
        continue;
      }
      const who = takers[cursor % takers.length]!;
      cursor++;
      room.set(who, room.get(who)! - 1);
      const dcs = dedicatedByPatient.get(c.patientId);
      placed.push({
        planId: c.planId,
        patientId: c.patientId,
        assigneeUserId: who,
        // ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。
        //   混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
        assignStrategy:
          dcs && rosterIds.has(dcs)
            ? AssignStrategy.SPREAD_OVERFLOW
            : AssignStrategy.SPREAD_NO_DEDICATED,
        selectionMode: c.selectionMode,
      });
    }

  return { placed, unplaced };
}

/**
 * 等距抽样 —— **确定性**,不用随机数。
 * 主管微调后会重算,随机会让名单无故跳动;而"同样的输入两次算出同样的分法"
 * 是他敢按确认键的前提。
 */
function pickEvenly<T>(arr: T[], n: number): T[] {
  if (n <= 0 || arr.length === 0) return [];
  if (n >= arr.length) return [...arr];
  const step = arr.length / n;
  return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!);
}

/**
 * 正整数校验(Json 里读出来的东西什么都可能是)。
 * ⛔ 这里**不做上下限** —— 容量是主管对自己团队的判断,系统没有依据卡他(见 AGENT_CAPACITY_DEFAULT)。
 */
function posInt(v: unknown): number | null {
  return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
}

/**
 * 精调表清洗 —— 来源有两处(Json 快照 / 模型生成的 tool 参数),**两处都不可信**:
 * 快照可能是老版本写的,模型可能吐出 `{capacity: "很多"}`。
 * 非法值**静默丢弃**而不是抛错:一条坏精调不该让整张确认单出不来。
 */
function sanitizeOverrides(
  raw: unknown,
): Record<string, { capacity?: number; expiresInDays?: number }> {
  if (!raw || typeof raw !== 'object') return {};
  const out: Record<string, { capacity?: number; expiresInDays?: number }> = {};
  for (const [userId, v] of Object.entries(raw as Record<string, unknown>)) {
    if (!userId || !v || typeof v !== 'object') continue;
    const o = v as { capacity?: unknown; expiresInDays?: unknown };
    const capacity = posInt(o.capacity);
    // 时效有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
    const days = posInt(o.expiresInDays);
    const expiresInDays = days != null && days <= 90 ? days : null;
    if (capacity == null && expiresInDays == null) continue;
    out[userId] = {
      ...(capacity != null ? { capacity } : {}),
      ...(expiresInDays != null ? { expiresInDays } : {}),
    };
  }
  return out;
}

/** 沿用的那次分配是哪天(只给日期,精确到秒对主管没意义) */
function ymd(iso: string): string {
  return iso.slice(0, 10);
}

/**
 * 容量说明 —— ⭐ 现在是**推导链 + 出处**,不再是一句"这是默认值"。
 *
 * 为什么必须显示推导链:批次规模由容量和在手量推出来,而在手量会变
 * (客服处理完 / 分配单到期回池)。同样的容量,这次推出 340、下次推出 80 ——
 * 主管看不到推导过程就只能怀疑系统抽风。把 `17 × 20 − 260 = 80` 摆出来,
 * 数字跳动立刻能归因到"老单还压着"。
 *
 * 为什么必须显示出处:同一个「20」,是**他上次自己调的**还是**系统第一次拍的**,
 * 信任度完全不同(T14:界面元素也算证据)。
 */
function capacityNote(x: {
  agents: number;
  capacity: number;
  inHandTotal: number;
  totalRoom: number;
  expiresInDays: number;
  basis: 'inherited' | 'default' | 'explicit';
  basisFrom: string | null;
  sizeBasis: 'explicit' | 'capacity';
  target: number;
  overrides: Array<{ name: string; capacity?: number; expiresInDays?: number }>;
}): string {
  const chain =
    `在岗 ${x.agents} 位 × 每人容量 ${x.capacity} − 已在手 ${x.inHandTotal} = 可分 ${x.totalRoom} 人` +
    (x.sizeBasis === 'explicit' ? `(本批按您指定的 ${x.target} 人走)` : '');
  // ⚠️ 精调必须**点名**:推导链里写的是整体容量,而李莉实际只有 5 条 ——
  //   不点出来,那条链自己就对不上账,主管会以为算错了。
  const tuned = x.overrides.length
    ? `其中${x.overrides
        .map(
          (o) =>
            `${o.name}${o.capacity != null ? ` 容量 ${o.capacity}` : ''}${
              o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : ''
            }`,
        )
        .join('、')}(单独精调,会一直沿用到您改回来);`
    : '';
  const from =
    x.basis === 'inherited' && x.basisFrom
      ? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「每人 30 条」「给 5 天」),下次自动记住。`
      : x.basis === 'explicit'
        ? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
        : `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推各人真实吞吐)——` +
          `觉得不合适直接说个数,确认后下次自动沿用。`;
  return `${chain};${tuned}${from}`;
}

function emptyProposal(
  clinicId: string,
  potentialTreatment: string | undefined,
  agents: AgentInfo[],
  rosterNote: string,
  base: {
    target: number;
    capacity: number;
    expiresInDays: number;
    basis: 'inherited' | 'default' | 'explicit';
    basisFrom: string | null;
    agentOverrides: Record<string, { capacity?: number; expiresInDays?: number }>;
    /** 逐人生效容量 —— ⚠️ 空提案也要用它判"谁已满",否则精调过的人会被算错 */
    capOf: (userId: string) => number;
  },
): AssignmentProposal {
  return {
    clinicId,
    potentialTreatment: potentialTreatment ?? null,
    candidateTotal: 0,
    target: base.target,
    capacity: base.capacity,
    expiresInDays: base.expiresInDays,
    basis: base.basis,
    basisFrom: base.basisFrom,
    agentOverrides: base.agentOverrides,
    placed: 0,
    unplaced: 0,
    items: [],
    byAgent: [],
    skippedAgents: agents
      .filter((a) => a.inHand >= base.capOf(a.userId))
      .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
    rosterNote,
    capacityNote: `每人容量 ${base.capacity} 条 / 时效 ${base.expiresInDays} 天。`,
    selectionNote:
      agents.length === 0
        ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
        : // ⚠️ 这句在新模型下会常见得多(容量默认从 50 降到 20):必须说清"加容量"这条出路,
          //   否则主管只看到"分不了",不知道旋钮在哪。
          `在岗 ${agents.length} 位客服在手量都已达容量 ${base.capacity} 条,本批无可分配余量 ——` +
          `要么等他们处理完(到期会自动回池),要么直接说「每人 ${base.capacity + 10} 条」提高容量。`,
  };
}
