import { z } from 'zod';
import { AssignStrategySchema } from '../enums';

// =============================================================
// 批次分配(门诊经理)—— POST /pac/v1/plans/assignments
// =============================================================

/**
 * 批次里的一条 —— **(planId, assigneeUserId) 对**,不是 `{assigneeUserId, planIds[]}` 分组。
 *
 * 为什么不用分组形状(看着更省字节):
 *   1. 归属是**逐条**算出来的 —— 溢出转铺平后同一个客服名下会混着 dedicated 与
 *      spread_overflow 两种来源,分组形状表达不了「这一条为什么落到他头上」。
 *   2. T13 允许主管在确认单上微调时效,而微调是**逐条**的
 *      (「本批 3 天,其中 5 条单独设了 7 天」)—— 分组形状里那 5 条无处安放。
 */
export const AssignmentItemSchema = z.object({
  planId: z.string().uuid(),
  /// 宿主侧 user id(与 JWT.sub 同一 id 空间,已于 2026-08 实测 81% 重合验证)
  assigneeUserId: z.string().min(1),
  /// ⭐ 逐条带:这条是怎么落到他头上的。事后补不回来,见 AssignStrategy 的注释
  assignStrategy: AssignStrategySchema,
  /// 单条覆盖批次时效(不传 = 跟批次)。相对天数,与批次同口径,服务端统一转绝对时刻
  expiresInDays: z.number().int().positive().max(90).optional(),
  /**
   * 这条是**怎么被选进批次**的。
   *   rank    按排序键正常入选(默认)
   *   explore 随机探索配额抽中的
   *
   * ⭐ 探索配额是整套系统里**唯一的因果抓手**:入选本身与结果相关(高分的人本来就更容易成),
   * 纯观察数据永远解不开"是排序键选得准,还是这批人本来就好"。留了配额却不标记,
   * 等于白留 —— 事后无法把两组分开。
   */
  selectionMode: z.enum(['rank', 'explore']).optional(),
});
export type AssignmentItem = z.infer<typeof AssignmentItemSchema>;

/**
 * ⚠️ 500 是**技术护栏**(单事务 + PG bind 变量上限),不是批次规模的业务上限。
 * 业务上限该由产品定(教条七·待确认),定了之后加在**助手圈人那一层**,别写死在这里 ——
 * 写在这里会让"技术能不能扛"和"运营该不该分这么多"两件事永远分不开。
 */
export const ASSIGNMENT_ITEMS_HARD_LIMIT = 500;

export const CreateAssignmentRequestSchema = z.object({
  /**
   * 幂等键 —— **服务端在生成确认单(propose)时铸造**,随确认单下发、确认时原样回传。
   * ⛔ 不由前端或模型生成:挡不住 SSE 重连与模型重试,而"重复分配"没有天然自然键可去重
   * (同一主管同一秒对同一批人再点一次,在业务上是合法操作)。
   */
  requestId: z.string().min(8).max(128),
  /// 批次不跨诊所(福利政策与效果归因都按诊所走)
  clinicId: z.string().min(1),
  /// 初筛条件快照 + 收敛规则。快照而非引用:口径会变,批次要能解释"当时按什么圈的"
  criteria: z.record(z.string(), z.unknown()),
  /// 附加属性。福利 v1 不核销、不接卡券,只是话术勾子 + 归因标签
  attributes: z
    .object({ benefit: z.object({ text: z.string().min(1).max(200) }).optional() })
    .optional(),
  /**
   * 批次时效(相对天数)。
   * ⚠️ 收相对天数而不是绝对时刻:让模型算 ISO 时刻正是本仓 commit 4549817 修过的坑
   * (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
   */
  expiresInDays: z.number().int().positive().max(90),
  items: z.array(AssignmentItemSchema).min(1).max(ASSIGNMENT_ITEMS_HARD_LIMIT),
});
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;

// =============================================================
// 客服名册 + 负载  —— GET /pac/v1/plans/agents
// =============================================================

/**
 * ═══ 分配只有两个基数:**本批人数 N** 和 **时效** ═══════════════════
 *
 * 两者的取值方式完全一致(2026-08-03 产品定):
 *   **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
 *
 * ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
 *   第一次他把人数和时效调到顺手,系统记住,**第二次起他什么都不用输**。
 *   这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
 * ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
 *   后者让第一次的调整白费。
 *
 * ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_PER_AGENT_FIRST)。
 *   负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
 */

