/**
 * 임계 산출 스크립트의 **순수 영역** spec(M4 Task 4). Nest 를 부팅하지 않는다 —
 * `alert-threshold-derive.ts` 는 Nest·DB 접근을 전부 `main()` 안 lazy require 로 두었고
 * `require.main === module` 가드가 있어 import 만으로는 아무것도 실행되지 않는다.
 *
 * 골든 픽스처가 **변환 규칙을 실제 수치로 고정**한다 — 서명이 구현이 고른 값을 그대로
 * 되풀이하는지만 보면 임의의 선택이 통과한다.
 */
import {
  Bucket,
  Observation,
  bucketize,
  buildSignature,
  ceil2,
  classifyRow,
  clampThreshold,
  defaultMinBuckets,
  deriveAxis,
  parseArgs,
  percentile,
  readSetting,
  renderReport,
  windowLowerBound,
} from './alert-threshold-derive';

const MIN_SAMPLE = 5;
const axisOpts = (over: Partial<{ minSample: number; minBuckets: number; targetFpr: number }> = {}) => ({
  minSample: MIN_SAMPLE,
  minBuckets: 100,
  targetFpr: 1,
  ...over,
});

/** 실패율 `i/100` 인 적격 버킷 100개 — 백분위가 인덱스와 1:1 이라 골든값이 읽힌다. */
const rampBuckets = (): Bucket[] =>
  Array.from({ length: 100 }, (_, i) => ({ total: 100, failed: i }));

describe('백분위·반올림·클램프', () => {
  it('percentile 은 알려진 입력에 알려진 값을 낸다(최근접 순위, 보간 없음)', () => {
    const v = [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9];
    expect(percentile(v, 50)).toBe(0.4); //* rank = ceil(0.5*10) = 5 → sorted[4]
    expect(percentile(v, 90)).toBe(0.8);
    expect(percentile(v, 100)).toBe(0.9);
    expect(percentile(v, 1)).toBe(0); //* rank 1 → 최솟값
    expect(percentile([], 99)).toBeNull();
    //* 정렬되지 않은 입력도 같은 값을 낸다.
    expect(percentile([0.9, 0.1, 0.5], 50)).toBe(0.5);
  });

  it('ceil2 는 부동소수 오차를 흡수하고 올림 방향이 발화를 줄이는 쪽이다', () => {
    expect(ceil2(0.1)).toBe(0.1); //* 0.1*100 = 10.000000000000002 — 그대로 ceil 하면 0.11
    expect(ceil2(0.98)).toBe(0.98);
    expect(ceil2(0.101)).toBe(0.11);
    expect(ceil2(0.2999)).toBe(0.3);
    expect(ceil2(0)).toBe(0);
  });

  it('clampThreshold 가 설정 정의역 경계에서 동작한다', () => {
    expect(clampThreshold(0)).toBe(0.01);
    expect(clampThreshold(0.009)).toBe(0.01);
    expect(clampThreshold(0.01)).toBe(0.01);
    expect(clampThreshold(1)).toBe(1);
    expect(clampThreshold(1.5)).toBe(1);
  });

  it('--min-buckets 기본값이 --target-fpr 과 함께 움직인다', () => {
    expect(defaultMinBuckets(1)).toBe(100); //* 상위 1% 가 관측 1건 이상이려면 100개
    expect(defaultMinBuckets(5)).toBe(30); //* ceil(100/5)=20 이지만 바닥 30
    expect(defaultMinBuckets(10)).toBe(30);
    expect(defaultMinBuckets(0.5)).toBe(200);
  });

  it('parseArgs 는 기본값을 갖고 벤더 목록을 정렬한다', () => {
    expect(parseArgs([])).toEqual({
      days: 90,
      vendors: null,
      targetFpr: 1,
      minBuckets: null,
      out: null,
    });
    const o = parseArgs(['--days', '30', '--vendors', 'octover, inhouse,coocon', '--target-fpr', '5']);
    expect(o.vendors).toEqual(['coocon', 'inhouse', 'octover']);
    expect(o.days).toBe(30);
    expect(o.targetFpr).toBe(5);
    expect(() => parseArgs(['--target-fpr', '0'])).toThrow();
    expect(() => parseArgs(['--days', 'nope'])).toThrow();
  });
});

