/**
 * 진료 벤더 발송 임계 산출 — 마일스톤 4 §D5. **읽기 전용**이며 프로덕션 코드를 한 줄도 바꾸지 않는다.
 *
 * 사용:
 *   cd insuniverse-server
 *   npx ts-node -r tsconfig-paths/register scripts/alert-threshold-derive.ts \
 *     --days 90 --vendors inhouse,octover,coocon [--target-fpr 1] [--min-buckets N] [--out FILE]
 *
 * 내놓는 것은 확정 수치가 아니라 **판정**이다. 표본이 부족하면 백분위는 정의되지만 의미가 없고,
 * 근거 없는 임계를 만드는 것이 PRD 가 지목한 ProviQ 실패의 반복이다(UI21). 그래서 축마다
 * 충분성을 먼저 판정하고, 부족하면 **권고 수치를 내지 않는다**.
 *
 * **부분 미달은 활성화 불가다**(§D5-4b): 풀 축과 스냅샷 벤더 **전부**가 `OK` 여야 서명이 나온다.
 * 한 벤더만 미달이면 그 벤더에는 오버라이드가 없고, 오버라이드가 없으면 규칙 단위 임계
 * (= 풀 분포에서 나온 값)로 판정받는데 그것은 그 벤더를 잰 값이 아니다.
 *
 * 임계·창은 하네스가 갖지 않는다 — `tn_alert_setting` 에서 읽는다. **규칙이 보는 값과 같은
 * 소스**여야 산출값이 규칙에 그대로 들어간다(`alert-inject.ts:30-33` 과 같은 원칙). 다만
 * `AlertSettingsService.read` 는 조회 실패를 삼키고 메타 기본값을 돌려주는 런타임 fail-soft 라
 * 여기서는 쓰지 않는다(Implement-Codex R1 F4) — 일시 실패가 **기본값으로 만든 서명**을 정상
 * 출력처럼 내면 §D10 이 막으려던 미측정 임계 승인이 산출 쪽에서 열린다. 접두 조회 1회로
 * 일관되게 읽고, 부재와 실패를 구분하며, 무엇이 기본값으로 채워졌는지 리포트에 적는다.
 */

//---------------------------------------------------------------//
// 순수 영역 — Nest 를 부팅하지 않고 spec 이 그대로 import 한다.
// 모듈 최상단에서 `@api/...` 를 import 하지 않는 이유가 이것이다(전부 main() 안의 lazy require).
//---------------------------------------------------------------//

/** 처리 완료된 진료 1건. 시각은 처리 시점(성공=완료, 실패=실패)이다. */
export interface Observation {
  provider: string;
  at: number; //* ms
  failed: boolean;
}

export interface DeriveOptions {
  days: number;
  vendors: string[] | null; //* null = 실행 시 tpr_enabled=1 로 채운다
  targetFpr: number; //* % 단위. "과거 정상 구간의 몇 %가 이 임계에서 발화했을 것인가"
  minBuckets: number | null; //* null = max(30, ceil(100/targetFpr))
  out: string | null;
}

export interface AxisResult {
  label: string;
  eligibleBuckets: number;
  lowSampleBuckets: number;
  lowSampleRows: number;
  p50: number | null;
  p90: number | null;
  p95: number | null;
  p99: number | null;
  verdict: 'OK' | 'INSUFFICIENT_SAMPLE';
  recommended: number | null;
}

export const DEFAULT_TARGET_FPR = 1;
export const MIN_BUCKET_FLOOR = 30;

