/**
 * Transform Pipeline Schema(Layer A.5)
 *
 * 定位:在 sources(SQL/CSV)和 AssemblerEngine 之间插一层 yaml 声明式形态改造。
 *
 * 纪律(canonical-fact-layer.md 原则 1):
 *   - 5 个 operator 白名单,**不允许任意 JS 表达式 / eval / 字符串模板填代码**
 *   - 声明式、无副作用、纯函数变换
 *   - 撞墙必须加新 operator(走 PR + review),不允许 hack
 *
 * 6 operator(对应 SQL 干过的事):
 *   ① project           — 选列 / 改名(等价 SELECT col1, col2 [AS alias])
 *   ② split_json_array  — JSON 数组字段拆行(等价 ARRAY JOIN + JSONExtract)
 *   ③ derive            — 字段派生(substring / concat / lower / upper / default)
 *   ④ route_by_pattern  — 按字段值分流到多个 output 表
 *   ⑤ pick_first_nonzero — 多个数值列挑第一个非零对应 label(等价 multiIf pay_* > 0)
 *   ⑥ filter            — 多字段行过滤(等价 SQL WHERE 行级过滤)
 *
 * 数据流:
 *   sources (raw tables) ──→ transforms[] ──→ tables dictionary ──→ AssemblerEngine
 *
 * 双源等价:transforms 输入是 row 数组,跟 SQL 直连或 CSV 文件无关。
 */
import { z } from 'zod';

// ─────────────────────────────────────────────────────────
// 通用类型
// ─────────────────────────────────────────────────────────

const tableNameSchema = z
  .string()
  .min(1)
  .describe('上下游表名(可以引用 source table 或前置 transform output)');

// ─────────────────────────────────────────────────────────
// ① project — 选列 / 改名
// ─────────────────────────────────────────────────────────

/**
 * 等价 SQL `SELECT col1, col2 AS alias FROM input`。
 * - keep:保留哪些列(白名单)。不写 = 全保留。
 * - rename:列改名(source → target);跟 keep 联动 — keep 数组写 source 名,rename 在 keep 通过后做改名。
 */
export const ProjectOpSchema = z.object({
  kind: z.literal('project'),
  input: tableNameSchema,
  output: tableNameSchema,
  keep: z.array(z.string().min(1)).optional(),
  rename: z.record(z.string(), z.string()).optional(),
});
export type ProjectOp = z.infer<typeof ProjectOpSchema>;

// ─────────────────────────────────────────────────────────
// ② split_json_array — JSON 数组字段拆行
// ─────────────────────────────────────────────────────────

/**
 * 把主表某 JSON 数组字段拆成 N 行子记录。等价 ClickHouse ARRAY JOIN + JSONExtract。
 *
 * 输入:1 行 = 主表行,含一个 JSON 数组字段(stringified 或已 parse 的数组)
 * 输出:N 行 = 数组每元素一行,继承 parent_keys 字段,加上 element_fields 提取的字段
 *
 * parent_keys 形态:`{ outputName: parentField }` 例:`{ emr_id: id }` 表示
 *   父行 `id` 字段值在拆出来的子行里命名为 `emr_id`(防跟元素 id 字段冲突)
 *
 * element_fields 形态同上:`{ outputName: jsonPath }`,jsonPath 当前只支持单层(对象属性名)
 *
 * where 形态:对**拆出来的子行**做过滤,key=子行字段名,value=过滤规则。
 *   - `not_empty: true` — 字符串非空 / null 过滤
 *   - `in: [...]` — 值在列表中
 *   - `equals: x` — 值等于 x
 *   - `lt/lte/gt/gte: n` — 数值比较(字段解析为数字;非数值/空 → 不通过)。
 *     让退费正负切分等"业务口径 WHERE"能在 PAC 侧做(宿主导原表,PAC transforms 过滤)。
 */
