/**
 * 운영 장애 알림 — 강제 주입 하네스 (마일스톤 2.5)
 *
 *  P0 알림 4종(A1 정기결제 실패 / A3 스케줄러 하트비트 / A4 API 5xx / A6 인프라)을 dev 환경에
 *  강제로 주입하고, 실제 발송을 계측해 PRD Success Metrics M1~M13 을 실측한다.
 *  로직을 복제하지 않는다 — Nest 컨텍스트를 열어 `AlertService` 실인스턴스를 그대로 구동한다.
 *
 * 사용:
 *   cd insuniverse-server
 *   npx ts-node -r tsconfig-paths/register scripts/alert-inject.ts <command> [args]
 *
 *   status                       현재 지표 + 억제 상태 + 대장 상태 덤프
 *   inject a1 [--sample over|under]   실패 결제 행 시딩 (over=발송 기대 / under=표본미달 무발송 기대)
 *   inject a3 [--name fax-status]     하트비트를 기대주기 밖으로 되돌림
 *   inject a4 --base http://localhost:3000 --path /does-not-exist [--ok N] [--bad M]
 *   inject a4 --redis [--ok N] [--bad M]   자연 500 경로를 못 찾았을 때의 강등 경로(배선 미검증)
 *   inject a5 [--vendor octover] [--sample over|under]
 *                                진료 벤더 실패 행 시딩. 규칙 ON + §D10 산출 서명까지 함께 만든다
 *   clear a1 | a3 | a4 | a5      주입 해제(복구 알림 유발). 대장은 남는다
 *   poll [n] [--advance 5m] [--expect-db-down]
 *   cleanup                      대장 기반 원상 복구. idempotent
 *   scenario list                돌릴 수 있는 시나리오 목록 (부팅·DB 없이 뜬다)
 *   scenario <id>                한 시나리오를 끝까지: 억제 초기화 → 주입 → poll → 판정 → cleanup
 *
 * 손으로 도는 테스트 flow 는 `scripts/alert-test-flow.md` 에 있다. 이 파일은 그 flow 의 도구다.
 *
 * 되돌리기(§D3): 대장은 파일이 아니라 `tn_alert_setting` KV 행 1개(`alert-inject:ledger`)이며
 *   시딩과 **같은 트랜잭션**에서 commit 된다. 대장 없는 행이 생길 창이 없다.
 *   트랜잭션을 못 쓰는 KV 조작(A3·§D7 시간 전진)은 **대장을 먼저 commit 하고 그 다음에 쓴다** —
 *   반대 순서면 중간에 죽었을 때 복구 기록 없는 변경 키가 남는다. 반대 실패(쓰지 않은 키가
 *   대장에 남음)는 무해하다: cleanup 의 CAS 가 현재 값 불일치로 건너뛴다.
 *
 * 임계는 하네스가 갖지 않는다: 표본 하한·실패율 임계·판정 창·기대 주기·grace 를 전부
 *   `AlertSettingsService` / `alert_schedule_expect` 에서 읽는다 — **규칙이 보는 값과 같은 소스**다.
 *   상수를 따로 들고 있으면 그 값이 어긋난 순간 주입이 임계 안쪽에 떨어지고, 발화하지 않은
 *   알림이 "발송 0건"으로 기록되어 재려던 축이 통째로 거짓이 된다.
 *
 * 상호배제(§D3-6, Implement-Codex R1 F2 흡수): plan 원문은 sentinel 행 `SELECT FOR UPDATE` 를
 *   커맨드 끝까지 보유하라고 했으나, 그러면 (a) 잠금 트랜잭션 안에서 역행시킨 `digestSentAt` 이
 *   **다른 pool 커넥션으로 도는 `AlertService.run()` 에 보이지 않아** §D7 시간 전진이 조용히
 *   무효가 되고, (b) REPEATABLE READ 스냅샷이 굳어 cleanup 이 낡은 값을 원본으로 오인하며,
 *   (c) 같은 행에서 lock wait 로 회차가 멈춘다. 그래서 **수단만** 세션 advisory mutex
 *   (`GET_LOCK`)로 바꾸고 데이터 변경은 매번 짧은 트랜잭션으로 commit 한다. 배제 대상과
 *   목적(회수 불가 행·키 0)은 plan 그대로다.
 *
 * 이 하네스는 프로덕션 코드를 한 줄도 바꾸지 않는다. 계측은 `AlertService.send` 를
 *   pass-through 로 감싸는 방식이며 실제 HTTP 는 그대로 나간다(§D2).
 */

/* eslint-disable @typescript-eslint/no-require-imports --
 * 아래 순수 영역을 spec 이 Nest 부팅 없이 import 할 수 있어야 하므로 `@api/...` 를 전부
 * `main()` 안의 lazy require 로 내렸다(바로 아래 주석). 그 설계의 필연적 결과이며 제품 코드가
 * 아니다. 규칙을 파일 단위로 끄되 이유를 남긴다 — 개별 require 마다 붙이면 33곳이 된다. */

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

export const LEDGER_KEY = 'alert-inject:ledger';
export const LOCK_NAME = 'alert-inject:ledger';

/** 대장 sentinel 은 **영구**다 — 비울 뿐 지우지 않는다(§D3-6). */
export interface Ledger {
  v: 1;
  createdAt: number | null;
  /**
   * **회수되지 않은 주입 축** 목록(`a1`·`a3`·`a4`). "주입 중"의 유일한 정의다.
   *
   * 이것을 `kv` 유무로 대신하면 안 된다 — `--sim-heartbeat` 가 남긴 측정용 하트비트 키까지
   * "주입 중"으로 읽혀 정당한 다음 `inject` 가 거부된다(실측에서 실제로 걸렸다).
   * 추적(회수 대상)과 주입(해제 대상)은 다른 축이다.
   */
  injected: string[];
  paymentIds: number[];
  orderIds: number[];
  /**
   * `a5`(treat-vendor) 가 심은 `tn_order_detail` 행. **`orderIds` 보다 먼저** 지운다 —
   * `odd.ord_id` 가 `tn_order` 를 `onDelete: restrict` 로 참조하므로(`drizzle/schema.ts:1634`)
   * 순서를 뒤집으면 주문 삭제가 FK 로 막혀 회수가 중간에 선다.
   *
   * 옛 대장에는 이 필드가 없다. `parseLedger` 가 빈 배열로 채우므로 하위호환이며, 그런 대장이
   * 가리키는 주입에는 애초에 detail 행이 없다.
   */
  orderDetailIds: number[];
  /** KV 원복 대장. `written` 은 CAS 비교값이다(Implement-Codex R1 F3). */
  kv: Record<string, { original: string | null; written: string }>;
  /**
   * `inject` 가 소유한 KV 키. `--sim-heartbeat` 의 정상 운행 모사가 **덮으면 안 되는** 키다 —
   * 덮으면 A3 주입이 폴링 첫 회차에 스스로 해제된다.
   */
  pinned: string[];
}

export function emptyLedger(): Ledger {
  return {
    v: 1,
    createdAt: null,
    injected: [],
    paymentIds: [],
    orderIds: [],
    orderDetailIds: [],
    kv: {},
    pinned: [],
  };
}

/** 주입 중인가 — `kv` 추적 유무와 무관하다. */
export function ledgerIsActive(l: Ledger): boolean {
  return l.injected.length > 0;
}

//* auto-increment PK 는 **양의 정수**다. `Number(null)`·`Number('')` 은 0 이라 단순
//*  `Number.isFinite` 로 거르면 손상된 대장이 id 0 을 낳고, 그 0 이 DELETE 대상으로 흘러들어
//*  "회수했다"는 거짓 기록을 만든다. 형식이 아니라 **값의 정의역**으로 거른다.
const toPk = (v: unknown): number[] => {
  const n = Number(v);
  return Number.isInteger(n) && n > 0 ? [n] : [];
};

export function parseLedger(raw: string | null): Ledger {
  if (!raw || raw.trim() === '') return emptyLedger();
  try {
    const p = JSON.parse(raw);
    if (!p || typeof p !== 'object' || Array.isArray(p)) return emptyLedger();
    const strs = (v: unknown): string[] =>
      Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
    return {
      v: 1,
      createdAt: typeof p.createdAt === 'number' ? p.createdAt : null,
      injected: strs(p.injected),
      paymentIds: Array.isArray(p.paymentIds) ? p.paymentIds.flatMap(toPk) : [],
      orderIds: Array.isArray(p.orderIds) ? p.orderIds.flatMap(toPk) : [],
      orderDetailIds: Array.isArray(p.orderDetailIds) ? p.orderDetailIds.flatMap(toPk) : [],
      kv: p.kv && typeof p.kv === 'object' && !Array.isArray(p.kv) ? p.kv : {},
      pinned: strs(p.pinned),
    };
  } catch {
    //* 대장이 깨졌으면 **추측 삭제 금지**(§D3 4). 빈 대장으로 읽되 호출자가 경고한다.
    return emptyLedger();
  }
}

export function ledgerIsEmpty(l: Ledger): boolean {
  return (
    l.paymentIds.length === 0 &&
    l.orderIds.length === 0 &&
    l.orderDetailIds.length === 0 &&
    Object.keys(l.kv).length === 0
  );
}

//--------------------- 감시 목록 해석 (§D7) ---------------------//

//* `schedule-heartbeat.rule.ts` 의 항목 검증을 그대로 복제한다 — 상수가 export 되지 않는다.
//*  프로덕션 코드를 고치지 않는다는 제약 때문에 복제이며, 어긋나면 하네스와 규칙이 서로 다른
//*  감시 목록을 보게 된다.
const SCHEDULE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const MAX_SCHEDULE_ENTRIES = 100;

/**
 * 규칙이 **실제로 보는** 감시 목록. `DEFAULT_SCHEDULE_EXPECT` 만 읽으면 안 된다 —
 * `alert_schedule_expect` 오버라이드가 기대 주기를 늘려 둔 환경에서는 기본값 기준으로 되돌린
 * 하트비트가 여전히 `expect × grace` **안**이라 A3 가 발화하지 않고, 그 침묵이 "무발송"으로
 * 기록된다. 오버라이드에만 있는 이름이 "감시 목록에 없다"로 거부되는 것도 같은 원인이다.
 */
export function resolveScheduleExpect(
  raw: string | null,
  fallback: Record<string, number>,
): Record<string, number> {
  if (typeof raw !== 'string') return { ...fallback };
  try {
    const parsed = JSON.parse(raw);
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...fallback };
    const out: Record<string, number> = {};
    for (const [name, ms] of Object.entries(parsed))
      //* `Number(ms)` 로 강제하지 않는다 — 규칙이 그러지 않기 때문이다(`true` 가 1ms 주기로 통과).
      if (
        SCHEDULE_NAME_PATTERN.test(name) &&
        typeof ms === 'number' &&
        Number.isFinite(ms) &&
        ms > 0 &&
        Object.keys(out).length < MAX_SCHEDULE_ENTRIES
      )
        out[name] = ms;
    //* 전부 탈락이면 규칙도 기본 목록으로 되돌린다 — 같은 자리에서 갈라지지 않게 한다.
    return Object.keys(out).length > 0 ? out : { ...fallback };
  } catch {
    return { ...fallback };
  }
}

//--------------------- 심각도 분류 (§D2) ---------------------//

export type Severity =
  | 'recovery'
  | 'digest'
  | 'snooze-summary'
  | 'test'
  | 'critical'
  | 'warn'
  | 'unclassified';

/**
 * 어휘는 **실제 코드가 만들어내는 문자열**에서 왔다. plan §D2 는 테스트 메시지 접두를
 * `🧪 [테스트]` 로 적었으나 `alert-admin.controller.ts:208` 의 실제 기본값은 `🔔 [테스트]` 다 —
 * plan 오기이며 코드를 사실로 삼는다(§D2 4 가 요구하는 고정이 정확히 이것이다).
 *
 * 순서가 곧 규약이다(first-match-wins). 해소가 먼저인 이유는 그것이 발화 원문을 재생하기
 * 때문이다. 지금은 렌더가 원문의 심각도 마커를 걷어내므로(`alert-noise.ts` `renderRecovery`)
 * 겹치지 않지만, **이 순서를 유지한다** — 이 하네스는 배포본이 아니라 로그·이력에 남은 구
 * 문면(`✅ [복구] 🔴 [위험] …`)도 읽고, 그때 마커가 먼저 걸리면 해소가 critical 로 오분류된다.
 *
 * `🟠 [스누즈 중 경과]` 는 마일스톤 3 이 신설한 종류다(`alert.service.ts` 의 해제 요약). 이 어휘가
 * 없으면 스누즈 해제가 일어나는 모든 시나리오(V12·V18·V20·V35)에서 요약이 `unclassified` 로
 * 떨어지고, 스파이가 "미분류가 있으면 이 측정은 무효"(§D2 분류 규약 3)를 걸어 **측정 자체가
 * 버려진다** — 발송은 정상인데 계측기가 판정을 거부하는 상태다. 세 `🟠` 어휘는 리터럴이 서로
 * 접두가 아니라 순서에 걸리지 않는다.
 */