export function parseArgs(argv: string[]): DeriveOptions {
  const get = (flag: string): string | null => {
    const i = argv.indexOf(flag);
    return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
  };
  const num = (flag: string, fallback: number | null): number | null => {
    const raw = get(flag);
    if (raw === null) return fallback;
    const v = Number(raw);
    if (!Number.isFinite(v)) throw new Error(`${flag} 값이 숫자가 아니다: ${raw}`);
    return v;
  };

  const targetFpr = num('--target-fpr', DEFAULT_TARGET_FPR)!;
  if (!(targetFpr > 0 && targetFpr <= 100))
    throw new Error(`--target-fpr 은 0 초과 100 이하여야 한다: ${targetFpr}`);

  const rawVendors = get('--vendors');
  return {
    days: num('--days', 90)!,
    vendors:
      rawVendors === null
        ? null
        : rawVendors
            .split(',')
            .map((v) => v.trim())
            .filter(Boolean)
            .sort(),
    targetFpr,
    minBuckets: num('--min-buckets', null),
    out: get('--out'),
  };
}

/**
 * 설정 1건 읽기 — **부재는 기본값, 값이 있으면 범위까지 검사하고 벗어나면 throw**.
 *
 * 범위 검사는 `AlertSettingsService.read`(`alert-settings.service.ts:43-44`)가 하던 일이다.
 * R1 F4 로 그 read 를 버릴 때 조회 실패를 삼키는 fail-soft 와 **함께 클램프도 빠졌다**.
 * 런타임은 범위 밖 값을 메타 기본값으로 되돌려 판정하는데 산출이 그 값을 그대로 쓰면
 * **두 파일이 다른 창으로 젠다**. 특히 `window_ms <= 0` 은 `bucketize` 의 `idx` 를 Infinity/NaN 으로
 * 만들어 `ensure` 가 무한히 버킷을 채우거나(OOM) `arr[NaN]` 에 쓰다 TypeError 로 죽는다 —
 * 활성화를 가르는 도구가 설정 1행에 죽는 자리다(code-review 가 닫은 `toMs` 상한과 같은 계열).
 *
 * 되돌리지 않고 **세운다**: 되돌리면 그 값으로 만든 서명이 정상 출력처럼 나가고, 그것이 §D10 이
 * 막으려던 미측정 임계 승인이다(R1 F4 와 같은 논거).
 */
export function readSetting(
  raw: Record<string, string>,
  key: string,
  fallback: number,
  range: { min: number; max: number },
): { value: number; source: string } {
  const v = raw[`treat-vendor_${key}`];
  if (v === undefined || v.trim() === '')
    return { value: fallback, source: '**설정 부재 → 규칙 메타 기본값**' };
  const n = Number(v);
  if (!Number.isFinite(n)) throw new Error(`treat-vendor_${key} 값이 숫자가 아니다: ${v}`);
  if (n < range.min || n > range.max)
    throw new Error(
      `treat-vendor_${key} 가 설정 정의역 밖이다: ${n} (허용 ${range.min}~${range.max}). ` +
        `런타임은 이 값을 메타 기본값으로 되돌려 판정하므로 이대로 산출하면 서명이 런타임과 다른 창에서 나온다`,
    );
  return { value: n, source: '`tn_alert_setting`' };
}

/**
 * 충분성 하한. 상위 `targetFpr`% 가 관측 1건 이상이려면 적격 버킷이 `ceil(100/targetFpr)` 개는
 * 있어야 한다 — 30개로 p99 를 주장할 수는 없다.
 */
export function defaultMinBuckets(targetFpr: number): number {
  return Math.max(MIN_BUCKET_FLOOR, Math.ceil(100 / targetFpr));
}

/**
 * 소수 둘째 자리 **올림**. 올림 방향은 발화를 줄이는 쪽이라 보수적이다.
 *
 * `Math.ceil(x * 100)` 을 그대로 쓰면 안 된다 — `0.1 * 100` 이 `10.000000000000002` 라 0.11 이 되고,
 * 그 오차가 그대로 서명에 박혀 §D10 대조까지 흘러간다. 스케일 후 유효자리를 잘라 흡수한다.
 */
export function ceil2(x: number): number {
  return Math.ceil(Number((x * 100).toFixed(9))) / 100;
}

