/**
 * FactContentSchemas — per fact_type zod 强校验
 *
 * **定位**:`patient_facts.content` JSONB 在写库前**必须**过本文件 schema;
 * 违反抛 `FactContentSchemaError`,**脏数据进不了库**。
 *
 * **纪律**(canonical-fact-layer.md §二):
 *   - 5 道闸第 1 道(强校验)+ 第 3 道(canonical-codes 闭集)
 *   - schema 字段命名 = 算法 SQL 直接 `content->>'<key>'` 查询路径,不可变
 *   - 新增字段需同步更新 db-design-v2.md §八.4
 *
 * **新增 fact_type 需要**:
 *   1. 在 packages/types/src/enums/index.ts 的 FactType 加新值
 *   2. 在本文件 FactContentSchemas 加对应 zod
 *   3. 在 parsers/ 加对应 parser 并在 parser.registry 注册
 *   4. CI 跑 mock 数据全套校验通过
 */
import { z } from 'zod';
import {
  FactType,
  PACDiagnosisCodeSchema,
  PACTreatmentCategorySchema,
  PACTreatmentStatusSchema,
  PACImageModalitySchema,
  RecommendationExtractedBySchema,
  FDIToothPositionSchema,
} from '@pac/types';

/**
 * 牙位字段(tooth_position)— 接受多种形态,降低 host 数据接入门槛:
 *   - 单 FDI 牙位:"36" / "11"
 *   - 多 FDI 牙位分号分隔:"18;28;48;38"(4 颗智齿)
 *   - 牙位 + 牙面标记:"14 O;17 M;26 M;27 M;44 D"(O=咬合面 M=近中面 D=远中面 B=颊面 P=腭面)
 *   - host 自定义编码:不强约束(算法 SQL 用 LIKE 兜底)
 *
 * 算法 SQL 直接 `content->>'tooth_position' LIKE '%36%'` 匹配牙位;
 * 牙面分析(O/M/D/B/P)留待 Layer C 信号抽取深化。
 */
const toothPositionText = () => nullableString();

// ─────────────────────────────────────────────────────────
// 通用 helper(可空 + 默认 null)
// ─────────────────────────────────────────────────────────

const nullableString = () => z.string().nullable().optional().default(null);
const nullableNumber = () => z.number().nullable().optional().default(null);
const isoDateString = z
  .string()
  .refine((v) => !Number.isNaN(Date.parse(v)), 'must be ISO 8601 date string');

// ─────────────────────────────────────────────────────────
// 各 fact_type 的 content schema
// ─────────────────────────────────────────────────────────

/**
 * encounter_record — 接诊事件,**只装元数据**(v2.1)
 * 诊断 / 治疗拆出 diagnosis_record / treatment_record 独立 fact。
 */
const EncounterRecordContent = z
  .object({
    encounter_external_id: z.string().min(1),
    doctor_id: nullableString(),
    doctor_name: nullableString(),
    /// 主诉(SOAP 的 S)— 通用临床字段,任何接诊都有
    chief_complaint: nullableString(),
    /// 接诊类型(初诊 / 复诊 / 急诊 / 常规检查 等)— 通用临床分类。
    /// ⚠️ 不接 host 的"复诊/初诊"标记(那是衍生,PAC 自己从 encounter 集合算更可信);
    /// 真正"急诊/常规"等结构化分类 host 多无,该字段大多 null,等其他 host 提供再填。
    encounter_type: nullableString(),
    /// 一般备注(自由文本)— 跟 chief_complaint 互补
    notes: nullableString(),
  })
  .passthrough();

/**
 * diagnosis_record — 诊断(独立 fact,v2.1 新)
 */
const DiagnosisRecordContent = z
  .object({
    // code 可空:host ~98% 诊断没填 ICD 码,enum_mapping 中文白名单也翻不全;
    // 翻不出的仍落 fact(保 name_zh 原文),code=null,留给 Layer C LLM 后续补码。
    // 算法 SQL `content->>'code'='K08'` 对 null 自然不命中,无副作用。
    code: PACDiagnosisCodeSchema.nullable(),
    /// 码来源溯源:'std_code'(host ICD 码)/ 'name_map'(中文名白名单翻译)/ 'llm'(W5+ 抽取)/
    ///   'image_ai'(影像 AI 分析,fact_emr_image_analysis_out)/ null(未定码)
    code_source: z
      .enum(['std_code', 'name_map', 'llm', 'image_ai'])
      .nullable()
      .optional()
      .default(null),
    name_zh: nullableString(),
    tooth_position: toothPositionText(),
    severity: z
      .enum(['mild', 'moderate', 'severe'])
      .nullable()
      .optional()
      .default(null),
    onset_date: nullableString(),
    /// 诊断医生(继承 emr 父级 user_id;医患关系信号)
    doctor_id: nullableString(),
    doctor_name: nullableString(),
    /// 反查源接诊;算法 JOIN 凭这字段
    source_encounter_external_id: z.string().min(1),
  })
  .passthrough();

