import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
  AlertRule,
  AlertRuleSetting,
  COMMON_SETTINGS,
  DAILY_SCHEDULE_GRACE,
  DAILY_SCHEDULE_NAMES,
  DAY,
  DEFAULT_SCHEDULE_EXPECT,
  SCHEDULE_EXPECT_DESC,
  SETTING_KEY,
} from './alert.entity';
import { ALERT_RULES } from './alert.module';
import { TEST_BANNER } from './alert.service';
import {
  DerivationSignature,
  TREAT_VENDOR_RULE_ID,
} from './rules/treat-vendor.rule';

/**
 * 설정 메타와 테스트 문면의 **계약**.
 *
 * 이 스펙이 지키는 것은 값이 아니라 축이다 — 화면이 단위·설명·키 목록을 서버 메타에서
 * 파생하려면 그 메타가 실제로 있어야 하고, 그 존재는 규칙이 늘 때마다 사람이 기억해서가 아니라
 * 검사로 유지돼야 한다. 그래서 **레지스트리를 전수**한다: 규칙 목록은 `alert.module.ts` 의
 * `ALERT_RULES` 하나에서 오고 스펙은 그것을 따로 적지 않는다(표본이 아니라 범위).
 *
 * 규칙 인스턴스는 의존 없이 만든다. `settings` 와 `sampleText` 는 생성자 인자를 건드리지 않는
 * 인스턴스 필드라 이 방식으로 정확히 같은 값을 본다.
 */
const rules: AlertRule[] = ALERT_RULES.map(
  (Cls) => new (Cls as unknown as new () => AlertRule)(),
);

/** 공용 노브 + 전 규칙 설정. 화면 폼과 같은 소스다. */
const catalog: AlertRuleSetting[] = [
  ...COMMON_SETTINGS,
  ...rules.flatMap((r) => [...r.settings]),
];

const VALID_SHAPES = ['int', 'ms', 'ratio', 'string[]', 'object'];
const VALID_FIELD_SHAPES = ['int', 'ratio'];

