import { z } from 'zod';

// =============================================================
// User Role + Permission
// =============================================================

export const UserRole = {
  STAFF: 'staff',
  LEADER: 'leader',
  ADMIN: 'admin',
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
export const UserRoleSchema = z.enum(['staff', 'leader', 'admin']);

export const Permission = {
  // Plan / recall pool
  PLAN_VIEW_OWN: 'plan:view_own',
  PLAN_VIEW_ALL: 'plan:view_all',
  PLAN_ASSIGN: 'plan:assign',
  PLAN_RECYCLE: 'plan:recycle',
  PLAN_EXECUTE: 'plan:execute',
  /// ⭐ 批次分配(门诊经理)—— **主管判据的唯一真理源**。
  /// 为什么不复用现成的:PLAN_ASSIGN 连 staff 都有(那是"自助认领"语义,见下方 STAFF 注释),
  /// 拿它区分主管等于没区分;STATS_VIEW 虽然只有 leader 有,但全仓零端点零组件(死权限),
  /// 借它当判据是把一个从没被验证过的开关变成生产依赖。
  /// ⚠️ 只给 LEADER + ADMIN,**永远不要加进 STAFF** —— 加了这条权限就跟 PLAN_ASSIGN 一样废掉。
  PLAN_DISPATCH: 'plan:dispatch',
  // Patient / persona
  PATIENT_VIEW: 'patient:view',
  PERSONA_VIEW: 'persona:view',
  // Agent
  AGENT_INVOKE: 'agent:invoke',
  // Facts(Pool 1)
  FACT_INGEST: 'fact:ingest',
  // Stats
  STATS_VIEW: 'stats:view',
  // Platform management — admin only
  PLATFORM_MANAGE: 'platform:manage',
} as const;
export type Permission = (typeof Permission)[keyof typeof Permission];

export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
  [UserRole.STAFF]: [
    Permission.PLAN_VIEW_OWN,
    /// W3 末:staff 自助认领池里的 plan(分配给自己);分配给他人仍是 leader 权限。
    /// 前端 plansApi.assign(planId, user.sub) 在 pool 视图按钮上调用,即"认领"语义。
    Permission.PLAN_ASSIGN,
    /// 2026-07-29:staff 也能返池 —— 认错人 / 打不通 / 不该由我跟,自己就该能退回池,
    /// 否则只能挂着占位或硬走"关闭机会"(那会污染放弃原因统计)。
    /// ⚠️ **只能退自己认领的**:归属校验在 PlanService.recycle 里按"有没有 PLAN_VIEW_ALL"分流,
    /// 光给权限不加那道校验 = 客服 A 能把客服 B 的单抢下来。
    Permission.PLAN_RECYCLE,
    Permission.PLAN_EXECUTE,
    Permission.PATIENT_VIEW,
    Permission.PERSONA_VIEW,
    Permission.AGENT_INVOKE,
  ],
  [UserRole.LEADER]: [
    Permission.PLAN_VIEW_OWN,
    Permission.PLAN_VIEW_ALL,
    Permission.PLAN_ASSIGN,    // leader 可分配给任意 staff
    Permission.PLAN_DISPATCH,  // ⭐ 批次分配(主管判据)—— staff 不给,见 Permission 声明处
    Permission.PLAN_RECYCLE,   // leader:可回收**任意人**已认领的 plan(staff 只能退自己的)
    Permission.PLAN_EXECUTE,
    Permission.PATIENT_VIEW,
    Permission.PERSONA_VIEW,
    Permission.AGENT_INVOKE,
    Permission.STATS_VIEW,
  ],
  [UserRole.ADMIN]: Object.values(Permission),
};

// =============================================================
// Patient(主档状态)
// =============================================================

/**
 * ⭐ 亲戚关系白名单 —— 详情页「关联客户」只列这几类。
 *
 * patient_relations.relationship 的全集还有 `friend` 和 `other`,**刻意不进**:
 * 那条边表摄自 fact_customer_referee_out(推荐关系),`other` 占了一半以上(本地 4043/6222),
 * 里面混的是推荐人、同事、代付人这类"认识但不是亲属"的关系。字典把 other 译成「亲属」,
 * 但按它列出来客服会把推荐人当家属去问病情,比不显示更糟。
 *
 * sibling(兄弟姐妹)是亲戚,进;family_structure 特征把它算作"非直系"是另一件事
 * (那里判的是家庭结构,不是能不能称作亲戚),两处口径不同是有意的。
 */
export const KIN_RELATIONSHIPS: readonly string[] = [
  'spouse',
  'child',
  'grandchild',
  'mother',
  'father',
  'grandparent',
  'sibling',
];

// =============================================================
// Host action URL keys — 前端跳宿主 deep-link 的 known action 集合。
//
// 用法:Host.actionUrls 是 Record<HostActionKey | string, string>(允许 + 自定义 custom_xxx)。
// admin 配置页用 enum 作下拉,自定义需带 `custom_` 前缀(避免跟未来新增官方 key 撞名)。
// =============================================================

export const HostActionKey = {
  VIEW_PATIENT: 'VIEW_PATIENT',                 // 看患者档案(单 patient)
  EDIT_PATIENT: 'EDIT_PATIENT',                 // 编辑患者基本信息
  CREATE_APPOINTMENT: 'CREATE_APPOINTMENT',     // 新建预约
  VIEW_APPOINTMENT: 'VIEW_APPOINTMENT',         // 看某次预约
  VIEW_ENCOUNTER: 'VIEW_ENCOUNTER',             // 看某次接诊
  VIEW_MEDICAL_RECORD: 'VIEW_MEDICAL_RECORD',   // 看病历
  VIEW_IMAGE: 'VIEW_IMAGE',                     // 看影像
  VIEW_PAYMENT: 'VIEW_PAYMENT',                 // 看收款/账单
  ADD_NOTE: 'ADD_NOTE',                         // 加备注
  OPEN_WECOM_CHAT: 'OPEN_WECOM_CHAT',           // 打开企微会话
  CALL_PATIENT: 'CALL_PATIENT',                 // 拨号
  // postMessage 动作(无 URL,PAC 通过 postMessage 通知宿主打开其组件,payload 带 patientId)
  OPEN_POTENTIAL_TREATMENT: 'OPEN_POTENTIAL_TREATMENT', // 打开潜在治疗
  OPEN_RETURN_VISIT: 'OPEN_RETURN_VISIT',               // 打开回访
  // 非跳转 URL,而是 postMessage 目标源(embed 配置)—— 上面两个 postMessage 动作的 targetOrigin;
  // 值填宿主承载 iframe 的页面 origin(如 https://host.example.com)。
  HOST_ORIGIN: 'HOST_ORIGIN',
} as const;
export type HostActionKey = (typeof HostActionKey)[keyof typeof HostActionKey];

/// 标签(中文)+ 占位变量提示(给 admin 配置页显示)
export const HOST_ACTION_META: Record<HostActionKey, { label: string; placeholders: string[] }> = {
  VIEW_PATIENT:        { label: '查看患者档案(原始档案)', placeholders: ['{patientId}', '{medicalRecordNumber}'] },
  EDIT_PATIENT:        { label: '编辑患者信息',    placeholders: ['{patientId}'] },
  CREATE_APPOINTMENT:  { label: '新建预约',        placeholders: ['{patientId}', '{doctorId}'] },
  VIEW_APPOINTMENT:    { label: '查看预约',        placeholders: ['{appointmentId}'] },
  VIEW_ENCOUNTER:      { label: '查看接诊',        placeholders: ['{encounterId}'] },
  VIEW_MEDICAL_RECORD: { label: '查看病历(电子病历)', placeholders: ['{brandId}', '{clinicId}', '{medicalRecordNumber}', '{patientId}'] },
  VIEW_IMAGE:          { label: '查看影像',        placeholders: ['{imageId}'] },
  VIEW_PAYMENT:        { label: '查看账单/收款',   placeholders: ['{paymentId}', '{orderId}'] },
  ADD_NOTE:            { label: '添加备注',        placeholders: ['{patientId}'] },
  OPEN_WECOM_CHAT:     { label: '打开企微会话',    placeholders: ['{patientId}', '{wecomExternalUserId}'] },
  CALL_PATIENT:        { label: '拨号',            placeholders: ['{phone}'] },
  // OPEN_* 两键:配 URL 模板走跳转(推荐);宿主无独立页时配哨兵值 postMessage 走消息通道(需 HOST_ORIGIN)。未配置则按钮不渲染。
  OPEN_POTENTIAL_TREATMENT: { label: '打开潜在治疗(URL 模板;或配 postMessage)', placeholders: ['{patientId}', '{brandId}', '{clinicId}', '{medicalRecordNumber}'] },
  OPEN_RETURN_VISIT:        { label: '打开回访(URL 模板;或配 postMessage)',     placeholders: ['{patientId}', '{brandId}', '{clinicId}', '{medicalRecordNumber}'] },
  HOST_ORIGIN:         { label: '宿主 Origin(postMessage 目标源)', placeholders: [] },
};

