/**
 * CI 防漂移测试 — canonical-fact-layer §二 闸 4
 *
 * 防漂移目标:阻止以下错误悄悄进 main:
 *   1. 加新 FactType 枚举值但忘了写 FactContentSchemas zod
 *   2. yaml enum_mapping 把 host 码翻译成不在 PAC canonical-codes 闭集的码
 *   3. yaml emits.action / subjectType 写错(不在 Action / SubjectType enum 里)
 *   4. canonical-codes 字典内部不自洽
 *
 * 跑:
 *   pnpm test -- canonical-fact-layer
 */
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import {
  FactType,
  ActionSchema,
  SubjectTypeSchema,
  PACDiagnosisCodes,
  PACTreatmentCategories,
  PACTreatmentStatuses,
  PACImageModalities,
  FDI_TOOTH_POSITIONS,
} from '@pac/types';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import {
  AssemblerConfigSchema,
  type AssemblerConfig,
} from '../src/modules/sync/assembler/assembler.schema';
import { resolveDictIncludes } from '../src/modules/sync/assembler/dict-include';

// ─────────────────────────────────────────────
// 闸 1:FactType ↔ FactContentSchemas 完整性
// ─────────────────────────────────────────────

describe('canonical-fact-layer | 闸 1:FactType 完整性', () => {
  test('每个 FactType 枚举值都有对应的 FactContentSchemas zod', () => {
    const missing: string[] = [];
    for (const ft of Object.values(FactType)) {
      if (!(ft in FactContentSchemas)) missing.push(ft);
    }
    expect(missing).toEqual([]);
  });

  test('FactContentSchemas 没有多余的 schema 漂浮(必须对应 FactType 枚举值)', () => {
    const factTypeSet = new Set<string>(Object.values(FactType));
    const stray: string[] = [];
    for (const k of Object.keys(FactContentSchemas)) {
      if (!factTypeSet.has(k)) stray.push(k);
    }
    expect(stray).toEqual([]);
  });
});

// ─────────────────────────────────────────────
// 闸 2:canonical-codes 字典自洽
// ─────────────────────────────────────────────

describe('canonical-fact-layer | 闸 2:canonical-codes 字典自洽', () => {
  test('PACDiagnosisCodes 非空且每条都有 nameZh', () => {
    const codes = Object.entries(PACDiagnosisCodes);
    expect(codes.length).toBeGreaterThan(15); // K00-K14 + 业务码 + 推荐码
    for (const [code, def] of codes) {
      expect(def.nameZh).toBeTruthy();
      expect(code).toMatch(/^[A-Z][A-Z0-9_]*$/); // K02 / IMPLANT_RECOMMENDED 等
    }
  });

  test('PACTreatmentCategories 至少 8 大类', () => {
    expect(Object.keys(PACTreatmentCategories).length).toBeGreaterThanOrEqual(8);
  });

  test('PACTreatmentStatuses 覆盖 planned/in_progress/completed/cancelled/failed', () => {
    const statuses = Object.keys(PACTreatmentStatuses);
    for (const s of ['planned', 'in_progress', 'completed', 'cancelled', 'failed']) {
      expect(statuses).toContain(s);
    }
  });

  test('FDI 牙位 52 个齐 (32 恒牙 + 20 乳牙) 且不重复', () => {
    expect(FDI_TOOTH_POSITIONS.length).toBe(52);
    expect(new Set(FDI_TOOTH_POSITIONS).size).toBe(52);
  });

  test('PACImageModalities 至少含 cbct + pano + pa 核心几种', () => {
    const modalities = Object.keys(PACImageModalities);
    for (const m of ['cbct', 'pano', 'pa']) {
      expect(modalities).toContain(m);
    }
  });
});

// ─────────────────────────────────────────────
// 闸 3 + 4:扫所有 data/<host>/assemblers/*.yaml,验证 emits + enum_mapping
// ─────────────────────────────────────────────

interface YamlFileInfo {
  host: string;
  file: string;
  config: AssemblerConfig;
}

function loadAllAssemblerYamls(): YamlFileInfo[] {
  const dataDir = path.join(__dirname, '..', 'data');
  if (!fs.existsSync(dataDir)) return [];
  const out: YamlFileInfo[] = [];
  for (const host of fs.readdirSync(dataDir)) {
    const asmDir = path.join(dataDir, host, 'assemblers');
    if (!fs.existsSync(asmDir)) continue;
    for (const f of fs.readdirSync(asmDir)) {
      if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue;
      const yamlPath = path.join(asmDir, f);
      const raw = fs.readFileSync(yamlPath, 'utf-8');
      // 走生产同款加载:dict_includes(共享字典分层)在 zod 校验前解析合并
      const parsed = resolveDictIncludes(yaml.load(raw), yamlPath);
      const r = AssemblerConfigSchema.safeParse(parsed);
      if (!r.success) {
        throw new Error(
          `yaml ${host}/${f} 不符合 AssemblerConfigSchema:\n` +
            JSON.stringify(r.error.issues, null, 2),
        );
      }
      out.push({ host, file: f, config: r.data });
    }
  }
  return out;
}