describe('§D5-2 적격 버킷 한정 — 저표본은 진단 통계로만', () => {
  it('적격 30개에 1건짜리 저표본 100개를 섞어도 백분위가 변하지 않는다', () => {
    const eligible: Bucket[] = Array.from({ length: 30 }, (_, i) => ({
      total: 10,
      failed: i % 10,
    }));
    const noisy: Bucket[] = Array.from({ length: 100 }, () => ({ total: 1, failed: 1 }));

    const clean = deriveAxis('a', eligible, axisOpts({ minBuckets: 30 }));
    const mixed = deriveAxis('a', [...eligible, ...noisy], axisOpts({ minBuckets: 30 }));

    expect(mixed.p50).toBe(clean.p50);
    expect(mixed.p90).toBe(clean.p90);
    expect(mixed.p95).toBe(clean.p95);
    expect(mixed.p99).toBe(clean.p99);
    expect(mixed.eligibleBuckets).toBe(30);
    //* 저표본은 버리지 않고 센다 — "적격 버킷이 왜 이렇게 적은가"의 답이 여기 있다.
    expect(mixed.lowSampleBuckets).toBe(100);
    expect(mixed.lowSampleRows).toBe(100);
    //* 표본 0 인 창은 진단에도 세지 않는다.
    expect(deriveAxis('a', [{ total: 0, failed: 0 }], axisOpts()).lowSampleBuckets).toBe(0);
  });

  it('적격 버킷 수가 하한 미만이면 판정이 INSUFFICIENT_SAMPLE 이고 권고 필드가 빈다', () => {
    const a = deriveAxis('a', rampBuckets().slice(0, 99), axisOpts({ minBuckets: 100 }));
    expect(a.verdict).toBe('INSUFFICIENT_SAMPLE');
    expect(a.recommended).toBeNull();
    //* 백분위 자체는 정의된다 — 의미가 없을 뿐이라 권고만 비운다.
    expect(a.p99).not.toBeNull();
  });
});

describe('§D5-4b 골든 픽스처 — 백분위에서 적용 임계로', () => {
  it('실패율 0.00~0.99 버킷 100개 · --target-fpr 1 → 권고 임계 0.98', () => {
    const a = deriveAxis('a', rampBuckets(), axisOpts({ targetFpr: 1, minBuckets: 100 }));
    expect(a.verdict).toBe('OK');
    expect(a.p99).toBe(0.98); //* rank = ceil(0.99*100) = 99 → sorted[98]
    expect(a.recommended).toBe(0.98);
  });

  it('--target-fpr 을 키우면 임계가 단조로 내려간다', () => {
    const at = (fpr: number) =>
      deriveAxis('a', rampBuckets(), axisOpts({ targetFpr: fpr, minBuckets: 30 })).recommended!;
    expect(at(1)).toBe(0.98);
    expect(at(5)).toBe(0.94);
    expect(at(10)).toBe(0.89);
    expect(at(1)).toBeGreaterThan(at(5));
    expect(at(5)).toBeGreaterThan(at(10));
  });

  it('전 버킷이 100% 실패면 클램프 상한 1 이 나온다', () => {
    const all: Bucket[] = Array.from({ length: 100 }, () => ({ total: 10, failed: 10 }));
    expect(deriveAxis('a', all, axisOpts({ minBuckets: 100 })).recommended).toBe(1);
  });

  it('전 버킷이 무실패면 클램프 하한 0.01 이 나온다', () => {
    const none: Bucket[] = Array.from({ length: 100 }, () => ({ total: 10, failed: 0 }));
    const a = deriveAxis('a', none, axisOpts({ minBuckets: 100 }));
    expect(a.p99).toBe(0);
    expect(a.recommended).toBe(0.01);
  });
});

