import { z } from 'zod';

/**
 * Push 事件 body 契约 — 宿主主动推送给 PAC。
 *
 * 一次请求可携带多条事件(批量推),按 occurredAt 顺序处理。
 * 每条事件 = 一个 patient_transactions 行(经过 synthesizer)。
 *
 * 字段已是 PAC canonical 命名:
 *   - subjectType(patient / appointment / encounter ...) → patient_transactions.subject_type
 *   - subjectRef → patient_transactions.subject_id
 *   - patientRef → 反查 patient.id(若 subjectType 不是 patient 本身)
 *   - sourceEventId → 幂等键
 *   - occurredAt → 时间戳
 *   - payload → 业务字段(canonical 已映射,如 appointmentAt / status / doctorId 等)
 */
export const PushEventSchema = z.object({
  subjectType: z.string().describe('CanonicalSubjectType,见事件枚举:appointment/diagnosis/...'),
  action: z.string().describe('canonical Action,见事件枚举'),
  /// 本记录在宿主侧的 id（= canonical externalId,进幂等键）。
  subjectId: z.string().min(1).describe('本记录在宿主侧的 id(= canonical externalId)'),
  /// 所属患者 id;subjectType=patient(主档自身)时可省。
  patientId: z.string().min(1).optional().describe('所属患者 id'),
  /// 集团 id（= 登录授权的 tenantId）。单集团固定值,多集团按归属传。
  tenantId: z.string().min(1).describe('集团 id'),
  /// 源组织命名空间 id;集团内给患者号消歧,进幂等键防跨命名空间撞号。单一命名空间可省(='')。
  sourceUnit: z.string().optional().default('').describe('源组织命名空间 id'),
  clinicId: z.string().min(1).describe('发生诊所(操作类必填)'),
  /// 时间格式随宿主系统(带时区直通;无时区按接入配置的 timezone 归一)—— 与形态 A 同一转,
  /// 宿主无需为 PAC 做格式转换(口径统一,消除"envelope 例外"心智负担)。
  occurredAt: z.string().min(1).describe('发生时间;无时区时按接入配置解释'),
  /// 末次修改时间;进幂等键 → 内容变升版本、重试不变去重;乱序裁决用。缺省回退 occurredAt。
  updatedAt: z.string().optional().describe('末次修改时间;驱动幂等 / 版本 / 乱序裁决'),
  eventSeq: z.number().int().nonnegative().optional().describe('递增序号,可选,用于水位'),
  payload: z.record(z.string(), z.unknown()).describe('已 canonical 化的业务字段'),
});

export type PushEvent = z.infer<typeof PushEventSchema>;

export const PushEventsRequestSchema = z.object({
  events: z.array(PushEventSchema).min(1).max(500),
});

export type PushEventsRequest = z.infer<typeof PushEventsRequestSchema>;

export const PushEventsResponseSchema = z.object({
  syncLogId: z.string().uuid(),
  accepted: z.number().int().describe('收到的事件总数'),
  transactionsWritten: z.number().int(),
  duplicates: z.number().int(),
  failed: z.number().int(),
});

export type PushEventsResponse = z.infer<typeof PushEventsResponseSchema>;

/**
 * 形态 A:推【原生表行】契约 — 宿主把"导出给数仓的那种原生表行"照抄推来。
 *
 *   { tenantId?, source, rows: [ {原生字段照抄...}, ... ] }
 *
 * - `source`  = 原生源表名(= 宿主数据字典里的表名 / manifest tables[].table)
 * - `rows`    = 原生行,PAC 用该 host 的 yaml 跑 transforms + assembler 拆分/映射/落库
 *
 * 集团(tenant)/ 命名空间(source_unit)/ 诊所(clinic)都从原生行的对应列**逐行**解析
 * (manifest brand→tenant + identity_namespace_field,同 pull / cold-import / reparse),host 不另传。
 *
 * 跟 pull / cold-import / reparse 走同一条摄入管线,只是数据入口是 webhook。
 */