export const PatientStatus = {
  ACTIVE: 'active',
  ARCHIVED: 'archived',
  MERGED: 'merged',
} as const;
export type PatientStatus = (typeof PatientStatus)[keyof typeof PatientStatus];
export const PatientStatusSchema = z.enum(['active', 'archived', 'merged']);

export const DoNotContactReason = {
  COMPLAINT: 'complaint',
  OPT_OUT: 'opt_out',
  TREATED_ELSEWHERE: 'treated_elsewhere',
  LEGAL_HOLD: 'legal_hold',
  OTHER: 'other',
} as const;
export type DoNotContactReason = (typeof DoNotContactReason)[keyof typeof DoNotContactReason];
export const DoNotContactReasonSchema = z.enum([
  'complaint',
  'opt_out',
  'treated_elsewhere',
  'legal_hold',
  'other',
]);

// =============================================================
// PatientTransaction(操作账本) — 三类 type 严格区分(db-design-v2 §13 D2)
//   - Action(动作事实,push 主导)
//   - SubjectType(被操作主体类型,PAC 归一化封闭集)
//   - FactType(parser 下游统一,事实单元;见下方 PatientFact 段)
//
// v2.1 重塑(W2 末):
//   - 拆 SubjectType:diagnosis / treatment / recommendation(对齐 fact_type 拆分)
//   - Action 加 diagnosis_recorded / treatment_planned / treatment_started /
//     treatment_completed / recommendation_extracted
// =============================================================

/// patient_transactions.action — PAC 归一化封闭集
/// 形式:`英文_key`(中文含义)
export const Action = {
  // ── 主流程动作 ──
  CONSULTATION_CREATED: 'consultation_created', // 咨询创建
  APPOINTMENT_CREATED: 'appointment_created', // 预约创建
  APPOINTMENT_CHANGED: 'appointment_changed', // 预约变更
  APPOINTMENT_CANCELLED: 'appointment_cancelled', // 预约取消
  APPOINTMENT_FULFILLED: 'appointment_fulfilled', // 患者到诊(预约履约)
  VISIT_REGISTERED: 'visit_registered', // 挂号
  RECEPTION_COMPLETED: 'reception_completed', // 前台接待完成
  DOCTOR_ENCOUNTER_STARTED: 'doctor_encounter_started', // 医生接诊开始(只元数据)

  // ── v2.1 拆出的临床动作 ──
  DIAGNOSIS_RECORDED: 'diagnosis_recorded', // 诊断记录(独立 fact)
  TREATMENT_PLANNED: 'treatment_planned', // 治疗计划录入(kind=planned)
  TREATMENT_STARTED: 'treatment_started', // 治疗开始(kind=actual,status=in_progress)
  TREATMENT_COMPLETED: 'treatment_completed', // 治疗完成(kind=actual,status=completed)
  RECOMMENDATION_EXTRACTED: 'recommendation_extracted', // 医生建议(Layer C 抽出/规则识别)

  // ── 单据 / 病历 / 影像 ──
  ORDER_SUBMITTED: 'order_submitted', // 收费单提交
  PAYMENT_RECEIVED: 'payment_received', // 收款
  REFUND_CREATED: 'refund_created', // 退费
  EMR_SUBMITTED: 'emr_submitted', // 病历提交
  EMR_REJECTED: 'emr_rejected', // 病历打回
  IMAGE_UPLOADED: 'image_uploaded', // 影像上传

  // ── 产品收集 ──
  RECHARGE_RECEIVED: 'recharge_received', // 充值入账
  COMPLAINT_FILED: 'complaint_filed', // 投诉提交
  COMPLAINT_RESOLVED: 'complaint_resolved', // 投诉结案
  REFERRAL_RECORDED: 'referral_recorded', // 转介绍记录
} as const;
export type Action = (typeof Action)[keyof typeof Action];
export const ActionSchema = z.enum([
  'consultation_created',
  'appointment_created',
  'appointment_changed',
  'appointment_cancelled',
  'appointment_fulfilled',
  'visit_registered',
  'reception_completed',
  'doctor_encounter_started',
  // v2.1 拆出
  'diagnosis_recorded',
  'treatment_planned',
  'treatment_started',
  'treatment_completed',
  'recommendation_extracted',
  // 单据/病历/影像
  'order_submitted',
  'payment_received',
  'refund_created',
  'emr_submitted',
  'emr_rejected',
  'image_uploaded',
  // 产品收集
  'recharge_received',
  'complaint_filed',
  'complaint_resolved',
  'referral_recorded',
]);

/// patient_transactions.subject_type — PAC 归一化封闭集
/// **纪律**:宿主侧 resource 不允许通过造新名进来,必须归一化到此集合
/// 形式:`英文_key`(中文含义)
export const SubjectType = {
  CONSULTATION: 'consultation', // 咨询
  APPOINTMENT: 'appointment', // 预约
  VISIT_REGISTRATION: 'visit_registration', // 挂号
  VISIT_RECEPTION: 'visit_reception', // 接待登记
  CLINICAL_ENCOUNTER: 'encounter', // 医生接诊(只元数据)— W3 末统一命名,对齐 FactType.ENCOUNTER_RECORD
  // v2.1 拆出 ─
  DIAGNOSIS: 'diagnosis', // 诊断(独立)
  TREATMENT: 'treatment', // 治疗(actual/planned 由 fact.kind 区分)
  RECOMMENDATION: 'recommendation', // 医生建议(LLM/规则抽出)
  // ─────────────
  EMR_DOCUMENT: 'emr', // 病历文档 — W3 末统一命名,对齐 FactType.EMR_RECORD
  ORDER: 'order', // 医嘱(治疗/耗材/药品)
  PAYMENT: 'payment', // 收款
  REFUND: 'refund', // 退费
  IMAGE_STUDY: 'image', // 影像资料 — W3 末统一命名,对齐 FactType.IMAGE_RECORD
  // ── 产品收集 ──
  RECHARGE: 'recharge', // 充值/储值/福利卡办理
  COMPLAINT: 'complaint', // 投诉
  REFERRAL: 'referral', // 转介绍记录
} as const;
export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType];
export const SubjectTypeSchema = z.enum([
  'consultation',
  'appointment',
  'visit_registration',
  'visit_reception',
  'encounter',
  // v2.1 拆出
  'diagnosis',
  'treatment',
  'recommendation',
  'emr',
  'order',
  'payment',
  'refund',
  'image',
  // 产品收集
  'recharge',
  'complaint',
  'referral',
]);

// =============================================================
// PatientFact(事实单元版本流)
// =============================================================

/// patient_facts.kind — actual / planned
export const FactKind = {
  ACTUAL: 'actual',
  PLANNED: 'planned',
} as const;
export type FactKind = (typeof FactKind)[keyof typeof FactKind];
export const FactKindSchema = z.enum(['actual', 'planned']);

/// patient_facts.status — 6 个生命周期状态
export const FactStatus = {
  ACTIVE: 'active',
  SUPERSEDED: 'superseded',
  CANCELLED: 'cancelled',
  FULFILLED: 'fulfilled',
  EXPIRED: 'expired',
  INVALIDATED: 'invalidated',
} as const;
export type FactStatus = (typeof FactStatus)[keyof typeof FactStatus];
export const FactStatusSchema = z.enum([
  'active',
  'superseded',
  'cancelled',
  'fulfilled',
  'expired',
  'invalidated',
]);

