import { z, ZodError } from 'zod';
import {
  CanonicalResourceKey,
  CanonicalResourceMeta,
  CanonicalResourceSchemas,
} from '@pac/types';

/**
 * FieldMapper — 三入口共享的"入站闸门"
 *
 * cold-import / pull-adapter / push-receiver 共用同一套流水线:
 *
 *   host raw row
 *      ↓  ① rename:apply mapping(canonical key ← host field)
 *   renamed row
 *      ↓  ② normalize:amountUnit(yuan → cents)/ timezone(无 tz → 加 offset)
 *      ↓                 / gender(男女 → M/F)/ boolean coerce / 数组反序列化
 *   normalized row
 *      ↓  ③ zod validate:canonical resource schema
 *   canonical row(parser / synthesizer 消费)
 *      └─→ 失配 → ContractDriftError(SyncLog status=failed,drift detector 拾起)
 *
 * 参考:docs/host-integration.md §7(fieldMapping)+ §7.4(类型自动转换)+ §3.4(canonical schemas)
 */

// ─────────────────────────────────────────────────────────────
// ContractDriftError — canonical schema 校验失败 = host 字段约定漂移
// ─────────────────────────────────────────────────────────────

export class ContractDriftError extends Error {
  readonly hostId: string;
  readonly resource: string;
  readonly issues: ZodError['issues'] | undefined;
  readonly sample: unknown;

  constructor(opts: {
    hostId: string;
    resource: string;
    message: string;
    issues?: ZodError['issues'];
    sample?: unknown;
  }) {
    super(opts.message);
    this.name = 'ContractDriftError';
    this.hostId = opts.hostId;
    this.resource = opts.resource;
    this.issues = opts.issues;
    this.sample = opts.sample;
  }
}

// ─────────────────────────────────────────────────────────────
// 配置:host-level type conversion 上下文(amountUnit / timezone)
// ─────────────────────────────────────────────────────────────

export interface NormalizeContext {
  /// 宿主金额数值单位:'yuan'(元小数,自动 ×100 转分) / 'fen'(分整数,直通)
  amountUnit: 'yuan' | 'fen';
  /// IANA 时区(如 'Asia/Shanghai'),用于解析"无 tz datetime"字符串
  timezone: string;
}

// ─────────────────────────────────────────────────────────────
// Stage ①:Apply mapping(canonical ← host field)
// ─────────────────────────────────────────────────────────────

/**
 * `mapping: { canonical: hostField }` 例:`{ phone: "mobile" }` 即从 raw.mobile 读到
 * canonical.phone slot。未列在 mapping 里的 host 字段**按原名透传**(host 命名跟
 * canonical 一致时免配置)。
 */
export function applyMapping(
  raw: Record<string, unknown>,
  mapping: Record<string, string> | undefined,
): Record<string, unknown> {
  const map = mapping ?? {};
  const aliasedHostFields = new Set(Object.values(map));
  const out: Record<string, unknown> = {};
  // 1. canonical key ← raw[hostField]
  for (const [canonical, hostField] of Object.entries(map)) {
    if (hostField in raw) out[canonical] = raw[hostField];
  }
  // 2. 透传 host 自身已用 canonical 命名的字段
  for (const [k, v] of Object.entries(raw)) {
    if (!(k in out) && !aliasedHostFields.has(k)) out[k] = v;
  }
  return out;
}

// ─────────────────────────────────────────────────────────────
// Stage ②:Normalize — §7.4 五件套类型转换
// ─────────────────────────────────────────────────────────────

/**
 * 在 zod 校验前做轻量类型规范化。规则:
 *   - id 去浮点尾巴:"676600.0" → "676600"(宿主把整数 id 过了一遍 float)
 *   - boolean coerce:0/1/"y"/"n"/"true"/"false"/"是"/"否" → bool
 *   - gender:"男" → "M"、"女" → "F"(其他自由文本直通)
 *   - amount:amountUnit=yuan 时 ×100 转 cents int(已 int 不转)
 *   - timezone:datetime 字符串无 tz(无 Z/+/-)时按 timezone 配置补 offset
 *   - array 反序列化:CSV 入口的 JSON 字符串数组 → 真数组(zod 期望数组)
 */
