import {
  AssignStrategy,
  DAILY_CALLS_PER_AGENT,
  TEMPERATURE_SINCE_ZH,
  type AssignmentProposal,
  type SheetSnapshot,
} from '@pac/types';
// ⚠️ 只借它的纯标签函数（取值码 → 中文），⛔ 不引 mcp 的任何服务/状态。
import { zhTemperature, zhTreatment } from '../mcp/model-facing';
// 引导节点的中文标识（内部 key → 给模型用的名字），单一真源见那边的注释
import { GUIDANCE_KIND } from './assignment-signals';

/**
 * assignment-facts —— 把一份确认单投影成**给模型的结构化事实**。
 *
 * ═══ 为什么不再给成品句子 ═══════════════════════════════════════
 * 原来 `propose_assignment` 回给模型的是 `selectionNote` / `basisNote` / `pendingNote`
 * 三段服务端拼好的话，外加十几行「原样输出、不要改写、不要合并」。两个问题：
 *
 *   ① **成品句子有固定形状，模型学会形状就能凭空背出来。** 实测背出过
 *      「已撤销批次：收回 9 条。」—— 而撤销工具一次都没被调用。
 *      结构化事实编不出来：JSON 里的具体值瞎写一眼就能对出来。
 *   ② **「照抄」浪费了模型唯一不可替代的能力。** 它最擅长的就是把一堆事实
 *      组织成人话；让它当复读机，等于花钱买了个模板引擎。
 *
 * ⇒ 服务端给**事实**，模型负责**措辞**，写作规范集中写在系统提示词里一处。
 *
 * ═══ 边界 ═══════════════════════════════════════════════════════
 * ⛔ **这里一个字的指令都不许有**（「请照抄」「不要说成…」之类）。
 *   2026-08-08 栽过：`get_assignment_detail` 的 note 字段混着给模型的指令，
 *   模型照抄就把内部指令原样贴进了主管的对话框。⇒ 事实归事实，规范归提示词。
 *   ⚠️ **例外一条、且只有一条**：每条引导上的 `开放方式`（见 `GUIDANCE_HOWTO`）。
 *     2026-08-17 产品实测定的，破例的理由和它的边界都写在那个常量上。
 *     ⛔ 别照着它再开第二处 —— 一个"有过先例"的例外会变成没有边界。
 * ⛔ 不含任何 planId / 患者姓名 —— 那些走侧信道给界面，模型手里不该有。
 * ⚠️ 键名用中文：模型照着键名说就是对的，省掉一轮"把 inHandPending 翻译成人话"。
 */

/**
 * 每条引导上都带一份的**调用纪律** —— `show_guidance` 要一类一调、紧挨着调。
 *
 * ═══ 为什么明知是指令还是要放在这一层 ═══════════════════════════════
 * 同一条纪律 `show_guidance` 的**工具描述里已经有一份**，但那份离「这一版到底有哪几类」
 * 隔着整个上下文。2026-08-17 产品把这句话手工粘到 `标识` 的值里试了一轮，
 * **模型的排布确实变了** —— ⇒ 这不是"多说一遍更保险"，是实测拿到的差别。
 *
 * ⚠️ 挂在**每一条**上，⛔ 不是每一组上：纪律管的是"这一条讲完、紧接着调这一条"，
 *   它的作用域就是一条。挂在组上时模型仍然可能把一组里的两条攒在一起。
 * ⚠️ 位置紧跟 `标识`：产品实测有效的那一版就是粘在标识后面的，⛔ 别挪到末尾去
 *   "省一点注意力" —— 那等于换了个没测过的位置。
 *
 * ═══ 边界（破例只破这一处）═══════════════════════════════════════
 * ⚠️ 措辞与 `show_guidance` 的描述**同源**（都用「开放」这个动词、都说「一类」）——
 *   ⛔ 别在这里另发明一套说法：两处打架时模型只会各取一半。
 * ⛔ 不写工具名 `show_guidance`：这一层是给模型的**中文事实**，代码标识符进来
 *   早晚会被念给主管听（沿革见 `assignment-signals.ts` 的 `GUIDANCE_KIND`）。
 *   「开放」就是工具描述里的原词，不会有歧义。
 * ⛔ 禁词表照旧管着这句话（「照抄」「原样」「绝不」「必须说」这些字面都不许出现）——
 *   例外放行的是"一句指令"，⛔ 不是把禁词表撕了。有测试盯着。
 * ⚠️ 改这句话要 bump `ASSISTANT_PROMPT_VERSION`：它每轮一模一样、且模型看得见，
 *   与工具描述同性质。
 *
 * ⇒ **怎么验它还在起作用**：`agent_invocations.output.steps`（`stepShapes`）——
 *   几条引导落在各自独立、且 textLen>0 的步里 = 守住了；挤在同一步 = 没守住。
 */