/**
 * **首次**分配时,每个在岗客服按多少条估 —— 首批 N = `min(在岗人数 × 20, 本批实际候选数)`。
 *
 * ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时效** 两个基数,⛔ **没有"容量"这个数**。
 *   这里的 20 **不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法 ——
 *   它只在**首次**参与一次计算,之后 N 沿用主管上次用的值,这个常量再也不出现。
 *
 * ── 为什么容量被删掉 ────────────────────────────────────────
 * 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
 *   ① **第二批必然是 0** —— 第一批把所有人填到水位 20,再算就是 `Σ(20−20)`。
 *     主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
 *   ② 真正要表达的是**负载**,而负载不需要阈值:**在手量本身就是负载**,
 *     水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
 * 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
 *
 * ⛔ 别给 N 加上下限:这是主管对自己团队的判断,系统没有依据卡他。
 */
export const BATCH_SIZE_PER_AGENT_FIRST = 20;

/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/**
 * 卡片上时效可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
 *
 * ⭐ 档位是我们拍的,而主管对自己团队的节奏有判断(「明天就要结果」= 1 天;「这周内」= 5 天)——
 *   ⛔ 别用三个我们拍的档位去限制他。
 * ⚠️ 到 7 为止不是业务上限:契约允许 90 天,真要更长在对话里说个数,助手照传。
 * ⚠️ 时效**不改人群**,所以留在卡片上;改人数会换一批人,那必须回对话重出单。
 */
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];

export const AgentInfoSchema = z.object({
  userId: z.string(),
  /// 服务端解析好的姓名(PAC 无 users 表,前端翻不了 —— 见 agent-roster.service 注释)
  name: z.string().nullable(),
  /// 在手总量,**跨诊所全量** —— 容量是人的属性不是诊所的属性(实测 24% 客服跨诊所)
  inHand: z.number().int(),
  /// 其中属于本诊所的。⚠️ 只用于展示拆分,**不参与容量判断** ——
  /// 不拆开显示的话,跨诊所客服会显得"手上很空",主管第一反应是数据错了
  inHandThisClinic: z.number().int(),
  recentVisits: z.number().int().describe('在岗窗口内的回访条数'),
  lastVisitAt: z.string().nullable(),
  /// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
  /// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
  inRoster: z.boolean(),
  /// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST)。
  /// `inHand` 就是负载本身,落人按它走水位法;返回一个区间只会被读成"合法范围"。
});
export type AgentInfo = z.infer<typeof AgentInfoSchema>;

export const ListAgentsResponseSchema = z.object({
  clinicId: z.string(),
  rosterMonths: z.number().int(),
  agents: z.array(AgentInfoSchema),
  /**
   * ⭐ **给助手直接照抄的成品句子**,不是给人读的说明。
   * T14 的落地方式是「提示词 + 工具返回值」双保险:LLM 做阈值判断和写免责声明
   * 都不可靠,把该说的话直接给它,任务就从"让模型算对"降级成"让模型照抄"。
   */
  rosterNote: z.string(),
});
export type ListAgentsResponse = z.infer<typeof ListAgentsResponseSchema>;

/// 没落上的单及原因 —— 助手照着这个说人话,别让主管对着 uuid 猜
export const AssignmentSkippedSchema = z.object({
  planId: z.string(),
  reason: z.enum([
    /// 已被别人认领 / 已分给别人(现有摩擦,教条七·待确认是否允许强制改派)
    'claimed_by_other',
    /// 已终态(completed / abandoned / superseded)
    'terminal',
    /// 同一患者在本批里有多条活动 plan,只留优先级最高的一条
    'duplicate_patient',
  ]),
});
export type AssignmentSkipped = z.infer<typeof AssignmentSkippedSchema>;

export const CreateAssignmentResponseSchema = z.object({
  assignmentId: z.string(),
  /// true = 同 requestId 重复提交,返回的是**已有批次**,本次没产生任何新写入
  duplicate: z.boolean(),
  assigned: z.number().int(),
  skipped: z.array(AssignmentSkippedSchema),
  /// 落库后的批次时效(绝对时刻,前端直接展示,不要自己再算一遍)
  expiresAt: z.string(),
});
export type CreateAssignmentResponse = z.infer<typeof CreateAssignmentResponseSchema>;

// ── 批次查询(跟踪的最小形态;完整跟踪见 P5)────────────────────