export const WhereRuleSchema = z.union([
  z.object({ not_empty: z.literal(true) }),
  z.object({ empty: z.literal(true) }), // 空:null/""/"[]"(空 JSON 数组也算空)— 用于 gate "无结构化字段"
  // blank_or_all_in:JSON 数组字段"空 或 每个元素 treatName ∈ 列表"。用于 dispose gate:
  //   treat_plan 空 / 全是复查类(无真治疗)→ 才去 dispose 抽治疗(陈昱天:treat_plan=["常规复查"])。
  //   列表 = C.3 路由的 review 词表(YAML anchor 复用,单一真理源)。
  z.object({ blank_or_all_in: z.array(z.union([z.string(), z.number()])).min(1) }),
  // real_no_tooth:JSON 数组"有真治疗(treatName 非空且 ∉ 列表)但全无牙位"。dispose gate path 2:
  //   treat_plan 有治疗名却漏填牙位(刘小源"充填"无牙位)→ 去 dispose 找回牙位。列表 = review 词表。
  z.object({ real_no_tooth: z.array(z.union([z.string(), z.number()])).min(1) }),
  z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
  z.object({ equals: z.union([z.string(), z.number()]) }),
  // 数值比较:字段解析为数字后比较;非数值/空值一律不通过(退费 receivable_this < 0 等)。
  z.object({ lt: z.number() }),
  z.object({ lte: z.number() }),
  z.object({ gt: z.number() }),
  z.object({ gte: z.number() }),
]);
export type WhereRule = z.infer<typeof WhereRuleSchema>;

export const SplitJsonArrayOpSchema = z.object({
  kind: z.literal('split_json_array'),
  input: tableNameSchema,
  output: tableNameSchema,
  array_field: z.string().min(1).describe('主表里的 JSON 数组字段名'),
  parent_keys: z
    .record(z.string(), z.string())
    .default({})
    .describe('父表字段继承,outputName: parentField'),
  element_fields: z
    .record(z.string(), z.string())
    .describe('数组元素字段提取,outputName: jsonPath(单层属性名)'),
  where: z.record(z.string(), WhereRuleSchema).optional(),
});
export type SplitJsonArrayOp = z.infer<typeof SplitJsonArrayOpSchema>;

// ─────────────────────────────────────────────────────────
// ③ derive — 字段派生(限定纯函数 op)
// ─────────────────────────────────────────────────────────

/**
 * 给行加新字段(in-place,output 可以同 input)。
 *
 * 8 个内置 op:
 *   - substring:  { op: substring, from: field, start: 0, end: 3 }
 *   - concat:     { op: concat, parts: ["${field}" | "literal", ...] }
 *   - lower:      { op: lower, from: field }
 *   - upper:      { op: upper, from: field }
 *   - default:    { op: default, from: field, value: "fallback" } — 原值空时填 value
 *   - trim:       { op: trim, from: field } — 去首尾空白 + 尾部标点(。;,?!、\r\n),脏字段归一
 *   - normalize:  { op: normalize, from: field } — trim 升级版 + CJK→ASCII 中段标点
 *                  (中括号()/中逗号,/中分号;/中尖括号 < >)统一 ASCII,中文宿主 enum 匹配必备
 *   - coalesce:   { op: coalesce, from: [f1, f2, ...] } — 取第一个非空字段值(诊断码兜底:stdCode→message)
 *
 * concat parts 里 `${fieldName}` 是字段引用,其他视为字面量。
 * 限定不允许任意表达式(canonical-fact-layer.md 原则 1)。
 */
export const DeriveSubstringSchema = z.object({
  op: z.literal('substring'),
  from: z.string().min(1),
  start: z.number().int().nonnegative().default(0),
  end: z.number().int().optional(),
});

export const DeriveConcatSchema = z.object({
  op: z.literal('concat'),
  parts: z.array(z.string()).min(1).describe('字符串或 ${field} 引用'),
});

export const DeriveLowerSchema = z.object({
  op: z.literal('lower'),
  from: z.string().min(1),
});

export const DeriveUpperSchema = z.object({
  op: z.literal('upper'),
  from: z.string().min(1),
});

export const DeriveDefaultSchema = z.object({
  op: z.literal('default'),
  from: z.string().min(1),
  value: z.union([z.string(), z.number(), z.boolean()]),
});

