import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
  BATCH_SIZE_PER_AGENT_FIRST,
  AssignStrategy,
  type AgentInfo,
  type AssignmentProposal,
  type ProposedItem,
  type TemperatureValue,
} from '@pac/types';
import { calcAge } from '@pac/utils';
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` 本身表达,不设阈值。
    /**
     * 基数 N —— **要被记住的那个数**。
     *   本次主管指定 > 沿用上一次 > 首次估法 `在岗人数 × 20`。
     * ⚠️ 首次那个 20 **不是容量上限**,只是"没有任何历史时一批推多大"的估法,
     *   之后就再也不出现了(沿用上次的值)。
     */
    const batchSize =
      input.targetCount != null
        ? Math.max(0, Math.floor(input.targetCount))
        : (baseline.batchSize ?? agents.length * BATCH_SIZE_PER_AGENT_FIRST);
    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 (batchSize === 0 || agents.length === 0) {
      return emptyProposal(clinicId, potentialTreatment, input.temperature, agents, roster.rosterNote, {
        target: 0,
        batchSize,
        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 数」,同一个人两条召回会被数两次,又对不上了。
    // ⭐ 取数上限按**基数**算(不是按 target)—— target 还没算出来:它是
    //   `min(基数, 候选总数)`,而候选总数正是下面这次 count 的结果。
    //   按基数取一定够(target ≤ 基数),这样 count 与取明细还能并发,不多一个来回。
    const fetchLimit = Math.ceil(batchSize * 1.5) + 20;
    const [counts, pool] = await Promise.all([
      this.countCandidates(scope, criteria, now),
      this.selectCandidates(scope, criteria, fetchLimit, now),
    ]);
    const candidateTotal = counts.total;
    /**
     * 本批**实际**分多少 = `min(基数 N, 候选总数)`。
     *
     * ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
     *   回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人,
     *   而他完全看不出为什么变小了(界面上只会显示"沿用上次")。
     */
    const target = Math.min(batchSize, candidateTotal);

    // 同患者只留一条(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, info } = await this.patientsOf(chosen.map((c) => c.patientId));
    const raw = placeAgents(chosen, agents, dedicated, capOf);
    // ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
    //   让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
    const items = {
      unplaced: raw.unplaced,
      placed: raw.placed.map((it) => {
        const p = info.get(it.patientId);
        return {
          ...it,
          patientName: p?.name ?? null,
          medicalRecordNumber: p?.mrn ?? null,
          gender: p?.gender ?? null,
          age: p?.age ?? null,
        };
      }),
    };

    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,
      // ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
      //   「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
      temperature: input.temperature ?? null,
      /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
      candidateTotal,
      target,
      /// ⭐ 要被记住的是**基数**不是 target —— 候选不够时 target 被压低,那是一批的偶然事实
      batchSize,
      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,
        batchSize,
        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]! })),
      }),
      /**
       * 「**怎么选**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
       *
       * ⛔ 不许出现「排序键 / 三层键 / 患者号 / 收敛 / 探索配额」这类内部词 ——
       *   主管问的是"凭什么是这 340 个人",不是我们的实现方式。
       * ⚠️ **候选不够时不能说"取前 N 人"**:主管从矩阵点了一个 44 人的格子进来,
       *   句子却说「取前 340 人」,他会以为有 296 个人被系统丢了(T14)。
       * ⚠️ 池子里还剩多少必须说 —— 否则他不知道"还能不能再分一批"(答案是:能)。
       */
      selectionNote:
        (candidateTotal <= target
          ? `符合条件的一共就 ${candidateTotal} 人,这批**全要了**,没有取舍。`
          : `符合条件的一共 ${candidateTotal} 人,这批挑了 ${target} 人:` +
            // ⚠️ 「没被分过的排在前面」**只在池子里真有回池的人时才说**(2026-08-03 走查)。
            //   池子的基线已经排除了"还在客服手上"的单,所以绝大多数时候池子里 100% 都没分过 ——
            //   这时说这句是废话,还会让主管以为系统在防什么根本不存在的情况。
            //   ⛔ 但也不能永远不说:分过又退回的人**确实还在池子里**(T7:退回是正常路径,
            //   不能因为分过一次就永不再召),他们排在后面,主管有权知道。
            (counts.repeat > 0
              ? `**之前分过又退回的 ${counts.repeat} 人排在后面**,其余按优先级高的排前面。`
              : `**优先级高的排在前面**。`) +
            `剩下 ${candidateTotal - target} 人在排队,**随时可以再分一批**。`) +
        (exploreN > 0
          ? `另外有 ${exploreN} 人是特意从排名靠后的位置抽的 —— 用来日后检验"排在前面的是不是真的更容易成"。`
          : '') +
        (items.unplaced > 0 ? `⚠️ 有 ${items.unplaced} 人没能分下去。` : ''),
    };
  }

  /**
   * 候选取数 —— 走原生 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 | null;
    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);
    // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
    // ⚠️ batchSize 读不到就返回 **null**,不在这里兜默认 —— 首次的估法要用到"在岗人数",
    //   而这里不认识名册。兜底交给 propose()。
    return {
      batchSize,
      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<{ total: number; repeat: number }> {
    const rows = await this.prisma.$queryRaw<Array<{ n: bigint | number; r: bigint | number }>>(
      Prisma.sql`
        SELECT count(DISTINCT fp.patient_id) AS n,
               count(DISTINCT fp.patient_id) FILTER (WHERE fp.assignment_id IS NOT NULL) AS r
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
      `,
    );
    return { total: Number(rows[0]?.n ?? 0), repeat: Number(rows[0]?.r ?? 0) };
  }

  /**
   * 一次取齐**专属客服**与**患者身份**(姓名 / 病历号)。
   *
   * ⭐ 合并成一次查询而不是两次:同一批 id、同一张表,分两次纯属多一个来回。
   * ⚠️ 姓名要跟着确认单下发 —— 主管展开客服看的是"这 20 个人是谁",
   *   给一串 `#912e5dae` 等于让他对着十六进制猜。
   */
  private async patientsOf(patientIds: string[]): Promise<{
    dedicated: Map<string, string>;
    info: Map<string, PatientBrief>;
  }> {
    const ids = [...new Set(patientIds)];
    if (ids.length === 0) return { dedicated: new Map(), info: new Map() };
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: ids } },
      select: {
        id: true,
        preferences: true,
        name: true,
        medicalRecordNumber: true,
        gender: true,
        birthDate: true,
      },
    });
    const dedicated = new Map<string, string>();
    const info = new Map<string, PatientBrief>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) dedicated.set(r.id, id);
      info.set(r.id, {
        name: r.name ?? null,
        mrn: r.medicalRecordNumber ?? null,
        gender: r.gender ?? null,
        // ⚠️ 存的是生日不是年龄 —— 年龄必须**读时算**(与列表页 plan.service 同一个 calcAge),
        //   两处各写一遍必然在生日当天差一岁
        age: r.birthDate ? calcAge(r.birthDate) : null,
      });
    }
    return { dedicated, info };
  }
}