describe('알림 설정 메타 카탈로그', () => {
  it('레지스트리 전수 전제: 규칙과 설정이 0건이 아니다', () => {
    //* 아래 계약들은 전부 "각 항목이" 형태라 카탈로그가 비면 통째로 공허하게 통과한다.
    //*  그 공허를 여기서 먼저 막는다.
    expect(rules.length).toBeGreaterThan(0);
    expect(catalog.length).toBeGreaterThan(0);
    expect(rules.length).toBe(ALERT_RULES.length);
  });

  /**
   * **양방향으로 본다.** `_ms` 인데 `unit` 이 없는 것만 잡으면, 단위를 지우는 대신 키 이름을
   * 바꾸는 우회가 남는다. 반대로 `unit: 'ms'` 인데 키가 `_ms` 가 아닌 것도 잡아야 저장 단위와
   * 이름이 갈리지 않는다.
   */
  it('설정 메타 계약: 시간 키는 unit ms 를 갖는다', () => {
    const byName = catalog.filter((s) => s.key.endsWith('_ms')).map((s) => s.key).sort();
    const byUnit = catalog.filter((s) => s.unit === 'ms').map((s) => s.key).sort();

    expect(byName.length).toBeGreaterThan(0);
    expect(byUnit).toEqual(byName);
    for (const s of catalog.filter((x) => x.unit === 'ms')) {
      //* 라벨에 단위를 다시 적으면 화면이 `초` 로 낸 옆에 `(ms)` 가 남아 서로 어긋난다.
      expect(s.label).not.toContain('(ms)');
      expect(typeof s.description).toBe('string');
      expect((s.description ?? '').length).toBeGreaterThan(0);
    }
  });

  it('설정 메타 계약: 비율 키는 unit ratio 를 갖는다', () => {
    const byName = catalog
      .filter((s) => s.key.endsWith('_ratio_threshold'))
      .map((s) => s.key)
      .sort();
    const byUnit = catalog.filter((s) => s.unit === 'ratio').map((s) => s.key).sort();

    expect(byName.length).toBeGreaterThan(0);
    expect(byUnit).toEqual(byName);
    for (const s of catalog.filter((x) => x.unit === 'ratio')) {
      expect(s.label).not.toContain('0~1');
      expect((s.description ?? '').length).toBeGreaterThan(0);
    }
  });

  it('설정 메타 계약: JSON 키 3종이 keyOptions·keySet·필드별 shape 를 갖는다', () => {
    const json = catalog.filter((s) => s.type === 'json');
    const jsonKeys = json.map((s) => s.key).sort();

    /**
     * 계획서가 이름으로 지목한 세 자리다. **집합을 양방향으로 맞춘다** — 아래 항목 검사는
     * "발견된 json 설정마다" 라서, 셋 중 하나를 json 이 아닌 타입으로 바꾸면 그 항목이
     * 검사 대상에서 조용히 빠진다.
     */
    const declared = [
      SETTING_KEY.scheduleExpect,
      `${TREAT_VENDOR_RULE_ID}_overrides`,
      `${TREAT_VENDOR_RULE_ID}_derivation`,
    ].sort();
    expect(jsonKeys).toEqual(declared);

    for (const s of json) {
      expect(['open', 'fixed']).toContain(s.keySet);
      expect(['scalar', 'object']).toContain(s.valueShape);
      expect((s.description ?? '').length).toBeGreaterThan(0);
      expect(s.label).not.toContain('JSON');

      for (const opt of s.keyOptions ?? []) {
        expect(VALID_SHAPES).toContain(opt.shape);
        expect(opt.label.length).toBeGreaterThan(0);
        expect(opt.description.length).toBeGreaterThan(0);
        if (opt.shape === 'object') {
          //* "객체다" 까지만 말하면 편집기가 안의 칸 이름을 하드코딩한다(R7 F3).
          expect(opt.fields?.length ?? 0).toBeGreaterThan(0);
          for (const f of opt.fields ?? []) expect(VALID_FIELD_SHAPES).toContain(f.shape);
        }
      }

      //* 닫힌 집합은 정적으로 전수돼야 편집기가 키를 더하거나 지우지 못하게 할 수 있다.
      if (s.keySet === 'fixed') expect((s.keyOptions ?? []).length).toBeGreaterThan(0);

      /**
       * 열린 집합인데 정적 키 목록이 없다면 키가 런타임 파생이라는 뜻이다. 그때도 **값의 모양**은
       * 메타가 말해야 한다 — 그러지 않으면 편집기가 값 칸의 이름을 박게 되고, 그것이 이 축이
       * 없애려는 것이다.
       */
      if (s.keySet === 'open' && (s.keyOptions ?? []).length === 0) {
        expect((s.valueFields ?? []).length).toBeGreaterThan(0);
        for (const f of s.valueFields ?? []) expect(VALID_FIELD_SHAPES).toContain(f.shape);
      }
    }

    //* 스케줄러 기대 주기 — 키 목록의 정본은 `DEFAULT_SCHEDULE_EXPECT` 다. 편집기 목록이
    //*  거기서 갈리면 감시 대상과 화면이 다른 것을 말한다.
    const expectSetting = json.find((s) => s.key === SETTING_KEY.scheduleExpect)!;
    expect((expectSetting.keyOptions ?? []).map((o) => o.key).sort()).toEqual(
      Object.keys(DEFAULT_SCHEDULE_EXPECT).sort(),
    );
    for (const opt of expectSetting.keyOptions ?? []) expect(opt.shape).toBe('ms');

    /**
     * 산출 서명 — 키 목록의 정본은 `DerivationSignature` **인터페이스**다. 런타임에 반영할 수
     * 없으므로 그 타입으로 만든 객체의 키를 기준으로 쓴다: 인터페이스에 키가 늘거나 줄면
     * 이 리터럴이 `tsc` 에서 먼저 떨어진다(게이트 `#14`).
     */
    const signatureShape: DerivationSignature = {
      window_ms: 0,
      min_sample: 0,
      vendors: [],
      ratio_threshold: 0,
      overrides: {},
      definition_version: 0,
    };
    const derivation = json.find((s) => s.key === `${TREAT_VENDOR_RULE_ID}_derivation`)!;
    expect(derivation.keySet).toBe('fixed');
    expect((derivation.keyOptions ?? []).map((o) => o.key).sort()).toEqual(
      Object.keys(signatureShape).sort(),
    );
  });

  /**
   * 테스트 발송 문면(§D6). **재사용이 아니라 복제**라는 것은 계획서에 적혀 있고, 이 계약이
   * 잡는 것은 그 복제가 **일반 안내 문장으로 대체되는** 드리프트 하나다 — 같은 수준 표지를 단 채
   * 수치만 낡는 드리프트는 여기서 안 잡힌다(알려진 사각지대).
   */
  it('테스트 문구 계약: 전 규칙이 sampleText 와 테스트 배너를 갖는다', () => {
    const LEVEL_MARKS = ['🔴 [위험] ', '🟠 [주의] '];

    for (const r of rules) {
      expect(typeof r.sampleText).toBe('string');
      const sample = String(r.sampleText);
      expect(sample.length).toBeGreaterThan(0);
      //* 첫 토큰이 실제 발화와 같은 수준 표지여야 한다 — 이것이 "실제 문면" 의 값싼 증거다.
      expect(LEVEL_MARKS.some((m) => sample.startsWith(m))).toBe(true);
      //* 옛 테스트 전용 문면으로 되돌아가지 않았다.
      expect(sample).not.toContain('[테스트]');
    }

    //* 배너는 본문 **첫 줄**로 붙는다 — 줄바꿈이 들어 있으면 첫 줄이 배너가 아니게 된다.
    expect(TEST_BANNER.length).toBeGreaterThan(0);
    expect(TEST_BANNER).not.toContain('\n');
    expect(TEST_BANNER).toContain('테스트');
  });
});