export const SEVERITY_VOCAB: ReadonlyArray<readonly [Severity, string]> = [
  ['recovery', '✅ [복구]'],
  ['digest', '🟠 [요약]'],
  ['snooze-summary', '🟠 [스누즈 중 경과]'],
  ['test', '🔔 [테스트]'],
  ['critical', '🔴 [위험]'],
  ['warn', '🟠 [주의]'],
];

/** 환경 라벨 접두 `[stg] `. **선택적**이다 — `POST /test` 는 `withLabel` 을 거치지 않는다. */
const LABEL_PREFIX = /^\[[^\]]*\]\s*/;

export function classifySeverity(raw: string): Severity {
  const text = String(raw ?? '');
  //* 라벨을 벗긴 몸통과 원문을 **둘 다** 본다. 라벨이 없는 메시지(POST /test)를 미분류로
  //*  떨어뜨리면 "미분류 0" Acceptance 와 "미분류는 측정 무효" 규칙이 서로를 잠근다.
  const body = text.replace(LABEL_PREFIX, '');
  for (const [sev, marker] of SEVERITY_VOCAB)
    if (body.startsWith(marker) || text.startsWith(marker)) return sev;
  return 'unclassified';
}

//--------------------- 시나리오 카탈로그 ---------------------//

/**
 * 한 관측 구간(poll 회차 묶음 또는 test 발송)의 요약. **판정식은 이 값만 본다** — 판정식이
 * DB·Nest 를 만지면 순수 영역 밖으로 나가고 spec 이 부팅 없이 못 돈다(파일 머리 주석).
 */
export interface ScenarioObservation {
  delivered: number;
  critical: number;
  warn: number;
  recovery: number;
  digest: number;
  test: number;
  unclassified: number;
  /** 같은 critical 이 몇 개의 **서로 다른** 목적지로 갔는가 — M10 의 N/N 이 이 값이다 */
  criticalDestinations: number;
  /** 평가되지 않은 회차 수. 1 이상이면 그 구간의 "0건"은 무발송의 증거가 아니다(`cmdPoll` 주석) */
  invalid: number;
}

export type ScenarioStep =
  | { do: 'inject'; signal: Signal; sample?: 'over' | 'under' }
  | { do: 'clear'; signal: Signal }
  | { do: 'poll'; rounds: number }
  | { do: 'test' };

export interface ScenarioContext {
  /** `tn_alert_channel` 중 `alc_enabled = 1` 인 채널 수 */
  channels: number;
}

export interface ScenarioDef {
  id: string;
  title: string;
  /** 사람이 읽는 기대. **판정식과 같은 말을 해야 한다** — 어긋나면 리포트가 거짓이 된다 */
  expect: string;
  /** PRD Success Metrics 중 이 시나리오가 겨냥하는 지표 */
  metrics: string[];
  steps: ScenarioStep[];
  /** 관측 구간을 순서대로 받는다. `null` = PASS, 문자열 = 실패 사유 */
  verdict: (obs: ScenarioObservation[], ctx: ScenarioContext) => string | null;
  /** 사전 조건 미충족 사유. `null` 이면 실행한다 */
  skipIf?: (ctx: ScenarioContext) => string | null;
}

/**
 * **판정식은 관측을 해석할 뿐 관측을 만들지 않는다.** `invalid > 0`·`unclassified > 0` 는
 * 판정식에 닿기 전에 러너가 통째로 무효 처리한다 — 두 경우의 "0건"은 무발송의 증거가 아니고
 * (`cmdPoll` 의 lease 함정 주석 · §D2 분류 규약 3), 그것을 시나리오마다 다시 적으면 한 곳만
 * 빠뜨렸을 때 그 시나리오가 조용히 거짓 PASS 를 낸다.
 */