/**
 * treatment_record — 治疗(独立 fact,v2.1 新)
 * 用 fact.kind 区分 actual / planned;合并旧 treatment_plan / review_plan(用 category=review 区分)
 */
const TreatmentRecordContent = z
  .object({
    category: PACTreatmentCategorySchema,
    subtype: nullableString(),
    tooth_position: toothPositionText(),
    status: PACTreatmentStatusSchema,
    started_at: nullableString(),
    completed_at: nullableString(),
    /// 执行医生 id(host 侧)— 医患关系信号(主治医生绑定),供 doctor_relation persona / 话术个性化
    doctor_id: nullableString(),
    doctor_name: nullableString(),
    /// 治疗份数(种植 4 颗 / 龈下刮治 3 区 / 套餐 5 套 …)— 通用临床计量
    quantity: nullableNumber(),
    /// 计量单位(颗/次/区/套 等)— 跟 quantity 配对
    unit_name: nullableString(),
    /// W4 末:PAC 标准 step enum 数组(host 字面值已在 yaml enum_mapping 翻译);
    /// 例:["pulp_extirpation","canal_preparation","canal_filling"]
    /// host 没填该字段 → undefined,chain-composer fallback subtype 词根
    treat_stages: z.array(z.string()).nullable().optional(),
    /// 反查源接诊
    source_encounter_external_id: nullableString(),
    /// 关联诊断 fact(可空 — 临床上治疗也可独立存在)
    related_diagnosis_subject_id: nullableString(),
    /// 复查类治疗(原 review_plan)用 review_window_days
    review_window_days: nullableNumber(),
  })
  .passthrough();

/**
 * recommendation_record — 医生建议(Layer C 抽取产物,v2.1 新)
 */
const RecommendationRecordContent = z
  .object({
    /// 推荐码(IMPLANT_RECOMMENDED 等);未命中白名单的长尾建议 → null(仅展示、不触发召回)
    code: PACDiagnosisCodeSchema.nullable(),
    /// 医生原文建议(如"建议更换")— 病历忠实展示用;canonical code 缺失时靠它显示
    name: nullableString(),
    /// 同次接诊病历 id — 病历快读按接诊匹配展示
    source_encounter_external_id: nullableString(),
    tooth_position: toothPositionText(),
    /// 建议医生(继承 emr 父级 user_id;话术个性化用)
    doctor_id: nullableString(),
    doctor_name: nullableString(),
    /// 抽取来源(规则 / LLM / 医生录入)
    extracted_by: RecommendationExtractedBySchema,
    /// LLM 置信度(0-1);rule / doctor_input 为 null
    confidence: z.number().min(0).max(1).nullable().optional().default(null),
    /// 反查源 fact(emr_record / image_record 的 subject_id)
    source_fact_subject_id: nullableString(),
    doctor_suggested_at: nullableString(),
    /// 建议触达时间窗(天)
    window_days: nullableNumber(),
  })
  .passthrough();

/**
 * emr_record — 电子病历(PAC 标准病历语义模型,覆盖完整 SOAP 环节)
 *
 * ⭐ **这是 PAC 自己定的标准,不被任何单一宿主形态牵着走**:
 *   - 字段 = 临床病历的通用语义全集(主诉/现病史/既往史/检查/诊断/治疗计划/处置/医嘱…),
 *     是所有宿主病历的统一投影目标。
 *   - 某宿主映射不上某字段 → 留 null,**绝不因此从标准里删字段**。
 *     例:瑞尔 DW 把诊断/治疗拆成结构化数组(→ diagnosis_record / treatment_record),
 *         其 emr 的 diagnosis_text / treatment_plan 自然留 null;
 *         但小诊所宿主病历常是"一段诊断描述 + 一段治疗计划自由文本",这俩字段就有值。
 *   - 宿主有什么、怎么翻译 = yaml field_mapping 的活,宿主形态不反向裁剪 PAC 标准。
 *
 * 字段是 Layer C(LLM/规则抽取)的源:exam_findings 常带牙位 + 临床描述,可补诊断码覆盖 / 牙位短板。
 */