describe('§D5-0 스냅샷 필터 — 제외는 버킷 집계 단계에서 일어난다', () => {
  const obs = (provider: string, bucket: number, failed: boolean): Observation => ({
    provider,
    at: bucket * 3_600_000 + 60_000,
    failed,
  });
  const opts = { fromMs: 0, toMs: 200 * 3_600_000, windowMs: 3_600_000, vendors: ['a', 'b'] };

  it('스냅샷 밖 벤더가 아무리 큰 물량을 가져도 벤더 백분위와 풀 백분위가 모두 불변이다', () => {
    const base: Observation[] = [];
    for (let i = 0; i < 40; i++)
      for (let k = 0; k < 10; k++) base.push(obs(k < 3 ? 'a' : 'b', i, k < 2));

    //* `codef` 처럼 원천 데이터에만 남은 키를 큰 물량으로 섞는다.
    const polluted = [...base];
    for (let i = 0; i < 40; i++)
      for (let k = 0; k < 500; k++) polluted.push(obs('codef', i, true));

    const clean = bucketize(base, opts);
    const dirty = bucketize(polluted, opts);

    expect(dirty.perVendor.get('a')).toEqual(clean.perVendor.get('a'));
    expect(dirty.perVendor.get('b')).toEqual(clean.perVendor.get('b'));
    //* 벤더 축에서만 빼고 합산에 남기면 같은 오염이 그대로다 — 풀도 불변이어야 한다.
    expect(dirty.pool).toEqual(clean.pool);

    const poolAxis = (b: Bucket[]) => deriveAxis('pool', b, axisOpts({ minBuckets: 30 }));
    expect(poolAxis(dirty.pool).p99).toBe(poolAxis(clean.pool).p99);
  });

  it('풀은 버킷별 합산에서 나온다 — 벤더별 실패율의 평균이 아니다', () => {
    //* A: 버킷마다 100건 전승(실패율 0) · B: 버킷마다 5건 전패(실패율 1).
    //*  벤더 실패율의 평균은 0.5 지만 버킷 합산은 5/105 = 0.0476 이다.
    const rows: Observation[] = [];
    for (let i = 0; i < 40; i++) {
      for (let k = 0; k < 100; k++) rows.push(obs('a', i, false));
      for (let k = 0; k < 5; k++) rows.push(obs('b', i, true));
    }
    const { pool } = bucketize(rows, opts);
    expect(pool[0]).toEqual({ total: 105, failed: 5 });
    const p = deriveAxis('pool', pool, axisOpts({ minBuckets: 30 })).p99!;
    expect(p).toBeCloseTo(5 / 105, 6);
    expect(p).toBeLessThan(0.1); //* 평균 0.5 와 명백히 다르다
  });

  it('창 시작 이전의 관측은 버킷에 들어가지 않는다', () => {
    const { pool } = bucketize([{ provider: 'a', at: -1000, failed: true }], opts);
    expect(pool).toEqual([]);
  });

  it('미래 시각 행 1건이 버킷 배열을 부풀리지 않는다 (SQL 에 상한이 없다)', () => {
    //* 9999년 값 하나면 60분 창 기준 7천만 버킷 — 상한이 없으면 스크립트가 OOM 으로 죽는다.
    const far = new Date('9999-12-31T00:00:00Z').getTime();
    const { pool, perVendor } = bucketize(
      [
        { provider: 'a', at: 1_000, failed: false },
        { provider: 'a', at: far, failed: true },
        { provider: 'a', at: Number.NaN, failed: true },
      ],
      opts,
    );
    expect(pool).toHaveLength(1);
    expect(pool[0]).toEqual({ total: 1, failed: 0 });
    expect(perVendor.get('a')).toHaveLength(1);
  });

  it('상한 경계(toMs) 관측은 남고 그 밖은 버린다', () => {
    const { pool } = bucketize(
      [
        { provider: 'a', at: opts.toMs, failed: true },
        { provider: 'a', at: opts.toMs + 1, failed: true },
      ],
      opts,
    );
    expect(pool[pool.length - 1]).toEqual({ total: 1, failed: 1 });
  });
});