export const DeriveTrimSchema = z.object({
  op: z.literal('trim'),
  from: z.string().min(1),
});

/// W4 末加:CJK→ASCII 标点 + trim 一站式归一(给中文宿主 enum_mapping 字符串匹配用)
/// trim 是子集(只去首尾噪音);normalize 还做"中文括号/逗号/分号 → ASCII"中段替换
/// 适用任何中文宿主的脏字段归一(不是 jvs-dw 特化),所以是通用 op
export const DeriveNormalizeSchema = z.object({
  op: z.literal('normalize'),
  from: z.string().min(1),
});

export const DeriveCoalesceSchema = z.object({
  op: z.literal('coalesce'),
  from: z.array(z.string().min(1)).min(1).describe('按顺序取第一个非空字段值'),
});

/// recode — 按覆盖表把字段值替换成另一个值(命中 map → 用 map 值;否则原样透传)。
/// 用途:宿主 ICD 字典怪码纠偏。例 K07.303 在中文临床版=牙体缺损(WHO K07=错颌畸形),
///   { from: std_code, map: { 'K07.303': 'K03' } } → K07.303 改 K03,再交 substring 截短;其余码不变。
export const DeriveRecodeSchema = z.object({
  op: z.literal('recode'),
  from: z.string().min(1),
  map: z.record(z.string(), z.string()).describe('原值 → 目标值;未命中则透传原值'),
});

export const DeriveExprSchema = z.discriminatedUnion('op', [
  DeriveSubstringSchema,
  DeriveConcatSchema,
  DeriveLowerSchema,
  DeriveUpperSchema,
  DeriveDefaultSchema,
  DeriveTrimSchema,
  DeriveNormalizeSchema,
  DeriveCoalesceSchema,
  DeriveRecodeSchema,
]);
export type DeriveExpr = z.infer<typeof DeriveExprSchema>;

export const DeriveOpSchema = z.object({
  kind: z.literal('derive'),
  input: tableNameSchema,
  output: tableNameSchema,
  fields: z.record(z.string(), DeriveExprSchema),
});
export type DeriveOp = z.infer<typeof DeriveOpSchema>;

// ─────────────────────────────────────────────────────────
// ④ route_by_pattern — 按字段值分流到多 output 表
// ─────────────────────────────────────────────────────────

/**
 * 一份 input → N 份 output(按字段值规则分流)。
 *
 * routes:
 *   - 顺序匹配,第一个命中规则入对应 output 表
 *   - 都没命中:看是否有 `default: true` 的兜底分支;没有 → 丢弃 + warn 日志
 *
 * 规则:
 *   - starts_with: [...]   字段值前缀匹配任一
 *   - equals: [...]        字段值等于任一
 *   - contains: [...]      字段值包含任一子串
 *   - in: [...]            等同 equals(命名习惯)
 *   - default: true        兜底
 */
export const RouteRuleSchema = z.union([
  z.object({ starts_with: z.array(z.string()).min(1) }),
  z.object({ equals: z.array(z.string()).min(1) }),
  z.object({ contains: z.array(z.string()).min(1) }),
  z.object({ in: z.array(z.string()).min(1) }),
  z.object({ default: z.literal(true) }),
]);
export type RouteRule = z.infer<typeof RouteRuleSchema>;

export const RouteByPatternOpSchema = z.object({
  kind: z.literal('route_by_pattern'),
  input: tableNameSchema,
  field: z.string().min(1).describe('看哪个字段决定路由'),
  routes: z
    .array(
      z.object({
        output: tableNameSchema,
        when: RouteRuleSchema,
      }),
    )
    .min(1),
});
export type RouteByPatternOp = z.infer<typeof RouteByPatternOpSchema>;

// ─────────────────────────────────────────────────────────
// ⑤ pick_first_nonzero — 多列推断单列
// ─────────────────────────────────────────────────────────

/**
 * 例:fact_settlement_mode_out 有 17 个 pay_* 列(pay_wechat / pay_alipay / ...),
 * 按候选顺序找第一个 > 0 的,把对应 label 写到 output_field。
 *
 * 输入 row 不变,加新字段 output_field;没命中任何候选 → output_field = default。
 */