describe('canonical-fact-layer | 闸 3:yaml emits 段合法', () => {
  const yamls = loadAllAssemblerYamls();
  // patient assembler 走 upsert 路径无 emits;其他必须有 emits
  const subjectYamls = yamls.filter((y) => y.config.canonical !== 'patient');

  test('扫到至少 1 份 yaml(否则 data/<host>/assemblers 没东西)', () => {
    expect(yamls.length).toBeGreaterThan(0);
  });

  if (subjectYamls.length > 0) {
    test.each(subjectYamls)(
      '$host/$file:emits 段存在且 action/subjectType 在 PAC enum 闭集',
      ({ config }) => {
        expect(config.emits).toBeDefined();
        const actionOk = ActionSchema.safeParse(config.emits!.action);
        expect(actionOk.success).toBe(true);
        const subjectOk = SubjectTypeSchema.safeParse(config.emits!.subjectType);
        expect(subjectOk.success).toBe(true);
      },
    );
  }
});

describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-codes', () => {
  const yamls = loadAllAssemblerYamls();

  // 字段名 → 闭集成员集合(应用层翻译目标)
  const closedSets: Record<string, Set<string>> = {
    code: new Set(Object.keys(PACDiagnosisCodes)),
    category: new Set(Object.keys(PACTreatmentCategories)),
    status: new Set(Object.keys(PACTreatmentStatuses)),
    modality: new Set(Object.keys(PACImageModalities)),
    // appointment.status 是 host-side enum(scheduled/arrived/cancelled/no_show),不在 PAC canonical-codes
    // 不强约束 — 校验跳过该字段
  };
  // 8 态细分(跟 fact-content-schemas AppointmentRecordContent.status 对齐)
  const appointmentStatusEnum = new Set([
    'scheduled',
    'rescheduled',
    'cancelled',
    'arrived',
    'in_treatment',
    'completed',
    'no_show',
    'walk_in',
  ]);

  for (const { host, file, config } of yamls) {
    const mappings = config.enum_mapping ?? {};
    for (const [canonicalField, valueMap] of Object.entries(mappings)) {
      // appointment.status 特例:用 host-side enum
      if (canonicalField === 'status' && config.canonical === 'appointment') {
        test(`${host}/${file} appointment.status enum_mapping 目标在 [scheduled/arrived/cancelled/no_show]`, () => {
          for (const target of Object.values(valueMap)) {
            expect(appointmentStatusEnum).toContain(target);
          }
        });
        continue;
      }
      const allowed = closedSets[canonicalField];
      if (!allowed) continue; // 不在我们校验范围内的字段(host-specific transform 等)
      test(`${host}/${file}:enum_mapping.${canonicalField} 目标 ∈ canonical-codes`, () => {
        const bad: string[] = [];
        for (const [hostKey, pacKey] of Object.entries(valueMap)) {
          // _default 是 AssemblerEngine 兜底键,允许任意值(含 '' 让 parser 跳过长尾)
          if (hostKey === '_default') continue;
          // 空字符串 '' 也是"sentinel for skip" — 用于显式标注"这个 host 值是 noise,跳过 fact"
          if (pacKey === '') continue;
          if (!allowed.has(pacKey)) bad.push(`${hostKey} → ${pacKey}(不在闭集)`);
        }
        expect(bad).toEqual([]);
      });
    }
  }
});

// ─────────────────────────────────────────────
// 闸 5:Action / SubjectType / FactType 三联枚举一致性
// ─────────────────────────────────────────────

describe('canonical-fact-layer | 闸 5:三联枚举无遗漏', () => {
  test('每个 Action 字符串都是合法的 ActionSchema 值', () => {
    const { Action } = require('@pac/types');
    for (const v of Object.values(Action) as string[]) {
      expect(ActionSchema.safeParse(v).success).toBe(true);
    }
  });

  test('每个 SubjectType 字符串都是合法的 SubjectTypeSchema 值', () => {
    const { SubjectType } = require('@pac/types');
    for (const v of Object.values(SubjectType) as string[]) {
      expect(SubjectTypeSchema.safeParse(v).success).toBe(true);
    }
  });
});