describe('§D5-4b 부분 미달 — 서명은 전 축이 OK 일 때만 나온다', () => {
  const okAxis = () => deriveAxis('x', rampBuckets(), axisOpts({ minBuckets: 100 }));
  const badAxis = () =>
    deriveAxis('x', rampBuckets().slice(0, 10), axisOpts({ minBuckets: 100 }));
  const cfg = { windowMs: 3_600_000, minSample: 5, vendors: ['b', 'a'], definitionVersion: 2 };

  it('전 축 OK 면 서명이 나오고 실행값·권고값을 그대로 담는다', () => {
    const sig = buildSignature(okAxis(), [
      { vendor: 'a', axis: okAxis() },
      { vendor: 'b', axis: okAxis() },
    ], cfg)!;
    expect(sig.window_ms).toBe(3_600_000);
    expect(sig.min_sample).toBe(5);
    expect(sig.vendors).toEqual(['a', 'b']); //* 정렬된다 — 규칙의 대조도 정렬 기준이다
    expect(sig.ratio_threshold).toBe(0.98);
    expect(sig.overrides).toEqual({ a: { threshold: 0.98 }, b: { threshold: 0.98 } });
    expect(sig.definition_version).toBe(2);
  });

  it('벤더 하나만 미달이어도 서명이 나오지 않는다', () => {
    expect(
      buildSignature(okAxis(), [
        { vendor: 'a', axis: okAxis() },
        { vendor: 'b', axis: badAxis() },
      ], cfg),
    ).toBeNull();
  });

  it('풀 축이 미달이면 벤더가 전부 OK 여도 서명이 나오지 않는다', () => {
    expect(
      buildSignature(badAxis(), [{ vendor: 'a', axis: okAxis() }], cfg),
    ).toBeNull();
  });

  /**
   * 계획 Task 4 Gate 는 "벤더가 전부 `OK` 여도 풀이 `INSUFFICIENT_SAMPLE` 일 수 있다"를 요구하지만
   * 그 상태는 **산술적으로 불가능**하다: 풀 버킷의 `total` 은 스냅샷 벤더 `total` 의 합이라
   * 어떤 벤더가 적격인 버킷은 풀에서도 반드시 적격이다. 따라서 풀 적격 수 ≥ 모든 벤더의 적격 수다.
   * 실재하는 부분 미달은 **반대 방향**(풀 OK · 벤더 미달)이며 §D5-4b 가 그것을 막는다.
   */
  it('풀 적격 버킷 수는 어떤 벤더의 적격 버킷 수보다 작을 수 없다', () => {
    const rows: Observation[] = [];
    for (let i = 0; i < 40; i++) {
      for (let k = 0; k < 10; k++) rows.push({ provider: 'a', at: i * 3_600_000, failed: k < 2 });
      if (i % 10 === 0) rows.push({ provider: 'b', at: i * 3_600_000, failed: true });
    }
    const { perVendor, pool } = bucketize(rows, {
      fromMs: 0,
      toMs: 200 * 3_600_000,
      windowMs: 3_600_000,
      vendors: ['a', 'b'],
    });
    const o = axisOpts({ minBuckets: 30 });
    const poolA = deriveAxis('pool', pool, o);
    const aA = deriveAxis('a', perVendor.get('a')!, o);
    const bA = deriveAxis('b', perVendor.get('b')!, o);
    expect(poolA.eligibleBuckets).toBeGreaterThanOrEqual(aA.eligibleBuckets);
    expect(poolA.eligibleBuckets).toBeGreaterThanOrEqual(bA.eligibleBuckets);
    //* 실재하는 부분 미달 — 풀은 OK 인데 저볼륨 벤더 b 가 미달이다.
    expect(poolA.verdict).toBe('OK');
    expect(bA.verdict).toBe('INSUFFICIENT_SAMPLE');
  });
});