/// patient_facts.type — v2.1 重塑(W2 末)
/// 形式:`英文_key`(中文含义)
///
/// **v2.1 关键变更**:
///   - encounter_record 退化为**只装元数据**(doctor_id / notes / 时间)
///   - 拆出独立 diagnosis_record / treatment_record / recommendation_record
///   - treatment_record 合并旧 treatment_plan / review_plan(用 kind=planned + content.category 区分)
///
/// **设计纪律**:
///   - fact 只装"源事件",不做衍生 fact 派生 — 衍生信息留在源 fact 的 content JSONB 里
///   - 算法 / Persona / 前端 / AI 永远只读 fact.content,不读 raw_payload
export const FactType = {
  CONSULTATION_RECORD: 'consultation_record', // 咨询记录
  VISIT_REGISTRATION_RECORD: 'visit_registration_record', // 挂号记录
  VISIT_RECEPTION_RECORD: 'visit_reception_record', // 接待记录
  ENCOUNTER_RECORD: 'encounter_record', // 接诊事件(**只装元数据**)

  // ── v2.1 新独立 fact_type ──
  DIAGNOSIS_RECORD: 'diagnosis_record', // 诊断(content: code / tooth_position / severity)
  TREATMENT_RECORD: 'treatment_record', // 治疗(actual/planned 由 kind 区分,合并旧 treatment_plan/review_plan)
  RECOMMENDATION_RECORD: 'recommendation_record', // 医生建议(Layer C 抽取产物)

  // ── 单据 / 病历 / 影像 / 预约 ──
  ORDER_RECORD: 'order_record', // 医嘱记录
  PAYMENT_RECORD: 'payment_record', // 收款记录
  REFUND_RECORD: 'refund_record', // 退费记录
  EMR_RECORD: 'emr_record', // 病历记录(自由文本)
  IMAGE_RECORD: 'image_record', // 影像记录
  APPOINTMENT_RECORD: 'appointment_record', // 预约(kind=planned/actual 区分)

  // ── 产品收集 ──
  RECHARGE_RECORD: 'recharge_record', // 充值记录
  COMPLAINT_RECORD: 'complaint_record', // 投诉记录
  REFERRAL_RECORD: 'referral_record', // 转介绍记录
} as const;
export type FactType = (typeof FactType)[keyof typeof FactType];
export const FactTypeSchema = z.enum([
  'consultation_record',
  'visit_registration_record',
  'visit_reception_record',
  'encounter_record',
  // v2.1 新
  'diagnosis_record',
  'treatment_record',
  'recommendation_record',
  // 单据/病历/影像/预约
  'order_record',
  'payment_record',
  'refund_record',
  'emr_record',
  'image_record',
  'appointment_record',
  // 产品收集
  'recharge_record',
  'complaint_record',
  'referral_record',
]);

// =============================================================
// 治疗类别(content.treatment_category)
// =============================================================

export const TreatmentCategory = {
  ENDODONTIC: 'endodontic', // 根管 / 牙髓
  ORTHODONTIC: 'orthodontic', // 正畸
  IMPLANT: 'implant', // 种植
  PERIODONTIC: 'periodontic', // 牙周
  RESTORATIVE: 'restorative', // 修复 / 充填 / 冠
  PROSTHODONTIC: 'prosthodontic', // 修复(义齿 / 桥)
  PEDODONTIC: 'pedodontic', // 儿牙
  ORAL_SURGERY: 'oral_surgery', // 口腔外科 / 拔牙
  COSMETIC: 'cosmetic', // 美白 / 美容
  PREVENTIVE: 'preventive', // 预防 / 洁牙
  CONSULTATION: 'consultation', // 咨询
} as const;
export type TreatmentCategory = (typeof TreatmentCategory)[keyof typeof TreatmentCategory];

// =============================================================
// Persona Feature
// =============================================================

/// persona_features.key — 13 起步 + 5 产品收集 = 18 候选(v1 起步实施 4 个)
/// 形式:`英文_key`(中文含义)
export const PersonaFeatureKey = {
  // v1 起步(规则路径)
  VALUE: 'value', // 患者价值(LTV / VIP 等级)
  TREATMENT_CHAIN_STATUS: 'treatment_chain_status', // 治疗链状态(进行中 / 已闭环 / 有缺口)
  RECALL_RISK: 'recall_risk', // 流失/复发风险
  DO_NOT_CONTACT_STATUS: 'do_not_contact_status', // [弃用 W7末] 免打扰并入 special_attention;合规闸召回读 profile 原始列。已故/投诉信号后续补

  // 统计层(RFM 八象限 — 融合 R 最近/F 频次/M 金额三种时间语义;统一旧 value+recall_risk)
  RFM: 'rfm', // 价值分群(RFM 八象限 + 生命周期;data 带 segment 供圈人群)
  // 规则层 · snapshot(从 birthDate 当下算)
  AGE_BRACKET: 'age_bracket', // 年龄段(婴幼儿..老年,匹配适龄项目/家庭决策)
  GENDER: 'gender', // 性别(男性/女性/未知,影响话术与项目推荐)
  ACQUISITION_CHANNEL: 'acquisition_channel', // 获客渠道(初诊来源,数仓 L2;副表立柱)
  FAMILY_STRUCTURE: 'family_structure', // 家庭构成(单身/两口/多口/多代;PatientRelation 反推)
  REFERRAL_CHAMPION: 'referral_champion', // 转介绍达人(家庭型/社交型;recommend_num+转化)
  LIFECYCLE_STAGE: 'lifecycle_stage', // 生命周期(潜客..流失客;时间+消费规则)
  TREATMENT_HISTORY: 'treatment_history', // 治疗史(种植/正畸/修复/牙周;读 treatment category)
  TIME_PREFERENCE: 'time_preference', // 时间偏好(工作日/周末/上午/下午/晚间;预约时刻统计)
  DISCOUNT_ANCHOR: 'discount_anchor', // 折扣锚点(历史最低折扣率+日期;价格底线参考)
  SPECIAL_ATTENTION: 'special_attention', // 特别关注(屡次爽约/经常迟到/免打扰/不可等候)
  TREATMENT_SENSITIVITY: 'treatment_sensitivity', // 治疗敏感(看牙恐惧/晕针/晕血/密闭恐惧;病历关键词)
  CONTRAINDICATION: 'contraindication', // 禁忌标签(v1 仅种植年龄≤18;余 Layer C)
  URGENCY_LEVEL: 'urgency_level', // 急迫等级(紧急/高/中/低;潜在治疗路径×末诊)

  // v1 候选(规则路径,业务方反馈后逐步上)
  ENTITLEMENT_STATUS: 'entitlement_status', // 权益身份(商保直付 / 医保 / 储值 / 私行;事实投影型,史+最近日期)
  INCOMPLETE_TREATMENT: 'incomplete_treatment', // 未完成治疗(治疗链有缺口)
  RECOMMENDED_ROLE: 'recommended_role', // 推荐执行角色(staff/leader/...)
  REFERRAL_RELATION: 'referral_relation', // 转介绍关系(老带新链)
  DOCTOR_RELATION: 'doctor_relation', // 医患关系(主治医生绑定)

  // W5+ 候选(agent 路径)
  COMMUNICATION_PREFERENCE: 'communication_preference', // 沟通偏好(微信 / 电话 / 拒接)
  CUSTOMER_EXPERIENCE_RISK: 'customer_experience_risk', // 客户体验风险(投诉前兆)
  TREATMENT_INTENT: 'treatment_intent', // 治疗意向(咨询转化等)
  FAMILY_SOCIAL_RELATION: 'family_social_relation', // 家庭/社交关系

  // v1 不实现(无 touchpoint 数据源,Phase 5+ 接入)
  ENGAGEMENT: 'engagement', // 触达活跃度

  // ── 产品收集(回访中心信息字段.docx)──
  PATIENT_SEGMENT: 'patient_segment', // 患者基础人群细分(儿童/中年男/老年女...)
  COMPLAINT_HISTORY: 'complaint_history', // 投诉历史(合规营销避雷)
  POTENTIAL_TREATMENT: 'potential_treatment', // 待治疗(AI 影像识别 / 病历推断)
  COOPERATION_ATTITUDE: 'cooperation_attitude', // 配合态度(好说话 / 难沟通 / 拒接)
  PRIMARY_CONTACT_RELATION: 'primary_contact_relation', // 主要联系人身份(本人/配偶/父母/子女)
} as const;
export type PersonaFeatureKey =
  (typeof PersonaFeatureKey)[keyof typeof PersonaFeatureKey];