const EmrRecordContent = z
  .object({
    emr_external_id: z.string().min(1),
    encounter_external_id: nullableString(),
    // 接诊医生(PAC 不建医生表,医生名作为 fact 快照随病历落库;
    //  doctor_id 与 settlement.doctor_id 同源 → treatment/payment 可借此映射到医生名)
    doctor_id: nullableString(),
    doctor_name: nullableString(),
    // S 主观:主诉 / 现病史 / 既往史 / 全身情况
    illness_desc: nullableString(), // 主诉 / 疾病描述
    pre_illness: nullableString(), // 现病史
    past_history: nullableString(), // 既往史
    general_condition: nullableString(), // 全身情况 / 系统回顾
    // O 客观:检查所见
    exam_findings: nullableString(), // 检查所见(可带牙位的临床观察)
    // A 评估:诊断自由文本(结构化诊断走 diagnosis_record;宿主无结构化时落这里)
    diagnosis_text: nullableString(),
    // P 计划:治疗计划自由文本(结构化治疗走 treatment_record)+ 处置 + 医嘱
    treatment_plan: nullableString(),
    disposal: nullableString(), // 处置(本次实操)
    doctor_advice: nullableString(), // 医嘱
  })
  .passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)

/**
 * image_record — 影像 metadata + finding(自由文本)
 */
const ImageRecordContent = z
  .object({
    image_external_id: z.string().min(1),
    encounter_external_id: nullableString(),
    modality: PACImageModalitySchema,
    tooth_positions: z.array(z.string()).nullable().optional().default(null),
    finding: nullableString(),
    captured_at: nullableString(),
    /// 拍摄/解读医生(继承 emr 父级 user_id,通常 = 接诊医生)
    doctor_id: nullableString(),
    doctor_name: nullableString(),
  })
  .passthrough();

/**
 * appointment_record — kind=planned/actual 区分
 *
 * **status 语义**(host 业务视角):
 *   - scheduled  已预约未到(对应 fact.status='active'   + kind='planned')
 *   - arrived    已到诊      (对应 fact.status='fulfilled'+ kind='actual')
 *   - cancelled  取消
 *   - no_show    爽约         (对应 fact.status='expired' + kind='planned')
 *
 * 跟 `fact.status` 区别:
 *   - fact.status   = PAC 版本流状态(active/superseded/fulfilled/cancelled/expired/invalidated)
 *   - content.status = host 业务状态(直接对应 host appo_status 经 yaml enum_mapping 翻译后的值)
 *   两者维度不同都保留 — fact.status 给版本流引擎用,content.status 给算法 / UI 业务判断用。
 */
const AppointmentRecordContent = z
  .object({
    appointment_external_id: z.string().min(1),
    scheduled_at: isoDateString.nullable().optional().default(null),
    arrived_at: isoDateString.nullable().optional().default(null),
    appointment_type: nullableString(),
    doctor_id: nullableString(),
    // 预约科目/就诊意向(常规/正畸/种植/修复/拔牙/牙周…),host appo_complaint_category
    complaint_category: nullableString(),
    // 预约主诉自由文本(跟 complaint_category 配对:分类 + 原文)— Layer C 源
    complaint_text: nullableString(),
    // 预约时长(分钟),host appo_time_period
    duration_minutes: nullableNumber(),
    // 取消原因(自由文本):"临时有事 / 不用回访 / 再约 / 去了其他诊所…"— 爽约/取消分析
    cancellation_reason: nullableString(),
    // 预约备注(运营内部自由文本):"转 X 医 / 后续修复 / …"— Layer C 备料
    staff_notes: nullableString(),
    // 8 态细分(host 8 状态 1:1):
    //   scheduled(正常,唯一有效预约)/ rescheduled(已改约,历史被取代)/ cancelled(已取消)
    //   arrived(到诊)/ in_treatment(接诊)/ completed(结算,到诊+消费完成)
    //   no_show(爽约)/ walk_in(walk-in 变更)
    status: z
      .enum([
        'scheduled',
        'rescheduled',
        'cancelled',
        'arrived',
        'in_treatment',
        'completed',
        'no_show',
        'walk_in',
      ])
      .nullable()
      .optional()
      .default(null),
  })
  .passthrough();