describe('출력', () => {
  const cfg = {
    windowMs: 3_600_000,
    minSample: 5,
    minBuckets: 100,
    targetFpr: 1,
    days: 90,
    vendors: ['a', 'b'],
    definitionVersion: 2,
    vendorSource: '`--vendors`',
    settingSource: { window_ms: '`tn_alert_setting`', min_sample: '`tn_alert_setting`' },
  };
  const ok = () => deriveAxis('x', rampBuckets(), axisOpts({ minBuckets: 100 }));

  it('첫 줄이 붙여넣기 가능한 서명 JSON 이다', () => {
    const vendorAxes = [
      { vendor: 'a', axis: ok() },
      { vendor: 'b', axis: ok() },
    ];
    const sig = buildSignature(ok(), vendorAxes, cfg)!;
    const report = renderReport({ signature: sig, pool: ok(), vendorAxes, cfg, undecidedVendors: 0 });
    const first = report.split('\n')[0];
    expect(JSON.parse(first)).toEqual(sig);
  });

  it('서명이 없으면 그 사실을 적고 JSON 을 내지 않는다', () => {
    const vendorAxes = [{ vendor: 'a', axis: ok() }];
    const report = renderReport({
      signature: null,
      pool: ok(),
      vendorAxes,
      cfg,
      undecidedVendors: 1,
    });
    expect(report).toContain('출력하지 않는다');
    expect(() => JSON.parse(report.split('\n')[0])).toThrow();
  });

  it('행이 벤더 N개 + 풀 1개이고 진단 통계가 판정하지 못한 벤더 수를 보고한다', () => {
    const vendorAxes = [
      { vendor: 'a', axis: deriveAxis('a', rampBuckets(), axisOpts({ minBuckets: 100 })) },
      { vendor: 'b', axis: deriveAxis('b', [{ total: 1, failed: 1 }], axisOpts()) },
    ];
    const pool = deriveAxis('**풀(집계)**', rampBuckets(), axisOpts({ minBuckets: 100 }));
    const report = renderReport({ signature: null, pool, vendorAxes, cfg, undecidedVendors: 1 });
    expect(report).toContain('| a |');
    expect(report).toContain('| b |');
    expect(report).toContain('**풀(집계)**');
    expect(report).toContain('INSUFFICIENT_SAMPLE');
    expect(report).toContain('판정하지 못한 벤더: **1종**');
  });
});

describe('설정 읽기 — 부재는 기본값, 범위 밖은 선다', () => {
  //* 런타임 설정 메타와 같은 범위(`TREAT_VENDOR_RANGE.windowMs` · `.minSample`). 여기서 상수를
  //*  import 하지 않는 이유는 그 모듈이 Nest 를 끌고 오기 때문이다 — main() 이 lazy require 로
  //*  넘기는 것과 같은 모양으로 인자로 받아 순수 영역을 유지한다.
  const WINDOW = { min: 5 * 60 * 1000, max: 24 * 60 * 60 * 1000 };

  it('행이 없거나 빈 문자열이면 기본값이고 그 사실을 출처에 적는다', () => {
    expect(readSetting({}, 'window_ms', 3600000, WINDOW)).toEqual({
      value: 3600000,
      source: '**설정 부재 → 규칙 메타 기본값**',
    });
    expect(
      readSetting({ 'treat-vendor_window_ms': '   ' }, 'window_ms', 3600000, WINDOW).value,
    ).toBe(3600000);
  });

  it('범위 안의 값은 그대로 쓰고 출처가 설정으로 바뀐다', () => {
    expect(readSetting({ 'treat-vendor_window_ms': '1800000' }, 'window_ms', 3600000, WINDOW)).toEqual(
      { value: 1800000, source: '`tn_alert_setting`' },
    );
  });

  it('숫자가 아니면 선다', () => {
    expect(() =>
      readSetting({ 'treat-vendor_window_ms': 'abc' }, 'window_ms', 3600000, WINDOW),
    ).toThrow(/숫자가 아니다/);
  });

  it('window_ms = 0 은 범위 밖이라 선다 — bucketize 까지 흘러가면 도구가 죽는다', () => {
    //* `idx = Math.floor(x / 0)` 가 Infinity 가 되어 `ensure` 가 버킷을 무한히 채운다(OOM).
    //*  x 가 0 이면 NaN 이 되어 `arr[NaN]` 에 쓰다 TypeError 로 죽는다.
    //*  code-review 가 닫은 `toMs` 상한과 같은 계열이고 나눗셈 쪽이 남아 있었다.
    expect(() =>
      readSetting({ 'treat-vendor_window_ms': '0' }, 'window_ms', 3600000, WINDOW),
    ).toThrow(/설정 정의역 밖이다/);
  });

  it('상한 밖도 선다 — 런타임은 메타 기본값으로 되돌려 판정하므로 그대로 쓰면 두 파일이 갈린다', () => {
    expect(() =>
      readSetting({ 'treat-vendor_min_sample': '10001' }, 'min_sample', 5, {
        min: 1,
        max: 10000,
      }),
    ).toThrow(/설정 정의역 밖이다/);
  });

  it('경계값은 통과한다', () => {
    expect(readSetting({ 'treat-vendor_window_ms': String(WINDOW.min) }, 'window_ms', 1, WINDOW).value).toBe(
      WINDOW.min,
    );
    expect(readSetting({ 'treat-vendor_window_ms': String(WINDOW.max) }, 'window_ms', 1, WINDOW).value).toBe(
      WINDOW.max,
    );
  });
});