/** 설정 정의역과 같은 범위로 클램프한다(`treat-vendor_ratio_threshold` 의 min/max). */
export function clampThreshold(x: number): number {
  return Math.min(1, Math.max(0.01, x));
}

/** 최근접 순위(nearest-rank) 백분위. 보간하지 않는다 — 관측되지 않은 값을 만들지 않기 위해서다. */
export function percentile(values: number[], p: number): number | null {
  if (!values.length) return null;
  const sorted = [...values].sort((a, b) => a - b);
  const rank = Math.ceil((p / 100) * sorted.length);
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
}

export interface Bucket {
  total: number;
  failed: number;
}

/**
 * 창 크기 단위 버킷으로 자른다. **스냅샷 밖 벤더의 행은 여기서 버린다**(§D5-0) — 벤더 축에서만
 * 빼고 합산에 남기면 풀 백분위가 오염된다. 실측 데이터의 `codef`·`hyphen` 이 그 실제 후보다.
 *
 * `toMs` 상한이 필수인 이유: 조회 SQL 에는 상한이 없어(`>= fromIso` 뿐) 시각이 미래인 행 하나가
 * `idx` 를 임의로 크게 만들고, 아래 `ensure` 가 그만큼 버킷 객체를 채워 넣는다. 9999년 값 하나면
 * 60분 창 기준 7천만 개다 — 활성화를 가르는 도구가 데이터 오염 1건에 OOM 으로 죽는다.
 */
export function bucketize(
  observations: ReadonlyArray<Observation>,
  opts: { fromMs: number; toMs: number; windowMs: number; vendors: ReadonlyArray<string> },
): { perVendor: Map<string, Bucket[]>; pool: Bucket[] } {
  const allowed = new Set(opts.vendors);
  const perVendor = new Map<string, Bucket[]>();
  const pool: Bucket[] = [];
  const ensure = (arr: Bucket[], i: number) => {
    while (arr.length <= i) arr.push({ total: 0, failed: 0 });
    return arr[i];
  };
  for (const v of opts.vendors) perVendor.set(v, []);

  for (const o of observations) {
    if (!allowed.has(o.provider)) continue; //* 스냅샷 밖 — 버킷 집계 단계에서 버린다
    if (!Number.isFinite(o.at) || o.at > opts.toMs) continue; //* 미래 시각 행은 관측이 아니다
    const idx = Math.floor((o.at - opts.fromMs) / opts.windowMs);
    if (idx < 0) continue;
    const vb = ensure(perVendor.get(o.provider)!, idx);
    vb.total += 1;
    if (o.failed) vb.failed += 1;
    const pb = ensure(pool, idx);
    pb.total += 1;
    if (o.failed) pb.failed += 1;
  }
  return { perVendor, pool };
}

/**
 * 한 축의 분포·충분성·권고값을 낸다.
 *
 * `total >= minSample` 인 버킷만으로 백분위를 낸다(§D5-2). 1~2건짜리 버킷의 실패율은 0% 아니면
 * 100% 두 값뿐이라 분포가 아니라 잡음이고, 그런 버킷이 다수면 적격 버킷이 30개 있어도 백분위가
 * 그쪽으로 끌려간다. 임계를 뽑는 표본은 **그 임계가 적용될 표본과 같은 조건**이어야 한다 —
 * 규칙은 `total >= minSample` 인 회차에서만 판정한다.
 */