// =============================================================
// FollowupPlan
// =============================================================

/// 业务场景 — PAC v1 的核心定位:
///   "治疗链已完成患者的疗效保障召回服务" + "新链召回"
///
/// 两条主战场(独立 scenario,同 patient 可同时命中,合并到一个 plan 的多个 reason):
///   - treatment_aftercare_recall  疗效保障召回(治疗链已完成,周期保养未做)
///   - treatment_initiation_recall 启治召回(漏治新链 — 诊断了 X 病但没启动 X 治疗)
///
/// 不在 v1:
///   - 链内中断(根管→未戴冠 / 正畸→保持期 / 等)— 业务拍板后再决定是否做
///   - 漏诊(影像有医生没看)— W6+,依赖 AI 影像分析
export const PlanScenario = {
  /// 治疗链已完成患者的疗效保障召回 — 链结束后周期性维护漏掉
  /// 例:种植冠装完 12 个月,医嘱年度复查,患者没回来 → 召回
  TREATMENT_AFTERCARE_RECALL: 'treatment_aftercare_recall',
  /// 启治召回 — 诊断了 X 病,患者从未启动 X 治疗
  /// 例:CT 显示 36 缺失,医生诊断"缺失牙",但无任何 implant/桥/义齿治疗 → 召回
  /// 状态:enum 占位,需 host 真实诊断数据丰富后启用(W3+)
  TREATMENT_INITIATION_RECALL: 'treatment_initiation_recall',
} as const;
export type PlanScenario = (typeof PlanScenario)[keyof typeof PlanScenario];
export const PlanScenarioSchema = z.enum([
  'treatment_aftercare_recall',
  'treatment_initiation_recall',
]);

/// Plan 生命周期状态(5 个)
export const PlanStatus = {
  ACTIVE: 'active', // 在召回池等待分配
  ASSIGNED: 'assigned', // 已分配给某客服
  COMPLETED: 'completed', // 完成
  ABANDONED: 'abandoned', // 客服放弃
  SUPERSEDED: 'superseded', // 被新版本取代
} as const;
export type PlanStatus = (typeof PlanStatus)[keyof typeof PlanStatus];
export const PlanStatusSchema = z.enum([
  'active',
  'assigned',
  'completed',
  'abandoned',
  'superseded',
]);

// =============================================================
// Plan Execution(接口 2 客服回写)
// =============================================================

/// plan_executions.outcome — 4 起步 + 7 产品收集 = 11 类
/// 形式:`英文_key`(中文含义)
///
/// W4 末调整(2026-05):
///   - 删 PROACTIVE_SMS:前端无对应按钮,生产用不到 → 清理
///   - MARKED_INVALID 状态机映射改 → abandoned(原 completed 语义错;标记无效不该算"成功完成")
export const ExecutionOutcome = {
  // 4 起步(W2 设计)
  ABANDONED: 'abandoned', // 已联系,客服放弃 → Plan abandoned
  NO_ANSWER: 'no_answer', // 未接通(打不通)→ Plan 仍 active 等下次
  QUICK_HANGUP: 'quick_hangup', // 接通秒挂(没说上话)→ 算"保持",Plan 仍 active
  SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned
  SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存)

  // ── 产品收集(回访中心信息字段.docx — 执行结果细化)──
  RESCHEDULED: 'rescheduled', // 已联系,改约
  CONSIDERING: 'considering', // 已联系,考虑中(近期再跟进)
  DECLINED_RECENT: 'declined_recent', // 已联系,近期不考虑
  NEEDS_DOCTOR: 'needs_doctor', // 已联系,需要找医生
  EXTERNAL_TREATMENT: 'external_treatment', // 已联系,已在外院治疗
  REFUSED: 'refused', // 已联系,明确拒绝,不需要治疗
  PENDING_INFO: 'pending_info', // 已联系,需进一步确认信息后回复
  SMS_SENT: 'sms_sent', // 电话未接,已发短信跟进
  MARKED_INVALID: 'marked_invalid', // 标记为无效(数据错 / 不该召回)→ abandoned
} as const;
export type ExecutionOutcome = (typeof ExecutionOutcome)[keyof typeof ExecutionOutcome];
export const ExecutionOutcomeSchema = z.enum([
  'abandoned',
  'no_answer',
  'quick_hangup',
  'scheduled_next',
  'success_appointed',
  // 产品收集
  'rescheduled',
  'considering',
  'declined_recent',
  'needs_doctor',
  'external_treatment',
  'refused',
  'pending_info',
  'sms_sent',
  'marked_invalid',
]);

/// outcome 提交后 Plan.status 怎么变(状态机决策)
///   completed — 任务结案(成功 / 终态客户决策)
///   abandoned — 任务关闭(放弃 / 无效)
///   keep      — Plan 状态不变(active 还是 active / assigned 还是 assigned),等下次跟进
///                注:keep 模式下熔断器仍可能介入 — contactAttempts 达上限自动 abandoned
export type ExecutionOutcomeDrives = 'completed' | 'abandoned' | 'keep';

/// outcome 的 UI tone(对应 shadcn 调色板),前端按按钮渲染
export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose';

/// "永久"抑制的天数哨兵(患者级粒度下 = 该患者长期不再自动召回,除非人工重激活)。
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500;

/// ⚠️ 原「弱语义短抑制档」WEAK_SIGNAL_SUPPRESS_DAYS(14d)2026-07 已删除。
///    它只服务过「已完成治疗」和「机会识别不准确」两个原因,两者现已全部改永久:
///    该档押的注是「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪(见 ABANDON_REASON_META
///    对应两条注释)。留着只会引诱后来者把新原因挂上一个已被推翻的假设,故不保留。

/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。
export const BREAKER_SUPPRESS_DAYS = 30;

/// 通话结果分组(UI 归类,group key 不变,仅 label 演进)。大体对应状态机:
///   成功 close → completed →「已完成」 / 保持 keep → keep →「进行中」 / 不成功 give_up → 多为 abandoned
///   ⚠️ 例外:不成功组里的「再考虑(冷静期7天)」drivesStatus='keep'(非终态)— group 只管 UI 归类,
///      真实状态看 drivesStatus(7天后自动浮现,plan 仍在「进行中」)。group≠status 是允许的。
export type ExecutionOutcomeGroup = 'close' | 'keep' | 'give_up';

/// 分组元数据(label / 显示顺序 / tone)。outcome-form 据此渲染分组标题。
export const EXECUTION_OUTCOME_GROUP_META: Record<
  ExecutionOutcomeGroup,
  { labelZh: string; order: number; tone: ExecutionOutcomeTone }
> = {
  close:   { labelZh: '成功',   order: 1, tone: 'emerald' },
  give_up: { labelZh: '不成功', order: 2, tone: 'rose' },
  keep:    { labelZh: '保持',   order: 3, tone: 'amber' },
};

/// ExecutionOutcome 单一真理源(group / label / tone / 状态机 / 抑制窗)
///
/// 用法:
///   - 前端 outcome-form 按 group 2 级渲染(大类 → 具体项)+ state hint
///   - 后端 execution.service.ts OUTCOME_TO_STATUS 派生自 drivesStatus
///   - 后端 execution.service.ts 用 suppressDays 算 snoozedUntil(回访冷静期 / 终态抑制窗)
///   → 前后端口径完全对齐,改 enum / group / drivesStatus / suppressDays 一处改完处处生效
///
/// suppressDays 语义(召回闭环核心):提交该 outcome 后,患者多久内不再自动进召回池
///   - 数字 N        — 写 snoozedUntil = now + N 天;到期自愈(重新可召回 / 重进池)
///   - null          — 不设固定冷静期(若执行带 scheduledNextAt,则按该时间 snooze;否则维持现状)
///   - PERMANENT     — 真闭环(外院治疗 / 无效),患者级长期不再召回
/// 标准档默认值(后续可接 tenant.rules_config 覆盖):
///   明确拒绝/近期不考虑=90d;成功转化/客服放弃=60d;外院/无效=永久;(熔断另算=30d)
///
/// hiddenInForm:历史 enum 值,新建执行不再展示(如 rescheduled 已并入"约定下次回访"),
///   但保留在 map 里让历史 plan_executions 仍能翻译 label。
export const EXECUTION_OUTCOME_META: Record<
  ExecutionOutcome,
  {
    group: ExecutionOutcomeGroup;
    labelZh: string;
    tone: ExecutionOutcomeTone;
    drivesStatus: ExecutionOutcomeDrives;
    /// 抑制天数:number=固定冷静期 / null=不固定(看 scheduledNextAt)
    suppressDays: number | null;
    /// 新建执行表单是否隐藏(历史值兼容用)
    hiddenInForm?: boolean;
  }