describe('신호 정의 승계', () => {
  it('성공은 ODD006 + 완료시각, 실패는 상태/최종상태 + 실패시각이다', () => {
    expect(
      classifyRow({
        provider: 'inhouse',
        state: 'ODD006',
        finalState: null,
        completedAt: '2026-08-01 10:00:00',
        failedAt: null, isDropout: false,
      }),
    ).toEqual([
      { provider: 'inhouse', at: new Date('2026-08-01 10:00:00').getTime(), failed: false },
    ]);

    expect(
      classifyRow({
        provider: 'coocon',
        state: 'ODD008',
        finalState: null,
        completedAt: null,
        failedAt: '2026-08-01 11:00:00', isDropout: false,
      }),
    ).toEqual([{ provider: 'coocon', at: new Date('2026-08-01 11:00:00').getTime(), failed: true }]);

    //* 스케줄러가 박은 최종 상태만 있어도 실패다.
    expect(
      classifyRow({
        provider: 'coocon',
        state: 'ODD005',
        finalState: 'ODD007',
        completedAt: null,
        failedAt: '2026-08-01 11:00:00', isDropout: false,
      })[0].failed,
    ).toBe(true);

    //* 진행 중인 행은 어느 쪽도 아니다 — 분모에 넣으면 실패율이 처리 지연에 희석된다.
    expect(
      classifyRow({ provider: 'a', state: 'ODD001', finalState: null, completedAt: null, failedAt: null, isDropout: false }),
    ).toEqual([]);
    //* 벤더가 없으면 비교 대상이 아니다.
    expect(
      classifyRow({ provider: null, state: 'ODD006', finalState: null, completedAt: '2026-08-01', failedAt: null, isDropout: false }),
    ).toEqual([]);
  });

  /**
   * 런타임 집계(`alert.repository.ts` `countVendorTreatResults`)는 성공·실패를 독립된 두 `SUM`
   * 으로 세므로 이 행을 `total 2 · failed 1` 로 본다. 산출이 실패 1건으로 접으면 `total 1` 이라
   * 백분위가 런타임과 다른 양에서 나온다 — §D10 서명은 설정값만 담아 이 차이를 못 잡는다.
   */
  it('ODD006 + 최종상태 실패 행은 관측 2건이다 (런타임 이중계상 재현)', () => {
    const out = classifyRow({
      provider: 'inhouse',
      state: 'ODD006',
      finalState: 'ODD004',
      completedAt: '2026-08-01 10:00:00',
      failedAt: '2026-08-01 10:30:00', isDropout: false,
    });
    expect(out).toHaveLength(2);
    expect(out.map((o) => o.failed)).toEqual([false, true]);
    expect(out[0].at).toBe(new Date('2026-08-01 10:00:00').getTime());
    expect(out[1].at).toBe(new Date('2026-08-01 10:30:00').getTime());

    //* 시각이 하나만 있으면 그쪽 1건이다 — 승계 정의가 각 축에 자기 시각을 요구한다.
    expect(
      classifyRow({
        provider: 'inhouse',
        state: 'ODD006',
        finalState: 'ODD004',
        completedAt: '2026-08-01 10:00:00',
        failedAt: null, isDropout: false,
      }),
    ).toHaveLength(1);
  });

  //* MS4: 이탈 행은 성공도 실패도 아니다 — 런타임 집계가 이탈을 빼므로 산출도 세지 않는다.
  it('MS4: 분류 함수는 이탈 행의 실패를 세지 않는다', () => {
    expect(
      classifyRow({
        provider: 'inhouse',
        state: 'ODD001',
        finalState: 'ODD004',
        completedAt: null,
        failedAt: '2026-08-01 11:00:00',
        isDropout: true,
      }),
    ).toEqual([]);
  });

  //* MS4: 로그 저장 실패 · 수집 타임아웃 모양은 이탈이 아니라 벤더 실패로 센다.
  it('MS4: 분류 함수는 로그 없는 실패를 센다', () => {
    expect(
      classifyRow({
        provider: 'inhouse',
        state: 'ODD004',
        finalState: null,
        completedAt: null,
        failedAt: '2026-08-01 11:00:00',
        isDropout: false,
      }),
    ).toEqual([{ provider: 'inhouse', at: new Date('2026-08-01 11:00:00').getTime(), failed: true }]);
  });
});

