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_DEFAULT)。
 *   负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
 */

/**
 * **首次**分配的批次人数(之后沿用上一次)—— 基数①「这一轮推多少人」。
 *
 * ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时效** 两个基数,⛔ **没有"容量"这个数**。
 *
 * ── 为什么容量被删掉 ────────────────────────────────────────
 * 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
 *   ① **第二批必然是 0** —— 第一批把所有人填到水位 20,再算就是 `Σ(20−20)`。
 *     主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
 *   ② 真正要表达的是**负载**,而负载不需要阈值:**在手量本身就是负载**,
 *     水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
 * 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
 *
 * ⚠️ 取 100 是 T5「宁可 100 人做透,不做 1000 人做浅」的量级,**仍是默认值**:
 * 没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出「批次规模 × 完成率」再替换。
 * ⛔ 别再给它加"上下限":这是主管对自己团队的判断,系统没有依据卡他。
 */
export const BATCH_SIZE_DEFAULT = 100;

/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/// 卡片上给的时效档位(时效不改人群 → 卡片可直接改;改人数会换人群 → 回对话让助手重出单)
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [3, 5, 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_DEFAULT)。
  /// `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(),
  /// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数)
  /**
   * 本批分了多少人(**按患者去重、取最新版本**)。
   * ⚠️ 口径**不是**「活跃版本」—— 那会把引擎判定"需求已了"的人静默剔掉,
   *   批次跑得越好、这个数缩水得越厉害。见 plan-assignment.service.detail 的红字注释。
   */
  planned: z.number().int().describe('本批分了多少人(按患者去重)'),
  inHand: z.number().int().describe('仍挂在客服名下'),
  released: 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(),
  overdue: z.number().int().describe('仍在手且已过 assignment_expires_at'),
});
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('退回或到期,已落回池子'),
    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(),
  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(),
  /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
  /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
  candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
  target: z.number().int().describe('本批人数 N(基数①:沿用上次 / 首次默认 / 本次指定)'),
  /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
  /// ⚠️ 出处必须跟着值走:主管看到「容量 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>;

// =============================================================
// 撤销整批  —— 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>;