export const PickFirstNonzeroOpSchema = z.object({
  kind: z.literal('pick_first_nonzero'),
  input: tableNameSchema,
  output: tableNameSchema,
  output_field: z.string().min(1),
  default: z.string().default('other'),
  candidates: z
    .array(
      z.object({
        column: z.string().min(1).describe('数值列名'),
        value: z.string().min(1).describe('命中时填入 output_field 的 label'),
      }),
    )
    .min(1),
});
export type PickFirstNonzeroOp = z.infer<typeof PickFirstNonzeroOpSchema>;

// ─────────────────────────────────────────────────────────
// ⑥ filter — 行过滤(等价 SQL WHERE 行级过滤)
// ─────────────────────────────────────────────────────────

/**
 * 按 where 规则过滤行。where 是 AND 关系(所有规则必须通过),复用 WhereRuleSchema。
 *
 * 例(filter encounter 行,要求 in_time 非空):
 *   - kind: filter
 *     input: appointment_encounter_rows
 *     output: appointment_encounter_rows
 *     where:
 *       in_time: { not_empty: true }
 */
export const FilterOpSchema = z.object({
  kind: z.literal('filter'),
  input: tableNameSchema,
  output: tableNameSchema,
  where: z.record(z.string(), WhereRuleSchema),
});
export type FilterOp = z.infer<typeof FilterOpSchema>;

/**
 * union — 把多个同结构表合并成一个(行级 concat)。
 * 场景:treat_plan 与 plan 两源各自 route 出的"建议"行,合并进同一 recommendation 表。
 * inputs 缺失的表当空表;output 可与某 input 同名(in-place 合并)。
 */
export const UnionOpSchema = z.object({
  kind: z.literal('union'),
  inputs: z.array(tableNameSchema).min(1),
  output: tableNameSchema,
});
export type UnionOp = z.infer<typeof UnionOpSchema>;

// ─────────────────────────────────────────────────────────
// ⑧ lookup — 按外键 join 子表,挑一条,把字段附加到主表(1:N → 标量)
// ─────────────────────────────────────────────────────────

/// lookup 的候选行预过滤规则(标量子集,不含 filter 的 JSON 数组规则)。
const LookupWhereRuleSchema = z.union([
  z.object({ not_empty: z.literal(true) }),
  z.object({ empty: z.literal(true) }),
  z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
  z.object({ equals: z.union([z.string(), z.number()]) }),
]);
export type LookupWhereRule = z.infer<typeof LookupWhereRuleSchema>;

/**
 * push 通道的 `from` 表回落 —— 从 PAC 已入库的实体重建候选行。
 *
 * 【为什么需要】push 是**单表**请求:ingestRawTables 只把被推那张表放进 tables,
 *   lookup 的 `from` 表根本不在场,于是全部未命中、select 字段填 null。
 *   而接入文档 channel-push.mdx §7.3 明写「推送顺序无要求」—— 不回落就等于偷偷要求
 *   宿主先推 from 表,违背自己的契约。
 *   2026-07-28 FRIDAY 实测踩到:customer_referee_circle 拿不到对方性别,关系码 3
 *   (本人是对方子女)无法拆成 father/mother,257 条关系边里 father/mother **全为 0**
 *   (同口径的 jvs-dw 走 cold-import,father+mother 占 23%),连带 family_structure
 *   的「多代之家」系统性漏判。
 *
 * 【语义】仅在 `tables[from]` 缺席或为空时生效(cold-import / reparse 表本来就在,不受影响)。
 *   按 input 行的 left_key 去 PAC 库捞实体,拼成"长得像 from 表"的合成行:
 *   `{ [right_key]: 实体的 external_id, ...select 映射出的列 }`。
 *
 * 【source_unit 消歧】external_id 在集团型宿主里跨品牌会撞号(FRIDAY 22 个 source_unit,
 *   实测 310 个 id 撞号)。捞取按 input 行解析出的 source_unit 圈定;若同一 external_id
 *   仍落到多个品牌 → **整条跳过**(填 null 走 _default),不硬猜。
 *
 * 【map】把 PAC 归一值反写回宿主原编码(如 male→'1'),保证 push 与 cold-import
 *   产出**同一个**下游键值 —— 否则两条通道的 enum_mapping 得各配一套,迟早漂移。
 */
