import {
  DAILY_CALLS_PER_AGENT,
  type AssignmentProposal,
  type Signal,
} from '@pac/types';
import type { NarrowFacts } from './cohort-attributes.service';

/**
 * assignment-signals —— 引导节点的**确定性判定**。
 *
 * 规格见 docs/design/assignment-agent-flow.md §五。五条设计规则，逐条对应到代码：
 *   ① **只陈述事实，不给建议** —— `why` 写「他们的专属客服已排满」，
 *      ⛔ 不写「建议你铺平」。选项中性并列，给建议就是替主管做决定。
 *   ② **默认永远是 no-op** —— 每个节点的 `defaultLabel` 描述的都是"不动会怎样"，
 *      这样「一条不点、直接确认」在任何情况下都安全。
 *   ③ **有后果的全亮，⛔ 不截断** —— 防噪音靠 tier 分层（action 展开 / info 折叠），
 *      不靠丢弃。藏起一条主管就不知道有东西卡着。
 *   ④ **每次重算，不做增量** —— 本模块是纯函数，输入一份提案、输出整套节点。
 *   ⑤ **不是关卡** —— 只产出数据，⛔ 不阻塞确认。
 *
 * ⛔ **判定归程序，不归模型。** 模型只拿 title / why / defaultLabel 去组织语言，
 *   ⛔ 拿不到也不需要 options 的技术细节。
 */

/**
 * 动作契约 —— **按钮与模型工具共用的同一组 id**。
 *
 * ⭐ 这是「按钮能做的、说话也能做」的结构保证：卡片点击直接带 intent 调后端，
 *   主管说话则由模型翻译成同一个 intent。⛔ 两边各写一套必然漂，而漂了不报错。
 */
export const ASSIGNMENT_INTENTS = {
  /// 待分配的各自回自己的专属客服（⛔ 一条专属关系都不动）
  PENDING_TO_OWNER: 'pending.to_owner',
  /// 待分配的铺平给在岗（谁手上少先给谁）
  PENDING_SPREAD: 'pending.spread',
  /// 用池子里等量的无主患者把这批填满（⛔ 不动任何专属关系）
  PENDING_REFILL: 'pending.refill',
  /// 待分配的整组移出本批
  PENDING_REMOVE: 'pending.remove',
  /// 改批次时效
  EXPIRY_SET: 'expiry.set',
  /**
   * 🔴 `DAILY_RATE_SET`（daily_rate.set）与 `BATCH_SIZE_SET`（batch_size.set）
   *   **删于 2026-08-15**，⛔ 别加回来。
   *
   * 它们只挂在 `daily_overload` 的两颗**不带数**的按钮上（「改每人每天打几通」
   * 「减少本批人数」），点下去替主管说的那句话同样不带数
   * （「减少一些」「按更少的通数算」）—— 那个数只能由模型自己拍。
   * ⚠️ 同样的两件事由 `BASIS_SET` 覆盖，而它**带输入框、预填当前值**，
   *   主管填的那个数会一路传到 `propose_assignment` 由服务端重估 N。
   *   ⇒ 一个 intent 只该有一种带数的走法。
   */
  /**
   * 改「本批多大」那个式子里的一项（`args.days` 时效 / `args.rate` 每天几通）——
   * **要重跑算法**：N = 在岗 × 每天几通 × 时效，改哪一项人数都跟着变。
   *
   * 🔴 ⛔ 别拿 `EXPIRY_SET` 去凑：那个只把已经算好的这批人的**到期日**改掉，
   *   人数一个不变 —— 而这条节点的原话是「改时效或改每天几通，这批人数都会跟着变」。
   *   用错了节点就在说谎（2026-08-13 实测：卡片显示 3 天，节点仍写着 ×1 天，人数没动）。
   */
  BASIS_SET: 'basis.set',
  // 🔴 曾有一个 `COHORT_WIDEN: 'cohort.widen'`（换时间档 / 换治疗项，重出一版），
  //   随 `short_supply` 一起删于 2026-08-14 —— **放宽这件事没有节点会提出来了**：
  //   换格子是主管在矩阵上一点的事，矩阵每格都写着人数，卡片上的按钮反而不带数。
  //   ⛔ 别只删节点留着 intent：留下来的话，下次有人看到前端还接着它，
  //   会以为"放宽"这条路还活着（死代码最贵的地方是它看起来还在服役）。
  /**
   * 收窄这批人再选一版 —— 两种参数形态,**都走这一个 intent**:
   *   · `args.personaTags` = `维度:取值`(同维度多值用逗号,语义是 OR)
   *   · `args.minSpendYuan` = 累计净消费下界(元)
   * ⚠️ 两者都必须带 `labelZh` + `count`(给人看的中文名与选完剩多少人)——
   *   ⛔ 缺了就退化成把内部码念给主管听。
   */
  COHORT_NARROW: 'cohort.narrow',
  /**
   * 「按别的条件选」—— 让模型去报一遍这批人**还有哪些维度可切、各多少人**。
   * ⚠️ 它不改任何东西,只是把 `get_cohort_attributes` 这一步做成按钮 ——
   *   ⛔ 别让主管自己打字问:固定的一句话做成按钮就没有理解错的余地(G10)。
   */
  COHORT_ATTRS_ASK: 'cohort.attrs_ask',
} as const;