describe('MS4 산출 서명 정의 버전', () => {
  //* MS4: 산출 서명은 정의 버전을 담는다 — 옛 산출은 버전이 달라 규칙이 불일치로 본다.
  it('MS4: 산출 서명은 정의 버전을 담는다', () => {
    const axis = deriveAxis('x', rampBuckets(), axisOpts({ minBuckets: 100 }));
    const sig = buildSignature(axis, [{ vendor: 'a', axis }], {
      windowMs: 3_600_000,
      minSample: 5,
      vendors: ['a'],
      definitionVersion: 2,
    })!;
    expect(sig.definition_version).toBe(2);
  });
});

describe('MS5 산출 하한 문자열 · 안내', () => {
  const cfg = {
    windowMs: 3_600_000,
    minSample: 5,
    minBuckets: 100,
    targetFpr: 1,
    days: 90,
    vendors: ['a', 'b'],
    definitionVersion: 2,
    vendorSource: '`--vendors`',
    settingSource: { window_ms: '`tn_alert_setting`', min_sample: '`tn_alert_setting`' },
  };
  const ok = () => deriveAxis('x', rampBuckets(), axisOpts({ minBuckets: 100 }));

  //* 고정 시각은 로컬 생성자로 만든다. 기대 문자열은 TZ 와 무관하게 같은 로컬 시각이다.
  it('산출 하한 문자열은 런타임 하한과 같은 로컬 기준으로 만든다', () => {
    expect(windowLowerBound(new Date(2026, 9, 20, 9, 0, 0).getTime())).toBe('2026-10-20 09:00:00');
  });

  it('산출 안내는 내부 키 이름과 DB 쓰기 문장을 담지 않는다', () => {
    const vendorAxes = [
      { vendor: 'a', axis: ok() },
      { vendor: 'b', axis: ok() },
    ];
    const sig = buildSignature(ok(), vendorAxes, cfg)!;
    const report = renderReport({ signature: sig, pool: ok(), vendorAxes, cfg, undecidedVendors: 0 });
    expect(report).not.toContain('alert_rule_enabled');
    expect(report).not.toContain('treat-vendor_');
    expect(report).toContain('「산출 승인값」');
    expect(JSON.parse(report.split('\n')[0])).toEqual(sig);
  });
});
