import { z } from 'zod';
import {
  AbandonReasonSchema,
  AssetSourceSchema,
  AssetStatusSchema,
  ExecutionChannelSchema,
  ExecutionOutcomeSchema,
  PlanStatusSchema,
  ReleaseReasonSchema,
} from '../enums';
import { PagingResponseSchema } from './common';
import { PatientSchema } from './patient';
import { PersonaSchema } from './persona';
import { ReasonSignalsSchema } from './reason-signals';

// =============================================================
// Layer 3 — FollowupPlan
// =============================================================

/// 证据引用 — fact 自带 transactionIds 可两跳回溯到原始 transaction(跟 PersonaFeature.evidence 对齐)
export const PlanEvidenceSchema = z
  .object({
    factIds: z.array(z.string().uuid()).optional(),
    personaVersion: z.number().int().optional(),
    agentInvocationIds: z.array(z.string().uuid()).optional(),
    ruleIds: z.array(z.string()).optional(),
  })
  .catchall(z.unknown());
export type PlanEvidence = z.infer<typeof PlanEvidenceSchema>;

export const FollowupPlanSchema = z.object({
  id: z.string().uuid(),
  hostId: z.string().uuid().describe('FK → platforms.id'),
  tenantId: z.string(),
  patientId: z.string().uuid(),
  personaId: z.string().uuid().nullable(),
  /// 期望执行召回的诊所(业务字段,创建锁定);null = 集团统一客服池
  targetClinicId: z.string().nullable(),
  scenario: z.string(),
  version: z.number().int(),
  priorityScore: z.number(),
  inclusionReason: z.string(),
  recommendedAt: z.string().nullable(),
  recommendedRole: z.string().nullable(),
  recommendedChannel: z.string().nullable(),
  evidence: PlanEvidenceSchema,
  status: PlanStatusSchema,
  /**
   * 认领的 24h 兜底回收时刻。⚠️ 生产**未启用**(PAC_PLAN_AUTO_RECYCLE 默认 off),
   * 绝大多数时候是 null —— ⛔ 别拿它当"这单什么时候到期"。
   */
  recycleAt: z.string().nullable(),
  /**
   * ⭐ **分配单的到期时刻** —— 这才是"这单什么时候自动退回池子"。
   *
   * 与 `recycleAt` 是**两个机制**,别混:
   *   · recycleAt          认领后 24h 兜底,**未启用**;
   *   · assignmentExpiresAt 主管在确认单上定的时限(1-7 天),**已启用**
   *     (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。
   * 客服要看的是后者:它决定他手上这单还剩多久。
   */
  assignmentExpiresAt: z.string().nullable(),
  /// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
  /// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
  snoozedUntil: z.string().nullable(),
  /// 实际指派给谁(宿主侧 user id)
  assigneeUserId: z.string().nullable(),
  /**
   * ⭐ 承接人姓名 —— **服务端解析后下发**,不要指望前端自己翻。
   * PAC 没有 users 表,前端的 `dictionary.users` 只覆盖当前登录人,
   * 于是详情页「承接人」长期显示的是一串 uuid。
   * 姓名源是回访表 `task_director_name`(取最近一次;id→姓名是时间性的)。
   * null = 未分配,或该 id 在回访表里查无记录(只做召回不做回访的人)。
   */
  assigneeName: z.string().nullable().optional(),
  assignedAt: z.string().nullable(),
  createdAt: z.string(),
  updatedAt: z.string(),
  /// 被新版本取代的时间;status='active'/'assigned' 等其他状态时为 null
  supersededAt: z.string().nullable(),
});
export type FollowupPlan = z.infer<typeof FollowupPlanSchema>;

// =============================================================
// Layer 3 — PlanScript / PlanSummary(plan 子资产)
// =============================================================

