import { z } from 'zod';

/**
 * ReasonSignals — plan_reasons.signals JSON 字段的强校验 schema。
 *
 * 设计原则(W3 末):
 *   - DB 存 **原始 enum / canonical code**,不存中文(name_zh 翻译归前端 i18n)
 *   - 字段命名 PAC 标准 camelCase,跨 scenario 通用
 *   - triggers 是**数组**(留多信号源融合余地;当前 scenario 单源 → 长度 1)
 *   - factId 不存这里 — 走 plan_reasons.evidence JSON(`{ factIds[] }`,跟 PersonaFeature.evidence 同形),避免重复
 *
 * 前端用 PACScenarioSubLabels / PACTriggerTypeLabels / diagnosisCodeNameZh /
 * treatmentCategoryNameZh 翻译展示。
 */
export const ReasonSignalsSchema = z.object({
  /// 子规则 key(对齐 scenario 内 subKey:missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct …)
  subKey: z.string().min(1),

  /// 触发信号数组(多信号源融合留余地)
  /// 当前 scenario 单源命中 → 长度 1;未来 K08 + IMPLANT_RECOMMENDED + CBCT 三源合并 → 长度 3
  triggers: z
    .array(
      z.object({
        /// 触发类型 enum:diagnosis / recommendation / image_finding / emr_signal / visit_gap / ...
        /// 留 z.string() 不强 enum,未来扩展不动 schema(canonical labels 字典翻译)
        type: z.string().min(1),
        /// 触发码(PAC canonical):K08 / IMPLANT_RECOMMENDED 等;visit_gap 等无 code 场景为 null
        code: z.string().nullable().optional(),
      }),
    )
    .min(1),

  /// 牙位原文(可空 — 全口治疗 / 沉睡客户激活 等场景无牙位)
  toothPosition: z.string().nullable().optional(),

  /// 紧迫维度:触发信号距今多少天(跨场景通用)
  /// ⚠️ 这是 plan 生成那一刻**固化的快照**,随天数推移会陈旧。
  ///   展示层应优先用 signalOccurredAt 实时重算(见 applyLiveDays),此字段作 AI prompt / 旧数据兜底。
  daysSince: z.number().int().nonnegative(),

  /// 触发信号发生时刻(诊断 occurred_at / 推荐 planned_for 的 ISO)— **不可变锚点**。
  /// 展示层据此实时算天数,跟治疗链断口(chain.gapDays)同源同公式,避免"召回 388 / 治疗链 389"漂移。
  /// optional/nullable:旧 plan 无此字段 → 回落 baked daysSince(无回归)。
  signalOccurredAt: z.string().datetime().nullable().optional(),

  /// 期望待启动类别(raw PAC treatment category enum;前端用 treatmentCategoryNameZh 翻译)
  /// 不同场景含义:
  ///   - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
  ///   - 沉睡激活:可能是 [recall_visit] 等
  expectedCategories: z.array(z.string()),

  /// 建议治疗**主类目**(已按患者年龄适配)—— 展示层「目标 · X」标签据此渲染。
  /// 为什么不直接取 expectedCategories[0]:那是排除闸的完整候选集(做过其中任一都算已解决),
  /// 首项恒为 implant,会让高龄患者也显示「目标 · 种植」(临床不合适,业务实测反弹)。
  /// 本字段由 scenario 用 primaryTreatmentCategoryForAge 算好;
  /// optional/nullable:旧 plan 无此字段 → 展示层回落 expectedCategories[0](无回归)。
  focusCategory: z.string().nullable().optional(),

  /// plan 生成时的患者年龄快照(无生日 → null)。
  /// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」,低龄正畸说「早期矫治」)。
  /// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。
  patientAge: z.number().int().nullable().optional(),

  /// 触发诊断的原文词快照(DW diag message,如「乳牙早失」;推荐类信号 → null)。
  /// 用途:前端 ReasonLine 用 refineCategoriesForDiagnosis 复算类目顺序,与后端 focusCategory 同口径
  ///   —— K00 是大口袋(滞留/早失/先天缺/釉质…方向完全不同),只给 code 前端分不出方向。
  /// optional/nullable:旧 plan 无此字段 → 细分不生效,回落原顺序(无回归)。
  dxNameZh: z.string().nullable().optional(),
});
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;

/**
 * 据锚点 signalOccurredAt 实时算"距今天数"。
 * **跟 chain-composer 的 gapDays 完全同款公式**(`Math.floor((now - occurred)/一天)`),
 * 保证召回理由天数与治疗链断口永远精确一致(差异源自两者算的时刻不同 → 统一到 read 时刻)。
 * @param iso 锚点 ISO 时间;为空返回 null(调用方回落 baked daysSince)
 * @param now 参考时刻(默认当下)— 便于测试注入
 */
export function liveDaysSince(
  iso: string | null | undefined,
  now: Date = new Date(),
): number | null {
  if (!iso) return null;
  const t = new Date(iso).getTime();
  if (Number.isNaN(t)) return null;
  return Math.max(0, Math.floor((now.getTime() - t) / 86_400_000));
}

/**
 * 把 signals 的 daysSince 用锚点实时重算后返回(不可变拷贝)。
 * 有锚点 → 覆盖为实时值;无锚点 → 原样返回(旧数据兜底)。
 * 服务端两条序列化路径(列表 / 详情)共用,保证两处口径一致。
 */
export function applyLiveDays<T extends { daysSince?: number; signalOccurredAt?: string | null }>(
  signals: T | null,
  now: Date = new Date(),
): T | null {
  if (!signals) return signals;
  const live = liveDaysSince(signals.signalOccurredAt, now);
  if (live === null) return signals;
  return { ...signals, daysSince: live };
}