/**
 * consultation_record — PAC 咨询主体(C.1.2 / 意向源)
 *
 * 定义:一次"咨询/初诊登记"事件(患者来咨询某些治疗意向)。区别于:
 *   - diagnosis_record(医生客观诊断)/ recommendation_record(医生建议)— 那是【临床事实】,喂召回;
 *   - consultation_record 的 intents = 【患者主观意愿】(咨询时想做啥)— 软信号,喂 treatment_intent,不进召回。
 * 数据源:fact_consult_out(5 试点诊所内,patient_register_id=patient_id;potential_cure=意向)。
 */
const ConsultationRecordContent = z
  .object({
    consultation_external_id: z.string().min(1),
    consult_date: nullableString(), // 咨询日(occurredAt 也带)
    source_channel: nullableString(), // 来源渠道(consult 暂无 → null,留扩展)
    intents: z.array(z.string()).optional().default([]), // 意向项目原文(种植/正畸/洁牙…)
    intent_categories: z.array(z.string()).optional().default([]), // 归一到 PAC 治疗类别
    is_first_visit: z.boolean().nullable().optional().default(null), // 初诊
    task_director: nullableString(), // 跟进人
    doctor_id: nullableString(),
    appointment_external_id: nullableString(), // 关联预约 appo_id
    intent: nullableString(), // 兼容旧字段(意向 join 串,展示用)
    content: nullableString(),
  })
  .passthrough();

/**
 * visit_registration_record — 挂号
 */
const VisitRegistrationRecordContent = z
  .object({
    registration_external_id: z.string().min(1),
    doctor_id: nullableString(),
    registered_at: isoDateString.nullable().optional().default(null),
  })
  .passthrough();

/**
 * visit_reception_record — 接待登记
 */
const VisitReceptionRecordContent = z
  .object({
    reception_external_id: z.string().min(1),
    reception_type: nullableString(),
    received_at: isoDateString.nullable().optional().default(null),
  })
  .passthrough();

/**
 * order_record — 医嘱(治疗 / 耗材 / 药品统一单据)
 */
const OrderRecordContent = z
  .object({
    order_external_id: z.string().min(1),
    encounter_external_id: nullableString(),
    amount_cents: z.number().int().nonnegative(),
    items: z
      .array(
        z.object({
          item_type: z.enum(['technical', 'material', 'drug']).optional(),
          item_code: nullableString(),
          item_name: nullableString(),
          quantity: nullableNumber(),
          unit_price_cents: nullableNumber(),
          amount_cents: nullableNumber(),
        }),
      )
      .nullable()
      .optional()
      .default(null),
  })
  .passthrough();

/**
 * payment_record / refund_record / recharge_record — 金额三件套(amount_cents Int 分)
 */
const PaymentRecordContent = z
  .object({
    payment_external_id: z.string().min(1),
    /// 实付金额(分)— 2026-07 业务定调:真金白银口径(与 refund 同口径,LTV 自洽)
    amount_cents: z.number().int().nonnegative(),
    /// 应收金额(分,可空)— 原价参考:折扣率分母 / 权益客识别(实付0+应收>0=纯权益客);
    /// 旧事实(reparse 前)无此字段,消费方需 fallback amount_cents(彼时 amount=应收)
    receivable_cents: z.number().int().optional().nullable(),
    channel: nullableString(),
    /// 商业保险公司名(可空)— 非空 = 商保结算;喂 persona「权益身份」。保司名脏,归一在 feature 层做。
    insurance_name: nullableString(),
    /// 卡券类型名 / 卡券名(可空)— B.1.3 权益身份关键词匹配源(储值/儿牙/银行私行等)
    card_type_name: nullableString(),
    card_name: nullableString(),
    /// 折扣额(分,4 渠道求和)+ 结算项目名 — D.1.3 折扣锚点(折扣率=1-discount/应收)
    discount_cents: z.number().int().optional().nullable(),
    settlement_project: nullableString(),
    /// 收费医生 id(host 侧)— 医患关系信号(患者主要花钱给哪个医生)
    doctor_id: nullableString(),
    /// 关联接诊 id(反查"这次收款关联哪次接诊")
    encounter_external_id: nullableString(),
    /// 关联医嘱单 id(charge_order)— 其他 host 可能提供;host 不区分 order/encounter 时留 null
    related_order_external_id: nullableString(),
  })
  .passthrough();

const RefundRecordContent = z
  .object({
    refund_external_id: z.string().min(1),
    amount_cents: z.number().int().nonnegative(),
    reason: nullableString(),
    related_payment_external_id: nullableString(),
  })
  .passthrough();