/**
 * 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关,
 * 由 propose() 在算完之后贴上(见那里的注释)。
 */
type PlacedItem = Omit<ProposedItem, 'patientName' | 'medicalRecordNumber' | 'gender' | 'age'>;

/** 确认单要展示的患者身份(与患者详情卡片同口径) */
interface PatientBrief {
  name: string | null;
  mrn: string | null;
  gender: string | null;
  age: number | null;
}

/**
 * ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账。
 *
 * ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
 *   而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
 *
 * ═══ 目标:**又满又平** ═══════════════════════════════════════
 *   满 = N 个名额全部落地(⛔ 不因为"他的专属满了"就把人丢掉);
 *   平 = 分完后大家在手量趋于齐平。
 *
 * ── 为什么必须是三趟,不能合成两趟 ────────────────────────────
 * 关键在**第二、三趟的先后**:两趟都是水位法、都填最空的人,合并起来总量一样,
 * 但**被拆散的专属关系数不一样** —— 先用无主的去补空手的人,能少动一个有主患者。
 * 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
 *
 * ── 目标水位 = (团队现有在手 + 本批人数) / 在岗人数 ──────────────
 * **不是新旋钮**,完全由 N 推出来。存在的唯一理由是**给第一趟封顶**:
 * 不封顶的话,专属集中的诊所会出现"一个人吃掉整批"——
 * 本地实测:池子 1,081 人里 755 人(70%)挂在同一个客服名下,不封顶他一批拿 248 条,
 * 而 17 位在岗客服里有 10 位名下一个患者都没有,只能靠 119 个自由患者过活。
 *
 * ⚠️ 超出水位的专属患者**不是被丢掉**,是进第三趟改派(标 `SPREAD_OVERFLOW`:
 *   关系还在、只是这轮没轮到)。⛔ 别把这一步理解成"抢客户"。
 */