export const AssignmentBriefSchema = z.object({
  id: z.string(),
  clinicId: z.string().nullable(),
  createdBy: z.string(),
  /// 服务端解析好的姓名 —— 前端拿不到 userId→姓名的字典(只覆盖当前登录人)
  createdByName: z.string().nullable(),
  criteria: z.record(z.string(), z.unknown()),
  benefitText: z.string().nullable(),
  status: z.string(),
  expiresAt: z.string(),
  createdAt: z.string(),
  /**
   * ⭐ 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
   *
   * 主管记不住也不该记 uuid。**没有批次列表页**的情况下,他指认一批的唯一办法就是描述它
   * (「撤销今天下午牙周窗口内那批」),模型再从列表里按这个名字匹配。
   * ⚠️ **服务端生成**,⛔ 不许让模型自己拼:措辞会在两轮之间漂,主管就对不上"上次说的那批"。
   * ⚠️ 时间精确到分钟是主要区分器 —— 同一个矩阵格子一天可能分好几批。
   */
  label: z.string().describe('人话批次名(服务端生成,唯一对外称呼)'),
  /// 以下计数**现算不落列**(T17)。⚠️ 但数据源分两处,别搞混:
  ///   · planned / agents / released / expired —— 从**账本** `plan_event_logs` 算(历史事实,永不变)
  ///   · inHand / done                        —— 从 `followup_plans` 现算(当前状态,会变)
  /**
   * 本批**当时**分了多少人(按患者去重)。
   *
   * 🔴 源必须是账本,⛔ 不能数 `followup_plans.assignment_id`:那一列**会被下一次分配覆盖**。
   *   一条单退回/到期回池后再被分进新批次,旧批次就少一个人 —— **批次跑得越久
   *   (退回重分越多)缩得越厉害**,且不报错。实测 c02e1b80 分过 9 条、重分后显示 0。
   * ⚠️ 口径也不是「活跃版本」—— 那会把引擎判定"需求已了"的人剔掉,同一个坑的另一面。
   */
  planned: z.number().int().describe('本批当时分了多少人(按患者去重,取自账本)'),
  inHand: z.number().int().describe('仍挂在客服名下(当前状态)'),
  /**
   * 客服**主动退回**的条数(取自账本 event='release')。
   * ⚠️ ⛔ 不数 `followup_plans.release_reason` —— 重分时那一列会被清空(assign 的
   *   SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。
   * ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 expired。
   */
  released: z.number().int().describe('客服主动退回(不含到期)'),
  /**
   * 到期自动回池的条数(账本 auto_release + reason=assignment_expired)。
   *
   * ⚠️ 这是**"没处置"**,不是"处置了"。它和 released 必须分开看,因为主管的下一步相反:
   *   退回多 → 分配策略不对(派给了不该派的人);
   *   到期多 → 派多了 / 时效太紧 / 人不在岗。
   *   合成一个"回池率"两种病都看不出来。
   */
  expired: z.number().int().describe('到期自动回池(客服没动)'),
  agents: z.number().int().describe('涉及几个客服(取自账本)'),
  /**
   * 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
   * 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
   * ⚠️ 是**处理**不是**成功**:只说"这单动过了",不说"谈成了"。
   */
  done: z.number().int().describe('已处理(召回已出池 / 被抑制 / 已结案)'),
});
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;

export const ListAssignmentsResponseSchema = z.object({
  items: z.array(AssignmentBriefSchema),
});
export type ListAssignmentsResponse = z.infer<typeof ListAssignmentsResponseSchema>;

/// 单批里某个客服的承接情况
export const AssignmentAgentStatSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  planned: z.number().int(),
  inHand: z.number().int(),
  released: z.number().int(),
  /**
   * 仍在手、已过时效、**且没有约定回访**。
   *
   * 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
   * (客服约了 6/10 回访就绝不能在那之前收走,否则毁掉他对患者的承诺)——
   * 于是这类单会一直"超期"下去。而 `progress` 那边已经把它算作 **suppressed(已处理)**。
   * 不加这条守卫,同一条单就会「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
   * 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
   */
  overdue: z.number().int().describe('仍在手、已过时效、且没约下次回访'),
});
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;