> = {
  // ── 成功(close → completed,「已完成」tab)──
  success_appointed:  { group: 'close',   labelZh: '转化新预约',       tone: 'emerald', drivesStatus: 'completed', suppressDays: 60 },
  // ── 不成功(give_up;明确拒绝/外院 = 终态 abandoned,再考虑 = keep+7天非终态)──
  refused:            { group: 'give_up', labelZh: '明确拒绝',         tone: 'rose',    drivesStatus: 'abandoned', suppressDays: 90 },
  external_treatment: { group: 'give_up', labelZh: '已在外院治疗',     tone: 'rose',    drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
  declined_recent:    { group: 'give_up', labelZh: '再考虑', tone: 'rose',  drivesStatus: 'abandoned', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」)
  /**
   * ⭐ 约定下次回访 —— **归「成功」组,但工单不结案**(2026-08-04 产品定)。
   *
   * ⚠️ `group` 与 `drivesStatus` 在这一项上**故意不一致**,这不是笔误:
   *   · `group: 'close'`      —— 统计口径:约到了下次,是**有效推进**,不该跟"未接通/秒挂"
   *                              一起算进「保持」。主管看成功率时要看得见这一类。
   *   · `drivesStatus: 'keep'` —— 状态机:工单**必须留着**并 snooze 到回访日。
   *                              ⛔ 千万别顺手改成 'completed' —— 那会让这单直接出池,
   *                              而客服刚刚答应了患者"那天再联系您",约好的回访再也不会发生。
   * ⇒ 改动只影响**怎么归类**,不影响**工单去哪儿**。
   */
  scheduled_next:     { group: 'close',   labelZh: '约定下次回访',     tone: 'amber',   drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
  // ── 保持(keep,留工单「进行中」)──
  no_answer:          { group: 'keep',    labelZh: '未接通',           tone: 'slate',   drivesStatus: 'keep', suppressDays: null },
  quick_hangup:       { group: 'keep',    labelZh: '秒挂',             tone: 'slate',   drivesStatus: 'keep', suppressDays: null }, // 接通秒挂,算保持,很快再试
  // ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ──
  considering:        { group: 'keep',    labelZh: '待跟进',           tone: 'sky',     drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
  marked_invalid:     { group: 'give_up', labelZh: '无效',             tone: 'rose',    drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS, hiddenInForm: true },
  rescheduled:        { group: 'keep',    labelZh: '改约',             tone: 'amber',   drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, // 已并入"约定下次回访"
  sms_sent:           { group: 'keep',    labelZh: '未接通·已发短信',  tone: 'slate',   drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
  needs_doctor:       { group: 'keep',    labelZh: '需要找医生',       tone: 'sky',     drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
  pending_info:       { group: 'keep',    labelZh: '需进一步确认信息', tone: 'sky',     drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
  abandoned:          { group: 'give_up', labelZh: '客服放弃',         tone: 'rose',    drivesStatus: 'abandoned', suppressDays: 60, hiddenInForm: true },
};

/// 召回反馈(选填·多选)— 一线客服对"这条召回本身准不准"的反馈,**正交于通话结果**。
/// 目的:把一线经验变成算法改进数据 —— 每个 tag 对应一个可改的召回算法维度,可聚合定位系统性问题。
///   info_wrong        → 信号抽取 / 数据质量(反查 extraction 规则 / 反馈 DW)
///   already_handled   → 排除闸 ⑤a/⑤b 漏(同步延迟 / 补排除)
///   duplicate_contact → 冷静期 / 去重(调 cooldown / 抑制窗)
///   bad_timing        → 黄金窗 / 入池窗(调 windowDays)
///   not_worth         → 打分 / 场景纳入(调 base 分 / scenario)
export const RECALL_FEEDBACK_OPTIONS = [
  { value: 'info_wrong',        labelZh: '信息有误',   hint: '诊断/牙位/姓名/电话不对' },
  { value: 'already_handled',   labelZh: '其实已处理', hint: '本院/外院做过·已有预约' },
  { value: 'duplicate_contact', labelZh: '重复联系',   hint: '最近已联系过' },
  { value: 'bad_timing',        labelZh: '时机不对',   hint: '太早 / 太晚' },
  { value: 'not_worth',         labelZh: '不值得召',   hint: '价值低 / 不是真机会' },
] as const;

export const ExecutionChannel = {
  PHONE: 'phone',
  WECOM: 'wecom',
  SMS: 'sms',
  OTHER: 'other',
} as const;
export type ExecutionChannel = (typeof ExecutionChannel)[keyof typeof ExecutionChannel];
export const ExecutionChannelSchema = z.enum(['phone', 'wecom', 'sms', 'other']);

/**
 * 放弃原因 —— **两种回访模式共用的一套 key**(执行结果 outcome='abandoned' 时的细分原因)。
 *
 * PAC 不为「关闭机会」另立概念:宿主回访模式(host.actionUrls.OPEN_RETURN_VISIT 已配)下
 * 回访在宿主侧做,PAC 只提供一个「关闭」弹窗做闭环 —— 它落库的仍是 `abandoned` + abandon_reasons,
 * 只是**换了一套面向宿主的文案**。模型层零分叉,分叉只发生在展示层(见 shownIn)。
 *
 * 两种模式按 host 配置**二选一、运行时不共存**,所以近义项(no_intent / patient_refused)
 * 刻意不合并 —— 同一个 host 的数据里只会出现自己那一套 key,统计不会碎;
 * 硬合并反而要牺牲某一边的文案。
 */
export const AbandonReason = {
  // ── PAC 自带通话结果表单(未配宿主回访)──
  WRONG_NUMBER: 'wrong_number',
  PATIENT_REFUSED: 'patient_refused',
  OUT_OF_SERVICE_AREA: 'out_of_service_area',
  TREATED_ELSEWHERE: 'treated_elsewhere',
  NOT_INTERESTED: 'not_interested',
  DO_NOT_CONTACT_REQUEST: 'do_not_contact_request', // 自动加 DNC(⚠️ 尚未实现,见 TODO)
  // ── 宿主回访模式的「关闭机会」弹窗 ──
  NO_INTENT: 'no_intent',
  INACCURATE: 'inaccurate',
  COMPETITOR: 'competitor',
  PRICE: 'price',
  TREATED: 'treated',
  UNREACHABLE: 'unreachable',
  // ── 两边共用 ──
  OTHER: 'other',
} as const;
export type AbandonReason = (typeof AbandonReason)[keyof typeof AbandonReason];
export const AbandonReasonSchema = z.enum([
  'wrong_number',
  'patient_refused',
  'out_of_service_area',
  'treated_elsewhere',
  'not_interested',
  'do_not_contact_request',
  'no_intent',
  'inaccurate',
  'competitor',
  'price',
  'treated',
  'unreachable',
  'other',
]);

/// 该原因在哪个面板展示:'form'=PAC 通话结果表单 / 'close'=宿主关闭弹窗 / 'both'=两边都有。
/// 分流只影响 UI 渲染,不影响落库(都是 abandon_reasons)。
export type AbandonReasonShownIn = 'form' | 'close' | 'both';

/**
 * 放弃原因单一真理源 —— 文案 + 展示分流 + 抑制窗。
 *
 *   labelZh / desc — 卡片标题 + 说明(desc 只有关闭弹窗渲染,表单是紧凑复选框)
 *   shownIn        — 展示分流(见上)
 *   suppressDays   — 该原因的抑制窗;**不填 = 沿用 outcome 自带的**(abandoned=60d)。
 *                    多选时取所有已选原因的 **max** —— 每个原因独立成立,该取最严的那个。
 *   needNote       — 必填文字说明(写进 notes,不另立列)
 *   hidden         — 历史值,任何面板都不展示(仅供翻译老数据)
 */
export const ABANDON_REASON_META: Record<
  AbandonReason,
  {
    labelZh: string;
    desc?: string;
    shownIn: AbandonReasonShownIn;
    suppressDays?: number;
    needNote?: boolean;
    hidden?: boolean;
  }
> = {
  // ── PAC 自带表单 ──
  wrong_number:           { labelZh: '号码空号 / 错号', shownIn: 'form',  suppressDays: 30 },              // 等 DW 补新号
  patient_refused:        { labelZh: '明确不需要治疗',  shownIn: 'form' },                                  // 60d(outcome 默认)
  out_of_service_area:    { labelZh: '迁居外地',        shownIn: 'form',  suppressDays: SUPPRESS_PERMANENT_DAYS },
  treated_elsewhere:      { labelZh: '已转介他人',      shownIn: 'form',  suppressDays: SUPPRESS_PERMANENT_DAYS },
  do_not_contact_request: { labelZh: '投诉 / 不愿打扰', shownIn: 'form',  suppressDays: SUPPRESS_PERMANENT_DAYS },
  not_interested:         { labelZh: '不感兴趣',        shownIn: 'form',  hidden: true },                    // 历史值,从没被写过

  // ── 宿主关闭弹窗(文案按宿主口径)──
  no_intent:   { labelZh: '客户无意愿',     desc: '客户明确表示没有治疗意愿',         shownIn: 'close' }, // 60d
  // ⭐ 永久(2026-07 由 14d 改):客服断言"PAC 这条召回identify错了"。原 14d 的理由是
  //    "算法修好后本就该重新召回",但那是**我们**的排期,不该由患者兜着 —— 两周后同一条
  //    不准的召回原样弹回来,客服只会再关一次、再骂一次,而算法多半还没改。
  //    真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」这条显式动作驱动,
  //    而不是靠一个赌"两周内一定修好"的定时器。
  inaccurate:  { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
  competitor:  { labelZh: '选择竞品机构',   desc: '客户已选择或倾向其他机构治疗',     shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
  price:       { labelZh: '价格因素',       desc: '客户因价格原因暂不考虑治疗',       shownIn: 'close' }, // 60d
  // ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**,
  //    不是"暂时不想做",没有理由两周后再问一遍。原 14d 档押的注是「等 DW 同步 + 排除闸接手」,
  //    生产实测证伪:缺牙召回里两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,
  //    到期只会原样弹回来再挨一次投诉。且档位本身不自洽 —— 「已在外院治疗」早就是永久,
  //    在别家做完永久闭嘴、在自己家做完两周后再问,说不通。
  //    误判不会把患者封死:抑制是**信号级**的(key=scenario|subKey,subKey 含牙位)且带时间锚,
  //    结案后**新发**的同类信号照常放行(见 plan-engine.fetchSnoozedSignalKeys)。
  treated:     { labelZh: '已完成治疗',     desc: '客户已完成该机会对应的治疗',       shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
  unreachable: { labelZh: '无法联系客户',   desc: '多次尝试后仍无法联系到客户',       shownIn: 'close', suppressDays: 30 },

  // ── 共用 ──
  other: { labelZh: '其他原因(需填写说明)', desc: '需要进一步说明具体原因', shownIn: 'both', needNote: true },
};

/// 取某面板要展示的原因清单(过滤 hidden;顺序 = META 声明顺序)
export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
  return (Object.keys(ABANDON_REASON_META) as AbandonReason[]).filter((k) => {
    const m = ABANDON_REASON_META[k];
    return !m.hidden && (m.shownIn === panel || m.shownIn === 'both');
  });
}

// =============================================================
// 退回原因(followup_plans.release_reason)
// =============================================================

/**
 * 退回原因 —— 客服把单退回池时填的**处置理由**。
 *
 * ⚠️⚠️ 三条边界,每一条都被踩过或差点踩:
 *
 * 1. **≠ 放弃原因 [[AbandonReason]]**。放弃是「这个患者不用再召了」(患者侧结论,进终态);
 *    退回是「这单不该由我做」(任务侧处置,**plan 仍 active,回池等下一个人**)。
 *    两者都进不了对方的统计口径。
 *
 * 2. **≠ 召回反馈 [[RECALL_FEEDBACK_OPTIONS]]**。反馈回答「这条召回准不准」(冲算法去的);
 *    退回回答「我为什么不接」(冲派单去的)。同一次退回可能两者都填,也可能只填一个。
 *    ⚠️ 值域刻意不复用:`bad_timing` 在反馈里指「召回时机不对(太早/太晚)」,
 *    在退回里会被读成「时效太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。
 *
 * 3. ⛔ **本枚举永远不带 `suppressDays`** —— 类型里就不给这个字段,用编译器拦住。
 *    照抄 ABANDON_REASON_META 那套抑制窗会把被退回的患者静默压 30~90 天,
 *    而退回的语义是「换个人来做」,不是「别做了」。压窗 = 池子里凭空少一批人,
 *    且正好是教条 T5「剩下的不是遗漏,是还没轮到」的反面。
 *
 * ── 为什么必须填(T7)──
 * 退回是正常路径不是异常。退回率与原因分布是**主管调整分配策略的输入**,
 * 每个原因都对应一根可调的杠杆(见 `lever`)—— 这才是"必须填"的目的,
 * 不要当成无谓摩擦砍掉。
 */
export const ReleaseReason = {
  /// 人不对 —— 这个患者不归我
  NOT_MY_PATIENT: 'not_my_patient',
  /// 接不住 —— 手上已经排满
  OVER_CAPACITY: 'over_capacity',
  /// 接不住 —— 我这段时间不在(休假 / 调岗 / 转岗)
  AGENT_UNAVAILABLE: 'agent_unavailable',
  /// 人不对 —— 该由别的角色跟(医生 / 咨询师 / 客户经理)
  NEEDS_OTHER_ROLE: 'needs_other_role',
  /// 时机不对 —— 给的时效内做不完
  DEADLINE_TOO_TIGHT: 'deadline_too_tight',
  /// 时机不对 —— 最近刚联系过,这会儿再打会烦
  RECENTLY_CONTACTED: 'recently_contacted',
  /// 信息不足 —— 缺电话 / 缺诊断,没法开口
  PATIENT_INFO_MISSING: 'patient_info_missing',
  /// 其他(必须写说明)
  OTHER: 'other',
} as const;
export type ReleaseReason = (typeof ReleaseReason)[keyof typeof ReleaseReason];
export const ReleaseReasonSchema = z.enum([
  'not_my_patient',
  'over_capacity',
  'agent_unavailable',
  'needs_other_role',
  'deadline_too_tight',
  'recently_contacted',
  'patient_info_missing',
  'other',
]);

/**
 * 主管可调的杠杆 —— 每个退回原因指向**一个**具体的默认值/策略。
 *
 * 这是退回原因分布唯一的下游用途:一批退回里 `capacity` 占了一半 → 容量默认值定高了;
 * `roster` 占一半 → 在岗判定不准。没有这一层映射,原因分布就只是一张好看的饼图。
 */
export type ReleaseReasonLever =
  /// 圈人条件 —— 分给谁、按什么圈(专属策略 / 初筛条件)
  | 'targeting'
  /// 容量默认值 —— 一个人同时能压多少
  | 'capacity'
  /// 名册口径 —— 在岗判定准不准
  | 'roster'
  /// 时效默认值 —— 给几天
  | 'timing'
  /// 数据质量 —— 摄入侧的缺口,不是分配策略问题
  | 'data'
  /// 无法归因(other)
  | 'none';

/**
 * 退回原因单一真理源。
 *
 *   labelZh / desc — 弹窗卡片的标题 + 说明
 *   group          — UI 分组(渲染顺序 = 声明顺序)
 *   lever          — 该原因反推哪个默认值,见 [[ReleaseReasonLever]]
 *   needNote       — 必填文字说明(存 release_note)
 *   hidden         — 历史值,不再展示(仅供翻译老数据)
 *
 * ⛔ **没有 suppressDays,也不要加**(见 [[ReleaseReason]] 第 3 条)。
 */
export const RELEASE_REASON_META: Record<
  ReleaseReason,
  {
    labelZh: string;
    desc?: string;
    group: 'person' | 'capacity' | 'timing' | 'data' | 'other';
    lever: ReleaseReasonLever;
    needNote?: boolean;
    hidden?: boolean;
  }
> = {
  not_my_patient:       { labelZh: '不是我的客户',   desc: '这位患者一直由别人跟',           group: 'person',   lever: 'targeting' },
  needs_other_role:     { labelZh: '该由其他角色跟', desc: '需要医生 / 咨询师介入,不是客服能推的', group: 'person', lever: 'targeting' },
  over_capacity:        { labelZh: '手上排满了',     desc: '在手单已超出能跟进的量',         group: 'capacity', lever: 'capacity' },
  agent_unavailable:    { labelZh: '这段时间不在',   desc: '休假 / 调岗 / 已不做召回',       group: 'capacity', lever: 'roster' },
  deadline_too_tight:   { labelZh: '时效太紧',       desc: '在给定期限内做不完',             group: 'timing',   lever: 'timing' },
  recently_contacted:   { labelZh: '最近刚联系过',   desc: '短期内重复触达会引起反感',       group: 'timing',   lever: 'targeting' },
  patient_info_missing: { labelZh: '患者信息不足',   desc: '缺号码 / 缺诊断,无法开口',       group: 'data',     lever: 'data' },
  other:                { labelZh: '其他原因',       desc: '需要写明具体情况',               group: 'other',    lever: 'none', needNote: true },
};

/// 退回弹窗要展示的原因清单(过滤 hidden;顺序 = META 声明顺序 = 按 group 已排好)
export function releaseReasonsForForm(): ReleaseReason[] {
  return (Object.keys(RELEASE_REASON_META) as ReleaseReason[]).filter(
    (k) => !RELEASE_REASON_META[k].hidden,
  );
}

// =============================================================
// 分配策略(followup_plans.assign_strategy)
// =============================================================

/**
 * 这条单子是**怎么落到该客服头上**的。
 *
 * ⚠️⚠️ **分配当时不记就永久没了**,这是它必须立柱的全部理由:
 * 唯一能反推的来源 `patients.preferences.dedicatedCs` 是摄入时 upsert 覆盖的「当前值」,
 * 半年后再查只会看到那时的专属客服,不是分配那一刻的。
 *
 * ── 为什么「铺平」拆成两个值,不是一个 ────────────────────────────
 * 直觉会写成 dedicated / spread / manual 三值。但 spread 会同时装下两群**完全不同**的人:
 *
 *   · spread_overflow      —— 有专属客服,只是这次容量满了没轮到他。**医患关系还在**。
 *   · spread_no_dedicated  —— 从头就没有可用的专属客服(无专属 / 专属已离职)。
 *                             这批人本来就是关系薄弱的那群。
 *
 * 实测(2026-08 本地 2,724 条候选):专属且在岗 85.1% / 专属但已离岗 7.5% / 无专属 7.3%
 * —— 后两类合计约 **15%**,量级不小。
 *
 * 混成一个值,T20 要算的「专属 vs 铺平的完成率差」就废了:铺平那一组的完成率低,
 * 到底是**铺平这个策略不好**,还是**那群人本来就难打**,永远分不出来。
 * 而这个差值正是用来决定"默认策略要不要改"的 —— 拿一个有混淆变量的数去改策略,
 * 比没有数更危险。
 *
 * 代价是真实的(见 ASSIGN_STRATEGY_META.groupZh):确认单上要多一种可见状态、
 * 助手要多一句解释、报表要多一行。所以**只在需要区分的地方区分** ——
 * 给主管看时两个 spread 合并成「铺平」一档(groupZh 就是干这个的),
 * 只有反推分析才拆开看。
 */
export const AssignStrategy = {
  /// 命中专属客服(该客服在岗且容量够)
  DEDICATED: 'dedicated',
  /// 铺平 · 溢出 —— 有专属客服但这次没轮到他(容量满)
  SPREAD_OVERFLOW: 'spread_overflow',
  /// 铺平 · 无专属 —— 从头就没有可用的专属客服(无专属 / 专属已离岗)
  SPREAD_NO_DEDICATED: 'spread_no_dedicated',
  /// 主管在确认单上手工指定(覆盖了助手的建议)
  MANUAL: 'manual',
} as const;
export type AssignStrategy = (typeof AssignStrategy)[keyof typeof AssignStrategy];
export const AssignStrategySchema = z.enum([
  'dedicated',
  'spread_overflow',
  'spread_no_dedicated',
  'manual',
]);

/**
 * 分配策略元数据。
 *
 *   labelZh  精确名称(反推分析 / 调试用)
 *   groupZh  **给主管看的合并档** —— 两种 spread 在界面上都叫「铺平」,
 *            不要把内部口径的精细度倒灌到主管的注意力上(与 T13「一次确认」相悖)
 *   isSpread 是否属于铺平(报表按它二分,别在调用处写 `startsWith('spread')`)
 */
export const ASSIGN_STRATEGY_META: Record<
  AssignStrategy,
  { labelZh: string; groupZh: string; isSpread: boolean }
> = {
  dedicated:           { labelZh: '专属客服',       groupZh: '专属', isSpread: false },
  spread_overflow:     { labelZh: '铺平·专属已满',  groupZh: '铺平', isSpread: true },
  spread_no_dedicated: { labelZh: '铺平·无专属',    groupZh: '铺平', isSpread: true },
  manual:              { labelZh: '主管指定',       groupZh: '指定', isSpread: false },
};

// =============================================================
// Plan 生命周期事件(plan_event_logs.event)
// =============================================================

/// plan_event_logs.event 的单一真理源。**新增事件必须在此登记**,不要在调用处随手写字符串
/// —— 这是这张表能长期扩展而不腐化的前提(否则半年后没人说得清有哪些取值)。
export const PlanEventType = {
  /// 归属:自认领(actor == assignee)
  CLAIM: 'claim',
  /// 归属:指派他人(actor != assignee)—— 门诊经理分配上线后启用
  ASSIGN: 'assign',
  /// 归属:主动返池
  RELEASE: 'release',
  /// 归属:超时自动回收(系统行为,无 actor)
  AUTO_RELEASE: 'auto_release',
  /// 反馈:召回准不准 👍/👎(reason 列存 up/down)
  FEEDBACK: 'feedback',
  /// 浏览:进入 plan 详情页一次(PV/UV 用)。**不是"处理"** —— 见下方 byHuman 说明。
  ///   三种入口全收:直接进/刷新 · 患者列表切换 · /plans 落地自动跳转;
  ///   同一 planId 每次进入记一次(前端 usePlanAggregate 去重守卫为界),手动刷新不重复计。
  VIEW: 'view',
} as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];

/**
 * 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
 *
 *   group   ownership 归属变更 / feedback 质量反馈 / view 浏览埋点(后续可加 lifecycle 等)
 *   byHuman ⭐ **口径开关,不是"是不是人做的"** —— 语义是「该事件算不算『客服处理过这个患者』」,
 *           即是否进 [[HUMAN_TOUCH_EVENTS]]。两类为 false,原因不同:
 *             · auto_release —— 系统超时回收,压根不是人的动作;
 *             · view         —— 是人的动作,但**浏览 ≠ 处理**。若给它 true,「处理过的患者」
 *               口径会静默膨胀成"看一眼也算",而 HUMAN_TOUCH_EVENTS 是 filter 派生的,
 *               改错**不会有任何编译错误或类型报错**。下方 spec 有断言锁死。
 *   holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
 */
export const PLAN_EVENT_META: Record<
  PlanEventType,
  {
    labelZh: string;
    group: 'ownership' | 'feedback' | 'view';
    byHuman: boolean;
    holdsPatient: boolean;
  }
> = {
  claim:        { labelZh: '认领',       group: 'ownership', byHuman: true,  holdsPatient: true },
  assign:       { labelZh: '指派',       group: 'ownership', byHuman: true,  holdsPatient: true },
  release:      { labelZh: '返池',       group: 'ownership', byHuman: true,  holdsPatient: false },
  auto_release: { labelZh: '超时自动回收', group: 'ownership', byHuman: false, holdsPatient: false },
  feedback:     { labelZh: '召回反馈',   group: 'feedback',  byHuman: true,  holdsPatient: true },
  // ⚠️ byHuman: false —— 浏览是人做的,但**不算"处理过"**(见上方字段说明)。别改成 true。
  view:         { labelZh: '查看详情',   group: 'view',      byHuman: false, holdsPatient: false },
};

/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
export const HUMAN_TOUCH_EVENTS: PlanEventType[] = (
  Object.keys(PLAN_EVENT_META) as PlanEventType[]
).filter((k) => PLAN_EVENT_META[k].byHuman);

/**
 * plan_event_logs.**reason** 列的取值登记表。
 *
 * 为什么要有:该列此前只在两处写过自由字符串(`'timeout'` / `'up'|'down'`),不登记也还看得懂;
 * 但引擎补记账后它要同时承载「系统为什么收走这单」的三四种原因,再加上客服退回的 8 个 key
 * —— 一列混着十几个未登记字符串,半年后没人说得清有哪些取值,
 * 正是 [[PlanEventType]] 注释里那句「不要在调用处随手写字符串」要防的事,只是换了一列违反。
 *
 * ⚠️ 本表**不含退回原因** —— `release` 事件的 reason 直接用 [[ReleaseReason]] 的值,
 * 那边已经是登记过的枚举,在这里再抄一份必然漂。合起来的取值域见 [[PlanEventReasonValue]]。
 */
export const PlanEventReason = {
  // ── feedback 事件 ──
  /// 召回准 👍
  UP: 'up',
  /// 召回不准 👎
  DOWN: 'down',
  // ── auto_release 事件(系统收走归属,无 actor)──
  /// 超过 recycle_at 被 cron 回收(自动回收开关开启时才可能出现)
  TIMEOUT: 'timeout',
  /// 跟进诊所重归属 → 单子漂出原认领人 scope,不带着归属走(否则成孤儿单)
  CLINIC_MOVED: 'clinic_moved',
  /// 该患者本轮召回信号全部消失 → plan 直接关闭,归属一并释放
  SIGNALS_CLEARED: 'signals_cleared',
  /**
   * 分配单到期,系统收回池子。
   *
   * ⚠️ 与客服**主动退回**是两件事,统计时必须分开:
   *   退回(release + ReleaseReason)= 客服看了、判断"不该我做",是**处置**
   *   到期(auto_release + 本值)    = 客服压根没动,是**没处置**
   * 把它算进退回率会让分母虚高、分子虚高,而"到期未动"这个数本身才是主管要的信号
   * (分多了?人不在?)。所以到期**不写 followup_plans.release_reason** ——
   * 那一列只属于客服的处置。
   */
  ASSIGNMENT_EXPIRED: 'assignment_expired',
  /**
   * 主管撤销整批。
   *
   * ⚠️ **撤销 ≠ 退回**,是两个不同的人做的两件事(T21):
   *   撤销 revoke  —— **主管**收回**整批**(分错人 / 条件填错),粒度是批次
   *   退回 release —— **客服**退回**单条**(不是我的客户 / 没时间),必须带原因
   * 所以它进 auto_release(系统代主管执行的批量收回),而不是 release,
   * 更**不写** followup_plans.release_reason —— 那列只属于客服的处置。
   */
  REVOKED: 'revoked',
} as const;
export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventReason];

/// plan_event_logs.reason 的完整取值域 = 系统原因 ∪ 客服退回原因。
/// ⚠️ 写入 reason 列的地方一律标这个类型,别用裸 string。
export type PlanEventReasonValue = PlanEventReason | ReleaseReason;



// =============================================================
// Plan Scripts / Summaries(异步生成,状态机)
// =============================================================

/// 资产生成状态(plan_scripts / plan_summaries 共用)
export const AssetStatus = {
  PENDING: 'pending', // 已 enqueue,正在跑或排队
  READY: 'ready', // content 已就绪
  FAILED: 'failed', // agent + 模板兜底都失败(罕见)
} as const;
export type AssetStatus = (typeof AssetStatus)[keyof typeof AssetStatus];
export const AssetStatusSchema = z.enum(['pending', 'ready', 'failed']);

/// 资产来源(content 怎么产出的;status=ready/failed 时有意义)
export const AssetSource = {
  AGENT: 'agent', // LLM 成功生成(agentInvocationId 非空)
  TEMPLATE_FALLBACK: 'template_fallback', // agent 失败,模板兜底
  MANUAL: 'manual', // 运维 / 调试手填
} as const;
export type AssetSource = (typeof AssetSource)[keyof typeof AssetSource];
export const AssetSourceSchema = z.enum(['agent', 'template_fallback', 'manual']);

/// PlanSummary 类型(per-plan × 3 type;PlanScript 单行无 type)
export const SummaryType = {
  ONE_PAGE: 'one_page',
  MEDICAL_RECORD: 'medical_record',
  TREATMENT_CHAIN: 'treatment_chain',
  /// 回访历史一句话摘要(详情页「回访历史」卡片;输入=plan_executions 结构化数据)
  RECALL_HISTORY: 'recall_history',
  /// 画像标签一句话摘要(详情页「画像标签」卡片;输入=persona_features 标签+取值)
  PERSONA_TAGS: 'persona_tags',
  /// 本次召回一句话简报(详情页「参考话术」标题下;输入=召回原因+历史治疗+画像,
  ///   回答 患者是谁 / 帮其解决什么问题 / 邀约到诊做什么 三问)
  RECALL_BRIEF: 'recall_brief',
} as const;
export type SummaryType = (typeof SummaryType)[keyof typeof SummaryType];
export const SummaryTypeSchema = z.enum([
  'one_page',
  'medical_record',
  'treatment_chain',
  'recall_history',
  'persona_tags',
  'recall_brief',
]);

// =============================================================
// Sync(同步运行账本)
// =============================================================

/// sync_logs.direction — pull / push(替代旧 AdapterKind)
export const SyncDirection = {
  PULL: 'pull', // PAC 主动拉
  PUSH: 'push', // 宿主主动推
} as const;
export type SyncDirection = (typeof SyncDirection)[keyof typeof SyncDirection];
export const SyncDirectionSchema = z.enum(['pull', 'push']);

/// sync_logs.status — 4 个(跟 PersonaRecomputeLog / PlanGenerationLog 对齐)
export const SyncStatus = {
  RUNNING: 'running',
  SUCCESS: 'success',
  PARTIAL: 'partial',
  FAILED: 'failed',
} as const;
export type SyncStatus = (typeof SyncStatus)[keyof typeof SyncStatus];
export const SyncStatusSchema = z.enum(['running', 'success', 'partial', 'failed']);


// =============================================================
// AgentInvocation
// =============================================================

/// Agent 类型(全部小写;每个 agent 一次输出可含多种子类型)
export const AgentKind = {
  SCRIPT: 'script', // 产出 PlanScript(每个 plan 1 次调用,Markdown 多子段)
  SUMMARY: 'summary', // 产出 PlanSummary(每个 plan 1 次调用,输出 JSON 含 3 type)
  PERSONA_FEATURE: 'persona_feature', // 产出 PersonaFeature(W5+ 接入)
  RANKING: 'ranking', // 将来,排序辅助
  PLAN_ANALYSIS: 'plan_analysis', // 将来,Plan 解释
} as const;
export type AgentKind = (typeof AgentKind)[keyof typeof AgentKind];

/// Agent 调用状态(3 个;流式过程也归 running,流式完成也归 succeeded)
export const AgentStatus = {
  RUNNING: 'running',
  SUCCEEDED: 'succeeded',
  FAILED: 'failed',
} as const;
export type AgentStatus = (typeof AgentStatus)[keyof typeof AgentStatus];
export const AgentStatusSchema = z.enum(['running', 'succeeded', 'failed']);