export function normalizeCanonical(
  raw: Record<string, unknown>,
  resource: CanonicalResourceKey,
  ctx: NormalizeContext,
): Record<string, unknown> {
  const meta = CanonicalResourceMeta[resource];
  const out: Record<string, unknown> = { ...raw };

  // ─ id 去浮点尾巴 ─(必须在最前:后续所有身份解析都用归一后的值)
  for (const k of Object.keys(out)) {
    if (isIdLikeKey(k)) {
      const fixed = stripFloatTail(out[k]);
      if (fixed !== null) out[k] = fixed;
    }
  }

  // ─ boolean coerce ─
  for (const k of meta.booleanFields) {
    if (k in out) {
      const coerced = coerceBoolean(out[k]);
      if (coerced !== null) out[k] = coerced;
    }
  }

  // ─ gender ─(仅 patient 资源有,但宽松)
  if ('gender' in out && typeof out.gender === 'string') {
    const g = out.gender.trim();
    if (g === '男') out.gender = 'M';
    else if (g === '女') out.gender = 'F';
    // 其他自由文本(M/F/Male/Female/unknown)直通
  }

  // ─ amount yuan → cents ─
  if (ctx.amountUnit === 'yuan') {
    for (const k of meta.moneyFields) {
      if (k in out) out[k] = yuanToCents(out[k]);
    }
    for (const { array, field } of meta.moneyArrayFields) {
      const arr = out[array];
      // 数组本身可能是 JSON 字符串(CSV 入口)— 先反序列化
      const items = coerceArray(arr);
      if (items) {
        out[array] = items.map((item) => {
          if (item && typeof item === 'object' && field in (item as Record<string, unknown>)) {
            return {
              ...(item as Record<string, unknown>),
              [field]: yuanToCents((item as Record<string, unknown>)[field]),
            };
          }
          return item;
        });
      }
    }
  } else {
    // amountUnit=fen,仍然需要把数组从 JSON 字符串反序列化(CSV 入口)
    for (const { array } of meta.moneyArrayFields) {
      const arr = out[array];
      const items = coerceArray(arr);
      if (items) out[array] = items;
    }
  }

  // ─ 通用数组反序列化(对 clinical_encounter.diagnoses / treatments 等)─
  // 策略:任何字段值是"看起来像 JSON 数组的字符串"则尝试反序列化。
  for (const [k, v] of Object.entries(out)) {
    if (typeof v === 'string') {
      const s = v.trim();
      if (s.startsWith('[') && s.endsWith(']')) {
        try {
          const parsed = JSON.parse(s);
          if (Array.isArray(parsed)) out[k] = parsed;
        } catch {
          /* 留原字符串,zod 阶段会报 */
        }
      }
    }
  }

  // ─ timezone:无 tz datetime → 补 offset ─
  for (const k of meta.datetimeFields) {
    if (k in out && typeof out[k] === 'string') {
      out[k] = normalizeDatetime(out[k] as string, ctx.timezone);
    }
  }

  return out;
}

// ─ helpers ─

/**
 * 身份类字段判定 —— canonical 命名里 id 一律以 `Id` 结尾
 * (externalId / patientExternalId / relatedExternalId / clinicId / doctorId /
 *  appointmentExternalId / sourceEncounterExternalId / relatedDiagnosisSubjectId …)。
 * 用后缀约定而不是逐资源枚举:新增 canonical 字段自动纳入,不会漏配。
 */
export function isIdLikeKey(key: string): boolean {
  return key.length > 2 && key.endsWith('Id') && key !== 'Id';
}