export function placeAgents(
  chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
  agents: AgentInfo[],
  dedicatedByPatient: Map<string, string>,
  /**
   * 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
   * ⛔ 这不是"容量" —— 容量那个概念已经删了。它只表达
   *   「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
   * ⚠️ 它**压过一切**(含专属):主管说了只给 5 条,第 6 个她的专属患者会被改派给别人。
   */
  maxOf: (userId: string) => number = () => Infinity,
): { placed: PlacedItem[]; 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);
  };
  /// 这个患者的专属客服(且在名册内);null = 无主,谁都能接
  const ownerOf = (patientId: string) => {
    const d = dedicatedByPatient.get(patientId);
    return d && rosterIds.has(d) ? d : null;
  };
  /// ⚠️ 向上取整 + 至少 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: PlacedItem[] = [];
  const free: typeof chosen = []; // 无主 / 专属已离岗
  const held: typeof chosen = []; // 有主,但专属这轮的份额已满
  let unplaced = 0;

  // ── 第一趟:专属命中(受目标水位封顶)────────────────────────
  for (const c of chosen) {
    const owner = ownerOf(c.patientId);
    if (owner == null) {
      free.push(c);
      continue;
    }
    if (!canTake(owner) || (load.get(owner) ?? 0) >= waterline) {
      held.push(c);
      continue;
    }
    take(owner);
    placed.push({
      planId: c.planId,
      patientId: c.patientId,
      assigneeUserId: owner,
      assignStrategy: AssignStrategy.DEDICATED,
      selectionMode: c.selectionMode,
    });
  }

  /**
   * 水位法取人:给**当前水位最低**的那个;同水位按 userId 打破平局(确定性 ——
   * 同样的输入两次算出同样的分法,是主管敢按确认键的前提)。
   * 返回 null = 没人能收(名册空 / 全被精调成 0 名额)。
   */
  const lowest = (): string | null => {
    let who: string | null = null;
    let best = Infinity;
    for (const a of agents) {
      if (!canTake(a.userId)) continue;
      const l = load.get(a.userId) ?? 0;
      if (l < best || (l === best && who != null && a.userId < who)) {
        best = l;
        who = a.userId;
      }
    }
    return who;
  };

  // ── 第二趟:**无主的先去补空手的人** ──────────────────────────
  // ⭐ 顺序在这里:无主患者没有关系要顾,拿他们填坑**零代价**;
  //   等无主的用完了,才不得已去动第三趟那些有主的。
  for (const c of free) {
    const who = lowest();
    if (who == null) {
      unplaced++;
      continue;
    }
    take(who);
    placed.push({
      planId: c.planId,
      patientId: c.patientId,
      assigneeUserId: who,
      assignStrategy: AssignStrategy.SPREAD_NO_DEDICATED,
      selectionMode: c.selectionMode,
    });
  }

  // ── 第三趟:无主的不够了 → 有主患者改派 ────────────────────────
  // ⚠️ 标 `SPREAD_OVERFLOW` 而不是 SPREAD_NO_DEDICATED:**关系还在,只是这轮没轮到**。
  //   两者混成一个值,日后算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
  for (const c of held) {
    const who = lowest();
    if (who == null) {
      unplaced++;
      continue;
    }
    take(who);
    const owner = ownerOf(c.patientId);
    placed.push({
      planId: c.planId,
      patientId: c.patientId,
      assigneeUserId: who,
      // 极端情况:水位法又转回了他自己的专属(他确实是最空的)→ 那就还是 dedicated
      assignStrategy: who === owner ? AssignStrategy.DEDICATED : AssignStrategy.SPREAD_OVERFLOW,
      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 里读出来的东西什么都可能是)。
 * ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他。
 */
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);
}