export function deriveAxis(
  label: string,
  buckets: ReadonlyArray<Bucket>,
  opts: { minSample: number; minBuckets: number; targetFpr: number },
): AxisResult {
  const eligible: number[] = [];
  let lowSampleBuckets = 0;
  let lowSampleRows = 0;
  for (const b of buckets) {
    if (b.total <= 0) continue; //* 표본 0 은 진단에도 세지 않는다 — 창이 비었을 뿐이다
    if (b.total < opts.minSample) {
      lowSampleBuckets += 1;
      lowSampleRows += b.total;
      continue;
    }
    eligible.push(b.failed / b.total);
  }

  const ok = eligible.length >= opts.minBuckets;
  const target = percentile(eligible, 100 - opts.targetFpr);
  return {
    label,
    eligibleBuckets: eligible.length,
    lowSampleBuckets,
    lowSampleRows,
    p50: percentile(eligible, 50),
    p90: percentile(eligible, 90),
    p95: percentile(eligible, 95),
    p99: percentile(eligible, 99),
    verdict: ok ? 'OK' : 'INSUFFICIENT_SAMPLE',
    //* 판정이 부족이면 **수치를 권고하지 않는다**. 낮은 신뢰도로 채우지 않는다.
    recommended: ok && target !== null ? clampThreshold(ceil2(target)) : null,
  };
}

export interface DerivationSignature {
  window_ms: number;
  min_sample: number;
  vendors: string[];
  ratio_threshold: number;
  overrides: Record<string, { threshold: number }>;
}

/**
 * 서명은 **전 축이 `OK`** 일 때만 나온다(§D5-4b · §D5-6). 넣을 값이 없는데 서명을 내면
 * 그것이 곧 미측정 임계의 승인이 된다.
 */
export function buildSignature(
  pool: AxisResult,
  vendorAxes: ReadonlyArray<{ vendor: string; axis: AxisResult }>,
  cfg: { windowMs: number; minSample: number; vendors: ReadonlyArray<string> },
): DerivationSignature | null {
  if (pool.verdict !== 'OK' || pool.recommended === null) return null;
  const overrides: Record<string, { threshold: number }> = {};
  for (const { vendor, axis } of vendorAxes) {
    if (axis.verdict !== 'OK' || axis.recommended === null) return null;
    overrides[vendor] = { threshold: axis.recommended };
  }
  return {
    window_ms: cfg.windowMs,
    min_sample: cfg.minSample,
    vendors: [...cfg.vendors].sort(),
    ratio_threshold: pool.recommended,
    overrides: Object.fromEntries(
      Object.keys(overrides)
        .sort()
        .map((k) => [k, overrides[k]]),
    ),
  };
}

const pct = (v: number | null) => (v === null ? '—' : v.toFixed(4));