export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
  /// ⚠️ 叫 agentStats 不叫 agents:Brief 里的 `agents` 是**人数**(number)。
  /// 同名不同型会让前端拿着 brief 的类型去读 detail 时静默拿到 undefined。
  agentStats: z.array(AssignmentAgentStatSchema),
  /// 退回原因分布。⚠️ 聚合时**必须同时过滤 event='release'** —— 同一列还存着
  /// auto_release 的 timeout/clinic_moved 与 feedback 的 up/down,不过滤就全混进来了
  releaseReasons: z.array(
    z.object({ reason: z.string(), labelZh: z.string(), n: z.number().int() }),
  ),
  /**
   * ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过),
   * 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
   * 只给一个百分比会让主管把"没人动"误读成"做得不错"。
   */
  untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
  /**
   * 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。
   *
   * 「完成」不需要等成功与否,只要知道**这个患者的召回还在不在池、有没有被抑制**:
   *   · `resolved`  出池了 —— 引擎判定该患者已无活信号(治疗真做了 / 诊断没了)。**零回写依赖**
   *   · `suppressed` 被抑制 —— 客服写了回访结果(约下次 / 拒绝 / 放弃)
   *   · `closed`     已结案
   * 两条判据互补:前者接住不写回访的 89%,后者接住写了的 11%。
   *
   * ⚠️ **是处理率不是成功率**。成功与否现在不算,但没丢 ——
   *   出池原因在 `plan_event_logs`、客观事实在 `patient_facts`(append-only),随时可回溯重算。
   * ⚠️ `done + inHandPending + backToPool` 恒等于 `planned`(五桶穷尽,回归里锁着)。
   */
  progress: z.object({
    done: z.number().int().describe('已处理 = resolved + suppressed + closed'),
    resolved: z.number().int().describe('召回已出池(引擎判定无活信号)'),
    suppressed: z.number().int().describe('被抑制(客服写了回访结果)'),
    closed: z.number().int().describe('已结案 / 已放弃'),
    inHandPending: z.number().int().describe('还在客服手上,尚未处理'),
    backToPool: z.number().int().describe('退回或到期,已落回池子(仍归本批)'),
    /**
     * 已经**被分进别的批次**的人数。
     *
     * 他们当时确实在本批(账本记着),但后来退回/到期回池、又被后面的批次挑走了 ——
     * 此后的表现该算给新批次,本批说不了。
     * ⚠️ 加这一桶是为了让**穷尽性重新成立**:
     *   `done + inHandPending + backToPool + reassigned === planned`(回归里锁着)。
     *   planned 改成账本口径后,不补这一桶就会"加不出来",而少的那几个永远查不出去哪了。
     */
    reassigned: z.number().int().describe('已被后续批次挑走(后续表现归那一批)'),
    ageDays: z.number().int().describe('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'),
    note: z.string().describe('成品句子,助手原话转述'),
  }),
});
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>;

// =============================================================
// 全景确认单(助手直出,主管确认)—— 只读提案,不落库
// =============================================================

export const ProposedItemSchema = z.object({
  planId: z.string(),
  patientId: z.string(),
  /**
   * ⭐ 患者姓名 —— **必须带**。主管展开客服看的是"这 20 个人是谁",
   * 光给 `#912e5dae` 这种 id 前缀等于让他对着一串十六进制猜(T14:证据要看得懂)。
   * ⚠️ 可能为 null(宿主没给姓名),前端退回 id 前缀。
   */
  patientName: z.string().nullable(),
  /// 病历号(宿主不一定给:jvs-dw 91% / friday 63%),缺就不占位
  medicalRecordNumber: z.string().nullable(),
  /// ⭐ 性别 / 年龄 —— 与患者详情卡片**同口径**(gender 是原始值,前端用 formatGender 归一)。
  ///   主管扫这一列时看的是"这批人大概是谁",光有姓名不够(同名、判断人群构成都要它)
  gender: z.string().nullable(),
  age: z.number().int().nullable(),
  assigneeUserId: z.string(),
  assignStrategy: AssignStrategySchema,
  selectionMode: z.enum(['rank', 'explore']),
});
export type ProposedItem = z.infer<typeof ProposedItemSchema>;

/**
 * 按客服的**精调**。
 *
 * 两个基数(容量/时效)是**整体默认**,这里是针对某一个人的覆盖:
 * 「李莉这周带教,只给 5 条」「王强手上都是老客,给他 7 天」。
 *
 * ⚠️ 两者的改动性质**不同**,所以入口也不同:
 *   · `capacity` 改的是**人数**(Σ 容量−在手)→ 会换人群 → 必须回对话让助手重出确认单;
 *   · `expiresInDays` 不改人群 → 就在卡片上那一行改(落到该客服名下**每一条任务**的
 *     `followup_plans.assignment_expires_at`,与批次时效同口径)。
 */
