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',
  /// 加一个画像条件再圈一版（args.personaTags = `维度:取值`）
  COHORT_NARROW: 'cohort.narrow',
  /// 换档位口径（重出一版）
  ANCHOR_SWITCH: 'anchor.switch',
} as const;

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

// ═══════════════════════════════════════════════════════════════
// 画像亮点
// ═══════════════════════════════════════════════════════════════

/** 一个维度的分布（结构上兼容 `CohortAttributeDim`，这里只取用得到的字段）。 */
export interface DimShare {
  id: string;
  nameZh: string;
  options: Array<{ value: string; zh: string; count: number }>;
}

/** 比较用的两份分布：这一格 vs 同治疗项整个池子。 */
export interface HighlightSource {
  batch: { size: number; dims: DimShare[] };
  baseline: { size: number; dims: DimShare[] };
}

/**
 * 亮点的三道闸 —— 缺任何一道都会让节点变噪音。
 *
 * ⚠️ 光看倍数不够：一格 200 人里某项 6 人（3%）对上全池 1%，倍数是 3，
 *   但 6 个人不值得主管改话术。⇒ 必须同时卡**绝对人数**。
 * ⚠️ 基线太小时比值没有意义（全池只有 20 人，谁占多数都不奇怪）。
 */
const HIGHLIGHT = {
  /// 本批占比 ÷ 基线占比 达到几倍才算「亮」（抓**小基数**维度的异常聚集）
  RATIO: 2,
  /**
   * 或者：占比比基线高出这么多个百分点（抓**大基数**维度的偏移）。
   *
   * 🔴 2026-08-12 本地实测补的。只用倍数时这条判据**结构上打不着大类**：
   *   朝阳公园的获客渠道分布是 口碑 33% / 集团营销 23% / 走入 22% ——
   *   口碑要够 2 倍得占到 66%（现实里不可能），而能翻倍的小类（电商 2.3%）
   *   在一格 71 人里连 10 个人都凑不出。⇒ 整个维度永远不会命中。
   * ⚠️ 两条是**或**的关系：倍数管小基数，百分点管大基数，各抓一种偏离。
   */
  POINTS: 12,
  /// 本批至少要有这么多人命中
  MIN_COUNT: 10,
  /// 基线人群至少这么大才拿来当基线
  MIN_BASELINE: 50,
  /// 最多亮几个（⚠️ 这是**同一个节点内**取前几项，⛔ 不是截断节点本身）
  TOP: 3,
} as const;

/**
 * 画像亮点 —— **这一格和整体比，哪里不一样**。
 *
 * ⭐ 判据用「相对基线的偏离」而不是绝对占比：占比高不等于有信息。
 *   「这批 80% 自费」没用（全池也是）；「转介绍是平时的 2.6 倍」才是主管会据此改打法的东西。
 * ⚠️ 基线取**同治疗项的整个池子**，⛔ 不取全池 ——
 *   种植的重要价值天然高于补牙，用全池会让种植永远命中。
 * ⚠️ 比的是**这一格的候选集**（圈定的人），不是最终发出去的那 N 个 ——
 *   两者在候选不够时相同，候选很多时是子集。措辞里因此说「这一格的人里」而不是「这批」。
 */
export function computeHighlights(src: HighlightSource): Signal[] {
  const { batch, baseline } = src;
  if (batch.size === 0 || baseline.size < HIGHLIGHT.MIN_BASELINE) return [];

  const baseCount = new Map<string, number>();
  for (const d of baseline.dims) {
    for (const o of d.options) baseCount.set(`${d.id}:${o.value}`, o.count);
  }

  const hits: Array<{
    dim: DimShare;
    zh: string;
    value: string;
    count: number;
    ratio: number;
    points: number;
  }> = [];
  for (const d of batch.dims) {
    for (const o of d.options) {
      if (o.count < HIGHLIGHT.MIN_COUNT) continue;
      const base = baseCount.get(`${d.id}:${o.value}`) ?? 0;
      // ⚠️ 基线为 0 时不比 —— 那多半是这个取值刚上线、或者基线口径没覆盖到,
      //   ⛔ 不能当成「这一格独有」来报（那会是个凭空的结论）。
      if (base === 0) continue;
      const share = o.count / batch.size;
      const baseShare = base / baseline.size;
      const ratio = share / baseShare;
      const points = (share - baseShare) * 100;
      if (ratio >= HIGHLIGHT.RATIO || points >= HIGHLIGHT.POINTS) {
        hits.push({ dim: d, zh: o.zh, value: o.value, count: o.count, ratio, points });
      }
    }
  }
  if (hits.length === 0) return [];

  /**
   * ⚠️ 两种命中口径要能**放在一起排序**：把倍数折算成可比的强度
   *   （2 倍 ≈ 10 个百分点的量级），取两者较大的那个。
   * ⛔ 别只按倍数排 —— 那会让百分点命中的项永远垫底。
   */
  const strength = (h: { ratio: number; points: number }) =>
    Math.max(h.points, (h.ratio - 1) * 10);
  hits.sort((a, b) => strength(b) - strength(a));
  return hits.slice(0, HIGHLIGHT.TOP).map((h) => ({
    key: `highlight:${h.dim.id}:${h.value}`,
    severity: SEV.OPPORTUNITY,
    tier: 'action' as const,
    title: `这一格里「${h.zh}」特别多：${h.count} 人`,
    // ⚠️ 只陈述事实。⛔ 不写「建议用它当话术钩子」—— 怎么用是主管的判断。
    why:
      `占这一格的 ${pct(h.count, batch.size)}，而同类治疗整体是 ${pct(baseCount.get(`${h.dim.id}:${h.value}`) ?? 0, baseline.size)}` +
      // ⚠️ 大基数维度说"高出 N 个百分点"比说"1.4 倍"直观；小基数反过来
      (h.ratio >= HIGHLIGHT.RATIO
        ? `，约 ${h.ratio.toFixed(1)} 倍。`
        : `，高出 ${Math.round(h.points)} 个百分点。`),
    defaultLabel: '不处理 = 照常整批发，不特殊对待',
    options: [
      {
        label: `只圈这 ${h.count} 人`,
        intent: ASSIGNMENT_INTENTS.COHORT_NARROW,
        args: {
          // 给工具用的筛选串（内部码）
          personaTags: `${h.dim.id}:${h.value}`,
          // ⭐ 给**人**看的两样：中文名 + 圈完有多少人。
          //   🔴 缺了它们，替主管说的那句话会变成「只圈符合 referral_champion:yes 的人」——
          //   内部码原样进对话框，正是 P0 花一整节要消灭的东西。
          labelZh: h.zh,
          count: h.count,
        },
      },
    ],
  }));
}

function pct(n: number, total: number): string {
  return total > 0 ? `${Math.round((n / total) * 100)}%` : '0%';
}

/**
 * 从一份**已经组装好**的提案里算出全套引导节点。
 *
 * ⚠️ 纯函数：不查库、不看时钟。所有输入都在 proposal 里 ——
 *   这样它可以被直接测，也不会因为"算节点时又查了一次"和提案本身对不上（T6a 同源）。
 * ⚠️ 画像亮点需要**额外两份分布**（这一格 vs 同治疗项池子），查库的那一步在调用方，
 *   算好之后作为 `highlights` 传进来 —— 本函数保持纯。
 */
export function computeSignals(p: AssignmentProposal, highlights: Signal[] = []): Signal[] {
  const out: Signal[] = [...highlights];
  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);
}