export const SCENARIOS: ReadonlyArray<ScenarioDef> = [
  {
    id: 'payment-fire',
    title: 'A1 정기결제 실패 급증 — 발송',
    expect: '주입 회차에 critical 1건 이상',
    metrics: ['M1', 'M2'],
    steps: [{ do: 'inject', signal: 'a1', sample: 'over' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.critical >= 1 ? null : `critical=${p.critical} (기대 ≥1)`),
  },
  {
    id: 'payment-under',
    title: 'A1 표본 하한 미달 — 무발송',
    expect: '표본이 하한에 못 미치면 발송 0건 (ProviQ 절대건수 오탐의 반대 축)',
    metrics: ['M7'],
    steps: [{ do: 'inject', signal: 'a1', sample: 'under' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.delivered === 0 ? null : `발송=${p.delivered} (기대 0)`),
  },
  {
    id: 'payment-suppress',
    title: 'A1 중복억제 — 장애 지속 중 재발송 없음',
    expect: '3회차를 연속으로 돌려도 critical 은 정확히 1건',
    metrics: ['M4'],
    steps: [{ do: 'inject', signal: 'a1', sample: 'over' }, { do: 'poll', rounds: 3 }],
    verdict: ([p]) => (p.critical === 1 ? null : `critical=${p.critical} (기대 정확히 1)`),
  },
  {
    id: 'payment-recover',
    title: 'A1 복구 알림 — 해소 시 정확히 1회',
    expect: '주입 회차 critical ≥1, 해제 회차 recovery 정확히 1건',
    metrics: ['M3'],
    steps: [
      { do: 'inject', signal: 'a1', sample: 'over' },
      { do: 'poll', rounds: 1 },
      { do: 'clear', signal: 'a1' },
      { do: 'poll', rounds: 1 },
    ],
    verdict: ([fire, rec]) =>
      fire.critical < 1
        ? `주입 회차 critical=${fire.critical} (기대 ≥1) — 발화하지 않았으므로 복구를 잴 수 없다`
        : rec.recovery === 1
          ? null
          : `해제 회차 recovery=${rec.recovery} (기대 정확히 1)`,
  },
  {
    id: 'multi-channel',
    title: '다중 채널 발송 — 1개 조건이 전 채널로',
    expect: '조건 1건 충족 시 활성 채널 전부가 같은 critical 을 받는다',
    metrics: ['M10'],
    steps: [{ do: 'inject', signal: 'a1', sample: 'over' }, { do: 'poll', rounds: 1 }],
    skipIf: (ctx) =>
      ctx.channels >= 2 ? null : `활성 채널이 ${ctx.channels}개다 — 2개 이상 등록해야 잴 수 있다`,
    verdict: ([p], ctx) =>
      p.criticalDestinations === ctx.channels
        ? null
        : `critical 목적지=${p.criticalDestinations} (기대 ${ctx.channels})`,
  },
  {
    id: 'heartbeat-fire',
    title: 'A3 스케줄러 미실행 — 발송',
    expect: '하트비트를 기대주기 밖으로 되돌리면 critical 1건 이상',
    metrics: ['M1', 'M2'],
    steps: [{ do: 'inject', signal: 'a3' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.critical >= 1 ? null : `critical=${p.critical} (기대 ≥1)`),
  },
  {
    id: 'heartbeat-recover',
    title: 'A3 복구 알림',
    expect: '하트비트 갱신 회차에 recovery 정확히 1건',
    metrics: ['M3'],
    steps: [
      { do: 'inject', signal: 'a3' },
      { do: 'poll', rounds: 1 },
      { do: 'clear', signal: 'a3' },
      { do: 'poll', rounds: 1 },
    ],
    verdict: ([fire, rec]) =>
      fire.critical < 1
        ? `주입 회차 critical=${fire.critical} (기대 ≥1)`
        : rec.recovery === 1
          ? null
          : `해제 회차 recovery=${rec.recovery} (기대 정확히 1)`,
  },
  {
    id: 'api5xx-fire',
    title: 'A4 API 5xx 급증 — 발송',
    expect: '5xx 비율이 임계를 넘으면 critical 1건 이상',
    metrics: ['M1'],
    steps: [{ do: 'inject', signal: 'a4' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.critical >= 1 ? null : `critical=${p.critical} (기대 ≥1)`),
  },
  {
    id: 'vendor-fire',
    title: '진료 벤더 장애 — 풀 축 발송 (마일스톤 4)',
    expect: '한 벤더를 전부 실패로 채우면 풀 실패율이 임계를 넘어 critical 1건 이상',
    metrics: ['M1', 'M6'],
    steps: [{ do: 'inject', signal: 'a5', sample: 'over' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.critical >= 1 ? null : `critical=${p.critical} (기대 ≥1)`),
  },
  {
    id: 'vendor-under',
    title: '진료 벤더 표본 하한 미달 — 무발송',
    expect: '표본이 하한에 못 미치면 발송 0건',
    metrics: ['M7'],
    steps: [{ do: 'inject', signal: 'a5', sample: 'under' }, { do: 'poll', rounds: 1 }],
    verdict: ([p]) => (p.delivered === 0 ? null : `발송=${p.delivered} (기대 0)`),
  },
  {
    id: 'quiet',
    title: '무장애 — 노이즈 상한',
    expect: '주입 없이 3회차를 돌리면 발송 0건',
    metrics: ['M9'],
    steps: [{ do: 'poll', rounds: 3 }],
    verdict: ([p]) => (p.delivered === 0 ? null : `발송=${p.delivered} (기대 0)`),
  },
  {
    id: 'channel-test',
    title: '채널 도달성 — 수동 테스트 발송',
    expect: '활성 채널 전부가 테스트 메시지를 받는다',
    metrics: ['M10'],
    steps: [{ do: 'test' }],
    skipIf: (ctx) => (ctx.channels >= 1 ? null : '활성 채널이 없다'),
    verdict: ([p], ctx) =>
      p.test === ctx.channels ? null : `test 수신=${p.test} (기대 ${ctx.channels})`,
  },
];

//--------------------- 인자 파싱 ---------------------//

export type Command = 'status' | 'inject' | 'clear' | 'poll' | 'cleanup' | 'test' | 'scenario';

/** `inject`·`clear` 가 받는 신호 축. `a5` 는 진료 벤더 비교(마일스톤 4)다. */
export type Signal = 'a1' | 'a3' | 'a4' | 'a5';

/**
 * `POST /admin/alert/test` 의 기본 문자열과 **같은 값**이다(`alert-admin.controller.ts:208`).
 * 채널 도달성 항목이 실제 라우트와 같은 메시지를 쓰게 해서 분류 결과도 같게 만든다.
 */
export const TEST_MESSAGE = '🔔 [테스트] 운영 알림 채널 연결 확인';

export interface ParsedArgs {
  command: Command;
  /** inject/clear 의 신호 축 */
  signal?: Signal;
  /** `scenario` 가 돌릴 시나리오 id. `scenario list` 는 `'list'` 로 들어온다 */
  scenario?: string;
  /** `inject a5` 대상 벤더 키(`tn_treat_provider.tpr_key`). null 이면 활성 벤더 중 첫 번째 */
  vendor: string | null;
  /** poll 회차 수 */
  rounds: number;
  /** 회차 사이 시간 전진 폭(ms). 0 이면 시각 조작 없음(9-a) */
  advanceMs: number;
  /** §D3-6 명시 예외 — DB 차단 구간 전용 모드 */
  expectDbDown: boolean;
  /** 회차마다 감시 목록 전 스케줄러의 하트비트를 가상 now 로 갱신한다(§D7 "정상 운행 모사") */
  simHeartbeat: boolean;
  sample: 'over' | 'under';
  name: string;
  base: string | null;
  path: string;
  redis: boolean;
  ok: number;
  bad: number;
}

const SIGNALS = new Set<string>(['a1', 'a3', 'a4', 'a5']);

export function parseDurationMs(raw: string): number {
  const m = /^(\d+)(ms|s|m|h)?$/.exec(String(raw ?? '').trim());
  if (!m) throw new Error(`기간 형식 오류: '${raw}' (예: 5m, 30s, 300000ms)`);
  const n = Number(m[1]);
  const unit = m[2] ?? 'ms';
  const mult = unit === 'ms' ? 1 : unit === 's' ? 1000 : unit === 'm' ? 60_000 : 3_600_000;
  return n * mult;
}

/**
 * 손으로 판다 — 의존성을 늘리지 않는다. 대신 **모르는 플래그는 조용히 무시하지 않고 던진다.**
 * `--expect-db-down` 오타가 일반 poll 로 조용히 강등되면 §D3-6 예외가 무력화된다(R1 F4).
 */
export function parseArgs(argv: string[]): ParsedArgs {
  const [cmdRaw, ...rest] = argv;
  const cmd = String(cmdRaw ?? '').trim();
  if (!['status', 'inject', 'clear', 'poll', 'cleanup', 'test', 'scenario'].includes(cmd))
    throw new Error(`알 수 없는 커맨드: '${cmd}'. status|inject|clear|poll|cleanup|test|scenario`);

  const out: ParsedArgs = {
    command: cmd as Command,
    rounds: 1,
    advanceMs: 0,
    expectDbDown: false,
    simHeartbeat: false,
    sample: 'over',
    name: 'fax-status',
    vendor: null,
    base: null,
    path: '/__alert-inject-not-found__',
    redis: false,
    ok: 20,
    bad: 20,
  };

  const positional: string[] = [];
  for (let i = 0; i < rest.length; i++) {
    const a = rest[i];
    const need = (): string => {
      const v = rest[++i];
      if (v === undefined || v.startsWith('--')) throw new Error(`${a} 에 값이 필요하다`);
      return v;
    };
    //* 값도 던진다. `Number('abc')` 는 NaN 이고 `for (n = 0; n < NaN; n++)` 는 **조용히 0회** 돈다 —
    //*  `--bad abc` 가 5xx 를 한 건도 만들지 않고, 그 침묵이 "자연 500 경로 없음"으로 오독된다.
    //*  플래그 이름만 검사하고 값을 안 보면 R1 F4 가 막으려던 조용한 강등이 값 쪽으로 옮겨갈 뿐이다.
    const needCount = (): number => {
      const raw = need();
      const n = Number(raw);
      if (!Number.isInteger(n) || n < 0) throw new Error(`${a} 는 0 이상의 정수여야 한다: '${raw}'`);
      return n;
    };
    if (!a.startsWith('--')) {
      positional.push(a);
      continue;
    }
    switch (a) {
      case '--advance': out.advanceMs = parseDurationMs(need()); break;
      case '--expect-db-down': out.expectDbDown = true; break;
      case '--sim-heartbeat': out.simHeartbeat = true; break;
      case '--sample': {
        const v = need();
        if (v !== 'over' && v !== 'under') throw new Error(`--sample 은 over|under`);
        out.sample = v;
        break;
      }
      case '--name': out.name = need(); break;
      case '--vendor': out.vendor = need(); break;
      case '--base': out.base = need().replace(/\/+$/, ''); break;
      case '--path': out.path = need(); break;
      case '--redis': out.redis = true; break;
      case '--ok': out.ok = needCount(); break;
      case '--bad': out.bad = needCount(); break;
      default: throw new Error(`알 수 없는 플래그: ${a}`);
    }
  }

  if (cmd === 'inject' || cmd === 'clear') {
    const sig = positional[0];
    if (!sig || !SIGNALS.has(sig)) throw new Error(`${cmd} 은 신호 축이 필요하다: a1|a3|a4|a5`);
    out.signal = sig as Signal;
  }
  //* 시나리오 id 는 **여기서** 검증한다. 부팅 뒤에 던지면 오타 한 번에 Nest 부팅 비용이 붙고,
  //*  더 나쁘게는 앞선 단계가 이미 대장을 올린 뒤에 멈출 수 있다(§D3-6 과 같은 이유).
  if (cmd === 'scenario') {
    const id = positional[0];
    if (!id) throw new Error(`scenario 는 id 가 필요하다. 목록: scenario list`);
    if (id !== 'list' && !SCENARIOS.some((s) => s.id === id))
      throw new Error(
        `알 수 없는 시나리오: '${id}'. 목록: ${SCENARIOS.map((s) => s.id).join(' | ')}`,
      );
    out.scenario = id;
  }
  //* 인자 오류는 여기서 끝낸다 — 부팅 뒤에 던지면 그때는 이미 a4 가 대장에 올라가 있고,
  //*  오타 한 번이 "활성 대장이 있다"로 다음 주입을 막는다.
  if (cmd === 'inject' && out.signal === 'a4' && !out.base && !out.redis)
    throw new Error('inject a4 는 --base <API 주소> 또는 --redis 가 필요하다');
  if (cmd === 'poll' && positional[0] !== undefined) {
    const n = Number(positional[0]);
    if (!Number.isInteger(n) || n < 1) throw new Error(`poll 회차 수가 잘못됐다: '${positional[0]}'`);
    out.rounds = n;
  }
  if (out.advanceMs > 0 && cmd !== 'poll') throw new Error('--advance 는 poll 전용이다');
  if (out.expectDbDown && cmd !== 'poll') throw new Error('--expect-db-down 은 poll 전용이다');
  if (out.simHeartbeat && cmd !== 'poll') throw new Error('--sim-heartbeat 는 poll 전용이다');
  //* 시간 전진은 하트비트 모사 없이는 성립하지 않는다(§D7 키별 조작 표) — 짝으로 묶는다.
  if (out.advanceMs > 0) out.simHeartbeat = true;
  return out;
}

//--------------------- 대상 DB 가드 ---------------------//

/**
 * **이 하네스는 DB 에 실제 실패 결제 행을 넣는다.** 대상을 잘못 잡으면 stg·운영 집계가
 * 오염되고 되돌릴 창구는 대장 하나뿐이다. 포트만으로는 못 가린다 — dev(13306)와 stg 포트가
 * 인접하고 터널 설정 하나로 뒤바뀐다. **DB 이름**을 사실 소스로 삼는다.
 *
 * 통과 조건은 둘 중 하나뿐이다:
 *   - 이름이 `-dev` 로 끝난다 (기본 안전지대)
 *   - `ALERT_INJECT_ALLOW_DB` 가 대상 이름과 **정확히** 같다 (와일드카드·접두 매칭 없음)
 *
 * 두 번째는 배포 수단이 생겼을 때 같은 판정을 stg 에서 재실행하기 위한 문이다(§D2).
 * 이름을 통째로 타이핑해야 열리므로 오조작으로는 열리지 않는다.
 *
 * `status` 는 읽기 전용이라 가드 밖이다.
 */
export function assertWritableDatabase(dbName: string | undefined, allow: string | undefined): void {
  const name = String(dbName ?? '').trim();
  if (name === '') throw new Error('DB_DATABASE 가 비어 있다 — 대상을 확인할 수 없으므로 진행하지 않는다');
  if (name.endsWith('-dev')) return;
  if (allow !== undefined && String(allow).trim() === name) return;
  throw new Error(
    `대상 DB '${name}' 는 dev 가 아니다. 이 하네스는 실패 결제 행을 실제로 넣는다 — 진행하지 않는다.\n` +
      `  의도한 것이라면 ALERT_INJECT_ALLOW_DB='${name}' 를 정확히 지정하라 (접두·와일드카드 불가).`,
  );
}

/**
 * DB 차단 회차가 낼 수 있는 유일한 결과 집합. 하나라도 벗어나면 그 측정은 무효다(R1 F4).
 *
 * **이 목록은 손으로 적은 사본이므로 자기를 검증하지 못한다.** spec 이 `onDbDown()` 원본에서
 * `db-down-*` 를 독립 재열거해 집합 대조를 건다 — 그러지 않으면 마일스톤이 새 skip 을 만들
 * 때마다 정상 폴백 회차가 조용히 "무효"로 찍히고, 그 침묵이 곧 미측정이 된다. M3 의
 * `db-down-snoozed` 가 실제로 그렇게 빠져 있었다(V26-a 회차 전량 무효).
 */
export const DB_DOWN_SKIPS: ReadonlySet<string> = new Set([
  'db-down-fallback',
  'db-down-streak',
  'db-down-no-snapshot',
  'db-down-disabled',
  'db-down-no-destination',
  'db-down-lease-not-acquired',
  'db-down-suppressed',
  'db-down-snoozed',
]);

//---------------------------------------------------------------//
// 실행 영역 — 여기서부터 Nest·DB 를 만진다.
//---------------------------------------------------------------//

interface SendRecord {
  url: string;
  text: string;
  severity: Severity;
  at: number;
  ok: boolean;
  status?: number;
  error?: string;
}

/**
 * `AlertService.send` pass-through 스파이. **인스턴스 프로퍼티**로 덮는다 — `run()` 이
 * `this.send(...)` 로 부르므로 그대로 잡히고, prototype 을 건드리지 않아 복원이 `delete` 하나다.
 *
 * 기록은 원본 호출 **후**이며 `SendResult` 를 함께 남긴다(Plan-Codex R5 F0):
 * 호출 전에 기록하면 HTTP 실패도 수신으로 세고 발송 시작 시각을 수신 시각으로 오인한다.
 *
 * **인자를 통째로 전달한다.** 초안은 `(url, text)` 두 개만 받아 `orig(url, text)` 로 넘겼는데,
 * 마일스톤 3 이 `send(url, text, ctx)` 로 컨텍스트를 추가하고 **`ctx` 가 있을 때만** 이력을
 * 기록하도록 바꿨다(`alert.service.ts` `if (ctx) await this.recordHistory(...)`). 그래서 스파이가
 * 3번째 인자를 떨어뜨리면 하네스 경유 발송이 `tn_alert_history` 에 **한 행도 남지 않는다** —
 * 게다가 유실 지점이 이력 훅보다 위쪽이라 `alert:historyGap` 표지조차 생기지 않아, 원장이
 * "발송이 없었다"와 구분되지 않는 상태로 조용히 비어 있게 된다. 계측기가 계측 대상을 껐다.
 * 가변 인자로 받으면 이후 시그니처가 또 바뀌어도 같은 방식으로 새지 않는다.
 */
function installSpy(svc: any, sink: SendRecord[]): () => void {
  const orig = svc.send.bind(svc);
  svc.send = async (...args: unknown[]) => {
    const [url, text] = args as [string, string];
    const r = await orig(...args);
    sink.push({
      url,
      text,
      severity: classifySeverity(text),
      at: Date.now(),
      ok: r?.ok === true,
      status: r?.status,
      error: r?.error,
    });
    return r;
  };
  return () => {
    delete svc.send; //* prototype 메서드로 되돌린다
  };
}

interface Lock {
  release(): Promise<void>;
}

/**
 * 세션 advisory mutex. **트랜잭션이 아니다** — MVCC 스냅샷을 만들지 않고 다른 커넥션의
 * 행 쓰기를 막지 않으므로 `AlertService.run()` 과 공존한다(R1 F2).
 * pool 이 아니라 전용 커넥션 1개에 세션을 고정해야 `GET_LOCK` 이 유지된다.
 */
async function acquireLock(cfg: any, timeoutSec = 15): Promise<Lock> {
  const mysql = require('mysql2/promise');
  const conn = await mysql.createConnection({
    host: cfg.get('db.host'),
    port: cfg.get('db.port'),
    user: cfg.get('db.user'),
    password: cfg.get('db.password'),
    database: cfg.get('db.database'),
    connectTimeout: 10_000,
  });
  const [rows]: any = await conn.query('SELECT GET_LOCK(?, ?) AS got', [LOCK_NAME, timeoutSec]);
  if (Number(rows?.[0]?.got) !== 1) {
    await conn.end();
    throw new Error(
      `대장 잠금 획득 실패 (${LOCK_NAME}) — 다른 alert-inject 커맨드가 실행 중이다. 끝난 뒤 재시도하라`,
    );
  }
  return {
    async release() {
      try {
        await conn.query('SELECT RELEASE_LOCK(?)', [LOCK_NAME]);
      } finally {
        await conn.end();
      }
    },
  };
}

//--------------------- KV / 대장 I/O ---------------------//

function kvHelpers(db: any) {
  const { sql } = require('drizzle-orm');
  return {
    async get(key: string): Promise<string | null> {
      const rows: any = await db.execute(
        sql`SELECT als_value AS v FROM tn_alert_setting WHERE als_key = ${key} LIMIT 1`,
      );
      const list = Array.isArray(rows) ? rows[0] : rows;
      return list?.[0]?.v ?? null;
    },
    async set(key: string, value: string): Promise<void> {
      await db.execute(sql`
        INSERT INTO tn_alert_setting (als_key, als_value, als_updated_at)
        VALUES (${key}, ${value}, NOW())
        ON DUPLICATE KEY UPDATE als_value = ${value}, als_updated_at = NOW()
      `);
    },
    async del(key: string): Promise<void> {
      await db.execute(sql`DELETE FROM tn_alert_setting WHERE als_key = ${key}`);
    },
    async byPrefix(prefix: string): Promise<Record<string, string>> {
      const pattern = prefix.replace(/[\\%_]/g, (c: string) => `\\${c}`) + '%';
      const rows: any = await db.execute(
        sql`SELECT als_key AS k, als_value AS v FROM tn_alert_setting WHERE als_key LIKE ${pattern}`,
      );
      const list = Array.isArray(rows) ? rows[0] : rows;
      const out: Record<string, string> = {};
      for (const r of list ?? []) out[r.k] = r.v ?? '';
      return out;
    },
  };
}

/** 읽기만 한다 — `status` 가 대상 DB 가드를 면제받으려면 정말로 아무것도 쓰지 않아야 한다. */
async function peekLedger(db: any): Promise<Ledger> {
  return parseLedger(await kvHelpers(db).get(LEDGER_KEY));
}

/** 쓰기 커맨드용. sentinel 행이 없으면 만든다 — 이후 `kv.set` 이 기댈 자리다. */
async function readLedger(db: any): Promise<Ledger> {
  const kv = kvHelpers(db);
  const raw = await kv.get(LEDGER_KEY);
  if (raw === null) await kv.set(LEDGER_KEY, JSON.stringify(emptyLedger()));
  return parseLedger(raw);
}

/**
 * KV 원본을 대장에 담는다. 원본 포착은 **최초 1회만** — 두 번 담으면 하네스가 쓴 값이
 * 원본으로 굳어 cleanup 이 되돌릴 곳을 잃는다.
 */
function recordKv(ledger: Ledger, key: string, original: string | null, written: string): void {
  const prev = ledger.kv[key];
  ledger.kv[key] = { original: prev ? prev.original : original, written };
}

/**
 * compare-and-swap 복원(R1 F3). 현재 값이 하네스가 쓴 값과 다르면 그 사이에 외부 기록자
 * (`markSchedule` · 별도 `run()`)가 갱신한 것이므로 **덮지 않는다.** 조용히 넘기지도 않는다 —
 * 건너뛴 키를 반환해 호출자가 리포트에 싣는다.
 */
async function restoreKv(db: any, ledger: Ledger): Promise<{ restored: string[]; skipped: string[] }> {
  const kv = kvHelpers(db);
  const restored: string[] = [];
  const skipped: string[] = [];
  for (const [key, rec] of Object.entries(ledger.kv)) {
    const cur = await kv.get(key);
    if (cur !== rec.written) {
      skipped.push(key);
      continue;
    }
    if (rec.original === null) await kv.del(key);
    else await kv.set(key, rec.original);
    restored.push(key);
  }
  for (const key of restored) delete ledger.kv[key];
  return { restored, skipped };
}

//--------------------- 커맨드 ---------------------//

/**
 * 마스킹은 호스트 뒤를 통째로 지운다 — 구글챗 웹훅 두 개는 **같은 문자열로 접힌다.**
 * 그대로 `Set` 에 넣으면 목적지 2건이 1건으로 보여 M10(N/N)의 증거가 사라진다.
 * 짧은 해시를 붙여 **구별은 되되 복원은 안 되게** 한다. 자격증명은 경로·쿼리에 있으므로
 * 원문 접두를 노출하는 방식은 쓰지 않는다.
 */
const mask = (u: string): string => {
  const { createHash } = require('crypto');
  const tag = createHash('sha256').update(String(u)).digest('hex').slice(0, 6);
  try {
    const { maskWebhookUrl } = require('@api/modules/alert/alert-url');
    return `${maskWebhookUrl(u)}#${tag}`;
  } catch {
    return `${String(u).replace(/\/\/([^/]*)\/.*/, '//$1/…')}#${tag}`;
  }
};

function summarize(records: SendRecord[]) {
  const by: Record<string, number> = {};
  let unclassified = 0;
  for (const r of records) {
    if (!r.ok) continue; //* 성공분만 수신으로 센다(§D2)
    by[r.severity] = (by[r.severity] ?? 0) + 1;
    if (r.severity === 'unclassified') unclassified++;
  }
  const ok = records.filter((r) => r.ok);
  //* M2(발생→통보 지연)의 두 번째 벽시계 점. **완료 시각**이지 발송 시작 시각이 아니다(§D2 R5 F0).
  const deliveredAt = ok.map((r) => r.at);
  return {
    by,
    unclassified,
    attempted: records.length,
    delivered: ok.length,
    firstDeliveredAt: deliveredAt.length ? Math.min(...deliveredAt) : null,
    lastDeliveredAt: deliveredAt.length ? Math.max(...deliveredAt) : null,
    //* 같은 심각도가 몇 개의 **서로 다른** 목적지로 갔는가 — M10 의 N/N 이 이 값이다.
    criticalDestinations: new Set(ok.filter((r) => r.severity === 'critical').map((r) => r.url)).size,
  };
}

async function cmdStatus(app: any, db: any): Promise<void> {
  const { AlertService } = require('@api/modules/alert/alert.service');
  const { STATE_KEY } = require('@api/modules/alert/alert.entity');
  const kv = kvHelpers(db);
  const status = await app.get(AlertService).status();
  const state = JSON.parse((await kv.get(STATE_KEY.suppress)) || '{}');
  const digest = JSON.parse((await kv.get(STATE_KEY.digest)) || '[]');
  const ledger = await peekLedger(db);

  const entries = Object.entries(state) as [string, any][];
  const sev = entries.map(([, e]) => classifySeverity(String(e?.text ?? '')));
  console.log(JSON.stringify(
    {
      status,
      suppress: {
        total: entries.length,
        critical: sev.filter((s) => s === 'critical').length,
        warn: sev.filter((s) => s === 'warn').length,
        unclassified: sev.filter((s) => s === 'unclassified').length,
      },
      digestQueue: Array.isArray(digest) ? digest.length : 0,
      ledger: { active: ledgerIsActive(ledger), injected: ledger.injected, payments: ledger.paymentIds.length, orders: ledger.orderIds.length, orderDetails: ledger.orderDetailIds.length, kvKeys: Object.keys(ledger.kv).length },
      hb: Object.keys(await kv.byPrefix(STATE_KEY.hbPrefix)).length,
    },
    null,
    2,
  ));
}

/**
 * 규칙이 실제로 보는 설정을 그대로 읽는다. KV 원문을 직접 읽으면 안 된다 —
 * 행이 없거나 범위를 벗어난 값은 `AlertSettingsService.coerce` 가 **규칙의 선언 기본값**으로
 * 채우는데(`alert-settings.service.ts:24`), 하네스가 자기 폴백을 따로 들고 있으면 그 둘이
 * 갈라진다. `applyRatio` 가 쓰는 것과 같은 `getForRule` 을 통과시켜 정의상 어긋날 수 없게 한다.
 */
async function ruleSettings(app: any, ruleId: string): Promise<Record<string, any>> {
  const { AlertService } = require('@api/modules/alert/alert.service');
  const { AlertSettingsService } = require('@api/modules/alert/alert-settings.service');
  const rule = app.get(AlertService).ruleCatalog.find((r: any) => r.id === ruleId);
  if (!rule) throw new Error(`규칙 '${ruleId}' 이 카탈로그에 없다 — 모듈 등록을 확인하라`);
  return app.get(AlertSettingsService).getForRule(rule);
}

/**
 * A1 — 실제 실패 결제 행 시딩(§D4). 시딩과 대장 갱신이 **한 트랜잭션**이다.
 *
 * 표본 하한을 KV 원문에서 읽고 부재 시 5 로 떨어뜨리던 것을 규칙 설정 읽기로 바꿨다.
 * 규칙의 선언 기본값은 **10** 이라(`subscription-payment.rule.ts` 표본 하한 메타) 행이 없는
 * dev DB 에서 5행만 심으면 `passesRatio` 가 표본 하한에서 걸러 A1 이 발화하지 않는다.
 * 그 침묵은 "발송 0건"으로 기록되므로 M1·M2·M10 이 통째로 거짓 음성이 된다.
 * (`alert.service.ts` 의 `?? 5` 는 규칙이 키를 선언하는 한 절대 걸리지 않는 폴백이다.)
 */
async function injectA1(app: any, db: any, ledger: Ledger, sample: 'over' | 'under'): Promise<void> {
  const { sql } = require('drizzle-orm');
  const dayjs = require('dayjs');
  const { passesRatio } = require('@api/modules/alert/alert-noise');
  const { AlertRepository } = require('@api/modules/alert/alert.repository');
  const {
    SUBSCRIPTION_PAYMENT_RULE_ID,
  } = require('@api/modules/alert/rules/subscription-payment.rule');

  const rs = await ruleSettings(app, SUBSCRIPTION_PAYMENT_RULE_ID);
  const minSample = Number(rs[`${SUBSCRIPTION_PAYMENT_RULE_ID}_min_sample`]);
  const threshold = Number(rs[`${SUBSCRIPTION_PAYMENT_RULE_ID}_ratio_threshold`]);
  const windowMs = Number(rs[`${SUBSCRIPTION_PAYMENT_RULE_ID}_window_ms`]);
  if (
    !Number.isInteger(minSample) ||
    minSample < 1 ||
    !Number.isFinite(threshold) ||
    !Number.isFinite(windowMs)
  )
    throw new Error(
      `규칙 설정을 읽지 못했다 — minSample=${minSample} threshold=${threshold} windowMs=${windowMs}`,
    );

  const total = sample === 'over' ? Math.max(minSample, 2) : Math.max(minSample - 1, 1);
  if (sample === 'under' && total >= minSample)
    throw new Error(`표본 하한(${minSample})이 1 이라 미달 조합을 만들 수 없다. 설정을 올리고 재시도하라`);

  await db.transaction(async (tx: any) => {
    //* FK 충족용 **읽기**다. `tn_payment.sub_id` 가 NOT NULL + FK 라 참조 대상 없이는 삽입이
    //*  불가능하다. plan §D4 의 "기존 행은 읽지도 않는다"는 이 제약을 반영하지 못한 문장이며,
    //*  읽기는 부작용이 없으므로 그대로 진행하고 Deviations 에 적는다.
    const subRows: any = await tx.execute(sql`SELECT sub_id FROM tn_subscribe ORDER BY sub_id LIMIT 1`);
    const subId = (Array.isArray(subRows) ? subRows[0] : subRows)?.[0]?.sub_id;
    if (subId === undefined)
      throw new Error('tn_subscribe 에 행이 없어 FK 를 만족시킬 수 없다 — dev DB 시드를 먼저 넣어라');

    const stamp = Date.now();
    for (let i = 0; i < total; i++) {
      //* 전부 실패로 넣는다 — 실패율 임계를 조합으로 맞추지 않고 100% 로 두면 판정이 결정적이다.
      const tid = `ALERT-INJECT-${stamp}-${i}`;
      const pmt: any = await tx.execute(sql`
        INSERT INTO tn_payment (sub_id, pmt_state, pmt_method, pmt_tid, pmt_amount, pmt_created_at)
        VALUES (${subId}, 'PAY003', 'PMT001', ${tid}, 0, NOW())
      `);
      const pmtId = Number((Array.isArray(pmt) ? pmt[0] : pmt)?.insertId);
      if (!Number.isFinite(pmtId) || pmtId <= 0) throw new Error('tn_payment insertId 를 읽지 못했다');
      ledger.paymentIds.push(pmtId);

      const ord: any = await tx.execute(sql`
        INSERT INTO tn_order (pmt_id, ord_type, ord_oid, ord_product_type, ord_product_code,
                              ord_product_name, ord_limited_qty, ord_created_at)
        VALUES (${pmtId}, 'ORD002', ${`AI${stamp}${i}`.slice(0, 20)}, 'PRD001', 'ALERT-INJECT',
                'alert-inject 주입', 0, NOW())
      `);
      const ordId = Number((Array.isArray(ord) ? ord[0] : ord)?.insertId);
      if (!Number.isFinite(ordId) || ordId <= 0) throw new Error('tn_order insertId 를 읽지 못했다');
      ledger.orderIds.push(ordId);
    }

    if (!ledger.injected.includes('a1')) ledger.injected.push('a1');
    ledger.createdAt = stamp;
    //* 대장 갱신이 시딩과 같은 트랜잭션에 있다 — 크래시하면 셋 다 없다(§D3 2).
    await tx.execute(sql`
      INSERT INTO tn_alert_setting (als_key, als_value, als_updated_at)
      VALUES (${LEDGER_KEY}, ${JSON.stringify(ledger)}, NOW())
      ON DUPLICATE KEY UPDATE als_value = ${JSON.stringify(ledger)}, als_updated_at = NOW()
    `);
  });

  /**
   * **심은 뒤 실제 모집단을 되읽는다.** 규칙은 심은 행이 아니라 판정 창 안의 FC 정기결제
   * **전체**를 센다(`countFcRegularPayments`). 창은 기본 2시간이라 dev DB 의 기존 행이 같은
   * 창에 들어온다 — `under` 는 그 행들 때문에 표본 하한을 넘겨 "미달 무발송" 전제가 무너지고,
   * `over` 는 성공 행에 희석돼 실패율이 임계 아래로 떨어진다. 둘 다 **발송 0건**이라는 같은
   * 모습으로 나타나므로 주입 시점에 수치를 남기고 기대와 어긋나면 여기서 멈춘다.
   */
  const fromIso = dayjs(Date.now() - windowMs).format('YYYY-MM-DD HH:mm:ss');
  const paid = await app.get(AlertRepository).countFcRegularPayments(fromIso);
  const fires = passesRatio(paid.total, paid.failed, minSample, threshold);
  console.log(
    `[inject a1] sample=${sample} seeded=${total}행(전부 결제실패) | 하한=${minSample} ` +
      `임계=${threshold} 창=${Math.round(windowMs / 60000)}분 → 창 내 모집단 ${paid.failed}/${paid.total} ` +
      `(발화예상=${fires})`,
  );
  if (fires !== (sample === 'over'))
    throw new Error(
      `주입 상태가 기대와 다르다 — sample=${sample} 인데 발화예상=${fires} ` +
        `(모집단 ${paid.failed}/${paid.total}, 하한 ${minSample}, 임계 ${threshold}). ` +
        `판정 창 안의 기존 행이 모집단을 바꿨다. cleanup 으로 회수한 뒤 ` +
        `${SUBSCRIPTION_PAYMENT_RULE_ID}_window_ms 를 좁히거나 창 안의 결제 행을 비우고 재시도하라`,
    );
}

/**
 * A5 — 진료 벤더 비교(`treat-vendor`) 시딩. 마일스톤 4 가 **표본 부족으로 한 번도 발화시켜
 * 보지 못한** 축이다(PRD M4 종료 기록: 전 축 `INSUFFICIENT_SAMPLE`, 규칙은 기본 OFF).
 *
 * 셋을 함께 만든다. 하나라도 빠지면 규칙이 한 회차도 돌지 않는다 —
 *   1. **규칙 ON** — `alert_rule_enabled:treat-vendor` 는 마이그레이션이 `'0'` 으로 시드한다(§D9).
 *   2. **산출 서명** — 규칙은 `treat-vendor_derivation` 이 비면 매 회차 `throw` 한다
 *      (§D10 fail-closed, `treat-vendor.rule.ts` `assertSignature`).
 *   3. **관측 행** — `tn_order_detail` 에 그 벤더의 실패 건을 심는다.
 *
 * 2 에 쓰는 서명은 **이 회차의 적용값을 그대로 옮겨 적은 배선 시험용**이며 실측 임계가 아니다.
 * `scripts/alert-threshold-derive.ts` 가 내는 서명과 성격이 다르다 — 그 둘을 같은 것으로 읽으면
 * §D10 이 막으려던 "측정된 바 없는 임계의 승인"이 산출 쪽이 아니라 **하네스 쪽으로** 열린다.
 * 그래서 이 함수는 서명을 남길 때마다 그 사실을 표준출력에 적고, 회수는 `cleanup` 의 CAS 복원에
 * 맡긴다(대장 `kv`). 하네스가 켠 규칙이 시험 뒤에도 켜진 채 남지 않게 하는 것이 이 배선의 목적이다.
 *
 * 행을 `odd_show=0 · odd_deletion=1` 로 심는 이유 — 규칙 쿼리(`countVendorTreatResults`)는 이 두
 * 열을 **보지 않으므로** 판정에는 그대로 잡히고, 진료 화면·목록에서는 빠진다. 규칙이 보는 것과
 * 사람이 보는 것을 갈라 놓아 시딩의 부수 피해를 줄인다. 규칙 쿼리가 나중에 이 열을 필터에 넣으면
 * 이 시딩은 조용히 0건이 되므로, 아래 되읽기 가드가 그 순간 실패로 드러낸다.
 */
async function injectA5(
  app: any,
  db: any,
  ledger: Ledger,
  vendorArg: string | null,
  sample: 'over' | 'under',
): Promise<void> {
  const { sql } = require('drizzle-orm');
  const dayjs = require('dayjs');
  const { passesRatio } = require('@api/modules/alert/alert-noise');
  const { AlertRepository } = require('@api/modules/alert/alert.repository');
  const { RULE_ENABLED_PREFIX } = require('@api/modules/alert/alert.entity');
  const {
    TREAT_VENDOR_RULE_ID,
    parseVendorOverrides,
  } = require('@api/modules/alert/rules/treat-vendor.rule');
  const {
    TreatProviderService,
  } = require('@api/modules/treat/provider/treat-provider.service');
  const kv = kvHelpers(db);

  const K = {
    windowMs: `${TREAT_VENDOR_RULE_ID}_window_ms`,
    minSample: `${TREAT_VENDOR_RULE_ID}_min_sample`,
    ratioThreshold: `${TREAT_VENDOR_RULE_ID}_ratio_threshold`,
    overrides: `${TREAT_VENDOR_RULE_ID}_overrides`,
    derivation: `${TREAT_VENDOR_RULE_ID}_derivation`,
  };

  const rs = await ruleSettings(app, TREAT_VENDOR_RULE_ID);
  const windowMs = Number(rs[K.windowMs]);
  const minSample = Number(rs[K.minSample]);
  const threshold = Number(rs[K.ratioThreshold]);
  if (
    !Number.isFinite(windowMs) ||
    !Number.isInteger(minSample) ||
    minSample < 1 ||
    !Number.isFinite(threshold)
  )
    throw new Error(
      `규칙 설정을 읽지 못했다 — windowMs=${windowMs} minSample=${minSample} threshold=${threshold}`,
    );

  //* 감시 대상은 코드가 아니라 `tn_treat_provider` 가 정한다 — 규칙의 `activeVendors` 와 같은
  //*  필터·같은 정렬이어야 서명의 `vendors` 가 일치한다. 어긋나면 §D10 이 drift 로 막는다.
  const provs: any[] = (await app.get(TreatProviderService).list())?.list ?? [];
  const vendors: string[] = provs
    .filter((p) => Number(p?.tprEnabled) === 1 && String(p?.tprKey ?? '').trim() !== '')
    .map((p) => String(p.tprKey).trim())
    .sort();
  if (vendors.length === 0)
    throw new Error('tn_treat_provider 에 tpr_enabled=1 인 행이 없다 — 감시 대상이 0종이면 잴 것이 없다');

  const target = vendorArg ?? vendors[0];
  if (!vendors.includes(target))
    throw new Error(`'${target}' 는 활성 벤더가 아니다. 활성 목록: ${vendors.join(', ')}`);

  const { overrides } = parseVendorOverrides(rs[K.overrides]);
  const ov = overrides[target] ?? {};
  const vMinSample = ov.minSample ?? minSample;
  const vThreshold = ov.threshold ?? threshold;

  /**
   * 심을 실패 건수. **두 축의 하한을 동시에 만족해야 한다** —
   *  - `over` 는 풀 축(critical)을 겨냥한다. 벤더 축(warn)만 넘기면 다이제스트로 묶여
   *    그 회차에 발송되지 않고, 그 침묵이 "발화하지 않았다"로 읽힌다. 그래서 풀 하한
   *    `minSample` 과 벤더 하한 `vMinSample` 중 **큰 쪽**을 넘긴다.
   *  - `under` 는 둘 다 못 넘겨야 한다. 그래서 **작은 쪽** 미만으로 둔다.
   */
  const floorMin = Math.min(minSample, vMinSample);
  const total =
    sample === 'over' ? Math.max(minSample, vMinSample, 2) : Math.max(floorMin - 1, 1);
  if (sample === 'under' && total >= floorMin)
    throw new Error(
      `표본 하한(풀 ${minSample} · 벤더 ${vMinSample})이 1 이라 미달 조합을 만들 수 없다. ` +
        `${K.minSample} 을 올리고 재시도하라`,
    );

  //* 서명은 규칙이 그 회차에 **적용할 값 그대로**다. 하나라도 다르면 `diffDerivationSignature`
  //*  가 drift 로 잡아 모든 회차가 미평가가 된다 — 그 미평가는 발송 0건과 구분되지 않는다.
  const signature = JSON.stringify({
    window_ms: windowMs,
    min_sample: minSample,
    vendors,
    ratio_threshold: threshold,
    overrides,
  });
  const enabledKey = `${RULE_ENABLED_PREFIX}${TREAT_VENDOR_RULE_ID}`;

  //* 대장을 **먼저** commit 하고 KV 를 쓴다(파일 머리 §D3 규약). 반대 순서면 중간에 죽었을 때
  //*  복구 기록 없는 변경 키가 남는다 — 규칙이 켜진 채로.
  const stamp = Date.now();
  recordKv(ledger, enabledKey, await kv.get(enabledKey), '1');
  recordKv(ledger, K.derivation, await kv.get(K.derivation), signature);
  if (!ledger.injected.includes('a5')) ledger.injected.push('a5');
  ledger.createdAt = stamp;
  await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  await kv.set(enabledKey, '1');
  await kv.set(K.derivation, signature);
  console.log(
    `[inject a5] 규칙 ON + 배선 시험용 산출 서명 기록 — 실측 임계가 아니다. ` +
      `cleanup 이 두 키를 CAS 복원한다 (${enabledKey}, ${K.derivation})`,
  );

  await db.transaction(async (tx: any) => {
    //* FK 충족용 **읽기**다(`odd.ord_id` NOT NULL + restrict). A1 과 달리 주문을 새로 만들지
    //*  않는다 — 주문을 만들면 결제까지 거슬러 올라가고, 그 결제 행이 A1 의 판정 창에 들어와
    //*  두 축이 서로의 모집단을 흔든다.
    const ordRows: any = await tx.execute(sql`SELECT ord_id FROM tn_order ORDER BY ord_id DESC LIMIT 1`);
    const ordId = (Array.isArray(ordRows) ? ordRows[0] : ordRows)?.[0]?.ord_id;
    if (ordId === undefined)
      throw new Error('tn_order 에 행이 없어 FK 를 만족시킬 수 없다 — dev DB 시드를 먼저 넣어라');

    for (let i = 0; i < total; i++) {
      //* 전부 실패로 넣는다 — 실패율을 조합으로 맞추지 않고 100% 로 두면 판정이 결정적이다.
      //*  `ODD008`(자료수집실패)은 규칙의 실패 집합에 있고 `odd_failed_at` 을 함께 요구한다.
      /**
       * **`odd_failed_at` 을 60초 뒤로 심는다.** 이 값은 MySQL `NOW()`(DB 시계)로 쓰이는데
       * 판정 창의 **상한은 `ctx.now`(앱 시계)** 다(`treat-vendor.rule.ts` 의 `toIso`, 그리고
       * 아래 되읽기). dev DB 는 터널 너머 원격이라 두 시계가 초 단위로 어긋나며, DB 가 1초라도
       * 앞서면 갓 심은 행이 **전부 상한 밖으로 떨어져** 집계가 0/0 이 된다.
       *
       * 실측에서 정확히 이 함정에 걸렸다 — 행 5건이 DB 에 그대로 있는데 규칙 경로의 집계는
       * 0/0 이었고, 원인은 1초 편차였다. 실데이터는 관측 시각이 몇 분 전이라 이 편차가 드러나지
       * 않는다. **주입만이 시계 경계에 앉는다.**
       *
       * 60초인 이유: 설정 가능한 최소 창이 5분이므로(`TREAT_VENDOR_RANGE.windowMs.min`) 60초를
       * 빼도 창의 80% 가 남는다. 실측 편차 ~1초 대비 60배 여유다.
       */
      const det: any = await tx.execute(sql`
        INSERT INTO tn_order_detail (ord_id, odd_transaction_id, odd_state, odd_provider,
                                     odd_failed_at, odd_show, odd_deletion, odd_created_at)
        VALUES (${ordId}, ${`AI5-${stamp}-${i}`.slice(0, 30)}, 'ODD008', ${target},
                DATE_SUB(NOW(), INTERVAL 60 SECOND), 0, 1, NOW())
      `);
      const oddId = Number((Array.isArray(det) ? det[0] : det)?.insertId);
      if (!Number.isFinite(oddId) || oddId <= 0)
        throw new Error('tn_order_detail insertId 를 읽지 못했다');
      ledger.orderDetailIds.push(oddId);
    }

    await tx.execute(sql`
      INSERT INTO tn_alert_setting (als_key, als_value, als_updated_at)
      VALUES (${LEDGER_KEY}, ${JSON.stringify(ledger)}, NOW())
      ON DUPLICATE KEY UPDATE als_value = ${JSON.stringify(ledger)}, als_updated_at = NOW()
    `);
  });

  /**
   * **심은 뒤 규칙이 실제로 볼 모집단을 되읽는다**(A1 과 같은 이유). 규칙은 심은 행이 아니라
   * 판정 창 안의 **전 벤더 집합**을 센다. 창 안의 기존 성공 행이 풀 실패율을 희석하면 `over`
   * 가 발화하지 않고, 기존 실패 행이 표본을 채우면 `under` 가 발화한다 — 둘 다 "발송 0건"
   * 또는 "발송 1건"이라는 **정상처럼 보이는 모습**으로 나타나므로 여기서 멈춘다.
   */
  const now = Date.now();
  const rows = await app
    .get(AlertRepository)
    .countVendorTreatResults(
      dayjs(now - windowMs).format('YYYY-MM-DD HH:mm:ss'),
      dayjs(now).format('YYYY-MM-DD HH:mm:ss'),
    );
  const byVendor = new Map(rows.map((r: any) => [r.provider, r]));
  const judged = vendors.map((key) => ({
    key,
    total: Number((byVendor.get(key) as any)?.total ?? 0),
    failed: Number((byVendor.get(key) as any)?.failed ?? 0),
  }));
  const poolTotal = judged.reduce((a, v) => a + v.total, 0);
  const poolFailed = judged.reduce((a, v) => a + v.failed, 0);
  const poolFires = passesRatio(poolTotal, poolFailed, minSample, threshold);
  const vendorFires = judged.some((v) => {
    const o = overrides[v.key] ?? {};
    return passesRatio(v.total, v.failed, o.minSample ?? minSample, o.threshold ?? threshold);
  });

  console.log(
    `[inject a5] vendor=${target} sample=${sample} seeded=${total}행(전부 수집실패) | ` +
      `풀하한=${minSample} 벤더하한=${vMinSample} 임계=${threshold}/${vThreshold} ` +
      `창=${Math.round(windowMs / 60000)}분 → 풀 ${poolFailed}/${poolTotal} ` +
      `(풀축 발화=${poolFires} · 벤더축 발화=${vendorFires})`,
  );

  //* `over` 는 **풀 축**만 성공으로 친다. 벤더 축만 넘긴 상태는 warn 이라 그 회차에 발송되지
  //*  않고(다이제스트), 그 침묵이 "발화하지 않았다"와 구분되지 않는다.
  //* 원인이 둘인데 모습이 하나다 — `poolTotal === 0` 은 **심은 행이 창에 안 잡힌다**는 뜻이고
  //*  희석과는 조치가 정반대다. 같은 문구로 안내하면 사람이 창을 좁히다가 더 못 잡게 된다.
  if (sample === 'over' && poolTotal === 0)
    throw new Error(
      `심은 ${total}행이 판정 창에 하나도 잡히지 않는다 — 집계 0/0. 행은 DB 에 있으므로 ` +
        `시각 경계 문제다(관측 시각은 DB 시계, 창 상한은 앱 시계). 두 시계의 편차가 60초를 ` +
        `넘는지 확인하라: SELECT NOW(); 와 로컬 시각을 비교한다. cleanup 으로 회수한 뒤 재시도`,
    );
  if (sample === 'over' && !poolFires)
    throw new Error(
      `풀 축이 발화하지 않는다 — ${poolFailed}/${poolTotal} (하한 ${minSample}, 임계 ${threshold}). ` +
        `판정 창 안의 다른 벤더 성공 행이 실패율을 희석했다. cleanup 으로 회수한 뒤 ` +
        `${K.windowMs} 를 좁히거나 창 안의 진료 행을 비우고 재시도하라`,
    );
  if (sample === 'under' && (poolFires || vendorFires))
    throw new Error(
      `미달 전제가 깨졌다 — 풀축=${poolFires} 벤더축=${vendorFires} (풀 ${poolFailed}/${poolTotal}). ` +
        `판정 창 안의 기존 실패 행이 표본을 채웠다. cleanup 으로 회수한 뒤 ${K.windowMs} 를 ` +
        `좁히거나 창 안의 진료 행을 비우고 재시도하라`,
    );
}

/**
 * A3 — 하트비트를 기대주기 × grace 밖으로 되돌린다(§Task 5).
 *
 * 기대 주기와 grace 를 **둘 다 규칙과 같은 소스**에서 읽는다. `DEFAULT_SCHEDULE_EXPECT` 와
 * `×10` 고정을 쓰던 이전 판은 `alert_schedule_expect` 오버라이드나 grace 상향이 있는 환경에서
 * 되돌린 하트비트가 여전히 `expect × grace` 안에 떨어져 A3 가 발화하지 않았다 — 그 침묵을
 * 하네스가 "무발송"으로 적었다. 오버라이드에만 있는 이름이 거부되던 것도 같은 원인이다.
 */
async function injectA3(app: any, db: any, ledger: Ledger, name: string): Promise<void> {
  const { STATE_KEY, SETTING_KEY, DEFAULT_SCHEDULE_EXPECT } = require('@api/modules/alert/alert.entity');
  const {
    SCHEDULE_HEARTBEAT_RULE_ID,
  } = require('@api/modules/alert/rules/schedule-heartbeat.rule');
  const kv = kvHelpers(db);

  const expect = resolveScheduleExpect(await kv.get(SETTING_KEY.scheduleExpect), DEFAULT_SCHEDULE_EXPECT);
  const expectMs = Number(expect[name]);
  if (!Number.isFinite(expectMs))
    throw new Error(`'${name}' 은 감시 목록에 없다. 목록: ${Object.keys(expect).join(', ')}`);

  const rs = await ruleSettings(app, SCHEDULE_HEARTBEAT_RULE_ID);
  const grace = Number(rs[`${SCHEDULE_HEARTBEAT_RULE_ID}_grace`]);
  if (!Number.isFinite(grace) || grace <= 0) throw new Error(`grace 설정을 읽지 못했다: ${grace}`);
  //* 실효 grace 의 5배(최소 10배 주기) 밖으로. 최초 관측 유예도 통과시켜야 판정이 열린다.
  const back = Math.ceil(expectMs * Math.max(grace * 5, 10));

  const now = Date.now();
  const hbKey = `${STATE_KEY.hbPrefix}${name}`;
  const seenKey = `${STATE_KEY.hbSeenPrefix}${name}`;
  const stale = String(now - back);
  const seen = String(now - back * 2);

  recordKv(ledger, hbKey, await kv.get(hbKey), stale);
  recordKv(ledger, seenKey, await kv.get(seenKey), seen);
  if (!ledger.injected.includes('a3')) ledger.injected.push('a3');
  //* 주입한 두 키는 --sim-heartbeat 가 덮으면 안 된다 — 덮으면 첫 회차에 스스로 해제된다.
  for (const k of [hbKey, seenKey]) if (!ledger.pinned.includes(k)) ledger.pinned.push(k);
  ledger.createdAt = ledger.createdAt ?? now;
  //* **대장이 먼저다.** A1 은 트랜잭션이 이 순서를 보장하지만 KV 경로에는 트랜잭션이 없다 —
  //*  키를 먼저 쓰고 죽으면 복구 기록 없는 변경 키가 남아 파일 머리의 §D3 약속이 여기서만 깨진다.
  await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  await kv.set(hbKey, stale);
  await kv.set(seenKey, seen);
  console.log(
    `[inject a3] name=${name} expect=${expectMs}ms grace=${grace} → hb 를 ${back}ms 전으로`,
  );
}

/**
 * A4 — 실제 5xx 를 만든다(§D5). `--redis` 는 자연 500 경로 부재 시의 강등 경로다.
 *
 * A4 만 대장을 건너뛰던 것을 고쳤다. `Ledger.injected` 는 `a1`·`a3`·`a4` 를 담는다고 선언해
 * 두었고 `status` 가 그 값을 보고하며 `clear a4` 가 그것을 뺀다 — a4 를 안 올리면 셋 다
 * a4 에 대해서만 조용히 무의미해지고, 주입-위-주입 가드도 a4 를 덮지 못한다. 회수할 DB 행이
 * 없다는 사실은 `cleanup` 이 이미 알고 있다(`ledgerIsEmpty` → 축만 비우는 no-op).
 */
async function injectA4(app: any, db: any, ledger: Ledger, opts: ParsedArgs): Promise<void> {
  const { AlertSignalService } = require('@api/modules/alert/alert-signal.service');

  //* 트래픽을 만들기 **전에** 커밋한다 — 도중에 죽어도 "주입 중"이 대장에 남는다.
  if (!ledger.injected.includes('a4')) ledger.injected.push('a4');
  ledger.createdAt = ledger.createdAt ?? Date.now();
  await kvHelpers(db).set(LEDGER_KEY, JSON.stringify(ledger));

  if (opts.redis) {
    const signals = app.get(AlertSignalService);
    for (let i = 0; i < opts.ok; i++) await signals.bumpRequest();
    for (let i = 0; i < opts.bad; i++) {
      await signals.bumpRequest();
      await signals.bump5xx();
    }
    console.log(
      `[inject a4] REDIS 강등 경로 — ok=${opts.ok} bad=${opts.bad}. ` +
        `인터셉터·필터 배선은 **미검증**이다. 리포트에 그대로 적어라(§D5)`,
    );
    return;
  }
  if (!opts.base) throw new Error('inject a4 는 --base <API 주소> 또는 --redis 가 필요하다');
  let ok = 0;
  let bad = 0;
  for (let i = 0; i < opts.ok; i++) {
    const r = await fetch(`${opts.base}/health`).catch(() => null);
    if (r) ok++;
  }
  for (let i = 0; i < opts.bad; i++) {
    const r = await fetch(`${opts.base}${opts.path}`).catch(() => null);
    if (r && r.status >= 500) bad++;
  }
  console.log(`[inject a4] HTTP 실경로 — 정상 ${ok}/${opts.ok}, 5xx ${bad}/${opts.bad} (path=${opts.path})`);
  if (bad === 0)
    console.log(
      `[inject a4] 경고: 5xx 가 한 건도 나오지 않았다. 자연 500 경로를 못 찾은 것이며 ` +
        `--redis 강등을 쓰되 "배선 미검증"을 리포트에 적어라(§D5)`,
    );
}

/** 해제 — 복구 알림을 유발한다. 대장은 남긴다(cleanup 이 회수). */
async function cmdClear(app: any, db: any, ledger: Ledger, signal: string): Promise<void> {
  const { sql } = require('drizzle-orm');
  const { STATE_KEY } = require('@api/modules/alert/alert.entity');
  const { AlertSignalService } = require('@api/modules/alert/alert-signal.service');
  const kv = kvHelpers(db);

  if (signal === 'a1') {
    //* 시딩 행을 지워 집계에서 빠지게 한다. 대장에서도 뺀다 — 이 시점부터 회수 대상이 아니다.
    for (const id of [...ledger.orderIds].reverse())
      await db.execute(sql`DELETE FROM tn_order WHERE ord_id = ${id}`);
    for (const id of [...ledger.paymentIds].reverse())
      await db.execute(sql`DELETE FROM tn_payment WHERE pmt_id = ${id}`);
    ledger.orderIds = [];
    ledger.paymentIds = [];
  } else if (signal === 'a3') {
    //* 하트비트 갱신이 해제다. **주입이 소유한 키(pinned)만** 만진다 — `--sim-heartbeat` 가
    //*  만든 측정용 키까지 건드리면 해제가 측정 상태를 흔든다. 대장의 `written` 도 함께
    //*  옮겨야 CAS 복원이 성립한다.
    const now = String(Date.now());
    for (const key of ledger.pinned) {
      if (!key.startsWith(STATE_KEY.hbPrefix) || !ledger.kv[key]) continue;
      await kv.set(key, now);
      ledger.kv[key] = { original: ledger.kv[key].original, written: now };
    }
  } else if (signal === 'a5') {
    //* 심은 관측 행을 지워 집계에서 뺀다. **규칙 ON 과 서명은 남긴다** — 지우면 다음 회차가
    //*  미평가(§D10 throw)가 되어 복구 알림이 나갈 자리 자체가 사라진다. 두 키의 원상 복구는
    //*  `cleanup` 의 CAS 책임이며, 그 경계가 A1 의 `kv` 취급과 같다.
    for (const id of [...ledger.orderDetailIds].reverse())
      await db.execute(sql`DELETE FROM tn_order_detail WHERE odd_id = ${id}`);
    ledger.orderDetailIds = [];
  } else {
    //* A4 는 분(minute) 버킷이 자연 만료된다. 강제 해제는 정상 트래픽을 흘려 비율을 떨어뜨리는 것.
    const signals = app.get(AlertSignalService);
    for (let i = 0; i < 60; i++) await signals.bumpRequest();
    console.log('[clear a4] 정상 요청 60건으로 5xx 비율 희석');
  }
  ledger.injected = ledger.injected.filter((x) => x !== signal);
  //* 해제된 축의 키는 더 이상 주입 소유가 아니다 — 이제부터 `--sim-heartbeat` 가 만져도 된다.
  //*  `kv` 추적은 그대로 남는다: 원상 복구 책임은 `cleanup` 이 계속 진다.
  if (signal === 'a3')
    ledger.pinned = ledger.pinned.filter(
      (k) => !k.startsWith(STATE_KEY.hbPrefix) && !k.startsWith(STATE_KEY.hbSeenPrefix),
    );
  await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  console.log(`[clear ${signal}] 완료. 다음 poll 회차에서 복구 알림을 확인하라`);
}

/**
 * §D7 시간 전진 — 시계를 미는 대신 **저장된 시각을 뒤로 민다.** 비교식이 전부
 * `now - stored >= threshold` 형태라 판정상 동치다.
 *
 * 조작은 두 짝이다: `digestSentAt` 은 **역행**(게이트를 전진시킨다), `hb:*` 는 가상 now 로
 * **갱신**(정상 운행 모사 — 역행시키면 하네스가 A3 장애를 만든다). `hbSeen`·`state.t` 는 불가침.
 */
async function simulateRound(
  db: any,
  ledger: Ledger,
  round: number,
  advanceMs: number,
  simHeartbeat: boolean,
): Promise<void> {
  const { STATE_KEY, SETTING_KEY, DEFAULT_SCHEDULE_EXPECT } = require('@api/modules/alert/alert.entity');
  const kv = kvHelpers(db);
  const virtualNow = Date.now() + round * advanceMs;

  //* 원본을 전부 담아 **대장을 먼저 commit 한 뒤** 실제 쓰기를 한다(A3 와 같은 이유).
  //*  여기서 죽으면 남는 것은 "쓰지 않았는데 대장에 있는 키"뿐이고, cleanup 의 CAS 가
  //*  현재 값 불일치로 건너뛴다 — 반대 순서의 실패(복구 기록 없는 변경 키)와 달리 무해하다.
  const pending: Array<[string, string]> = [];

  if (advanceMs > 0) {
    const cur = await kv.get(STATE_KEY.digestSentAt);
    const curN = Number(cur);
    const next = String((Number.isFinite(curN) && curN > 0 ? curN : Date.now()) - advanceMs);
    recordKv(ledger, STATE_KEY.digestSentAt, cur, next);
    pending.push([STATE_KEY.digestSentAt, next]);
  }

  if (simHeartbeat) {
    /**
     * **감시 목록 전체**를 갱신한다. 존재하는 키만 갱신하면 하트비트가 하나도 없는 환경
     * (스케줄러를 한 번도 안 돌린 dev 가 정확히 그 상태다)에서 아무것도 갱신되지 않고,
     * 회차가 쌓여 실시간이 흐르는 동안 최초 관측 유예가 만료되면서 `fax-status`(기대주기 1분)
     * 부터 A3 critical 이 뜬다 — 무장애를 재려던 측정이 **하네스가 만든 장애**를 재게 된다.
     * §D7 R2-F0 가 지목한 구멍과 같은 것이며, 여기서는 "역행"이 아니라 "부재"가 원인이다.
     *
     * 없던 키는 원본을 `null` 로 담으므로 cleanup 이 **삭제**로 되돌린다 — 흔적이 남지 않는다.
     */
    //* 항목 검증까지 규칙과 같은 해석을 쓴다 — `injectA3` 와 갈라지면 주입한 이름이 여기서
    //*  갱신 대상이 되거나 그 반대가 된다.
    const expect = resolveScheduleExpect(
      await kv.get(SETTING_KEY.scheduleExpect),
      DEFAULT_SCHEDULE_EXPECT,
    );
    const names = new Set([
      ...Object.keys(expect),
      ...Object.keys(await kv.byPrefix(STATE_KEY.hbPrefix)).map((k) => k.slice(STATE_KEY.hbPrefix.length)),
    ]);
    const v = String(virtualNow);
    for (const name of names) {
      const key = `${STATE_KEY.hbPrefix}${name}`;
      //* 주입이 소유한 키는 건너뛴다 — 덮으면 A3 주입이 폴링 첫 회차에 스스로 해제된다.
      if (ledger.pinned.includes(key)) continue;
      recordKv(ledger, key, await kv.get(key), v);
      pending.push([key, v]);
    }
  }

  //* 회차마다 commit 한다 — 다음 run() 이 다른 커넥션으로 읽으므로 여기서 안 끊으면 못 본다(R1 F2).
  await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  for (const [key, value] of pending) await kv.set(key, value);
}

async function cmdPoll(app: any, db: any, opts: ParsedArgs, records: SendRecord[]): Promise<number> {
  const { AlertService } = require('@api/modules/alert/alert.service');
  const svc = app.get(AlertService);
  let invalid = 0;

  for (let round = 0; round < opts.rounds; round++) {
    if ((opts.advanceMs > 0 || opts.simHeartbeat) && !opts.expectDbDown) {
      const ledger = await readLedger(db);
      await simulateRound(db, ledger, round + 1, opts.advanceMs, opts.simHeartbeat);
    }
    const before = records.length;
    const t0 = Date.now();
    const r = await svc.run();
    const fresh = records.slice(before);
    const s = summarize(fresh);

    /**
     * **평가하지 않은 회차는 관측이 아니다.** `run()` 이 `skipped` 를 돌려준 회차는 규칙을
     * 한 번도 돌리지 않았으므로 그 회차의 "발송 0건"은 무장애의 증거가 될 수 없다.
     *
     * 실측에서 정확히 이 함정에 걸렸다 — 강제 종료된 이전 실행이 `alert:lease` 를 물고 죽어
     * TTL(15분)이 남아 있었고, 288회차가 전부 `lease-not-acquired` 로 건너뛰어지면서 9-a 가
     * "발송 0건"으로 **깨끗하게 통과할 뻔했다.** 침묵을 통과로 읽지 않는다.
     *
     * `--expect-db-down` 모드에서는 `db-down-*` 계열 skip 이 정상 결과이므로 아래 별도 분기가
     * 진다(여기서는 제외).
     */
    if (!opts.expectDbDown && r.skipped) {
      invalid++;
      console.error(
        `[poll ${round + 1}] 무효 — 회차가 평가되지 않았다 (skipped='${r.skipped}'). ` +
          `이 회차의 발송 0건은 무장애의 증거가 아니다`,
      );
    }
    if (opts.expectDbDown && !DB_DOWN_SKIPS.has(String(r.skipped))) {
      invalid++;
      console.error(
        `[poll ${round + 1}] 무효 — --expect-db-down 인데 결과가 skipped='${r.skipped}' 다. ` +
          `DB 가 살아났거나 모드를 잘못 걸었다`,
      );
    }
    //* §D7 불변식 — 시간 전진 회차에 critical 이 1건이라도 뜨거나 미분류가 나오면 그 측정은 무효다.
    if (opts.advanceMs > 0 && ((s.by.critical ?? 0) > 0 || s.unclassified > 0)) {
      invalid++;
      console.error(
        `[poll ${round + 1}] 무효 — 9-b 불변식 위반: critical=${s.by.critical ?? 0} 미분류=${s.unclassified}`,
      );
    }
    console.log(
      JSON.stringify({
        round: round + 1,
        wallMs: Date.now() - t0,
        run: r,
        delivered: s.delivered,
        attempted: s.attempted,
        bySeverity: s.by,
      }),
    );
  }
  return invalid;
}

/**
 * 채널 **도달성** 확인 — Task 1 Validate 항목이지 M10 이 아니다.
 *
 * 이 경로는 `POST /admin/alert/test` 와 마찬가지로 조건·규칙 라우팅을 건너뛰고 채널 목록을
 * 직접 순회한다. 그래서 "조건 충족 시 한 채널만 선택되는 결함"이 있어도 통과한다
 * (Plan-Codex R6). **M10 판정은 Task 3 의 실제 A1 규칙 발화로만 한다.**
 *
 * 하네스 컨텍스트에서 구동하므로 스파이가 기록한다 — 별도 API 프로세스의 `POST /test` 는
 * 다른 인스턴스라 보이지 않으며 그것은 눈으로 확인하는 별개 항목이다(§D2).
 */
async function cmdTest(app: any, db: any): Promise<void> {
  const { sql } = require('drizzle-orm');
  const { AlertService } = require('@api/modules/alert/alert.service');
  const svc = app.get(AlertService);
  const rows: any = await db.execute(
    sql`SELECT alc_id, alc_name, alc_url FROM tn_alert_channel WHERE alc_enabled = 1 ORDER BY alc_id`,
  );
  const channels = (Array.isArray(rows) ? rows[0] : rows) ?? [];
  if (channels.length === 0) throw new Error('활성 채널이 없다');
  for (const c of channels) {
    const r = await svc.send(c.alc_url, TEST_MESSAGE);
    console.log(
      JSON.stringify({ channel: c.alc_name, id: c.alc_id, url: mask(c.alc_url), ok: r.ok, status: r.status, error: r.error }),
    );
  }
}

/** 대장 기반 원상 복구. idempotent — 두 번 돌려도 안전하고 중간에 죽어도 이어서 끝낸다. */
async function cmdCleanup(db: any): Promise<void> {
  const { sql } = require('drizzle-orm');
  const kv = kvHelpers(db);
  const ledger = await readLedger(db);
  if (ledgerIsEmpty(ledger)) {
    console.log('[cleanup] 대장이 비어 있다 — 회수할 것이 없다 (idempotent no-op)');
    ledger.injected = [];
    ledger.pinned = [];
    await kv.set(LEDGER_KEY, JSON.stringify(ledger));
    return;
  }

  //* 외래 참조 역순 — detail 먼저, order, payment 나중(§D3 3).
  //*  `odd.ord_id` 가 restrict 라 detail 이 남아 있으면 order 삭제가 FK 로 막힌다.
  for (const id of [...ledger.orderDetailIds].reverse()) {
    await db.execute(sql`DELETE FROM tn_order_detail WHERE odd_id = ${id}`);
    ledger.orderDetailIds = ledger.orderDetailIds.filter((x) => x !== id);
    await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  }
  for (const id of [...ledger.orderIds].reverse()) {
    await db.execute(sql`DELETE FROM tn_order WHERE ord_id = ${id}`);
    ledger.orderIds = ledger.orderIds.filter((x) => x !== id);
    await kv.set(LEDGER_KEY, JSON.stringify(ledger)); //* 지운 만큼 즉시 대장에서 뺀다
  }
  for (const id of [...ledger.paymentIds].reverse()) {
    await db.execute(sql`DELETE FROM tn_payment WHERE pmt_id = ${id}`);
    ledger.paymentIds = ledger.paymentIds.filter((x) => x !== id);
    await kv.set(LEDGER_KEY, JSON.stringify(ledger));
  }

  const { restored, skipped } = await restoreKv(db, ledger);
  ledger.injected = [];
  ledger.pinned = [];
  ledger.createdAt = null;
  await kv.set(LEDGER_KEY, JSON.stringify(ledger)); //* sentinel 은 비우되 **지우지 않는다**(§D3-6)

  console.log(`[cleanup] KV 복원 ${restored.length}건, CAS 불일치로 보류 ${skipped.length}건`);
  if (skipped.length > 0)
    console.error(
      `[cleanup] 보류 키(외부 기록자가 그 사이 갱신함 — 덮지 않는다): ${skipped.join(', ')}`,
    );
}

//--------------------- 시나리오 러너 ---------------------//

/**
 * 신호 축 분기. `inject` 커맨드와 시나리오가 **같은 함수**를 탄다 — 두 곳에 따로 적으면
 * 새 축이 한쪽에만 붙고, 그쪽으로만 도는 시나리오가 조용히 옛 경로를 쓴다.
 */
async function injectSignal(app: any, db: any, ledger: Ledger, opts: ParsedArgs): Promise<void> {
  if (opts.signal === 'a1') await injectA1(app, db, ledger, opts.sample);
  else if (opts.signal === 'a3') await injectA3(app, db, ledger, opts.name);
  else if (opts.signal === 'a5') await injectA5(app, db, ledger, opts.vendor, opts.sample);
  //* a4 는 `--base` 가 없으면 Redis 강등 경로다. `inject` 는 parseArgs 가 둘 중 하나를 요구하지만
  //*  시나리오는 기본값을 골라 준다 — 사람이 축마다 다른 플래그를 외우게 하지 않는다.
  else await injectA4(app, db, ledger, opts.base ? opts : { ...opts, redis: true });
}

function observe(records: SendRecord[], from: number, invalid: number): ScenarioObservation {
  const s = summarize(records.slice(from));
  return {
    delivered: s.delivered,
    critical: s.by.critical ?? 0,
    warn: s.by.warn ?? 0,
    recovery: s.by.recovery ?? 0,
    digest: s.by.digest ?? 0,
    test: s.by.test ?? 0,
    unclassified: s.unclassified,
    criticalDestinations: s.criticalDestinations,
    invalid,
  };
}

async function countEnabledChannels(db: any): Promise<number> {
  const { sql } = require('drizzle-orm');
  const rows: any = await db.execute(
    sql`SELECT COUNT(*) AS n FROM tn_alert_channel WHERE alc_enabled = 1`,
  );
  return Number((Array.isArray(rows) ? rows[0] : rows)?.[0]?.n ?? 0);
}

/**
 * 시나리오 시작 전 억제 상태를 비운다. **이것이 없으면 시나리오는 30분에 한 번만 참이다** —
 * 같은 키가 억제창 안에 남아 있으면 두 번째 실행의 "발송 0건"이 억제 때문인지 발화하지 않아서인지
 * 구분되지 않는다. 사람이 손으로 도는 하네스에서 그 30분은 곧 잘못된 결론이다.
 *
 * 되돌리기는 `cleanup` 의 CAS 에 맡긴다 — 회차가 억제 상태를 다시 쓰므로 대개 CAS 불일치로
 * **보류**되고 그 사실이 리포트에 찍힌다. dev 억제 상태는 회수 대상이 아니라 관측 부산물이며,
 * 조용히 덮어쓰는 대신 보류를 드러내는 쪽이 대장 규약과 일관된다.
 */
async function resetSuppressState(db: any): Promise<number> {
  const { STATE_KEY } = require('@api/modules/alert/alert.entity');
  const kv = kvHelpers(db);
  const ledger = await readLedger(db);
  const raw = await kv.get(STATE_KEY.suppress);
  let count = 0;
  try {
    const parsed = JSON.parse(raw || '{}');
    count = parsed && typeof parsed === 'object' ? Object.keys(parsed).length : 0;
  } catch {
    count = -1; //* 깨진 값 — 개수를 모른다. 비우는 것은 그대로 진행한다
  }
  if (count === 0) return 0;
  recordKv(ledger, STATE_KEY.suppress, raw, '{}');
  await kv.set(LEDGER_KEY, JSON.stringify(ledger)); //* 대장 먼저(§D3)
  await kv.set(STATE_KEY.suppress, '{}');
  return count;
}

function printScenarioList(): void {
  console.log('시나리오 목록 — `scenario <id>` 로 한 건씩 돈다\n');
  for (const s of SCENARIOS)
    console.log(
      `  ${s.id.padEnd(18)} ${s.title}\n` +
        `  ${''.padEnd(18)} 기대: ${s.expect}\n` +
        `  ${''.padEnd(18)} 지표: ${s.metrics.join(', ')}\n`,
    );
}

/**
 * 한 시나리오를 끝까지 돈다: 억제 초기화 → 단계 실행 → 판정 → **무조건 cleanup**.
 * cleanup 을 `finally` 에 둔 것이 핵심이다 — 중간에 실패한 시나리오가 회수되지 않은 주입을
 * 남기면 다음 시나리오가 `활성 대장이 있다` 로 막히고, 사람은 실패 원인 대신 그 메시지를 본다.
 *
 * 반환값은 프로세스 종료코드다. 0=PASS 또는 SKIP, 1=FAIL.
 */
async function cmdScenario(
  app: any,
  db: any,
  opts: ParsedArgs,
  records: SendRecord[],
): Promise<number> {
  if (opts.scenario === 'list') {
    printScenarioList();
    return 0;
  }
  const def = SCENARIOS.find((s) => s.id === opts.scenario);
  if (!def) throw new Error(`시나리오 '${opts.scenario}' 를 찾지 못했다`); //* parseArgs 가 먼저 막는다

  const ctx: ScenarioContext = { channels: await countEnabledChannels(db) };
  const skip = def.skipIf?.(ctx) ?? null;
  if (skip !== null) {
    console.log(JSON.stringify({ scenario: def.id, verdict: 'SKIP', reason: skip }, null, 2));
    return 0;
  }

  if (ledgerIsActive(await readLedger(db)))
    throw new Error('활성 대장이 있다 — 먼저 cleanup 을 돌려라. 시나리오는 깨끗한 상태에서 시작한다');

  console.log(
    `[scenario ${def.id}] ${def.title}\n  기대: ${def.expect}\n  지표: ${def.metrics.join(', ')}\n` +
      `  활성 채널: ${ctx.channels}개`,
  );

  const obs: ScenarioObservation[] = [];
  let thrown: string | null = null;
  try {
    const cleared = await resetSuppressState(db);
    if (cleared !== 0) console.log(`[scenario] 억제 상태 초기화 (이전 ${cleared}건)`);

    for (const step of def.steps) {
      const from = records.length;
      if (step.do === 'inject') {
        await injectSignal(app, db, await readLedger(db), {
          ...opts,
          signal: step.signal,
          sample: step.sample ?? opts.sample,
        });
      } else if (step.do === 'clear') {
        await cmdClear(app, db, await readLedger(db), step.signal);
      } else if (step.do === 'poll') {
        /**
         * **하트비트 모사는 항상 켠다.** dev·local 은 EventBridge 가 없어 스케줄러 15종이 한
         * 번도 돌지 않고, 그래서 A3 가 회차마다 13건씩 상시 발화한다. 그 상태로 재면
         *  - `payment-under`·`vendor-under` 의 "발송 0건"이 **A3 때문에 깨져** 무발송 축이
         *    실제로 멀쩡한데도 FAIL 로 찍히고,
         *  - 나머지 시나리오는 재려던 축의 발송이 무관한 13건에 섞여 사람이 못 읽는다.
         * 모사는 감시 목록 전체의 하트비트를 가상 now 로 갱신해 "정상 운행"을 만든다(§D7).
         *
         * A3 시나리오가 죽지 않는 이유 — `simulateRound` 는 대장의 `pinned` 키를 건너뛴다.
         * `inject a3` 가 되돌린 그 스케줄러만 낡은 채로 남아 판정 대상이 된다.
         *
         * 갱신한 키는 원본과 함께 대장에 담기므로 `cleanup` 이 되돌린다(없던 키는 삭제).
         */
        const invalid = await cmdPoll(
          app,
          db,
          { ...opts, rounds: step.rounds, simHeartbeat: true, advanceMs: 0, expectDbDown: false },
          records,
        );
        obs.push(observe(records, from, invalid));
      } else {
        await cmdTest(app, db);
        obs.push(observe(records, from, 0));
      }
    }
  } catch (e: any) {
    thrown = e?.message ?? String(e);
  } finally {
    //* 실패했든 아니든 회수한다. 회수 자체가 실패하면 그 예외가 밖으로 나가야 한다 —
    //*  회수되지 않은 행을 "정리했다"로 덮으면 다음 시나리오의 모집단이 오염된다.
    await cmdCleanup(db);
  }

  //* 판정식에 닿기 전에 무효를 거른다. 이 둘의 "0건"은 무발송의 증거가 아니다.
  const invalidRounds = obs.reduce((a, o) => a + o.invalid, 0);
  const unclassified = obs.reduce((a, o) => a + o.unclassified, 0);
  const reason =
    thrown !== null
      ? `단계 실행 실패: ${thrown}`
      : invalidRounds > 0
        ? `평가되지 않은 회차 ${invalidRounds}건 — 이 관측은 판정에 쓸 수 없다`
        : unclassified > 0
          ? `미분류 메시지 ${unclassified}건 — 분류 어휘가 실제 메시지와 어긋났다(§D2)`
          : def.verdict(obs, ctx);

  console.log(
    JSON.stringify(
      {
        scenario: def.id,
        title: def.title,
        expect: def.expect,
        metrics: def.metrics,
        channels: ctx.channels,
        observations: obs,
        verdict: reason === null ? 'PASS' : 'FAIL',
        ...(reason === null ? {} : { reason }),
      },
      null,
      2,
    ),
  );
  return reason === null ? 0 : 1;
}

//--------------------- main ---------------------//

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

  //* 목록은 순수 데이터다 — Nest 부팅도 DB 도 필요 없다. 부팅 뒤에 찍으면 DB 가 안 뜬 자리에서
  //*  "무엇을 돌릴 수 있는가"조차 못 보게 된다.
  if (opts.command === 'scenario' && opts.scenario === 'list') {
    printScenarioList();
    return;
  }

  //* 대상 DB 가드는 **Nest 부팅보다 먼저** 돈다. 부팅 자체가 커넥션 풀을 잡으므로 그 뒤에
  //*  확인하면 이미 잘못된 DB 를 붙든 상태다. `status` 는 읽기 전용이라 면제한다.
  if (opts.command !== 'status') {
    require('dotenv').config();
    assertWritableDatabase(process.env.DB_DATABASE, process.env.ALERT_INJECT_ALLOW_DB);
    console.log(`[alert-inject] 대상 DB: ${process.env.DB_DATABASE} (가드 통과)`);
  }

  //* --expect-db-down fail-closed(R1 F4): DB 가 **살아 있으면 거부**한다. 이 모드가 일반 poll 의
  //*  잠금 우회로 쓰이면 §D3-6 상호배제가 통째로 사라진다.
  if (opts.expectDbDown) {
    const alive = await probeDbAlive();
    if (alive)
      throw new Error(
        '--expect-db-down 인데 DB 가 응답한다. 이 모드는 DB 차단 구간 전용이다 — 일반 poll 을 쓰라',
      );
    console.log('[poll] --expect-db-down: DB 미응답 확인. 대장 잠금을 건너뛴다(§D3-6 명시 예외)');
  }

  const { NestFactory } = require('@nestjs/core');
  const { Logger } = require('nestjs-pino');
  const { AppModule } = require('@api/app.module');
  const { ConfigService } = require('@nestjs/config');
  const { AlertService } = require('@api/modules/alert/alert.service');
  const { DrizzleService } = require('@lib/drizzle/src/drizzle.service');

  const app = await NestFactory.createApplicationContext(AppModule, { bufferLogs: true });
  app.useLogger(app.get(Logger));

  const db = app.get(DrizzleService);
  const records: SendRecord[] = [];
  const restoreSpy = installSpy(app.get(AlertService), records);

  let lock: Lock | null = null;
  let exitCode = 0;
  try {
    if (!opts.expectDbDown) lock = await acquireLock(app.get(ConfigService));

    switch (opts.command) {
      case 'status':
        await cmdStatus(app, db);
        break;
      case 'inject': {
        const ledger = await readLedger(db);
        //* 회수되지 않은 주입 위에 주입을 얹지 않는다(§D3-6).
        if (ledgerIsActive(ledger))
          throw new Error('활성 대장이 있다 — 먼저 cleanup 을 돌려라. 주입 위에 주입하지 않는다');
        await injectSignal(app, db, ledger, opts);
        break;
      }
      case 'clear': {
        const ledger = await readLedger(db);
        await cmdClear(app, db, ledger, opts.signal!);
        break;
      }
      case 'poll': {
        const invalid = await cmdPoll(app, db, opts, records);
        if (invalid > 0) {
          console.error(`[poll] 무효 회차 ${invalid}건 — 이 측정은 판정에 쓸 수 없다`);
          exitCode = 2;
        }
        break;
      }
      case 'cleanup':
        await cmdCleanup(db);
        break;
      case 'test':
        await cmdTest(app, db);
        break;
      case 'scenario':
        exitCode = Math.max(exitCode, await cmdScenario(app, db, opts, records));
        break;
    }

    if (records.length > 0) {
      const s = summarize(records);
      console.log(
        JSON.stringify(
          {
            spy: {
              attempted: s.attempted,
              delivered: s.delivered,
              bySeverity: s.by,
              unclassified: s.unclassified,
              criticalDestinations: s.criticalDestinations,
              firstDeliveredAt: s.firstDeliveredAt,
              lastDeliveredAt: s.lastDeliveredAt,
              destinations: [...new Set(records.filter((r) => r.ok).map((r) => mask(r.url)))],
              //* 발송 원문 표본 — 분류 근거를 사람이 대조할 수 있게 남긴다. 목적지 URL 과 달리
              //*  본문에는 자격증명이 없다(경고 문구와 집계 수치뿐).
              samples: [...new Set(records.map((r) => r.text))]
                .slice(0, 3)
                .map((t) => (t.length > 160 ? `${t.slice(0, 159)}…` : t)),
            },
          },
          null,
          2,
        ),
      );
      if (s.unclassified > 0) {
        console.error('[spy] 미분류 메시지가 있다 — 이 측정은 무효다(§D2 분류 규약 3)');
        exitCode = Math.max(exitCode, 2);
      }
    }
  } catch (e: any) {
    console.error(`[alert-inject] 실패: ${e?.message ?? e}`);
    exitCode = 1;
  } finally {
    restoreSpy();
    if (lock) await lock.release().catch(() => {});
    await app.close().catch(() => {});
  }
  process.exit(exitCode);
}

/** DB 생존 프로브 — Nest 부팅 전에 돈다(부팅 자체가 DB 를 잡으면 모드 판정을 못 한다). */
async function probeDbAlive(): Promise<boolean> {
  try {
    require('dotenv').config();
    const mysql = require('mysql2/promise');
    const conn = await mysql.createConnection({
      host: process.env.DB_HOST,
      port: Number(process.env.DB_PORT ?? 3306),
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      connectTimeout: 3000,
    });
    await conn.query('SELECT 1');
    await conn.end();
    return true;
  } catch {
    return false;
  }
}

//* spec 이 import 할 때는 돌지 않는다.
if (require.main === module) void main();