/** 출력: 서명(있으면 맨 앞) + 마크다운 표. 행은 **벤더 N개 + 풀 1개**다(§D5-6). */
export function renderReport(input: {
  signature: DerivationSignature | null;
  pool: AxisResult;
  vendorAxes: ReadonlyArray<{ vendor: string; axis: AxisResult }>;
  cfg: {
    windowMs: number;
    minSample: number;
    minBuckets: number;
    targetFpr: number;
    days: number;
    vendors: ReadonlyArray<string>;
    vendorSource: string;
    settingSource: Record<string, string>;
  };
  undecidedVendors: number;
}): string {
  const c = input.cfg;
  const lines: string[] = [];

  if (input.signature) {
    //* **첫 줄이 곧 서명**이다. 사람이 손으로 옮겨 적으면 그 자리에서 어긋나므로(§D9 4단계)
    //*  완성된 JSON 한 줄을 맨 앞에 둔다 — `treat-vendor_derivation` 에 그대로 붙여넣는다.
    lines.push(JSON.stringify(input.signature), '');
    //* 서명만 안내하면 2~3 단계를 건너뛴 활성화가 §D10 drift throw 로 떨어져 원인을 못 찾는다.
    //*  서명은 증서일 뿐이고 규칙이 실제로 읽는 자리는 아래 두 키다.
    lines.push(
      '> **활성화는 이 한 줄로 끝나지 않는다**(§D9 활성화 5단계). 서명은 증서이고, 규칙이 실제로 읽는 값은 따로 넣어야 한다:',
      '>',
      `> 2. \`treat-vendor_ratio_threshold\` ← \`${input.signature.ratio_threshold}\``,
      `> 3. \`treat-vendor_overrides\` ← \`${JSON.stringify(input.signature.overrides)}\``,
      '> 4. `treat-vendor_derivation` ← 위 서명 한 줄 그대로',
      '> 5. 2~4 를 DB 에서 재조회해 대조한 뒤에야 `alert_rule_enabled:treat-vendor` = `1`',
      '>',
      '> 2~3 을 빼면 규칙이 매 회차 `산출 조건·적용값이 서명과 다르다` 로 throw 한다.',
      '',
    );
  } else {
    lines.push(
      '## 서명 — **출력하지 않는다**',
      '',
      '풀 축이나 스냅샷 벤더 중 하나 이상이 `INSUFFICIENT_SAMPLE` 이다(§D5-4b 부분 미달 규칙).',
      '넣을 값이 없는데 서명을 내면 그것이 곧 미측정 임계의 승인이 된다.',
      '',
    );
  }

  lines.push('## 산출 조건', '');
  lines.push('| 항목 | 값 | 출처 |');
  lines.push('|---|---|---|');
  lines.push(`| 기간 | 최근 ${c.days}일 | \`--days\` |`);
  lines.push(`| 모집단(스냅샷) | ${c.vendors.join(' · ') || '(없음)'} | ${c.vendorSource} |`);
  lines.push(`| 창(window_ms) | ${c.windowMs} | ${c.settingSource.window_ms} |`);
  lines.push(`| 표본 하한(min_sample) | ${c.minSample} | ${c.settingSource.min_sample} |`);
  lines.push(`| 목표 오발화율 | ${c.targetFpr}% → p${100 - c.targetFpr} | \`--target-fpr\` |`);
  lines.push(`| 적격 버킷 하한 | ${c.minBuckets} | \`--min-buckets\` (기본 max(30, ceil(100/fpr))) |`);
  lines.push('');

  lines.push('## 축별 판정', '');
  lines.push('| 축 | 적격 버킷 | p50 | p90 | p95 | p99 | 판정 | 권고 임계 |');
  lines.push('|---|---|---|---|---|---|---|---|');
  const row = (a: AxisResult) =>
    `| ${a.label} | ${a.eligibleBuckets} | ${pct(a.p50)} | ${pct(a.p90)} | ${pct(a.p95)} | ${pct(a.p99)} | \`${a.verdict}\` | ${a.recommended === null ? '**없음**' : a.recommended} |`;
  for (const { axis } of input.vendorAxes) lines.push(row(axis));
  lines.push(row(input.pool));
  lines.push('');

  lines.push('## 진단 통계', '');
  lines.push('| 축 | 저표본 버킷 수 | 그 안의 총 건수 |');
  lines.push('|---|---|---|');
  for (const { axis } of input.vendorAxes)
    lines.push(`| ${axis.label} | ${axis.lowSampleBuckets} | ${axis.lowSampleRows} |`);
  lines.push(`| ${input.pool.label} | ${input.pool.lowSampleBuckets} | ${input.pool.lowSampleRows} |`);
  lines.push('');
  lines.push(
    `표본 하한 미달로 판정하지 못한 벤더: **${input.undecidedVendors}종** — §D4 가 의도한 침묵의 크기다.`,
  );
  lines.push('');
  return lines.join('\n');
}

/** 성공·실패 정의는 `treat-monitor.service.ts:171-177` 승계. 진행 중인 행은 어느 쪽도 아니다. */
export const SUCCESS_STATE = 'ODD006';
export const FAIL_STATES = ['ODD004', 'ODD007', 'ODD008'];
export const FINAL_FAIL_STATES = ['ODD004', 'ODD007'];

/**
 * 행 1건 → 관측 **0~2건**. 실패 우선으로 접지 않는다.
 *
 * 런타임 집계(`alert.repository.ts` 의 `countVendorTreatResults`)는 성공 조건과 실패 조건을
 * **독립된 두 `SUM`** 으로 세고 `total = success + failed` 로 묶는다. 그래서 `odd_state='ODD006'`
 * 이면서 `odd_final_state='ODD004'` 이고 두 시각이 모두 창 안인 행은 거기서 `total 2 · failed 1`
 * 이다. 여기서 실패 1건으로 접으면 같은 행이 `total 1 · failed 1` 이 되어 **백분위가 런타임이 잴
 * 양과 다른 양에서 나온다**. §D10 서명은 설정값만 담으므로 이 차이를 잡지 못한다 — 세는 규칙은
 * 두 파일이 문면으로 맞추는 수밖에 없다.
 */