export const LookupPushFallbackSchema = z.object({
  entity: z.literal('patients').describe('回落取数的 PAC 实体(目前只支持 patients)'),
  select: z
    .record(
      z.string(),
      z.object({
        column: z
          .enum(['gender', 'birthDate', 'name', 'medicalRecordNumber'])
          .describe('PAC 实体列(白名单;需要别的列时在此显式加,不做动态列名)'),
        map: z
          .record(z.string(), z.string())
          .optional()
          .describe('PAC 归一值 → 宿主原编码;未命中的值当作取不到(填 null)'),
      }),
    )
    .describe('合成行列名 ← PAC 实体列(与 lookup.select 的来源侧对齐)'),
});
export type LookupPushFallback = z.infer<typeof LookupPushFallbackSchema>;

/**
 * 把 `from` 子表按外键 join 到 `input` 主表,多条候选时按 order_by 挑第一条,
 * 把 select 里的字段附加到主表行(等价 LEFT JOIN + 取一条 + 选列)。
 *
 * 用途:1:N 子表归约成主表**标量**字段 —— join_arrays 只能产数组、transforms 其余
 *   operator 全是单表内变换,都填不了这个空。典型:患者主档(customer_basic_info)从
 *   联系方式子表(customer_contacts)取"默认电话"填 patient.phone。
 *
 * 语义:
 *   - 输出行数 = input 行数(左连接:未命中 → select 目标字段填 null,保证列存在)
 *   - order_by 多键稳定排序,第一条胜出;不配 → 取该外键下"首个遇到"的候选
 *     例:[{field: is_default, dir: desc}, {field: contacts_type, dir: asc}, {field: id, dir: asc}]
 *         → 优先默认号,再优先本人(type=1<2),再取最早 id
 *   - where(可选)对候选行预过滤(AND;如 contacts_tel not_empty)
 *   - select:`目标字段(主表列名) ← 来源字段(from 列名)`,与 field_mapping 同向
 *   - __source_row 保留主表的(不合并 from 的),合规留底不变
 */
export const LookupOpSchema = z.object({
  kind: z.literal('lookup'),
  input: tableNameSchema,
  output: tableNameSchema,
  from: tableNameSchema.describe('被 join 的子表(需在 tables 或前置 transform 产出里)'),
  left_key: z.string().min(1).describe('input 行的关联键字段'),
  right_key: z.string().min(1).describe('from 行里指向 input 的外键字段'),
  where: z.record(z.string(), LookupWhereRuleSchema).optional().describe('对候选行预过滤(AND)'),
  order_by: z
    .array(
      z.object({
        field: z.string().min(1),
        dir: z.enum(['asc', 'desc']).default('asc'),
      }),
    )
    .optional()
    .describe('多候选排序,第一条胜出;不配 = 首个遇到'),
  select: z
    .record(z.string(), z.string())
    .describe('附加字段:目标字段(主表列名) ← 来源字段(from 列名)'),
  push_fallback: LookupPushFallbackSchema.optional().describe(
    'push 单表时 from 表缺席 → 从 PAC 库重建候选行(不配 = 保持旧行为:全部未命中填 null)',
  ),
});
export type LookupOp = z.infer<typeof LookupOpSchema>;

// ─────────────────────────────────────────────────────────
// operator union + transforms[] 顶层
// ─────────────────────────────────────────────────────────

export const TransformOpSchema = z.discriminatedUnion('kind', [
  ProjectOpSchema,
  SplitJsonArrayOpSchema,
  DeriveOpSchema,
  RouteByPatternOpSchema,
  PickFirstNonzeroOpSchema,
  FilterOpSchema,
  UnionOpSchema,
  LookupOpSchema,
]);
export type TransformOp = z.infer<typeof TransformOpSchema>;

export const TransformsSchema = z.array(TransformOpSchema);
export type TransformsConfig = z.infer<typeof TransformsSchema>;