/**
 * 去掉整数 id 被 float 化后的尾巴:"676600.0" → "676600"。
 *
 * 【为什么必须防】宿主导出脚本(尤其 pandas)在列含空值时会把 int64 提升成 float64,
 * 导出就变成 "676600.0"。PAC 原样收下 → 患者索引查 "676600.0" 命中不了真档 "676600"
 * → 走 ensurePatientStub 建出**无名幽灵患者**,把该批病历全吸走。
 * 2026-07-28 19:19 friday 推 med_emr_info 就踩了:3 个幽灵档吃掉 4224 条事务,
 * 其中一个带着 2246 条诊断进了召回池、算了画像、生成「应治未治」计划等客服打电话,
 * 而回执是干干净净的 failed=0 —— 只有人肉在池子里看到无名患者才会发现。
 *
 * 【口径】只处理**整数值**的浮点写法(`123.0` / `123.00`),不动 `123.5` 这种
 * 真带小数的值 —— 那说明宿主那边是另一种问题,应该让它显形而不是被悄悄改写。
 * 返回 null = 无需改写。
 */
export function stripFloatTail(v: unknown): string | null {
  // 只管字符串:JSON number 676600.0 在 JS 里就是整数 676600,String() 本来就没尾巴
  if (typeof v !== 'string') return null;
  const s = v.trim();
  // ^ 可选负号 + 至少一位数字 + 小数点 + 全 0 $
  if (!/^-?\d+\.0+$/.test(s)) return null;
  return s.slice(0, s.indexOf('.'));
}

function coerceBoolean(v: unknown): boolean | null {
  if (typeof v === 'boolean') return v;
  if (typeof v === 'number') return v !== 0;
  if (typeof v === 'string') {
    const s = v.trim().toLowerCase();
    if (['true', '1', 'y', 'yes', '是'].includes(s)) return true;
    if (['false', '0', 'n', 'no', '否', ''].includes(s)) return false;
  }
  return null;
}

function yuanToCents(v: unknown): unknown {
  if (typeof v === 'number') return Math.round(v * 100);
  if (typeof v === 'string') {
    const n = Number(v);
    if (Number.isFinite(n)) return Math.round(n * 100);
  }
  return v; // 留原值,zod 会报 expected int
}

function coerceArray(v: unknown): unknown[] | null {
  if (Array.isArray(v)) return v;
  if (typeof v === 'string') {
    const s = v.trim();
    if (s.startsWith('[') && s.endsWith(']')) {
      try {
        const parsed = JSON.parse(s);
        if (Array.isArray(parsed)) return parsed;
      } catch {
        return null;
      }
    }
  }
  return null;
}

/**
 * 把 datetime 字符串规范成 ISO 8601 带 offset 形态:
 *   - 已带 tz('Z' / '+08:00' / '-05:00')→ 直通
 *   - 仅日期 'YYYY-MM-DD' → **补 offset 到当地零点**('2026-05-10T00:00:00+08:00')
 *   - 无 tz('2026-05-10 14:00:00' / '2026-05-10T14:00:00')→ 按 timezone 配置补 offset
 *
 * ⚠️ 【为什么仅日期也要补 offset】2026-08-01 实测发现的既有 bug:
 *   旧版对纯日期直通,下游 `new Date('2026-05-10')` 按 **ISO 规范解析成 UTC 零点** ——
 *   而宿主给的日期是**当地日期**(DW 是 Asia/Shanghai),于是整整偏 8 小时:
 *     DW created_date = 2022-09-30(北京)→ 存成 2022-09-30T00:00:00Z = 北京 09-30 **08:00**
 *     正确应为                              2022-09-29T16:00:00Z = 北京 09-30 **00:00**
 *   测试服实测:diagnosis_record 有 106,860 条 occurred_at 落在 UTC 零点(6%),
 *   正是这条路径来的;落在 UTC 16:00(正确形态)的只有 5 条。
 *
 * ⚠️ 【为什么不怕"补 offset 导致跨日"】只有目标是 **@db.Date**(纯日期列)的字段才有这风险
 *   (北京零点 → UTC 前一天 16:00 → PG 取 UTC 日期会退一天)。而本函数只作用于
 *   `CanonicalResourceMeta.datetimeFields` 声明的字段,那些目标**全是 timestamptz**;
 *   真正的纯日期列(patient_return_visit.taskDate / patient.birthDate)刻意不在该清单里,
 *   走各自的 `new Date(...)` 直解,行为不变。新增字段时按此规则归类。
 */