export const PushRowsRequestSchema = z.object({
  source: z.string().min(1).describe('原生源表名(= manifest tables[].table)'),
  rows: z
    .array(z.record(z.string(), z.unknown()))
    .min(1)
    // 500 上限 = 磨平存量/增量的体制约束:单批处理时间可控(webhook 同步跑管线不超时),
    // 存量 = 同一通道按批串行重放,与形态 C 的 events 上限对齐。
    .max(500)
    .describe('原生表行,字段照抄;单批 ≤500,存量分批串行推'),
});

export type PushRowsRequest = z.infer<typeof PushRowsRequestSchema>;

export const PushRowsResponseSchema = z.object({
  syncLogId: z.string().uuid(),
  source: z.string(),
  accepted: z.number().int().describe('收到的原生行数'),
  transactionsWritten: z.number().int(),
  duplicates: z.number().int(),
  failed: z.number().int(),
  /**
   * transaction 已落账、但衍生 fact 被 content schema 拒收的条数。
   *
   * 跟 failed 分开:failed 是"这行没进 PAC";factsFailed 是"行进了,但派生不出可用事实"
   * (典型:金额为负、必填字段空)。**>0 表示这批数据有一部分对 PAC 无效,要看 factRejects**。
   *
   * 2026-07-29 补:此前这类拒收既不计数也不回报,回执是 success/failed=0,
   * 宿主完全看不出问题在自己哪一行,只看到超时 → 重推同一批 13 小时(见 factRejects)。
   */
  factsFailed: z.number().int().describe('衍生 fact 被 schema 拒收的条数(0 = 全部正常)'),
  factRejects: z
    .array(
      z.object({
        type: z.string().describe('fact 类型,如 payment_record'),
        subjectId: z.string().describe('形如 <类型>:<宿主主键> —— 据此定位到源库那一行'),
        transactionId: z.string(),
        issues: z.array(z.string()).describe('字段级原因,如 "amount_cents: 需 >= 0"'),
      }),
    )
    .describe('拒收明细样本(封顶 10 条;不含 content 原文,避免患者信息随回执外流)'),
  personaEnqueued: z.number().int().describe('入队画像重算的患者数(plan 不在此触发,由定时任务保)'),
  /**
   * 本批因「引用的患者号在 PAC 找不到」而新建的空壳患者主档数。
   *
   * >0 本身不是错 —— 文档承诺的「推送顺序无要求」正是靠空壳兜底(先推病历后推主档也能落)。
   * 但**持续 >0 说明患者号对不上**,典型是 id 格式漂移:宿主导出脚本把整数 id 过了一遍
   * float,推来 "676600.0",PAC 匹配不到真档 "676600" → 建出无名幽灵患者,把整批病历吸走,
   * 还会被算画像、进召回池等客服打电话。2026-07-28 friday 推 med_emr_info 实际踩过。
   */
  patientStubsCreated: z.number().int().describe('新建空壳患者主档数(持续 >0 = 患者号对不上)'),
  /// 宿主自检信号:样本批推完看这两个数,>0 先停下联系 PAC。
  mappingMisses: z.number().int().describe('映射覆盖缺口种类数(有原值落 _default)'),
  suspectFields: z.number().int().describe('疑似宿主改表项数(列缺席/新增,vs 历史基线)'),
  /// 预检模式(?dryRun=1):跑完整管线(transforms+装配+归一+tenant 解析)但**不落任何库**,
  /// 数字表示"若正式推会发生什么"。对接自测用:先预检到 failed=0 且两个自检信号为 0,再正式推。
  dryRun: z.boolean().describe('true = 本次为预检,未落库'),
  samples: z
    .array(z.object({ resource: z.string(), canonical: z.unknown() }))
    .describe('预检样本:各 canonical 资源首行的翻译结果(仅 dryRun 返回,供宿主核对 PAC 是否理解正确)'),
});

export type PushRowsResponse = z.infer<typeof PushRowsResponseSchema>;