/**
 * 引导节点的**语义标识** —— 内部 key → 给模型用的中文名。
 *
 * ═══ 为什么不是序号，也不是内部 key（2026-08-14 改判）═══════════════
 * 原来给模型的是**序号**（`编号: 1`），理由写在 `assignment-facts.ts`：
 *   「用序号而不是 signal.key：`highlight:rfm_segment:…` 这种内部码一旦进了模型上下文，
 *     **早晚会**被它念出口。序号没有语义，念出来也只是个数字。」
 *
 * 🔴 复核之后这条理由**站不住**，两处都有问题：
 *   ① 「早晚会」是**预测**，不是事故记录 —— 却写在事故档案那一栏的语气里，
 *      被后来的人反复当成既成事实引用。
 *   ② 它举的 `highlight:rfm_segment:…` **已经不存在了**（那批节点早换成 `narrow`）。
 *      现存四个 key 短、干净，念出来也不吓人。
 *
 * 🔴 而序号是**有代价**的，裸台实测拿到了：把编号整个去掉、工具改成无参数之后，
 *   模型**只调了一次**，而且连另外两条的内容都没讲 —— 三条在它眼里失去了个体身份。
 *   ⇒ 标识不只是句柄，它承载「这是可以逐个处理的几件事」。
 *
 * ⇒ 结论：**要语义，但要中文的语义**。原来那条理由真正反对的是"丑陋的内部码"，
 *   而不是"语义"本身 —— 中文标识即使被念出口，读起来也是一句正常的话。
 *
 * ⚠️ 这也是 `show_guidance` 参数的**取值域**（enum）。判据是固定的四条，所以它是静态的：
 *   ⛔ 别做成"随每版提案变"—— 工具清单在 propose_assignment 跑之前就建好了。
 * ⛔ 改中文名要连着前端一起看：它会出现在模型嘴里，但**不**参与任何匹配
 *   （界面拿的是服务端解析好的 `signalKey`，见 show_guidance 的 execute）。
 */
export const GUIDANCE_KIND: Readonly<Record<string, string>> = {
  pending: '待分配',
  narrow: '换个条件选',
  batch_size_basis: '本批人数',
  daily_overload: '打不完',
};

/** `show_guidance` 的取值域 —— 与上表同源，⛔ 别另写一份。 */
export const GUIDANCE_KINDS = Object.values(GUIDANCE_KIND);

/** 严重度 —— 排序规则必须确定性，⛔ 不许由模型判断。 */
const SEV = {
  /// 有人会被静默漏掉
  SILENT_DROP: 1,
  /// 会造成错误预期
  WRONG_EXPECTATION: 2,
  /// 机会性的
  OPPORTUNITY: 3,
  /// 纯信息
  INFO: 4,
} as const;

// ═══════════════════════════════════════════════════════════════
// 换个条件选这一格
// ═══════════════════════════════════════════════════════════════

/**
 * 两道闸 —— **就这两条,没别的了**（2026-08-13 产品定）。
 *
 * 🔴 删掉的是「倍数 ≥2 或高出 ≥12 个百分点」那套偏差判据,以及它要的那份基线分布。
 *   删的理由不是它算错了,是**它在替主管判断"这值不值得注意"**——
 *   而他要的只是「还能怎么切」。⇒ 不比基线,直接把几刀摆出来,切不切他定（T8）。
 *   ⛔ 别再加回任何"够不够显著"的闸:那等于又替他判断了一次。
 */