export function normalizeDatetime(s: string, timezone: string): string {
  const trimmed = s.trim();
  // 已有 tz
  if (/[Zz]$/.test(trimmed) || /[+\-]\d{2}:?\d{2}$/.test(trimmed)) {
    return trimmed.replace(' ', 'T');
  }
  // 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
  if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
    return `${trimmed}T00:00:00${timezoneToOffsetSuffix(timezone)}`;
  }
  // 看起来像 datetime 无 tz
  if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(trimmed)) {
    const offset = timezoneToOffsetSuffix(timezone);
    return trimmed.replace(' ', 'T') + offset;
  }
  return trimmed;
}

/**
 * IANA 时区 → 当前时刻的 UTC offset suffix(如 '+08:00')。
 * 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
 * v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
 */
export function timezoneToOffsetSuffix(tz: string): string {
  const fixed: Record<string, string> = {
    'Asia/Shanghai': '+08:00',
    'Asia/Hong_Kong': '+08:00',
    'Asia/Taipei': '+08:00',
    'Asia/Singapore': '+08:00',
    'Asia/Tokyo': '+09:00',
    'Asia/Seoul': '+09:00',
    UTC: 'Z',
    'Etc/UTC': 'Z',
  };
  return fixed[tz] ?? '+00:00';
}

// ─────────────────────────────────────────────────────────────
// Stage ③:zod 校验 + 漂移抛错
// ─────────────────────────────────────────────────────────────

export function validateCanonical<T>(opts: {
  raw: unknown;
  schema: z.ZodType<T>;
  hostId: string;
  resource: string;
}): T {
  try {
    return opts.schema.parse(opts.raw);
  } catch (err) {
    if (err instanceof ZodError) {
      const issuesSummary = err.issues
        .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
        .join('; ');
      throw new ContractDriftError({
        hostId: opts.hostId,
        resource: opts.resource,
        message: `Field mapping mismatch on ${opts.resource}: ${issuesSummary}`,
        issues: err.issues,
        sample: opts.raw,
      });
    }
    throw err;
  }
}

// ─────────────────────────────────────────────────────────────
// 综合入口:rename → normalize → validate
// ─────────────────────────────────────────────────────────────

/**
 * 三入口共用:把单条 host raw row 跑完整闸门,返回 canonical row。
 *
 * @throws ContractDriftError on zod 失败(host 字段约定漂移)
 */
export function mapNormalizeValidate<T>(opts: {
  raw: unknown;
  resource: CanonicalResourceKey;
  mapping: Record<string, string> | undefined;
  normalize: NormalizeContext;
  hostId: string;
  /// 可选:覆盖默认 canonical schema(测试用)
  schema?: z.ZodType<T>;
}): T {
  if (typeof opts.raw !== 'object' || opts.raw === null) {
    throw new ContractDriftError({
      hostId: opts.hostId,
      resource: opts.resource,
      message: `Expected an object for ${opts.resource}, got ${typeof opts.raw}`,
      sample: opts.raw,
    });
  }

  const renamed = applyMapping(opts.raw as Record<string, unknown>, opts.mapping);
  const normalized = normalizeCanonical(renamed, opts.resource, opts.normalize);
  const schema =
    (opts.schema ?? (CanonicalResourceSchemas[opts.resource] as unknown as z.ZodType<T>)) ;
  return validateCanonical({
    raw: normalized,
    schema,
    hostId: opts.hostId,
    resource: opts.resource,
  });
}

/**
 * Canary:跑首条 raw row 校验 — 失败 → 整批 abort,SyncLog status=failed。
 * 让 contract drift 早发现,不要静默写半库脏数据。
 */
export function canaryValidate(opts: {
  items: unknown[];
  resource: CanonicalResourceKey;
  mapping: Record<string, string> | undefined;
  normalize: NormalizeContext;
  hostId: string;
}): void {
  if (opts.items.length === 0) return;
  mapNormalizeValidate({
    raw: opts.items[0],
    resource: opts.resource,
    mapping: opts.mapping,
    normalize: opts.normalize,
    hostId: opts.hostId,
  });
}