export function classifyRow(r: {
  provider: unknown;
  state: unknown;
  finalState: unknown;
  completedAt: unknown;
  failedAt: unknown;
}): Observation[] {
  const provider = String(r.provider ?? '').trim();
  if (!provider) return [];
  const state = String(r.state ?? '');
  const finalState = String(r.finalState ?? '');
  const completed = r.completedAt ? new Date(String(r.completedAt)).getTime() : NaN;
  const failedAt = r.failedAt ? new Date(String(r.failedAt)).getTime() : NaN;

  const out: Observation[] = [];
  if (state === SUCCESS_STATE && Number.isFinite(completed))
    out.push({ provider, at: completed, failed: false });
  if (
    (FAIL_STATES.includes(state) || FINAL_FAIL_STATES.includes(finalState)) &&
    Number.isFinite(failedAt)
  )
    out.push({ provider, at: failedAt, failed: true });
  return out;
}

//---------------------------------------------------------------//
// main — 여기부터 Nest·DB. lazy require 라 import 만으로는 아무것도 실행되지 않는다.
//---------------------------------------------------------------//

//* `require()` 가 이 절의 **요건**이다 — 상단 `import` 로 올리면 spec 이 이 파일을 import 하는
//*  순간 Nest 와 DB 커넥션이 딸려 온다. `alert-inject.ts` 가 같은 이유로 같은 형태다.
/* eslint-disable @typescript-eslint/no-require-imports */

