import { z } from 'zod';

// =============================================================
// 主管调整 —— 调整各客服**在手**的单
//   GET  /pac/v1/plans/rearrange/draft   取一版可编辑快照(只读)
//   POST /pac/v1/plans/rearrange         提交(主管手动确认后才走这一步)
// =============================================================

/**
 * ── 与「分配」是**两条独立的路**,⛔ 别互相引用 ──────────────────
 *
 * 分配(`plan-assignment.ts`)回答的是「从池子里挑一批人发下去」;
 * 调整回答的是「已经在客服手上的这些,怎么调」。两者的形状看着像,语义不通:
 *
 *   · 调整**不建批次** —— 单子的 `assignment_id` 一律不动。动了原批的
 *     `planned` 会静默缩水(见 schema.prisma 里 assignment_id 那段红字)。
 *   · 调整**没有福利** —— 福利挂在批次上才归得了因,而调整的人来自若干不同批次。
 *     这里只**只读**展示"这条来自哪批、带什么福利"。
 *   · 调整**没有「本批多大」** —— 人数不是算出来的,是"他手上现有多少"。
 *   · 调整**没有「待分配」** —— 人已经在手上了,不存在"专属客服排满"这回事。
 *
 * ⇒ 共用一套 schema 必然要给一半字段配 null,而那一半迟早有人在某个分支上忘了判。
 */

/// 单次调整最多动多少条 —— 与分配同一道技术护栏(单事务 + PG bind 变量上限)
export const REARRANGE_ITEMS_HARD_LIMIT = 500;

// ── 一条在手的单 ─────────────────────────────────────────────
export const RearrangeItemSchema = z.object({
  planId: z.string(),
  patientId: z.string(),
  /// ⭐ 姓名必须带 —— 主管要判断的是"这个人该不该挪",给 id 前缀等于让他对着乱码猜
  patientName: z.string().nullable(),
  medicalRecordNumber: z.string().nullable(),
  gender: z.string().nullable(),
  age: z.number().int().nullable(),
  /**
   * 最近一次**就诊**的医生 —— 直接取 `patient_profiles.last_visit_doctor`,与列表页同源。
   *
   * ⛔ 这里**刻意不给「主治医生」**:确认单那一列是从触发本次召回的那条诊断现算的
   *   (排除影像 AI、没有则回落全量最高频真人),口径不平凡。在这儿另写一遍
   *   必然漂 —— 调整单显示 A、点进详情显示 B,而且不报错。要给就复用那条路,别重写。
   */
  lastVisitDoctor: z.string().nullable(),

  /// **此刻**在谁手上
  assigneeUserId: z.string(),
  /**
   * 这位患者的**专属客服**(宿主 DW 的 current_task_director,PAC 只读)。
   *
   * ⭐ 界面据此在行上标「专属」——判据是 `dedicatedCsUserId === assigneeUserId`,
   *   也就是「这条现在正在他的专属客服手上」。主管把它拖走 = 拆掉一段老关系,
   *   他有权在动手**之前**看见这件事(与确认单那一列同一个词、同一支色)。
   * ⚠️ null 有两种情况:这位患者没有专属客服 / 有但不是当前持有人。
   *   界面只在"是当前持有人"时标 —— 不是他的老客户反而不标,更好读(同确认单)。
   */
  dedicatedCsUserId: z.string().nullable(),

  /// 这条单是什么机会(潜在治疗 code,如 implant)+ 患者末诊落在哪一档
  potentialTreatment: z.string().nullable(),
  temperature: z.string().nullable(),

  /// 当前时限(ISO 时刻);null = 这条不是批次分下来的(老数据 / 自认领)
  expiresAt: z.string().nullable(),
  /**
   * 已经超期几天 —— 未超期为 null。
   * ⚠️ 与团队状态表**同口径**:约了下次回访的(见 snoozedUntil)不算超期。
   *   ⛔ 两处判据不许分家,否则主管在两个界面上看到两个数。
   */
  overdueDays: z.number().int().nullable(),
  /**
   * 客服约了下次回访的时刻 —— 有值且在未来 = 这条**已经处理过了**。
   * ⭐ 界面要标出来:把这种单挪给别人,等于把已经约好的人从原客服手里拿走。
   */
  snoozedUntil: z.string().nullable(),

  /// 这条来自哪一批(⛔ 只读,调整不改它)
  fromAssignmentId: z.string().nullable(),
  /// 那一批挂的福利(⛔ 只读。⛔ 调整不能改福利 —— 见文件头)
  benefit: z.string().nullable(),
});
export type RearrangeItem = z.infer<typeof RearrangeItemSchema>;