export const AgentOverrideSchema = z.object({
  /**
   * **本批**最多给他几条(⛔ 不是"容量"——那个概念已经删了,这是一次性的名额上限)。
   * 「李莉这周带教,这批最多给 5 条」。⚠️ 0 也合法 = 这轮不给他。
   */
  maxThisBatch: z.number().int().min(0).optional(),
  expiresInDays: z.number().int().positive().max(90).optional(),
});
export type AgentOverride = z.infer<typeof AgentOverrideSchema>;

export const ProposalAgentRowSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  inHandBefore: z.number().int().describe('分配前在手(跨诊所全量)'),
  count: z.number().int(),
  dedicated: z.number().int(),
  spread: z.number().int().describe('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
  /// ⭐ 分配**之后**他手上有多少(= inHandBefore + count)。
  ///   这是「负载」的唯一表达 —— 没有阈值线,主管自己看这一列齐不齐、高不高
  loadAfter: z.number().int(),
  /// 本批对**这个人**生效的时效(没精调就等于批次时效)。逐人下发,⛔ 别让前端再合并一次
  expiresInDays: z.number().int(),
  /// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上)
  overridden: z.boolean(),
});
export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;

/**
 * 全景确认单的数据体。
 *
 * ⚠️ 三个 `*Note` 字段是**给助手直接照抄的成品句子**,不是给人读的说明文字。
 * T14/T20 那两类要求(标注默认值、样本不足不出百分比)全是除法和阈值判断 ——
 * 恰恰是 LLM 最不可靠的地方。把话写好交给它抄,任务就从"让模型算对"降级成"让模型照抄"。
 */
export const AssignmentProposalSchema = z.object({
  clinicId: z.string(),
  potentialTreatment: z.string().nullable(),
  /**
   * ⭐ 矩阵 Y 轴:窗口温度(hot/warm/cold)。
   *
   * 🔴 2026-08-04 走查:它**参与了圈人**(cohort-filter 的 temperatureSql),
   *   却一直没随确认单下发、也就没进 `plan_assignments.criteria` 快照 ——
   *   而 schema 对 criteria 的承诺是「批次要能解释**当时是按什么圈的**」。
   *   后果:矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批完全不同的人,
   *   落库后**分不出来**;T20 想反推"哪种格子分下去效果好"直接答不了。
   * ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。
   */
  temperature: z.string().nullable(),
  /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
  /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
  candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
  target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'),
  /**
   * 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
   * ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
   *   ⛔ 不能回写成基数 —— 否则点一次 44 人的小格子,以后所有批次就永远是 44 人,
   *   而主管完全看不出为什么变小了。
   */
  batchSize: z.number().int(),
  /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
  /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
  ///   沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
  expiresInDays: z.number().int().describe('批次时效天数(基数②;卡片初值,主管可在卡片上改)'),
  basis: z
    .enum(['inherited', 'default', 'explicit'])
    .describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
  /// 沿用时是"沿用哪一次"(ISO 日期);非沿用为 null
  basisFrom: z.string().nullable(),
  /// 按客服的精调(userId → 覆盖值)。⚠️ 与基数一样会被记住,所以卡片上必须标出来 ——
  /// 「李莉休假那周只给 5 条」如果悄悄沿用三个月,没人会发现
  agentOverrides: z.record(z.string(), AgentOverrideSchema),
  placed: z.number().int(),
  /**
   * 没落上的条数。
   * ⚠️ 水位法**没有上限**,所以正常情况恒为 0 —— 只有"名册为空"或
   * "所有人都被精调成 0 名额"时才 > 0。⛔ 别把它读成"团队满了"。
   */
  unplaced: z.number().int(),
  items: z.array(ProposedItemSchema),
  byAgent: z.array(ProposalAgentRowSchema),
  /**
   * 本批**一条都没分到**的在岗客服。**仍然列出来** —— 主管要看见"他不是被漏了"。
   * ⚠️ 语义变了:不再是"已达容量",而是**水位法没轮到他**(手上本来就最多),
   *   或者被精调成 0 名额。附上 inHand 让原因不言自明。
   */
  skippedAgents: z.array(
    z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
  ),
  rosterNote: z.string(),
  /// 基数说明:两个基数的值 + **出处**(沿用哪次/首次默认/本次指定)+ 分配后的水位
  basisNote: z.string(),
  selectionNote: z.string(),
});
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;

