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
// =============================================================

/**
 * ═══ 分配只有两个基数:**容量** 和 **时效** ═══════════════════════
 *
 * 两者的取值方式完全一致(2026-08-03 产品定):
 *   **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
 *
 * ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
 *   第一次他在卡片/对话里把容量和时效调到顺手,系统记住,**第二次起他什么都不用输**。
 *   这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
 * ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
 *   后者让第一次的调整白费。
 *
 * ⚠️ 批次规模**不是基数**,是这两个基数 + 在手量推出来的结果:
 *   `本批人数 = Σ max(0, 容量 − 该客服在手)`。
 *   ⛔ 别再引入"默认批次规模"那种第三个旋钮 —— 两个旋钮能推出的东西,
 *   再给一个就会互相打架(改容量还是改人数?两者矛盾时听谁的?)。
 */

/**
 * **首次**分配的容量起点(之后沿用上一次)—— 单个客服同时能跟进的**在手总量**(不是每日增量)。
 *
 * ⛔ **容量没有上下限**(2026-08-03 产品定):这是主管对自己团队的判断,
 * 系统没有任何数据可以证明 5 太少或 200 太多(全生产 `plan_executions` 仅 7 条)。
 * 拿一个同样没有依据的区间去卡他,只会让他撞上一个解释不了的墙。
 * ⚠️ 只校验**正整数** —— 那不是业务上下限,是"0 条 / -3 条"没有意义。
 *
 * ⚠️ 取 20 作起点而不是更大的数:批次规模由容量推出来(17 人 × 50 = 850 条一批,
 * 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法)。
 * 起点低、主管觉得不够再往上调 —— 反过来(起点高、发现做不完再往下调)那一批已经分出去了。
 */
export const AGENT_CAPACITY_DEFAULT = 20;

/// **首次**分配的时效起点(之后沿用上一次)。同容量,是基数不是常量。
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(),
  /// ⛔ 这里**不再返回容量区间** —— 容量没有上下限(见 AGENT_CAPACITY_DEFAULT),
  /// 返回一个 [20,50] 会被读成"合法范围",而它从来不是。
  /// 本批实际用的容量在确认单的 byAgent 里逐人给。
});
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({
  /// ⛔ 无上下限,只要正整数(理由同 AGENT_CAPACITY_DEFAULT)
  capacity: z.number().int().positive().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('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
  /// ⭐ 本批对**这个人**实际生效的两个值(没精调就等于整体基数)。
  ///   逐人下发而不是让前端自己合并 —— 合并逻辑写两遍必然漂,而漂了不报错
  capacity: 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('拟分人数 —— 由容量与在手量推出,不是独立旋钮'),
  /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
  /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
  ///   沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
  capacity: z.number().int().describe('每个客服的在手容量(本批实际用的那个值)'),
  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(),
  /// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回,
  /// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了
  unplaced: z.number().int(),
  items: z.array(ProposedItemSchema),
  byAgent: z.array(ProposalAgentRowSchema),
  /// 已达容量、本批跳过的人。**仍然列出来** —— 主管要看见"他不是被漏了,是已经满了"
  skippedAgents: z.array(
    z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
  ),
  rosterNote: z.string(),
  capacityNote: 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>;