const GUIDANCE_HOWTO =
  '一类一调：讲完其中一类，紧接着开放这一类；再讲下一类，再开放一类。' +
  '不许连着开放两类 —— 中间要隔着你讲下一类的话。';

/** 给模型的事实投影。⚠️ 纯函数，输入一份提案、输出一个可序列化对象。 */
export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
  /// ⚠️ **本批分到人的客服数**,⛔ 不是在岗名册人数 —— 名册走 `p.rosterCount`。
  ///   2026-08-15 实测:拿这个数去填「在岗 N 人 × …」印出了「在岗 9 人」,
  ///   而主管同屏写着 33 位在岗(沿革见 `rosterCount` 的字段注释)。
  const agents = p.byAgent.length;
  /**
   * 引导节点按 `stage` 分组落地，每条带一个**中文语义标识**。
   *
   * 🔴 2026-08-14 由**序号**改成**标识**（沿革见 `assignment-signals.ts` 的 `GUIDANCE_KIND`）：
   *   序号没有语义，模型得自己维护"我讲的这段是第几条"这套记账；标识让它按**意思**对上。
   *   裸台实测：去掉编号、工具改无参数之后，三条引导它只讲了一条也只调了一次 ——
   *   标识不只是句柄，它承载「这是可以逐个处理的几件事」。
   * ⚠️ 标识放在**每条的第一位**：键序就是模型讲述的顺序，标识要先于内容出现。
   * ⛔ 别把 `signal.key` 直接给模型（`pending` / `batch_size_basis` 是内部码）——
   *   中文标识即使被念出口，读起来也是一句正常的话。
   * ⚠️ `开放方式` 紧跟标识 —— 这一层唯一一处指令，理由和边界见 `GUIDANCE_HOWTO`。
   */
  const pick = (stage: 'cohort' | 'size' | 'dispatch') =>
    p.signals
      .filter((s) => s.tier === 'action' && s.stage === stage)
      .map((s) => ({
        '标识': GUIDANCE_KIND[s.key] ?? s.key,
        '开放方式': GUIDANCE_HOWTO,
        '是什么': s.title,
        '为什么': s.why,
        '不处理会怎样': s.defaultLabel,
      }));
  const dedicated = p.byAgent.reduce((s, a) => s + a.dedicated, 0);
  const spread = p.byAgent.reduce((s, a) => s + a.spread, 0);
  const loads = p.byAgent.map((a) => a.loadAfter);
  const perBatch = p.byAgent.map((a) => a.count);
  /**
   * 🔴 **精调过的人要点名** —— 否则「为什么李莉只有 5 条」没人答得上来。
   *
   * ⚠️ 2026-08-14 补。在此之前这件事**只写在 `basisNote` 里，而那个字段没有任何消费方**
   *   （服务端算一遍、随确认单发到浏览器、然后被丢掉）——
   *   也就是说这个能力早就不在了，删掉 basisNote 只是让它显形。
   * ⚠️ 卡片上也没有：精调只在确认时随请求回传，主管在界面上看不见它。
   *   ⇒ 这一段是「他手上这批为什么长这样」的**唯一出处**。
   */
  // ⚠️ 名字要从两处找：精调成 0 的人**不在 byAgent 里**（他这轮一条都没拿到，
  //   落在 skippedAgents），而"为什么某某一条都没有"恰恰最需要答得上来。
  const nameOf = new Map<string, string | null>(
    [...p.byAgent, ...p.skippedAgents].map((a) => [a.userId, a.name]),
  );
  // ⚠️ `?? {}`:老快照里这个字段可能压根没有 —— ⛔ 别假设它一定在
  const tuned = Object.entries(p.agentOverrides ?? {}).map(([userId, o]) => ({
    '客服': nameOf.get(userId) ?? userId.slice(0, 8),
    ...(o.maxThisBatch != null ? { '这批最多给他': o.maxThisBatch } : {}),
    ...(o.expiresInDays != null ? { '他这批的时限天数': o.expiresInDays } : {}),
  }));

  return {
    '怎么选的': {
      '治疗项': zhTreatment(p.potentialTreatment),
      // ⛔ **仍然不给"口径"这个键** —— 只剩末诊一版锚点（2026-08-13），
      //   单独一个口径字段读起来像"还有别的口径"，说它等于让主管读一句永远为真的话。
      // 🔴 但 2026-08-14 走查发现：光给「3 年以上」，**它自己说不清是什么的 3 年**。
      //   界面上不缺这半句（列头上方就写着按什么分档，位置提供了上下文）；
      //   JSON 里没有位置，模型拿到的是个孤零零的字符串 ⇒ 把**值**补全到自足。
      //   ⚠️ 补前缀 ≠ 加口径字段：前者让这一个值读得通，后者是新开一个永远为真的键。
      '时间档': `${TEMPERATURE_SINCE_ZH} ${zhTemperature(p.temperature)}`,
      '符合条件的总人数': p.candidateTotal,
      '本批人数': p.target,
      /**
       * ⚠️ 候选不够时 target 被压低，但这个数没变 —— 两个数要分别给，
       *   不然主管会以为系统偷偷把他的设置改小了。
       *
       * 🔴 2026-08-14 实测改名，原键名是 `您设的基数`，两处都错：
       *   ① 「基数」**就列在提示词的禁说清单里**（只在代码里存在的概念）——
       *      而这里是**键名**，模型看到的是紧挨着数字的那个词，禁令根本拦不住：
       *      当轮原话「这一格总共就 3 人，比估算的**基数**（120）少得多」。
       *   ② 「您设的」是**假的**：120 是「在岗 8 × 每天 15 通 × 1 天」算出来的，
       *      主管一个数都没设过 —— 他会去找自己什么时候设过 120。
       * ⇒ 键名自己说清楚是什么，⛔ 别指望提示词去兜。
       *
       * 🔴 而且**只在这个数是他的时候才给**（`basis !== 'default'`）。
       *   改名之后再测，模型换了个词照说不误：「系统按 3 名在岗客服 × 每天 15 通 × 1 天
       *   估的**基数**是 45，但这一格实际只有 5 人」——「基数」同样在禁说清单里。
       *   ⇒ 光换词治不住:**这个数一给出来，模型就要解释它**。
       *   而首次默认（45 = 在岗 3 × 每天 15 通 × 1 天）根本不是他设的，
       *   没有"设置被改小"这回事可防 —— 那它就是纯机器细节，⛔ 不该进模型上下文。
       *   沿用 / 他自己指定的才要给：那时"我上次说 200，这次怎么只有 44"是**真问题**。
       */
      ...(p.target < p.batchSize && p.basis !== 'default'
        ? { '人够的话本来能发': p.batchSize }
        : {}),
      /**
       * ⚠️ 式子后面**必须带上它自己的积**（`= N 人`）。
       *   2026-08-15 实测那一版是「在岗 9 人 × 每天 15 通 × 1 天」，紧挨着「本批人数 214」——
       *   两个数怎么算都对不上（9×15×1=135），而**没有任何一个字段承载 495**，
       *   模型手里根本没有能让这句话自洽的材料，只能去圆。
       * ⭐ 带上积之后，「估的是 495、这一格只有 214、所以发 214」三个数**全在事实里**，
       *   够它自己说明白 —— ⛔ 不需要再加一条"候选是瓶颈时别讲这个式子"的闸:
       *   把数藏起来跟印错一样，都是让主管拿不到真相。
       */
      '本批人数怎么来的':
        p.basis === 'explicit'
          ? '主管这次指定的'
          : p.basis === 'inherited'
            ? `沿用 ${p.basisFrom ?? '上一次'}`
            : `系统默认：在岗 ${p.rosterCount} 人 × 每天 ${p.dailyCalls} 通 × ${p.expiresInDays} 天 = ${p.batchSize} 人`,
      '排序': '没被分过的排在前面，其余按优先级从高到低',
      ...(p.candidateTotal > p.target
        ? { '这轮还没轮到': p.candidateTotal - p.target }
        : {}),
      /**
       * ⭐ 这两组跟着**这一段**走（2026-08-13 产品定）—— 它们本质还在选人，不是派活。
       *   放进「要主管定的」时它们挨着「专属客服排满」，而两者之间隔着整整一段
       *   「怎么派的」，读起来像不相干的话题被硬凑在一起。
       * ⚠️ 键嵌在「怎么选的」里面**就是它们的位置说明** —— ⛔ 别再去提示词里加一条
       *   「这几条要讲在第 1 段」：结构能表达的事就别写成规则（规则越多越不被遵守）。
       *
       * 🔴 **两组必须分开给。** 合成一个键时模型会拿一个标题罩住两组按钮 ——
       *   实测走查抓到「还能怎么选：」下面挂着「改时限 / 改每天几通」，
       *   而改时限根本不是选人。⇒ 一个键只说一件事。
       * ⚠️ 顺序 `人群 → 人数`，⛔ 不许反：换人群会让本批人数重新估一遍
       *   （实测候选 2509→312 后，本批 495 被压成 312），先调人数那下就白调了。
       */
      ...(pick('cohort').length > 0 ? { '这一格还能怎么选': pick('cohort') } : {}),
      ...(pick('size').length > 0 ? { '本批人数还能怎么调': pick('size') } : {}),
    },
    '怎么派的': {
      '已排好': p.placed,
      /**
       * 🔴 **点确认真正会落下去几条** —— 2026-08-20 走查加。
       *
       * 在此之前模型手里只有「已排好 200」和引导节点上的「300 人要您定」,
       * 「确认会写几条」得它自己减一次。实测它没减:收尾那句写的是
       * 「确认之后这 **500** 人的名单和 3 天时限就定死了」—— 而待分配那 300 条
       * 主管不处理就**不落库**,确认按钮上写的也是 200。
       * ⇒ 要它说准的数就**直接给**,⛔ 别让它做算术(同「本批人数取小的那个」那次)。
       * ⚠️ 与卡片上确认按钮里的数**同源同值**(都是 placed),⛔ 别让两处对不上。
       */
      '点确认会落下去的条数': p.placed,
      '涉及客服数': agents,
      '分给自己专属客服的': dedicated,
      '给手上最空的人的': spread,
      // ⚠️ 「一条都没分到」的人也要给 —— 主管要看见"他不是被漏了"
      '本批没分到的客服': p.skippedAgents.length,
      /**
       * 🔴 **他们手上有多少,必须一起给**（2026-08-15 实测补的）。
       *
       * 在此之前这里只有一个光秃秃的数字「本批没分到的客服: 1」——
       * 主管当场问「那 1 位为什么没轮到」，模型手里**一个字都没有**：
       * 姓名和在手量都在 `skippedAgents` 里，但没投影出来；卡片上写着
       * 「本批未分到：某某(在手 193)」，而模型看不见卡片。
       * ⇒ 它只有两条路：说不知道，或者编一个。
       *
       * ⭐ 给了这个数，答案就在**它和下面那行的对比**里：没分到的手上 193，
       *   而分到的人分完才 49 —— 「水位法一直没轮到他」自己就说清了。
       * ⚠️ 给**区间**不给逐人名单：这一格可能有二十几位没分到（实测 24），
       *   逐人列出来是一屏噪音，而"为什么"只取决于他们比别人多多少。
       *   ⛔ 他要问"是谁"，那是卡片上就有的一行字。
       */
      ...(p.skippedAgents.length > 0
        ? { '他们现在手上有': range(p.skippedAgents.map((a) => a.inHand)) }
        : {}),
      '时限天数': p.expiresInDays,
      '本批每人拿到': range(perBatch),
      '分完之后每人手上': range(loads),
      // ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
      '按每天几通算': p.dailyCalls,
      // ⚠️ 措辞用「无专属」,⛔ 别写「无主」—— 界面上的按钮文案就是「无专属客服的患者」,
      //   模型会照着这句念给主管听,两处不一致他会以为是两拨人。
      '落人规则': '有专属的先回自己人手上；无专属的给当前手上最少的那个',
      // ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里
      ...(tuned.length > 0 ? { '他单独设过的': tuned } : {}),
      ...(p.pending.length > 0 ? { '专属客服排满、这批没发的': p.pending.length } : {}),
      /**
       * ⚠️ 只给 action 层：info 层是折叠在卡片里的说明，模型复述它纯属噪音。
       * ⛔ 不给 options 的技术细节 —— 那是按钮的事，模型说出 `pending.to_owner` 只会吓到主管。
       *
       * 🔴 **2026-08-14 从顶层搬进这一段。** 键序就是段序，而它属于**派活**：
       *   `propose_assignment` 的描述里写了讲述顺序（人对不对 → 分完什么样 → 可动手的就近开放），
       *   顶层挂着第三个键时模型只会把它讲成第四段，跟描述打架。
       * ⚠️ 2026-08-15 实测:那段讲述顺序里的**第一步一度整个丢了**(重写时被顺手删掉),
       *   于是「怎么选的」这一段没人讲,它底下那两组按钮被模型丢到了最末尾 ——
       *   ⇒ **键序对不救场**:段序只在描述里,这边的键序是跟着它走的,⛔ 改一边必须看另一边。
       * ⚠️ 顶层从三段变两段，每个可动手的东西都嵌在它要改的那一段里
       *   （改人群/改人数那两组同理，在「怎么选的」里面）。
       */
      ...(pick('dispatch').length > 0 ? { '要你定的': pick('dispatch') } : {}),
    },
  };
}