// =============================================================
// 助手直接改确认单 —— 本地工具 edit_assignment_sheet 的载荷
// =============================================================

/**
 * 确认单的**语义编辑指令**。
 *
 * ⭐ 为什么用「姓名」而不是 planId:模型**拿不到也不该拿到** planId
 * (确认单的肥载荷走侧信道直接给界面,不进模型上下文 —— 见 assistant.service 的注释)。
 * 所以指令用主管说得出口的东西(患者姓名 / 病历号 / 客服姓名)表达,
 * **由界面拿自己手里的那份确认单去匹配**。模型负责"听懂",界面负责"找到人"。
 *
 * ⚠️ 能做的**只有卡片上能做的那几件**(局部修正),⛔ 不含换人群 / 改批次人数 ——
 *   那两件要重跑服务端算法,只能重出一版确认单。
 * ⚠️ 匹配不到 / 匹配到多个,由界面回一句话进对话(见 sheetEditReport),
 *   ⛔ 别让模型自己猜"大概是成功了"。
 */
export const SheetEditOpSchema = z.discriminatedUnion('action', [
  z.object({
    action: z.literal('remove_patient'),
    patient: z.string().describe('患者姓名或病历号'),
  }),
  z.object({
    action: z.literal('remove_agent'),
    agent: z.string().describe('客服姓名'),
  }),
  z.object({
    action: z.literal('move_patient'),
    patient: z.string(),
    toAgent: z.string().describe('转给谁(客服姓名);必须已在本批里'),
  }),
  z.object({
    /**
     * 批次福利 —— T4:福利挂在**批次**上,不挂个人;它是**话术勾子 + 归因标签**,
     * v1 不核销、不接宿主卡券。落库进 `attributes.benefit.text`,
     * 生成话术时作为事实输入进 prompt(已打通,见 plan-script.orchestrator)。
     * ⚠️ 空串 = 撤掉福利。
     */
    action: z.literal('set_benefit'),
    text: z.string().max(200),
  }),
  z.object({
    action: z.literal('set_expiry'),
    days: z.number().int().positive().max(90),
    /// 三选一:给某个患者 / 给某个客服名下全部 / 都不给 = 整批
    patient: z.string().optional(),
    agent: z.string().optional(),
  }),
]);
export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;

// =============================================================
// 撤销整批  —— POST /pac/v1/plans/assignments/:id/revoke
// =============================================================

/**
 * 撤销窗口(分钟)。
 *
 * ⚠️ 语义是「**手滑 / 分错人**的补救」,不是「改主意重新调度」——
 * 改主意应该走"客服退回 + 重新分配",那条路有完整的原因记录;
 * 撤销是把一次误操作当作没发生过,所以必须限时。
 *
 * ⚠️⚠️ 它是 **UX 摩擦,不是安全边界**:leader 本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
 * 30 分钟后照样能逐条 recycle 把单收回来。⛔ **别基于「30 分钟后就锁死了」去设计别的东西**
 * (比如"过了窗口就认为归属永久稳定"),那个前提不成立。
 */
export const REVOKE_WINDOW_MINUTES = 30;

export const RevokeAssignmentResponseSchema = z.object({
  assignmentId: z.string(),
  /// 真正收回池子的条数
  revoked: z.number().int(),
  /**
   * ⭐ **客服已经打开过详情页、故意跳过没收**的条数。
   *
   * 判据只能用 `view` 事件 —— `plan_executions` 回写率仅 11%(生产 65 认领 / 7 条结果),
   * 拿它当"动过没"会把 89% **已经打过电话**的单当成没动收走,那通电话就永久蒸发了
   * (正是 claim-guard 第 1 条要防的:执行必须有归属,否则统计里直接消失)。
   * `contactAttempts` 与 plan_executions 同源、同为 7 条,同样不可用。
   */
  skippedTouched: z.number().int(),
  /// 已经被客服退回 / 已到期回收的,本来就不在人手上,不计入
  alreadyReleased: z.number().int(),
  /// 给主管看的一句话(成品句子,助手照抄)
  note: z.string(),
});
export type RevokeAssignmentResponse = z.infer<typeof RevokeAssignmentResponseSchema>;