//* MS2 D4 — 억제창 메타 계약. 값은 양의 유한수이며, 부재는 관리자 설정이다.
//*  MS3(D12) 로 진료 모니터링도 24시간을 선언한다. MS6 는 정기결제 회차 판정에 36시간을 준다 (편차 2).
describe('알림 억제창 메타 계약 (MS2 D4 · MS3 D12)', () => {
  it('MS6-C1 억제창을 선언한 규칙은 하트비트와 진료 모니터링 24시간, 정기결제 36시간이다', () => {
    const declared = rules.filter(
      (r) => r.noise.suppressWindowMs !== undefined,
    );
    expect(declared.map((r) => r.id).sort()).toEqual([
      'schedule-heartbeat',
      'subscription-payment',
      'treat-monitor',
    ]);
    const window = Object.fromEntries(
      declared.map((r) => [r.id, r.noise.suppressWindowMs]),
    );
    expect(window['schedule-heartbeat']).toBe(24 * 60 * 60 * 1000);
    expect(window['treat-monitor']).toBe(24 * 60 * 60 * 1000);
    expect(window['subscription-payment']).toBe(36 * 60 * 60 * 1000);
  });

  it('MS2-7: 억제창 값은 양의 유한수이고, 부재는 관리자 설정 alert_dedupe_ms 로 읽힌다', () => {
    for (const r of rules) {
      const w = r.noise.suppressWindowMs;
      if (w !== undefined) expect(Number.isFinite(w) && w > 0).toBe(true);
    }
    expect(COMMON_SETTINGS.some((m) => m.key === SETTING_KEY.dedupeMs)).toBe(true);
  });
});

/**
 * MS6 계약 — 감시 목록 · 배포 분기 · 일 1회 집합 (편차 5 · 8 · 9). 키 집합의 정본은 `DEFAULT_SCHEDULE_EXPECT` 다.
 */
describe('MS6 감시 목록 계약 (배포 분기 · 일 1회 집합)', () => {
  //* 배포 분기에만 있고 감시 목록에 없는 이름. ddx-verdict 는 MS8 에서 편입해 여기서 뺐다.
  const EXCEPTIONS = ['samsung-s2-report-final', 'samsung-s2-report-preview'];

  it('[MS8-C1] ddx-verdict 는 2분 기대와 설명으로 감시 목록에 있다', () => {
    expect(DEFAULT_SCHEDULE_EXPECT['ddx-verdict']).toBe(2 * 60 * 1000);
    expect(SCHEDULE_EXPECT_DESC['ddx-verdict']).not.toBe('');
  });

  it('MS6-C2 감시 목록 키 집합과 dockerfile case 집합의 차이는 명시 예외 목록과 같다', () => {
    const dockerfile = readFileSync(resolve(__dirname, '../../../dockerfile'), 'utf8');
    const cases = new Set(
      [...dockerfile.matchAll(/\\"([a-z0-9-]+)\\"\)\s+node/g)].map((m) => m[1]),
    );
    expect(cases.size).toBeGreaterThan(0);
    const expectKeys = new Set(Object.keys(DEFAULT_SCHEDULE_EXPECT));
    const onlyInDeploy = [...cases].filter((n) => !expectKeys.has(n)).sort();
    const onlyInList = [...expectKeys].filter((n) => !cases.has(n)).sort();
    expect([...onlyInDeploy, ...onlyInList].sort()).toEqual([...EXCEPTIONS].sort());
    expect(onlyInList).toEqual([]);
  });

  it('MS6-C3 일 1회 집합은 감시 목록의 부분집합이고 하루 기대이며 전역 유예 기본값은 2 다', () => {
    expect(DAILY_SCHEDULE_NAMES.size).toBe(8);
    for (const name of DAILY_SCHEDULE_NAMES) {
      expect(Object.prototype.hasOwnProperty.call(DEFAULT_SCHEDULE_EXPECT, name)).toBe(true);
      expect(DEFAULT_SCHEDULE_EXPECT[name]).toBe(DAY);
    }
    expect(DAILY_SCHEDULE_GRACE).toBe(1.1);
    const hb = rules.find((r) => r.id === 'schedule-heartbeat')!;
    const grace = hb.settings.find((s) => s.key === 'schedule-heartbeat_grace')!;
    expect(grace.default).toBe(2);
  });
});