/**
 * 草稿态确认单 → **给模型的中文事实**（见 `SheetSnapshotSchema` 上那段：
 * 快照由浏览器产，因为确认之前那份状态只存在于卡片里）。
 *
 * ⚠️ 与 `modelFacts` 同一条纪律：⛔ 一个字的指令都不许有，只给事实。
 * ⚠️ 为 0 的项**照给** —— 「待你定的 0 人」和"没有这个键"是两件事：
 *   前者是"都处置完了"，后者是"不知道"。⛔ 别学别处的 `...(x ? {} : {})` 省略法。
 * ⚠️ `已经分下去了` 与 `propose_assignment` 返回值里那个键**同名同义**，⛔ 别起第二个说法。
 */
export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
  return {
    /**
     * ⭐ **人群条件排在最前** —— 「这批人是谁」是重算的前提，⛔ 不能让它排在
     *   一堆分配结果后面：模型读到「改成 200 人」时第一件要确认的就是这一格是什么。
     * ⚠️ 中文措辞与 `modelFacts.怎么选的` 对齐（治疗项 / 时间档）——
     *   ⛔ 同一件事别起两个说法，否则模型会以为是两批人。
     */
    这批人是谁: {
      治疗项: zhTreatment(s.criteria.potentialTreatment ?? undefined),
      时间档: s.criteria.temperature
        ? `${TEMPERATURE_SINCE_ZH} ${zhTemperature(s.criteria.temperature)}`
        : null,
      ...(s.criteria.narrowedBy?.personaTags
        ? { 他追加的画像条件: s.criteria.narrowedBy.personaTags }
        : {}),
      ...(s.criteria.narrowedBy?.minSpendYuan != null
        ? { 他设的消费下界: s.criteria.narrowedBy.minSpendYuan }
        : {}),
      符合条件的总人数: s.criteria.candidateTotal,
      这一版估了多大: s.criteria.batchSize,
    },
    已经分下去了: s.confirmed,
    // ⭐ 「已排好」而不是「已分配」：与卡片上那行字逐字一致（2026-08-13 定的措辞）
    已排好: s.placed,
    涉及客服: s.agents,
    // 🔴 确认时**不会落库** —— 这个数不为 0 就意味着有人会被放掉
    待你定的: s.pending,
    他移出本批的: s.dropped,
    被改派过的: s.moved,
    整批时限天数: s.expiresInDays,
    单独改过时限的条数: s.rowExpiryOverrides,
    本批福利: s.benefit,
    每位客服现在几条: s.byAgent.map((a) => ({ 客服: a.name, 条数: a.count })),
  };
}

/**
 * `[3, 5, 4]` → `{ 最少: 3, 最多: 5 }`；全相等时给单个数字。空数组给 0。
 *
 * 🔴 **⛔ 别改回 `'3~5'` 这类字符串区间。** 实测（2026-08-12 浏览器）：
 *   返回 `'4~15'` 时模型把波浪号吞掉，对主管说「每人拿到 **415** 条」——
 *   而真值是 4~15（162 条 / 33 位客服）。同一份数据它上一轮读对、这一轮读错，
 *   属于**不稳定**，⛔ 不是提示词能修的（加一条「区间要读成 X 到 Y」也只是③级）。
 * ⇒ 两个数字分成两个键，JSON 里就**粘不到一起**（①级：结构上不可能）。
 */
function range(xs: number[]): number | { 最少: number; 最多: number } {
  if (xs.length === 0) return 0;
  const lo = Math.min(...xs);
  const hi = Math.max(...xs);
  return lo === hi ? lo : { 最少: lo, 最多: hi };
}

/** 落人策略的取值码 → 中文（给明细用；目前 facts 里只用聚合数，留着备用）。 */
export function zhAssignStrategy(s: string): string {
  return s === AssignStrategy.DEDICATED ? '专属客服' : '手上最空的人';
}