/// PlanScript = 每个 plan 一份完整话术(Markdown 含 ## 开场 / ## 跟进 / ## 异议处理 多子段),
/// 一次 agent 调用产出全部 — schema 不分 type
export const PlanScriptSchema = z.object({
  id: z.string().uuid(),
  hostId: z.string().uuid(),
  tenantId: z.string(),
  planId: z.string().uuid(),
  content: z.string().max(8000).nullable(),
  status: AssetStatusSchema,
  source: AssetSourceSchema.nullable(),
  agentInvocationId: z.string().uuid().nullable(),
  createdAt: z.string(),
  updatedAt: z.string(),
});
export type PlanScript = z.infer<typeof PlanScriptSchema>;

/// PlanSummary = 每个 plan × 3 type(one_page / medical_record / treatment_chain)。
/// 一次 agent 调用产出 3 个 type 内容,共享同一 agentInvocationId
export const PlanSummarySchema = z.object({
  id: z.string().uuid(),
  hostId: z.string().uuid(),
  tenantId: z.string(),
  planId: z.string().uuid(),
  type: z.string(),
  content: z.string().max(8000).nullable(),
  status: AssetStatusSchema,
  source: AssetSourceSchema.nullable(),
  agentInvocationId: z.string().uuid().nullable(),
  createdAt: z.string(),
  updatedAt: z.string(),
});
export type PlanSummary = z.infer<typeof PlanSummarySchema>;

// =============================================================
// Layer 3 — PlanExecution(执行回写)
// =============================================================

export const PlanExecutionSchema = z.object({
  id: z.string().uuid(),
  hostId: z.string().uuid(),
  tenantId: z.string(),
  /// 实际执行客服所在诊所(执行发生地,必填)
  executorClinicId: z.string(),
  planId: z.string().uuid(),
  /// 操作人 user id(从 access token 取);展示 name 走 token 字典 / host /users:resolve
  operatorUserId: z.string(),
  channel: z.string(),
  outcome: ExecutionOutcomeSchema,
  notes: z.string().nullable(),
  abandonReasons: z.array(z.string()),
  /// 「机会识别不准确」勾中的治疗 code(仅统计用,不参与抑制)
  inaccurateTreatments: z.array(z.string()),
  scheduledNextAt: z.string().nullable(),
  /// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行
  createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
});
export type PlanExecution = z.infer<typeof PlanExecutionSchema>;

// =============================================================
// API 请求 / 响应
// =============================================================