/**
 * 「**怎么分**」—— ⭐ 说给主管听,不是说给程序员听(2026-08-03 走查)。
 *
 * ⛔ 不许出现「水位法 / 基数 / 铺平 / 目标水位 / 溢出」这些内部词。
 *   主管问的是"凭什么李莉拿这 20 个",答案要能用一句大白话说清:
 *   **有专属的先回自己人手上,剩下的谁手上活少先给谁**。
 *
 * ⚠️ 三件事一件都不能少(都是 T14 的要求,只是换了说法):
 *   ① 分完之后每人手上多少 —— 他要判断会不会压太狠(⛔ 只报事实,不替他下"超载"的判断);
 *   ② 这两个数(人数/时效)**哪来的** —— 沿用上次 / 第一次估的 / 他自己刚说的,信任度不同;
 *   ③ 精调过的人要**点名** —— 否则"为什么李莉只有 5 条"没人答得上来。
 */
function basisNote(x: {
  agents: number;
  placed: number;
  inHandTotal: number;
  expiresInDays: number;
  basis: 'inherited' | 'default' | 'explicit';
  basisFrom: string | null;
  /// 基数(要被记住的那个);⚠️ 与 target 不同 —— 候选不够时 target 被压低
  batchSize: number;
  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 how =
    x.placed > 0
      ? `${x.placed} 人分给 ${x.loads.length} 位客服:**有专属客服的先回到自己人手上**,` +
        `剩下的**谁手上活少先给谁**。分完后每人手里 ` +
        (lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最多的是${top?.name})`) +
        (x.inHandTotal > 0 ? `(他们原本还有 ${x.inHandTotal} 条在手)` : '') +
        `,${x.expiresInDays} 天没动会自动退回池子。`
      : '';
  // ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了
  const tuned = x.overrides.length
    ? `其中${x.overrides
        .map(
          (o) =>
            `${o.name}${o.maxThisBatch != null ? `这批最多 ${o.maxThisBatch} 条` : ''}${
              o.expiresInDays != null ? `时效 ${o.expiresInDays} 天` : ''
            }`,
        )
        .join('、')}(您单独设过,会一直沿用到改回来)。`
    : '';
  // ⚠️ 候选不够被压低时要说清"您设的数没变" —— 否则他以为系统偷偷把设置改小了
  const capped = x.target < x.batchSize ? `(符合条件的只有这么多,您设的 ${x.batchSize} 没变)` : '';
  const from =
    x.basis === 'inherited' && x.basisFrom
      ? `人数和时效**沿用您 ${ymd(x.basisFrom)} 那次**${capped},要改直接说(比如「这批 200 人」「给 5 天」),下次自动记住。`
      : x.basis === 'explicit'
        ? `人数和时效是**您这次定的**${capped},确认后下次自动沿用。`
        : `人数和时效是**第一次的估算值**${capped} —— 按每位客服 ${BATCH_SIZE_PER_AGENT_FIRST} 条估的,` +
          `没有历史数据可参考;觉得不合适直接说个数,确认后下次就按您的来。`;
  return `${how}${tuned}${from}`;
}

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