import {
  AnchorMode,
  DAILY_CALLS_PER_AGENT,
  type AssignmentProposal,
  type Signal,
} from '@pac/types';

/**
 * 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',
  /// 改「每人每天打几通」的估法 —— 它同时决定 N（在岗 × 每日通数 × 时效）
  DAILY_RATE_SET: 'daily_rate.set',
  /// 改本批人数
  BATCH_SIZE_SET: 'batch_size.set',
  /// 换时间档 / 换治疗项（重出一版）
  COHORT_WIDEN: 'cohort.widen',
  /// 换档位口径（重出一版）
  ANCHOR_SWITCH: 'anchor.switch',
} as const;

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

/**
 * 从一份**已经组装好**的提案里算出全套引导节点。
 *
 * ⚠️ 纯函数：不查库、不看时钟。所有输入都在 proposal 里 ——
 *   这样它可以被直接测，也不会因为"算节点时又查了一次"和提案本身对不上（T6a 同源）。
 */
export function computeSignals(p: AssignmentProposal): Signal[] {
  const out: Signal[] = [];
  const d = Math.max(1, p.expiresInDays);

  // ── ① 待分配 —— 唯一一个「不处理就真的有人被漏掉」的节点 ──────────────
  if (p.pending.length > 0) {
    out.push({
      key: 'pending',
      severity: SEV.SILENT_DROP,
      tier: 'action',
      title: `${p.pending.length} 人的专属客服这轮已排满`,
      // ⚠️ 事实。⛔ 不写「建议…」——把患者从专属客服手里挪走是关系层面的决定，只有主管拍板。
      why: '他们有专属客服，但那位客服本轮已经排满了，所以我没有分配他们。',
      defaultLabel: '不处理 = 这批不发给他们',
      options: [
        { label: '各自归专属客服', intent: ASSIGNMENT_INTENTS.PENDING_TO_OWNER },
        { label: '铺平给在岗', intent: ASSIGNMENT_INTENTS.PENDING_SPREAD },
        ...(p.canRefill
          ? [{ label: '换无主患者补上', intent: ASSIGNMENT_INTENTS.PENDING_REFILL }]
          : []),
        { 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',
      title: `这批发下去，最忙的是${heaviest.name ?? '有人'}：手上共 ${heaviest.loadAfter} 条，约 ${needDays} 天的量`,
      // ⚠️ **必须把数字拆开** —— 「手上 75 条」里有多少是这批带来的、多少是原本就有的，
      //   不拆开主管会以为这批一下压了 75 条给他（实测同类问题：增量与总量分不开）。
      // ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值，不是实测。
      why:
        `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条；` +
        `按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天，本批时效定的是 ${d} 天。`,
      defaultLabel: `不处理 = 就按 ${d} 天发，到期没打完的自动落回池子，下批还能再分`,
      options: [
        {
          label: `时效改成 ${needDays} 天`,
          intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
          args: { days: needDays },
        },
        { label: '改每人每天打几通', intent: ASSIGNMENT_INTENTS.DAILY_RATE_SET },
        { label: '减少本批人数', intent: ASSIGNMENT_INTENTS.BATCH_SIZE_SET },
      ],
    });
  }

  // ── ③ 候选不足 ────────────────────────────────────────────────────
  //
  // ⚠️ 主管设的 N 没变（batchSize），只是这一格没那么多人。
  //   ⛔ 别把 target 回写成基数 —— 否则点一次小格子，以后所有批次就永远那么小。
  if (p.candidateTotal < p.batchSize) {
    out.push({
      key: 'short_supply',
      severity: SEV.WRONG_EXPECTATION,
      tier: 'action',
      title: `这一格只有 ${p.candidateTotal} 人，不够 ${p.batchSize} 人`,
      why: `符合条件的就这么多，本批实际发 ${p.target} 人。`,
      defaultLabel: '不处理 = 有多少发多少',
      options: [
        { label: '往后放一档', intent: ASSIGNMENT_INTENTS.COHORT_WIDEN },
        { label: '换个治疗项', intent: ASSIGNMENT_INTENTS.COHORT_WIDEN },
      ],
    });
  }

  // ── ④ 时效用的是默认值 ───────────────────────────────────────────
  //
  // ⚠️ tier=info：不管也没事，所以默认折叠。但仍然要说 —— T14：
  //   主管看到「3 天」时，"这 3 是哪来的"和这个数本身一样重要。
  if (p.basis === 'default') {
    out.push({
      key: 'expiry_default',
      severity: SEV.INFO,
      tier: 'info',
      title: `时效 ${d} 天 · 本批 ${p.batchSize} 人，都是系统默认`,
      why: `按在岗 ${p.byAgent.length} 人 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × ${d} 天估的，不是根据历史算的。`,
      defaultLabel: '不处理 = 就用这两个数',
      options: [{ label: '改天数', intent: ASSIGNMENT_INTENTS.EXPIRY_SET }],
    });
  }

  // ── ⑤ 档位口径不是默认 ───────────────────────────────────────────
  //
  // 🔴 同一个「三个月内」，两版口径圈出来是**两批完全不同的人**。
  //   主管切过口径之后隔一会儿再回来，很容易忘了自己切过。
  if (p.anchorMode === AnchorMode.LAST_VISIT) {
    out.push({
      key: 'anchor_nondefault',
      severity: SEV.INFO,
      tier: 'info',
      title: '这批是按「末诊距今」圈的',
      why: '默认口径是按医生最后一次提到这个治疗算，两种口径圈出来是两批不同的人。',
      defaultLabel: '不处理 = 保持按末诊',
      options: [{ label: '换回按诊断', intent: ASSIGNMENT_INTENTS.ANCHOR_SWITCH }],
    });
  }

  // ⚠️ 确定性排序：severity 升序，同级按加入顺序（上面的书写顺序即优先级）。
  //   ⛔ 不截断 —— 分层交给前端（action 展开 / info 折叠）。
  return out.sort((a, b) => a.severity - b.severity);
}