const RechargeRecordContent = z
  .object({
    recharge_external_id: z.string().min(1),
    amount_cents: z.number().int().nonnegative(),
    card_type: nullableString(),
  })
  .passthrough();

const ComplaintRecordContent = z
  .object({
    complaint_external_id: z.string().min(1),
    severity: z.enum(['low', 'medium', 'high']).nullable().optional().default(null),
    status: z.enum(['filed', 'in_progress', 'resolved']).nullable().optional().default(null),
    content: nullableString(),
  })
  .passthrough();

/**
 * referral_record — 转介绍(老带新 / 渠道归因)
 *
 * fact 挂在**被推荐人**(referee = fact.patient_id),表达"我是被 XXX 推荐来的"。
 * 推荐人(referrer)信息作为**快照**进 content(PAC 不维护医生/员工/患者主数据外的 referrer 表;
 *  referrer 可能跨 host / 跨诊所 / 是员工,统一存名 + 类型即可,跟 emr.doctor_name 模式一致)。
 */
const ReferralRecordContent = z
  .object({
    referral_external_id: z.string().min(1),
    /// 推荐人 host id(可能是 patient_id / employee_id);仅作标识,PAC 不强约束类型
    referrer_external_id: nullableString(),
    /// 推荐人姓名(快照)
    referrer_name: nullableString(),
    /// 推荐人病历号(若 referrer 是患者;辅助标识)
    referrer_chart_number: nullableString(),
    /// 推荐人类型:patient(老带新)/ staff(员工)/ partner(合作渠道)/ other
    referrer_type: z
      .enum(['patient', 'staff', 'partner', 'other'])
      .nullable()
      .optional()
      .default(null),
    /// 推荐渠道(host 可能不提供)
    channel: nullableString(),
  })
  .passthrough();

// ─────────────────────────────────────────────────────────
// 统一注册表
// ─────────────────────────────────────────────────────────

export const FactContentSchemas = {
  [FactType.ENCOUNTER_RECORD]: EncounterRecordContent,
  [FactType.DIAGNOSIS_RECORD]: DiagnosisRecordContent,
  [FactType.TREATMENT_RECORD]: TreatmentRecordContent,
  [FactType.RECOMMENDATION_RECORD]: RecommendationRecordContent,
  [FactType.EMR_RECORD]: EmrRecordContent,
  [FactType.IMAGE_RECORD]: ImageRecordContent,
  [FactType.APPOINTMENT_RECORD]: AppointmentRecordContent,
  [FactType.CONSULTATION_RECORD]: ConsultationRecordContent,
  [FactType.VISIT_REGISTRATION_RECORD]: VisitRegistrationRecordContent,
  [FactType.VISIT_RECEPTION_RECORD]: VisitReceptionRecordContent,
  [FactType.ORDER_RECORD]: OrderRecordContent,
  [FactType.PAYMENT_RECORD]: PaymentRecordContent,
  [FactType.REFUND_RECORD]: RefundRecordContent,
  [FactType.RECHARGE_RECORD]: RechargeRecordContent,
  [FactType.COMPLAINT_RECORD]: ComplaintRecordContent,
  [FactType.REFERRAL_RECORD]: ReferralRecordContent,
} as const;

export type FactContentSchemaKey = keyof typeof FactContentSchemas;

/// 抛错型:违反 schema 时由 FactWriter 抛
export class FactContentSchemaError extends Error {
  constructor(
    public readonly factType: string,
    public readonly subjectId: string,
    public readonly issues: z.ZodIssue[],
  ) {
    super(
      `fact.content schema violation: type=${factType} subjectId=${subjectId} ` +
        `issues=${JSON.stringify(issues, null, 2)}`,
    );
    this.name = 'FactContentSchemaError';
  }
}

/**
 * 校验入口 — FactWriter 写库前调用。
 * 未注册 fact_type 直接放行(理论上 ts 枚举已经穷尽,运行期 belt-and-suspenders)。
 *
 * @returns parse 后的 content(已应用 default,清洗过空值)
 */
export function validateFactContent(
  factType: string,
  subjectId: string,
  content: unknown,
): unknown {
  const schema = (FactContentSchemas as Record<string, z.ZodTypeAny>)[factType];
  if (!schema) {
    return content; // 不认识的 fact_type 不拦(防 ts 枚举漏配引发 prod 雪崩)
  }
  const r = schema.safeParse(content);
  if (!r.success) {
    throw new FactContentSchemaError(factType, subjectId, r.error.issues);
  }
  return r.data;
}