async function main(): Promise<void> {
  const opts = parseArgs(process.argv.slice(2));
  require('dotenv').config();

  const { NestFactory } = require('@nestjs/core');
  const { AppModule } = require('@api/app.module');
  const { AlertRepository } = require('@api/modules/alert/alert.repository');
  const {
    TreatProviderService,
  } = require('@api/modules/treat/provider/treat-provider.service');
  const { DrizzleService } = require('@lib/drizzle/src/drizzle.service');
  const { sql } = require('drizzle-orm');
  //* 설정 부재 시 폴백은 **규칙이 들고 있는 값 그대로**여야 한다 — 여기 숫자를 따로 적으면
  //*  한쪽만 바뀌었을 때 서명이 영구 drift 로 떨어져 규칙이 통째로 멈춘다(§D10).
  const {
    DEFAULT_WINDOW_MS,
    DEFAULT_MIN_SAMPLE,
    TREAT_VENDOR_RANGE,
  } = require('@api/modules/alert/rules/treat-vendor.rule');

  //* 부팅은 수십 초가 걸리고 `bufferLogs` 라 Nest 로그도 안 나온다 — 진행 표시가 없으면
  //*  멈춘 것과 구분되지 않는다. 실측에서 실제로 걸렸다.
  console.log('[derive] Nest 컨텍스트 부팅 중…');
  const app = await NestFactory.createApplicationContext(AppModule, { bufferLogs: true });
  console.log('[derive] 부팅 완료. 설정·관측 조회 시작');
  try {
    const repo = app.get(AlertRepository);

    //* 설정은 접두 조회 **1회**로 일관되게 읽는다(R1 F4). 실패는 여기서 throw 로 번지고,
    //*  부재는 기본값으로 채우되 무엇이 채워졌는지 리포트에 적는다.
    const raw: Record<string, string> = await repo.getSettingsByPrefix('treat-vendor_');
    const settingSource: Record<string, string> = {};
    const read = (key: string, fallback: number, range: { min: number; max: number }): number => {
      const r = readSetting(raw, key, fallback, range);
      settingSource[key] = r.source;
      return r.value;
    };
    const windowMs = read('window_ms', DEFAULT_WINDOW_MS, TREAT_VENDOR_RANGE.windowMs);
    const minSample = read('min_sample', DEFAULT_MIN_SAMPLE, TREAT_VENDOR_RANGE.minSample);

    let vendors = opts.vendors;
    let vendorSource = '`--vendors`';
    if (vendors === null) {
      const res = await app.get(TreatProviderService).list();
      vendors = ((res?.list ?? []) as any[])
        .filter((p) => Number(p?.tprEnabled) === 1)
        .map((p) => String(p.tprKey))
        .sort();
      vendorSource = '`tpr_enabled=1` 자동 채움';
      console.log(`[derive] --vendors 생략 → ${vendors.join(',')} 로 채웠다`);
    }

    const minBuckets = opts.minBuckets ?? defaultMinBuckets(opts.targetFpr);
    const toMs = Date.now();
    const fromMs = toMs - opts.days * 24 * 60 * 60 * 1000;
    const fromIso = new Date(fromMs).toISOString().slice(0, 19).replace('T', ' ');

    const db = app.get(DrizzleService);
    const rows: any[] = await db.execute(sql`
      SELECT odd_provider AS provider, odd_state AS state, odd_final_state AS finalState,
             odd_completed_at AS completedAt, odd_failed_at AS failedAt
        FROM tn_order_detail
       WHERE odd_provider IS NOT NULL
         AND (odd_completed_at >= ${fromIso} OR odd_failed_at >= ${fromIso})`);
    const flat: any[] = Array.isArray(rows?.[0]) ? rows[0] : rows;
    const observations = flat.flatMap((r) => classifyRow(r));

    const { perVendor, pool } = bucketize(observations, { fromMs, toMs, windowMs, vendors });
    const axisOpts = { minSample, minBuckets, targetFpr: opts.targetFpr };
    const vendorAxes = vendors.map((v) => ({
      vendor: v,
      axis: deriveAxis(v, perVendor.get(v) ?? [], axisOpts),
    }));
    const poolAxis = deriveAxis('**풀(집계)**', pool, axisOpts);
    const signature = buildSignature(poolAxis, vendorAxes, { windowMs, minSample, vendors });

    const report = renderReport({
      signature,
      pool: poolAxis,
      vendorAxes,
      cfg: {
        windowMs,
        minSample,
        minBuckets,
        targetFpr: opts.targetFpr,
        days: opts.days,
        vendors,
        vendorSource,
        settingSource: {
          window_ms: settingSource.window_ms ?? '`tn_alert_setting`',
          min_sample: settingSource.min_sample ?? '`tn_alert_setting`',
        },
      },
      undecidedVendors: vendorAxes.filter((a) => a.axis.verdict !== 'OK').length,
    });

    console.log(`\n[derive] 관측 ${observations.length}건 / 원본 ${flat.length}행\n`);
    console.log(report);
    if (opts.out) {
      require('fs').writeFileSync(opts.out, report, 'utf8');
      console.log(`[derive] 리포트 기록: ${opts.out}`);
    }
  } finally {
    await app.close();
  }
}

//* `app.close()` 뒤에도 Nest 가 쥔 핸들이 남아 이벤트 루프가 비지 않는다 — 리포트는 이미
//*   stdout 에 찍힌 채 셸만 돌아오지 않아 **행(hang)으로 보인다**(실측: 30분 넘게 "결과 없음"으로
//*   오독). 형제 `alert-inject.ts:1182` 와 같이 명시 종료한다. 빈 write 의 콜백은 앞선 쓰기가
//*   전부 flush 된 뒤에 불리므로, 파이프 stdout 에서 리포트가 잘리지 않는다.
if (require.main === module)
  main().then(
    () => process.stdout.write('', () => process.exit(0)),
    (e) => {
      console.error(e);
      process.exit(1);
    },
  );