const NARROW = {
  /// 候选集至少这么大 —— 人太少时"平均数""最多的那一项"本身就是噪音
  MIN_COHORT: 50,
  /// 一刀切完至少要剩这么多人,否则这个选项不给
  MIN_COUNT: 10,
} as const;

/**
 * 「还能再筛一刀」—— **一个节点、几个按钮**，挂在「这批人怎么来的」下面。
 *
 * ⭐ 为什么是一个节点而不是一维一个（2026-08-13 产品定）：这几刀回答的是**同一个问题**
 *   （这批人还能怎么收窄），四个各带标题的节点会把一句话的事撑成一屏，
 *   而每个标题说的又都是同一件事。⇒ 合成一条，解释靠按钮文案自己带。
 *
 * 🔴 换的是**这一格选进来哪些人**（`criteria`），⛔ 不是在已排好的那 N 个里再挑 ——
 *   点下去会**重新出一版**，本批人数跟着重新估。措辞见下方 title 那段。
 * ⚠️ 每个按钮都把**选完剩多少人**写进文案：换完批次会缩水多少，他点之前就该看见
 *   （权益那种最高项只占 3% 的维度，切完可能从 495 人剩到 76 人）。
 *   ⛔ 不为此加占比闸 —— 人数写在按钮上就是「可见」（②可见可撤）：点之前知道会剩多少，
 *   点完嫌少还能重来。
 *   ⚠️ 2026-08-14：原文这里还写着「重排后 `short_supply` 会兜住」—— **那句话早就不成立了**：
 *     `short_supply` 加了 `narrowedBy` 闸之后，恰恰在收窄出来的那一版不出；而它现在整条已删。
 *     ⇒ 拿"另一条判据会兜住"当理由时，去看一眼那条判据当下的闸。
 *
 * 🔴 **已经切过的维度不再给**（`narrowedBy`，2026-08-13）。主管按消费切完之后，
 *   这里原本又会递上「只选消费高于这一格平均（¥5,246）的 78 人」—— 而 ¥5,246
 *   **正是他那一刀造出来的**（砍掉低端，均值从 ¥720 跳上去）。再点一次又跳一次，
 *   是个**自我收窄的螺旋**：每一步都"看起来有依据"，其实只是在追自己的尾巴。
 */
