import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
  BATCH_SIZE_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;
      /** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */
      expiresInDays?: number;
      /**
       * 按客服的**精调**(userId → 覆盖值)。
       * 「李莉这周带教,这批最多给 5 条」「王强那批给 7 天」。
       * ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
       *   让一条三个月前的临时精调不至于永远静默生效。
       */
      agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
      /**
       * 本批人数 N(基数①);不传 = 沿用上一次分配的值。
       * ⭐ 与时效同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
       */
      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;

    // ── 两个基数:本批人数 N + 时效 ──────────────────────────
    // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
    // ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。
    //
    // ⛔ **没有"容量"这个数了**(2026-08-03 第二次改判)。它当过一阵上限并用来推批次规模,
    //   结果是**第二批必然为 0**:第一批把所有人填到水位,再算就是 Σ(容量−容量)。
    //   主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
    //   现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
    const target =
      input.targetCount != null
        ? Math.max(0, Math.floor(input.targetCount))
        : baseline.batchSize;
    const expiresInDays =
      input.expiresInDays != null && input.expiresInDays > 0
        ? Math.floor(input.expiresInDays)
        : baseline.expiresInDays;
    const basis: 'inherited' | 'default' | 'explicit' =
      input.targetCount != null || input.expiresInDays != null
        ? 'explicit'
        : baseline.from
          ? 'inherited'
          : 'default';
    // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢)
    const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides });
    /// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍
    const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity;
    const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
    const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
    if (target === 0 || agents.length === 0) {
      return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, {
        target,
        expiresInDays,
        basis,
        basisFrom: baseline.from,
        agentOverrides,
      });
    }

    // ── ① 收敛:按三层键取前 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,
      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,
        /// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐)
        loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length,
        /// 逐人下发生效时效 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
        expiresInDays: expOf(userId),
        overridden: agentOverrides[userId] != null,
      })),
      agentOverrides,
      // 本批一条没分到的人**仍然列出来**:主管要看见"他不是被漏了"。
      // ⚠️ 语义已变:不是"满了"(没有上限这回事了),是**水位法没轮到他** —— 他手上本来就最多。
      skippedAgents: agents
        .filter((a) => !byAgent.has(a.userId))
        .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
      rosterNote: roster.rosterNote,
      // ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
      basisNote: basisNote({
        agents: agents.length,
        placed: items.placed.length,
        inHandTotal,
        expiresInDays,
        basis,
        basisFrom: baseline.from,
        target,
        loads: [...byAgent].map(([u, l]) => ({
          name: agentById.get(u)?.name ?? u.slice(0, 8),
          after: (agentById.get(u)?.inHand ?? 0) + l.length,
        })),
        // 精调过的人要点名 —— 「为什么李莉只有 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} 人;`) +
        // ⚠️ 池子里还剩多少必须说 —— 否则主管不知道「还能不能再分一批」(答案是:能)
        (candidateTotal > target
          ? `池子里还有 ${candidateTotal - target} 人排队,**随时可以再分一批**(要多分就说个数)。`
          : '') +
        (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<{
    batchSize: number;
    expiresInDays: number;
    from: string | null;
    agentOverrides: Record<string, { maxThisBatch?: 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 {
      batchSize?: unknown;
      /// ⚠️ 老快照里叫 `target`(那时它是推出来的结果,不是基数)—— 一并认,否则第一次改版后
      ///   所有人的"沿用"都会退回默认值,而界面上只会显示"首次默认",看不出丢了东西
      target?: unknown;
      expiresInDays?: unknown;
      agentOverrides?: unknown;
    } | null;
    const batchSize = posInt(c?.batchSize) ?? posInt(c?.target);
    const expiresInDays = posInt(c?.expiresInDays);
    // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
    return {
      batchSize: batchSize ?? BATCH_SIZE_DEFAULT,
      expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
      from: batchSize != 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>,
  /**
   * 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
   * ⛔ 这不是"容量" —— 容量那个概念已经删了(见 BATCH_SIZE_DEFAULT)。
   *   它只表达「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
   */
  maxOf: (userId: string) => number = () => Infinity,
): { placed: ProposedItem[]; unplaced: number } {
  const rosterIds = new Set(agents.map((a) => a.userId));
  /// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。两趟共用同一本账。
  const load = new Map<string, number>(agents.map((a) => [a.userId, a.inHand]));
  const given = new Map<string, number>(agents.map((a) => [a.userId, 0]));
  const canTake = (u: string) => (given.get(u) ?? 0) < maxOf(u);
  const take = (u: string) => {
    load.set(u, (load.get(u) ?? 0) + 1);
    given.set(u, (given.get(u) ?? 0) + 1);
  };

  /**
   * 🔴 **目标水位** = 这一批分完之后,大家理想中应该停在的高度。
   *
   *   `(团队现有在手 + 本批人数) / 在岗人数`
   *
   * 它不是新旋钮 —— 完全由 N 和在手量推出来。存在的理由只有一个:
   * **给专属那一趟封顶**。容量删掉之后没有任何东西约束专属了,实测一次:
   * 100 条里 76 条是同一个人的专属,他一个人吃到 76,水位法只剩零头可铺,
   * 「最后保持齐平」当场落空。
   *
   * ⚠️ 这不是"不给专属了" —— 是他**这一批的份额已经满了**,后面同样是他专属的患者
   *   转成铺平(标 SPREAD_OVERFLOW,关系还在,只是这轮没轮到)。语义与原来
   *   「专属客服已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
   * ⚠️ 向上取整 + 至少 1:否则 N 比人数还小时目标水位算出 0,专属一条都进不去,
   *   整批全走铺平 —— 那等于把专属关系整个关掉。
   */
  const inHandSum = agents.reduce((a, g) => a + g.inHand, 0);
  const waterline =
    agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;

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

  // ── 第一趟:专属命中(**受目标水位约束**)────────────────────
  for (const c of chosen) {
    const dcs = dedicatedByPatient.get(c.patientId);
    if (dcs && rosterIds.has(dcs) && canTake(dcs) && (load.get(dcs) ?? 0) < waterline) {
      take(dcs);
      placed.push({
        planId: c.planId,
        patientId: c.patientId,
        assigneeUserId: dcs,
        assignStrategy: AssignStrategy.DEDICATED,
        selectionMode: c.selectionMode,
      });
    } else {
      overflow.push(c);
    }
  }

  // ── 第二趟:溢出**水位法** ────────────────────────────────
  // 每一条都给**当前水位最低**的人 —— 分完后大家手上的量趋于齐平。
  //
  // ⚠️ 与原来的「轮转均分」不是一回事,差别正是主管抱怨的那个场景:
  //   轮转均分给每人**加一样多**,起点不齐(有人刚被专属灌了 20 条)终点还是不齐;
  //   水位法给**手上最少的人先加**,把差距抹平。
  // ⚠️ 必须接着第一趟的账算(load 是同一个 Map)—— 分开算的话专属大户会被再灌一轮,
  //   而这一点在代码上完全不显眼。
  // ⚠️ 同水位按 userId 排序打破平局 —— 两次算出同样的分法是主管敢按确认键的前提。
  let unplaced = 0;
  for (const c of overflow) {
    let who: string | null = null;
    let best = Infinity;
    for (const a of agents) {
      if (!canTake(a.userId)) continue; // 精调成 0 / 已到本批名额上限
      const l = load.get(a.userId) ?? 0;
      if (l < best || (l === best && who != null && a.userId < who)) {
        best = l;
        who = a.userId;
      }
    }
    if (who == null) {
      // ⚠️ 水位法**没有上限**,所以这里正常到不了:只有名册为空、
      //   或所有人都被精调成 0 名额时才会落到这一支。⛔ 别把它读成"团队满了"。
      unplaced++;
      continue;
    }
    take(who);
    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 里读出来的东西什么都可能是)。
 * ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他(见 BATCH_SIZE_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, { maxThisBatch?: number; expiresInDays?: number }> {
  if (!raw || typeof raw !== 'object') return {};
  const out: Record<string, { maxThisBatch?: 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 { maxThisBatch?: unknown; expiresInDays?: unknown };
    // ⚠️ 名额上限**允许 0**(= 这轮不给他),所以不能用 posInt
    const maxThisBatch =
      typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0
        ? o.maxThisBatch
        : null;
    // 时效有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
    const days = posInt(o.expiresInDays);
    const expiresInDays = days != null && days <= 90 ? days : null;
    if (maxThisBatch == null && expiresInDays == null) continue;
    out[userId] = {
      ...(maxThisBatch != null ? { maxThisBatch } : {}),
      ...(expiresInDays != null ? { expiresInDays } : {}),
    };
  }
  return out;
}

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

/**
 * 基数说明 —— **值 + 出处 + 分配后的水位**。
 *
 * 为什么必须给出处:同一个「100 人」,是**他上次自己定的**还是**系统第一次拍的**,
 * 信任度完全不同(T14:界面元素也算证据)。
 *
 * 为什么必须给水位:批次规模不再由在手量推导,所以主管看不到"团队现在多满" ——
 * 但他仍然需要知道这一批压下去之后每人手上有多少。⛔ 这里**不设阈值、不做判断**
 * ("是否超载"没有数据支撑),只报事实:分完后大家在 X~Y 条之间。
 */
function basisNote(x: {
  agents: number;
  placed: number;
  inHandTotal: number;
  expiresInDays: number;
  basis: 'inherited' | 'default' | 'explicit';
  basisFrom: string | null;
  target: number;
  loads: Array<{ name: string; after: number }>;
  overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>;
}): string {
  const after = x.loads.map((l) => l.after);
  const lo = after.length ? Math.min(...after) : 0;
  const hi = after.length ? Math.max(...after) : 0;
  const top = x.loads.find((l) => l.after === hi);
  const water =
    x.placed > 0
      ? `本批 ${x.placed} 人铺给 ${x.loads.length} 位客服(**从手上最少的开始填**),` +
        `分完后每人在手 ${lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最高 ${top?.name})`};` +
        `团队原有在手 ${x.inHandTotal} 条。`
      : '';
  // ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」时要立刻知道那是他自己设的,不是算错了
  const tuned = x.overrides.length
    ? `其中${x.overrides
        .map(
          (o) =>
            `${o.name}${o.maxThisBatch != null ? ` 本批最多 ${o.maxThisBatch} 条` : ''}${
              o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : ''
            }`,
        )
        .join('、')}(单独精调,会一直沿用到您改回来);`
    : '';
  const from =
    x.basis === 'inherited' && x.basisFrom
      ? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。`
      : x.basis === 'explicit'
        ? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
        : `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推团队真实吞吐)——` +
          `觉得不合适直接说个数,确认后下次自动沿用。`;
  return `${water}${tuned}${from}`;
}

function emptyProposal(
  clinicId: string,
  potentialTreatment: string | undefined,
  agents: AgentInfo[],
  rosterNote: string,
  base: {
    target: number;
    expiresInDays: number;
    basis: 'inherited' | 'default' | 'explicit';
    basisFrom: string | null;
    agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
  },
): AssignmentProposal {
  return {
    clinicId,
    potentialTreatment: potentialTreatment ?? null,
    candidateTotal: 0,
    target: base.target,
    expiresInDays: base.expiresInDays,
    basis: base.basis,
    basisFrom: base.basisFrom,
    agentOverrides: base.agentOverrides,
    placed: 0,
    unplaced: 0,
    items: [],
    byAgent: [],
    // 空提案里所有在岗的人都"没分到"
    skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
    rosterNote,
    basisNote: `本批人数 ${base.target} / 时效 ${base.expiresInDays} 天。`,
    selectionNote:
      agents.length === 0
        ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
        : // target=0 只能是主管自己说的(基数默认永远 > 0)
          '本批人数被设成了 0,没有可分配的量 —— 说个数字我重出一版。',
  };
}