// ── 按客服的汇总行 ───────────────────────────────────────────
export const RearrangeAgentRowSchema = z.object({
  userId: z.string(),
  name: z.string().nullable(),
  /// 此刻在手多少条
  inHand: z.number().int(),
  /// 其中已超期多少条
  overdue: z.number().int(),
});
export type RearrangeAgentRow = z.infer<typeof RearrangeAgentRowSchema>;

// ── 草稿(只读快照)────────────────────────────────────────────
export const RearrangeDraftSchema = z.object({
  /**
   * 幂等键 —— **服务端在出草稿时铸造**,提交时原样回传。
   * ⛔ 不由前端或模型生成:挡不住重连与重试,而"又调整了一次"没有天然自然键可去重。
   */
  operationId: z.string(),
  clinicId: z.string(),
  /// 全景(全队在手)还是局部(只这几位客服)
  scope: z.enum(['all', 'agents']),
  /// scope='agents' 时主管点名的那几位(用于界面回显"你在调谁")
  scopedAgentUserIds: z.array(z.string()),

  agents: z.array(RearrangeAgentRowSchema),
  items: z.array(RearrangeItemSchema),

  /// 这一版一共多少条 / 其中超期多少条
  total: z.number().int(),
  overdueTotal: z.number().int(),
  /**
   * 被硬上限截断了多少条 —— **> 0 时界面必须说出来**。
   * ⛔ 不许静默截断:少了的那些主管永远不知道(no silent cap)。
   */
  truncated: z.number().int(),
  /// 换算尺:每人每天打几通(界面算"这些约几天的量"用,与确认单同一个常量)
  dailyCalls: z.number().int(),
});
export type RearrangeDraft = z.infer<typeof RearrangeDraftSchema>;

// ── 提交 ─────────────────────────────────────────────────────
export const RearrangeSubmitRequestSchema = z.object({
  operationId: z.string().min(8).max(128),
  clinicId: z.string().min(1),
  /// 改派:这条换给谁
  moves: z.array(z.object({ planId: z.string().uuid(), toUserId: z.string().min(1) })).default([]),
  /// 改时限:相对天数(与分配同口径,服务端按 host 时区转成当地日末)
  expiry: z
    .array(
      z.object({ planId: z.string().uuid(), expiresInDays: z.number().int().positive().max(90) }),
    )
    .default([]),
  /// 移出:收回池子。⛔ **不带原因** —— 这是主管侧的调度,不是客服「我不接这单」
  removes: z.array(z.object({ planId: z.string().uuid() })).default([]),
});
export type RearrangeSubmitRequest = z.infer<typeof RearrangeSubmitRequestSchema>;

/**
 * 一条没落上的原因。
 *
 * 🔴 调整**逐条落、落不上的如实回报**,⛔ 不照抄分配那套「数量对不上整体拒绝」:
 *   分配那边整批还没发出去,重来一次代价小;而调整时主管可能已经调了二十条,
 *   因为客服刚好打完一条就全部作废 —— 那个体验一次就废掉这个功能。
 */
export const RearrangeSkipReasonSchema = z.enum([
  /// 已经不在人手上了(客服在这期间退回 / 被收回)
  'not_in_hand',
  /// 客服已经处理完了(completed / abandoned)
  'already_done',
  /// 被引擎重算取代(该患者的召回信号变了)
  'superseded',
  /// 这期间被改派给了别人 —— 主管手里那份草稿已经过时
  'assignee_changed',
  /// 改派的目标就是当前这位,什么都不用做
  'same_assignee',
  /// 不在本诊所 / 不在你的数据范围内
  'out_of_scope',
]);
export type RearrangeSkipReason = z.infer<typeof RearrangeSkipReasonSchema>;