export function computeNarrowSignal(
  f: NarrowFacts,
  /** 主管已经追加过的条件 —— 非空就整条不出（见上方 🔴） */
  narrowedBy?: AssignmentProposal['narrowedBy'],
  /**
   * 这一版是不是「重新排一版」出来的（`preferPlaceable`）。
   *
   * 🔴 2026-08-14 走查：主管点完「重新排一版」，卡片上写着「**已重挑这 495 人**：
   *   池子里 495 位无主的全换进来了」，而它**正上方**还挂着「这一格 2509 人，
   *   也可以只选其中一类」—— 点下去这一版整个作废，他刚做的动作白做。
   * ⚠️ 闸的本意从来不是"切过 personaTags 才关"，是**主管已经在这一版上动过手**：
   *   收窄是动手，重挑也是动手。⇒ 两种都关。
   */
  repicked?: boolean,
): Signal | null {
  // 🔴 主管已经在这一版上动过手 → 整条不出（收窄见上方三版沿革；重挑见上方参数注释）
  if (narrowedBy?.minSpendYuan != null || narrowedBy?.personaTags) return null;
  if (repicked) return null;
  if (f.size < NARROW.MIN_COHORT) return null;
  const options: Signal['options'] = [];

  /**
   * ① 消费 —— 门槛取**这一批自己的平均**，⛔ 不是全库分位。
   *
   * 🔴 文案必须写「这一格的平均」：门槛随格子飘得厉害（实测全库平均 ¥4,442，
   *   而「拔牙·3 年以上」这一格只有 ¥720，差六倍）。写成「高消费客户」，
   *   主管就会拿它跨格子比，比出来的是两把不同的尺子。
   * ⚠️ 消费分布**右偏**：均值被少数大额拉高，所以"高于平均"选出的是一成多而不是一半
   *   （实测 2,509 人里 312 人 = 12%，中位数是 ¥0）。这正合适 —— 要的就是高价值那一撮。
   */
  if (f.money && f.money.above >= NARROW.MIN_COUNT) {
    const { avgYuan, above } = f.money;
    options.push({
      label: `只选消费高于这一格平均（¥${avgYuan.toLocaleString('zh-CN')}）的 ${above} 人`,
      intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
      args: {
        minSpendYuan: avgYuan,
        // ⭐ 给**人**看的：这句会原样进对话框，⛔ 不能出现内部码
        labelZh: `消费高于这一格平均 ¥${avgYuan.toLocaleString('zh-CN')}`,
        count: above,
      },
    });
  }

  for (const dim of f.dims) {
    /**
     * ② 转介绍达人 —— **两个取值合起来**（家庭型 ∪ 社交型）。
     *
     * ⚠️ 这个维度的门槛是「推荐 ≥3 人且带来成交」，两个取值只是分"带的是家人还是外人"，
     *   主管要切的是"有没有这个能力"，不是哪一型。`personaTags` 同维度多选天然是 OR。
     * 🔴 ⛔ 文案只能写「转介绍达人」，**不能写「有过转介绍的」**：后者推荐 1 人也算，
     *   是另一个判据，现有画像里没有。写错了主管会以为漏了人（实测这一格只有 2 个达人）。
     */
    if (dim.id === 'referral_champion') {
      const n = dim.options.reduce((s, o) => s + o.count, 0);
      if (n >= NARROW.MIN_COUNT) {
        options.push({
          label: `只选转介绍达人那 ${n} 人`,
          intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
          args: {
            personaTags: dim.options.map((o) => `${dim.id}:${o.value}`).join(','),
            labelZh: '转介绍达人',
            count: n,
          },
        });
      }
      continue;
    }

    /**
     * ③ 权益身份 / 获客渠道 —— 各给**这一批里最多的那一项**。
     *
     * ⭐ 为什么是"最多的"而不是"最偏离基线的"：主管切这一刀是为了**一批人用同一套话术**
     *   （同一个渠道进来的人，开场白可以一样）。最多的那一项覆盖面最大，一刀下去最划算。
     * ⚠️ 平手时取**闭集声明序里靠前**的那个 —— 稳定优先于"随便挑一个"：
     *   同一份数据每次算出来的按钮必须一模一样，否则主管刷新一下选项就换了（⛔ 不许不确定）。
     */
    const top = dim.options.reduce((a, b) => (b.count > a.count ? b : a));
    if (top.count < NARROW.MIN_COUNT) continue;
    options.push({
      label: `只选${dim.nameZh}是「${top.zh}」的 ${top.count} 人`,
      intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
      args: { personaTags: `${dim.id}:${top.value}`, labelZh: top.zh, count: top.count },
    });
  }

  if (options.length === 0) return null;

  /**
   * ④ 兜底 —— 「按别的条件选」。
   *
   * ⭐ 上面几刀是**挑出来的**，不是全部。这一条把剩下十几个维度的门留着：
   *   它让模型去调 `get_cohort_attributes` 报一遍各维度各多少人，主管再挑。
   * ⚠️ 走 `COHORT_ATTRS_ASK` 而不是让主管自己打字：打字要过「模型理解 → 翻成动作」，
   *   而这一句是**固定的**，做成按钮就没有理解错的余地（G10 按钮优先于打字）。
   */
  options.push({ label: '按别的条件选', intent: ASSIGNMENT_INTENTS.COHORT_ATTRS_ASK });


  return {
    key: 'narrow',
    severity: SEV.OPPORTUNITY,
    tier: 'action',
    stage: 'cohort',
    /**
     * 🔴 措辞的要害:这是**换个条件重新选初选人群**，⛔ 不是在已排好的那 N 个里再挑一遍。
     *
     * 原来写的是「这批 2663 人还能再筛一刀」，产品走查当场读成了
     * 「在本批 495 人之后的进一步筛选」—— 「再筛一刀」自带先后顺序的暗示，
     * 而「这批」在这条链路里同时指过候选集和本批，正好是最不该含糊的那个词。
     * ⇒ 一律说**「这一格」**（他就是从矩阵格子点进来的，短缺那条节点也这么叫）。
     *
     * ⭐ 动词是**「选」**（2026-08-15 产品定，此前是「圈」，更早是被否掉的「筛」）。
     *   理由不是文风：这条线的四步已经叫 `选人 → 分人 → 他确认 → 确认之后`
     *   （系统提示词、`propose_assignment` 描述、`modelFacts` 键名都用这套），
     *   而按钮上写「圈」等于给同一个动作起第二个名字 —— 主管读到的和模型讲的对不上。
     * ⛔ 「筛」仍然不许用（自带"在已排好的人里再挑一遍"的先后暗示，走查栽过）。
     */
    title: `这一格 ${f.size} 人，也可以只选其中一类`,
    // ⚠️ 只陈述事实。⛔ 不写「建议按渠道切」—— 选哪一类是主管的判断（T8）。
    why:
      '点哪一条都是重新选一版：换的是这一格选进来多少人，本批人数跟着重新估，' +
      '不是在已经排好的那些人里再挑。每个选项上的数字是换完之后这一格还剩多少人。',
    defaultLabel: `不处理 = 就按这一格全部 ${f.size} 人来`,
    options,
  };
}