export const ListPlansQuerySchema = z.object({
  scenario: z.string().optional(),
  status: PlanStatusSchema.optional(),
  /// 按 target_clinic_id 过滤召回池(null target 跟着 staff 自己 clinicIds 决定可见性)
  targetClinicId: z.string().optional(),
  /// 按诊所多选过滤(target_clinic_id IN [...])。前端逗号串上线,这里 preprocess 拆数组。
  targetClinicIds: z.preprocess(
    (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : v),
    z.array(z.string()).optional(),
  ),
  assigneeUserId: z.string().optional(),
  view: z.enum(['pool', 'mine', 'all']).optional()
    .describe('"pool" = active+unassigned;  "mine" = assigned to caller;  "all" = no extra filter'),
  /// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter)
  keyword: z.string().trim().min(1).optional(),
  /// 画像标签筛选:"key:value" 逗号串(如 "rfm:important_value,urgency_level:high")。
  /// 维度/取值见 PERSONA_TAG_FILTER_DIMS;同维多选 OR,跨维 AND,匹配当前版画像。
  personaTags: z.string().trim().min(1).optional(),
  /**
   * 初选矩阵的 Y 轴:窗口温度。**必须与 `potentialTreatment` 一起给** ——
   * 同一个人可能「潜在种植·热」而「潜在补牙·冷」,脱离治疗项的"热"没有意义。
   *
   * ⚠️ 刻意**不进 `PERSONA_TAG_FILTER_DIMS`**:那张表是客服筛选面板的渲染源
   * (`patient-picker-rail` 直接遍历它),加进去温度就会出现在客服面板 —— 违 T16;
   * 且温度是**初选轴**不是精选标签(六·已定取舍:精选不再切窗口)。
   */
  /// ⚠️ 六档(2026-08 冷端拆四);旧值 `cold` 仍收 —— 服务端 expandTemperatureFilter 展开成四档并集,
  ///    这样已有的链接 / MCP 工具调用 / 助手上下文里的 `temperature=cold` 不会一夜之间全变非法。
  temperature: z
    .enum(['hot', 'warm', 'cold_1y', 'cold_2y', 'cold_3y', 'cold_over', 'cold'])
    .optional(),
  /// 与 temperature 配套的治疗项(矩阵 X 轴)。单给 temperature 会被服务端拒。
  potentialTreatment: z.string().trim().min(1).optional(),
  /// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。
  phoneVerified: z.preprocess(
    (v) => (v === 'true' ? true : v === 'false' ? false : v),
    z.boolean().optional(),
  ),
  /// 排序:服务端 ORDER BY(W3 末加,替代前端 .sort)
  sort: z.enum(['priority_desc', 'priority_asc', 'created_desc']).default('priority_desc'),
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type ListPlansQuery = z.infer<typeof ListPlansQuerySchema>;

/// 列表行用的患者简介 — 避免列表每行再发请求。
/// name 是原值(权限内客服可见),nameMasked 仅作脱敏展示场景备用。
export const PlanPatientBriefSchema = z.object({
  externalId: z.string(),
  name: z.string().nullable(),
  nameMasked: z.string().nullable(),
  phoneMasked: z.string().nullable(),
  /**
   * 手机号**明文** —— 列表直接显示(2026-08-04 产品定:客服要照着拨,脱敏号没法用)。
   *
   * ⚠️ 这**不是**新开的暴露面:详情页早就明文下发同一个号(plan-aggregate 的 `phone`),
   *   两处同权限、同 tenant scope。列表脱敏而详情明文,只是让客服多点一次。
   * ⚠️ `phoneMasked` **保留**:宿主配了原始档案(VIEW_PATIENT)时 PAC 侧是造数假号,
   *   那种场景下前端整行隐藏手机号,两个字段都不显示 —— ⛔ 别因为加了明文就把脱敏删了。
   */
  phone: z.string().nullable(),
  /// 号码真实性(外部对照表核实过 = true);列表给"真"角标 + 筛选用
  phoneVerified: z.boolean().optional(),
  gender: z.string().nullable(),
  age: z.number().int().nullable(),
  /// 病历号(客服口头核对身份用的档案号)。宿主可能不给 —— jvs-dw 覆盖 91%、friday 63%。
  medicalRecordNumber: z.string().nullable(),
  /// 最近一次到诊日期(YYYY-MM-DD)。口径同详情页 lastVisit:encounter_record ∪ emr_record
  /// 取 occurred_at 最大的那次(部分宿主 appointment.in_time 缺失、只有 EMR,故必须并集)。
  lastVisitAt: z.string().nullable(),
  /// **那一次**的接诊医生姓名(不是"历史最常见医生")—— 日期与医生同源于同一次就诊,
  /// 否则会出现"日期是A次、医生是B次"的错配。doctor_name 只有 emr_record 带
  /// (encounter_record 实测 0% 有名),故同日并列时优先取 emr。取不到 → null,前端不显示。
  lastVisitDoctor: z.string().nullable(),
  /// 潜在治疗 **code**(画像 potential_treatment.types,如 implant / perio)。
  /// 刻意给 code 不给中文:中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL,改措辞即时生效;
  /// 若透画像里存的 labels,改词就得全量重算画像才看得到。
  /// 无画像 / 无该特征 → 空数组。
  potentialTreatments: z.array(z.string()),
});
export type PlanPatientBrief = z.infer<typeof PlanPatientBriefSchema>;

/// 列表行用的 reason 简介 — 富文本由前端 ReasonLine 用 signals 组装。
/// 跟 plan-aggregate 详情页 reasons 同构(字段子集),保证前端可共享 ReasonLine 组件。
export const PlanReasonBriefSchema = z.object({
  id: z.string().uuid(),
  scenario: z.string(),
  subKey: z.string().nullable(),
  signals: ReasonSignalsSchema.nullable(),
  priorityScore: z.number(),
  /// 老数据 / signals 缺失时的兜底自然语言
  reason: z.string(),
  /// 6 因子算分明细 — 前端 PriorityHover 渲染。无 breakdown 时 fallback 简版文案。
  breakdown: z.unknown().nullable().optional(),
});
export type PlanReasonBrief = z.infer<typeof PlanReasonBriefSchema>;

/// 列表行 = FollowupPlan + patient brief + reasons brief + 中文 scenarioLabel(后端预翻译,前端零计算)
export const PlanListItemSchema = FollowupPlanSchema.extend({
  patient: PlanPatientBriefSchema,
  scenarioLabel: z.string(),
  reasons: z.array(PlanReasonBriefSchema),
  /// 该 plan 最近一次执行(回访提交)的 outcome — 列表左栏「成功/不成功/保持」角标用。
  /// null = 尚无执行记录。分组/文案/色板查 EXECUTION_OUTCOME_META。
  lastOutcome: ExecutionOutcomeSchema.nullable().optional(),
});
export type PlanListItem = z.infer<typeof PlanListItemSchema>;

export const ListPlansResponseSchema = z
  .object({ items: z.array(PlanListItemSchema) })
  .extend(PagingResponseSchema.shape);
export type ListPlansResponse = z.infer<typeof ListPlansResponseSchema>;

/// KPI / Tab badge 用的聚合计数 — 后端一次 SQL 给齐,避免列表页 5 个并发 list 请求。
export const PlanCountsResponseSchema = z.object({
  mine: z.number().int(),
  mineAssigned: z.number().int(),
  mineCompleted: z.number().int(),
  pool: z.number().int(),
  all: z.number().int(),
});
export type PlanCountsResponse = z.infer<typeof PlanCountsResponseSchema>;

export const PlanDetailResponseSchema = z.object({
  plan: FollowupPlanSchema,
  patient: PatientSchema,
  persona: PersonaSchema.nullable(),
  script: PlanScriptSchema.nullable().describe('电话话术(分段;可能在生成中)'),
  /**
   * ⭐ 企微话术(一整块可直接发送的消息)。null = 还没生成过。
   *
   * ⚠️ 与 `script` 是**两条独立的生成链**,各自有自己的 status ——
   *   ⛔ 别把它当成 `script` 的另一种渲染:内容、prompt、校验规则都不一样
   *   (企微多一条"可直接发送"的校验:无小标题、无占位残留、无给客服看的话)。
   * ⚠️ 企微**没有模板兜底**:生成失败就是 `status='failed'` + content 为空,
   *   前端要如实显示"生成失败",⛔ 不许回落去显示电话稿 —— 那份是口语分段的,
   *   复制发给患者会很怪,而且客服不会注意到自己发错了东西。
   */
  wecomScript: z
    .object({
      id: z.string().uuid(),
      status: AssetStatusSchema,
      /**
       * ⭐ **原文**,不拆段。⛔ 别复用 `PlanScriptSchema` —— 那个形状是给电话稿的
       *   (它带 `sections`,序列化时把正文拆掉),企微稿拆完就什么都不剩了(踩过)。
       */
      content: z.string().nullable(),
      updatedAt: z.string(),
    })
    .nullable()
    .describe('企微话术(单块原文;独立生成链,无模板兜底)'),
  summaries: z.array(PlanSummarySchema).describe('3 种 type 各一条(部分可能在生成中)'),
  executions: z.array(PlanExecutionSchema),
  evidence: PlanEvidenceSchema,
});
export type PlanDetailResponse = z.infer<typeof PlanDetailResponseSchema>;

export const AssignPlanRequestSchema = z.object({
  assigneeUserId: z.string().min(1),
});
export type AssignPlanRequest = z.infer<typeof AssignPlanRequestSchema>;

/**
 * 退回(返池)请求。
 *
 * ⚠️ 2026-08 换掉了原来的 `reason?: string` —— 那个字段**从来没被读过**
 * (controller 写作 `@Body() _dto`,service 签名里根本没有该参),
 * 于是"客服为什么退回"这条信息在链路第一步就被丢了,退回原因分布无从统计。
 *
 * 换成结构化枚举而不是自由文本:分布要能聚合、要能反推「该调哪个默认值」
 * (见 RELEASE_REASON_META.lever),自由文本做不到这两件事。
 * ⚠️ 命名用 `releaseReason` 不用 `reason` —— 与 `recallFeedback`(召回准不准)
 * 在语义上是两回事,叫 `reason` 迟早被人当成同一个东西聚合到一起。
 */
export const RecyclePlanRequestSchema = z.object({
  releaseReason: ReleaseReasonSchema.optional().describe('退回原因(结构化);分配单必填'),
  releaseNote: z.string().optional().describe('文字补充;原因为 other 时必填'),
});
export type RecyclePlanRequest = z.infer<typeof RecyclePlanRequestSchema>;

export const SubmitExecutionRequestSchema = z.object({
  channel: ExecutionChannelSchema,
  outcome: ExecutionOutcomeSchema,
  notes: z.string().optional(),
  /// 放弃原因(多选,无上限)—— PAC 表单 / 宿主关闭弹窗共用;说明写进 notes
  abandonReasons: z.array(AbandonReasonSchema).optional(),
  /// 「机会识别不准确」勾了哪几类推荐治疗不准 —— 画像 potential_treatment 的 code,不是中文。
  /// 选了 inaccurate 就必填(服务端也校验,别只信前端);其他原因不带这个字段。
  /// 用途只有统计 / 改算法,不参与抑制(见 schema.prisma 那列的注释)。
  inaccurateTreatments: z.array(z.string()).optional(),
  scheduledNextAt: z.string().optional(),
});
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;

/// 召回反馈(plan 级)— 详情页标题栏拇指 + 文字。data 本质 = 正/反 + 文字。
export const SubmitRecallFeedbackRequestSchema = z.object({
  feedback: z.enum(['up', 'down']),
  /// down 时弹窗收集的文字说明(预选项是快速输入,最终都落这一段文字);up 一般为空
  note: z.string().max(2000).optional(),
});
export type SubmitRecallFeedbackRequest = z.infer<typeof SubmitRecallFeedbackRequestSchema>;

export const SubmitExecutionResponseSchema = z.object({
  /// plan 新状态(execution outcome 驱动状态机后的结果)
  planStatus: z.string(),
  /// 累计触达次数(本次 ++)
  contactAttempts: z.number().int().min(0),
  /// 是否触发熔断(>= maxContactAttempts → 自动 abandoned)
  breakerTripped: z.boolean(),
  executionId: z.string().uuid(),
});
export type SubmitExecutionResponse = z.infer<typeof SubmitExecutionResponseSchema>;

export const ListExecutionsResponseSchema = z.object({
  items: z.array(PlanExecutionSchema),
});
export type ListExecutionsResponse = z.infer<typeof ListExecutionsResponseSchema>;

export const PlanActionAckSchema = z.object({ ok: z.literal(true) });
export type PlanActionAck = z.infer<typeof PlanActionAckSchema>;

export const RecomputeAckSchema = z.object({
  accepted: z.literal(true),
  plansCreated: z.number().int(),
});
export type RecomputeAck = z.infer<typeof RecomputeAckSchema>;

/// Plan 资产单独 regen API(运维 / 客服不满意场景)
export const RegenerateAssetsRequestSchema = z.object({
  /// true = 先即时 pull 患者数据再 regen
  refreshFacts: z.boolean().default(false),
  /// 指定要重生的资产(空 = 全部:script + 3 个 summary type)
  scripts: z.boolean().optional(),
  summaries: z.array(z.enum(['one_page', 'medical_record', 'treatment_chain'])).optional(),
});
export type RegenerateAssetsRequest = z.infer<typeof RegenerateAssetsRequestSchema>;