export const REARRANGE_SKIP_REASON_ZH: Record<RearrangeSkipReason, string> = {
  not_in_hand: '这条已经不在客服手上了',
  already_done: '客服已经处理完了',
  superseded: '这条已被重算取代',
  assignee_changed: '这期间被改派给了别人',
  same_assignee: '本来就在这位客服手上',
  out_of_scope: '不在你的数据范围内',
};

export const RearrangeSubmitResponseSchema = z.object({
  operationId: z.string(),
  /// 同一个 operationId 重复提交 → true,一个字都没写
  duplicate: z.boolean(),
  moved: z.number().int(),
  expiryChanged: z.number().int(),
  removed: z.number().int(),
  skipped: z.array(
    z.object({
      planId: z.string(),
      op: z.enum(['move', 'expiry', 'remove']),
      reason: RearrangeSkipReasonSchema,
    }),
  ),
  /// 成品句子 —— 界面与助手都直接念它(口径收口在服务端,⛔ 两边别各拼一份)
  note: z.string(),
});
export type RearrangeSubmitResponse = z.infer<typeof RearrangeSubmitResponseSchema>;

// =============================================================
// 助手对调整单的语义编辑指令(`edit_rearrange_sheet` 走侧信道下发,界面执行)
// =============================================================

/**
 * ⛔ 与确认单的 `SheetEditOp` **刻意不共用**。
 *
 * 两边看着像,取值域不同、且差别正是"调整是什么"的定义:
 *   · 没有 `set_benefit` —— 这一版的人来自若干不同批次,福利挂在批次上;
 *   · 没有 `pending` / `batch` 分组 —— 调整里不存在"待分配",也没有"整批";
 *   · 多了 `overdue` 分组 —— 「把超期的都转走」是调整最常说的一句;
 *   · `to.mode` 只有 `balance` —— 「各自归专属客服」属于分配那一版的落人规则,
 *     在调整里没有意义(人已经在手上了,专属关系也不由 PAC 决定)。
 * 共用一套枚举必然要在两边各加一堆"这个值在这条路上不合法"的判断。
 */
export const RearrangeSelectSchema = z.object({
  /// patients=点名的患者;agent=某位客服手上全部;overdue=这一版里已超期的;all=这一版全部
  group: z.enum(['patients', 'agent', 'overdue', 'all']),
  /// 患者姓名或病历号(group='patients' 必填)
  patients: z.array(z.string()).optional(),
  /// 客服姓名(group='agent' 必填)
  agent: z.string().optional(),
});
export type RearrangeSelect = z.infer<typeof RearrangeSelectSchema>;

export const RearrangeEditOpSchema = z
  .object({
    select: RearrangeSelectSchema,
    action: z.enum(['move', 'set_expiry', 'remove']),
    /// 换给谁(action='move' 必填)。⛔ 只有 balance:见上面为什么没有 owner
    to: z
      .object({ mode: z.literal('balance'), agents: z.array(z.string()).optional() })
      .optional(),
    /// 新时限天数(action='set_expiry' 必填),从今天起算
    days: z.number().int().positive().max(90).optional(),
  })
  /**
   * ⚠️ 校验放 schema 而不是执行处:这些指令由**模型**生成,少一格是常态。
   *   在这里拦住,模型能拿到一句说得清的错并重试;放到界面里拦,主管看到的是
   *   "指令执行了但什么都没变"。
   */
  .superRefine((op, ctx) => {
    if (op.action === 'move' && !op.to) {
      ctx.addIssue({ code: 'custom', message: "action:'move' 必须给 to(谁手上少先给谁)" });
    }
    if (op.action === 'set_expiry' && op.days == null) {
      ctx.addIssue({ code: 'custom', message: "action:'set_expiry' 必须给 days" });
    }
    if (op.select.group === 'patients' && !op.select.patients?.length) {
      ctx.addIssue({ code: 'custom', message: "group:'patients' 必须给患者姓名或病历号" });
    }
    if (op.select.group === 'agent' && !op.select.agent) {
      ctx.addIssue({ code: 'custom', message: "group:'agent' 必须给客服姓名" });
    }
  });
export type RearrangeEditOp = z.infer<typeof RearrangeEditOpSchema>;