/**
 * 从一份**已经组装好**的提案里算出全套引导节点。
 *
 * ⚠️ 纯函数：不查库、不看时钟。所有输入都在 proposal 里 ——
 *   这样它可以被直接测，也不会因为"算节点时又查了一次"和提案本身对不上（T6a 同源）。
 * ⚠️ 「还能再筛一刀」要查这批人的画像分布，查库那一步在调用方（`narrowFacts`），
 *   算好之后作为 `extra` 传进来 —— 本函数保持纯。
 */
export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Signal[] {
  const out: Signal[] = [...extra];
  const d = Math.max(1, p.expiresInDays);

  // ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ──────────────
  if (p.pending.length > 0) {
    out.push({
      key: 'pending',
      severity: SEV.SILENT_DROP,
      tier: 'action',
      /// 人已经选完了,这是**派活**层面的决定 —— 跟「要主管定的」那一段走
      stage: 'dispatch',
      title: `${p.pending.length} 人的专属客服这轮已排满`,
      // ⚠️ 事实。⛔ 不写「建议…」——把患者从专属客服手里挪走是关系层面的决定，只有主管拍板。
      why: '他们有专属客服，但那位客服本轮已经排满了，所以我没有分配他们。',
      defaultLabel: '不处理 = 这批不发给他们',
      /**
       * 🔴 顺序 = **推荐度 × 使用频度**（2026-08-13 产品定），⛔ 别按代码里顺手的顺序排。
       *   ① 换无主患者补上 —— 一个专属关系都不动就把这批填满，代价最小;
       *   ② 铺平给在岗 —— 要动关系，但至少人发出去了;
       *   ③ 各自归专属客服 —— 等于让专属客服超出水位多接自己的人;
       *   ④ 移出本批 —— 这批就不发给他们了，后果最重，放最后。
       * ⚠️ 第一个位置是**默认视线落点**，放错等于在替主管推荐一个更贵的做法。
       */
      options: [
        /**
         * 🔴 **补上的人从哪儿来，必须写在按钮上**（2026-08-15 产品定）。
         *
         * 原来只写「换无专属客服的患者补上」—— 主管很可能**已经收窄过几遍**
         * （「只选消费高于 ¥721 的 314 人」），那这一下是从他收窄后的那批里补，
         * 还是退回整格重取？按钮不说，他只能猜；而两者差着几千人。
         * ⚠️ 判据用 `narrowedBy`（提案里专门记"他追加了什么"的字段），
         *   ⛔ 别去数 criteria：那里混着矩阵两轴，分不出哪些是他加的。
         * ⚠️ 这句话现在**是真的** —— 同日修掉了 refill 不带 `narrowedBy` 的漏
         *   （前端只回传了矩阵两轴，服务端于是退回整格取人，不报错）。
         *   ⛔ 改文案前先确认那条链路还带着，否则按钮会比原来更理直气壮地说谎。
         */
        ...(p.canRefill
          ? [
              {
                label: p.narrowedBy
                  ? '换无专属客服的患者补上（还在你收窄后的这批里）'
                  : '换无专属客服的患者补上（还是这一格的人）',
                intent: ASSIGNMENT_INTENTS.PENDING_REFILL,
              },
            ]
          : []),
        { label: '铺平给在岗', intent: ASSIGNMENT_INTENTS.PENDING_SPREAD },
        { label: '各自归专属客服', intent: ASSIGNMENT_INTENTS.PENDING_TO_OWNER },
        { label: '移出本批', intent: ASSIGNMENT_INTENTS.PENDING_REMOVE },
      ],
    });
  }

  // ── ② 工作量预估 —— 这批要打几天 ──────────────────────────────────
  //
  // ⭐ 2026-08-12 产品定：这**不是异常告警，是每批都该讲清楚的一件事**。
  //   N 按 `在岗 × 每天 15 通 × 时效` 算，只算新增、⛔ 不扣在手 ——
  //   所以只要谁手上还有东西，需要的天数就会超过时效。**那是事实，不是 bug**：
  //   主管要么延长时效、要么调低每日通数（N 跟着变小）、要么直接减这批人数。
  // ⛔ 所以措辞里不许出现「打不完 / 超了 / 过载」这类像报错的词 ——
  //   它常亮，说成故障主管就会开始怀疑系统而不是做决定。
  //
  // ⚠️ 判据看**分完之后手上最多的那位**：他手上原本还有别的批次，那些也要打。
  // ⚠️ 15（DAILY_CALLS_PER_AGENT）只是**换算尺**，⛔ 不是上限、⛔ 不参与落人。
  const heaviest = p.byAgent.reduce<AssignmentProposal['byAgent'][number] | null>(
    (max, a) => (max === null || a.loadAfter > max.loadAfter ? a : max),
    null,
  );
  if (heaviest && heaviest.loadAfter > DAILY_CALLS_PER_AGENT * d) {
    const needDays = Math.ceil(heaviest.loadAfter / DAILY_CALLS_PER_AGENT);
    out.push({
      key: 'daily_overload',
      severity: SEV.WRONG_EXPECTATION,
      tier: 'action',
      stage: 'dispatch',
      title: `这批发下去，最忙的是${heaviest.name ?? '有人'}：手上共 ${heaviest.loadAfter} 条，约 ${needDays} 天的量`,
      // ⚠️ **必须把数字拆开** —— 「手上 75 条」里有多少是这批带来的、多少是原本就有的，
      //   不拆开主管会以为这批一下压了 75 条给他（实测同类问题：增量与总量分不开）。
      // ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值，不是实测。
      why:
        `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条；` +
        `按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天，本批时效定的是 ${d} 天。`,
      defaultLabel: `不处理 = 就按 ${d} 天发，到期没打完的自动落回池子，下批还能再分`,
      /**
       * 🔴 **只留带数的那一个**（2026-08-15 产品定）。
       *
       * 删掉的是「改每人每天打几通」和「减少本批人数」—— 它们**一个数都不带**：
       * 既没算出目标值（不像 `时效改成 ${needDays} 天`），也没有输入框
       * （不像 `batch_size_basis` 那两条，各自预填着当前值）。点下去发出的那句话
       * 于是也是空的：「这批人数**减少一些**，重新排一版」「每人每天按**更少的通数**算」——
       * 那个数只能由模型自己拍，而它没有依据可拍。
       * ⇒ 判据与 `cohort.widen` 删除时逐字相同：**按钮不带数 ⇒ 严格弱于他自己说一句**。
       * ⚠️ 这两件事他仍然做得到：`batch_size_basis` 那两个输入框（带当前值），
       *   以及旁边常驻的「我自己说」。⛔ 别以"少了个入口"为由加回来。
       */
      options: [
        {
          label: `整批时效改成 ${needDays} 天`,
          intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
          args: { days: needDays },
        },
      ],
    });
  }

  // ── ③ 候选不足 —— 🔴 **整条已删（2026-08-14 产品定）** ─────────────────
  //
  // 曾经有一条 `short_supply`：「这一格只有 41 人，不够 495 人」，配两个按钮
  // （往后放一档 / 换个治疗项）。删掉的理由：
  //
  //   🔴 **初选是主管自己在矩阵上点的，而矩阵每一格都写着人数。** 再引导他"换一格"，
  //     按钮上却**不带别的格子有多少人** —— ⇒ 这组按钮**严格弱于他身后那张矩阵**，
  //     还占着卡片最显眼的位置（走查截图：41 人那一版，引导就压在正文第一段下面）。
  //   ⚠️ 「发不满」仍然要让他知道，但那是**事实**不是**决定**：`modelFacts` 里
  //     「符合条件的总人数 / 本批人数 / 您设的基数」三个数已经说清，模型正文会讲
  //     （实测那一版写的是「基数 495 用不上……这格总共只有 41 人，有多少发多少」）。
  //   ⛔ 别再加回来。觉得"发不满"不够显眼，就去看那三个数怎么呈现，
  //     ⛔ 不是再配两个把他刚选的格子丢掉的按钮。
  //
  // ⚠️ 主管设的 N 没变（batchSize），只是这一格没那么多人 ——
  //   ⛔ 别把 target 回写成基数：点一次小格子，以后所有批次就永远那么小。
  //   （这条与上面的删除无关，它管的是 `propose` 那边怎么记基数，⛔ 别一起删。）

  // ── ④ 这批多大是怎么估出来的 ─────────────────────────────────────
  //
  // 🔴 2026-08-13 从 tier=info 升成 **action**（产品定）。
  //   原来它折叠在卡片的「另有 N 条说明」里，而模型的正文早就把同一句话说了
  //   （「本批人数按在岗 33 人 × 每天 15 通 × 1 天估算」）—— 折叠区等于让主管读两遍。
  //   ⇒ 折叠区去掉，这条升为引导节点，跟着**正文那一句**走（show_guidance）。
  //
  // ⚠️ 两个可调的量都给**输入框**，⛔ 不列固定档：天数和通数是连续量，
  //   列 3/5/7 天必然漏掉他要的那个，而漏掉那次他只能去打字 → 走模型理解 → 慢且可能错。
  // ⚠️ 15（DAILY_CALLS_PER_AGENT）是**换算尺**，⛔ 不是容量上限、⛔ 不参与落人。
  //
  // 🔴 **候选够不着 N 时不出**（2026-08-13）：`target = min(batchSize, candidateTotal)`,
  //   候选 312 / N 495 时**瓶颈是候选不是 N** —— 而这两个按钮预填当前值、往上调:
  //     改时效 1→3 天 ⇒ N=1485，改每天几通 15→30 ⇒ N=990，target 都还是 312。
  //   点了没有任何反应，而这条的 why 写着「这批人数都会跟着变」⇒ **它在说谎**。
  //   ⚠️ 判据要用严格 `>`：相等时候选同样是瓶颈（往上调仍然无效）。
  if (p.basis === 'default' && p.candidateTotal > p.batchSize) {
    out.push({
      key: 'batch_size_basis',
      severity: SEV.INFO,
      tier: 'action',
      /// 改的是**这一批发多少**,⛔ 不是选哪些人 —— 与 narrow 分属两个键,别混在一个标题下
      stage: 'size',
      /// ⚠️ 「在岗」= **名册人数**(`rosterCount`),⛔ 不是 `byAgent.length`(本批分到人的客服数)——
      ///   2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。
      title: `本批 ${p.batchSize} 人 = 在岗 ${p.rosterCount} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${d} 天`,
      // ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。
      why: `这是系统按这个式子估的，不是根据历史算的。改时效或改每天几通，这批人数都会跟着变。`,
      defaultLabel: '不处理 = 就按这个数发',
      options: [
        {
          label: '改时效',
          intent: ASSIGNMENT_INTENTS.BASIS_SET,
          input: { argKey: 'days', unit: '天', min: 1, max: 90, default: d },
        },
        {
          label: '改每天几通',
          intent: ASSIGNMENT_INTENTS.BASIS_SET,
          input: {
            argKey: 'rate',
            unit: '通',
            min: 1,
            max: 60,
            default: DAILY_CALLS_PER_AGENT,
          },
        },
      ],
    });
  }

  // ⚠️ 「这批是按什么口径选的」那条 info 节点**已删**（2026-08-13）：
  //   档位口径只剩一版（距上次到诊多久），没有第二种可能就没什么可交代的。⛔ 别加回来。

  /**
   * 确定性排序：severity 升序。
   * ⚠️ 用**稳定**的比较（同级保持加入顺序），⛔ 别引入任何随机或时间相关的因素 ——
   *   同一份提案每次算出来的顺序必须一模一样，否则主管刷新一下节点就换位置。
   * ⛔ 不截断 —— 分层交给前端（action 展开 / info 折叠）。
   */
  return out.sort((a, b) => a.severity - b.severity);
}
