import { MedicationStandardRepository } from '@api/modules/medication-standard/medication-standard.repository';
import { Injectable } from '@nestjs/common';
import { DdxAnalyzeResponse, DdxClient, DdxOut } from './ddx.client';
import { Dosing30DayGroupInput, buildAnalyzeRequest } from './ddx-payload';
import {
  DDX_VERDICT_STATE,
  DdxVerdictRepository,
  DdxVerdictState,
} from './ddx-verdict.repository';

/**
 * 판정 생산 규약.
 *
 * 순서가 계약이다 — 마커 선행 INSERT -> `sent` 전이 -> POST 1회 -> 상한 있는 GET 폴링 ->
 * 실명 제거 -> 적재 -> `cleanup_pending` -> DELETE 종결 -> `done`.
 *
 * **재조회 경로는 POST 를 부르지 않는다**(UI2). `cleanup_pending` 재개는 DELETE 만 재시도하고,
 * `failed_unknown` 은 아예 재시도 대상이 아니다 — 도달 여부를 모르는 요청의 재전송이 곧 두 번째
 * 판정이 될 수 있고 스펙에 멱등 키가 없어 제공자가 막아 주지 않는다(D2).
 */

const EXTERNAL_REF = 'insuniverse-ddx-verdict';

//* 상한 상수. 배치가 무한히 살지 않게 하는 유일한 장치다.
const POLL_MAX_ATTEMPTS = 12;
const POLL_INTERVAL_MS = 5000;
const POLL_DEADLINE_MS = 120000;

/**
 * 실행 전체 마감.
 *
 * 건당 상한만으로는 실행 상한이 서지 않는다 — 폴링 마감(120s)은 sleep **전에** 재므로 마지막
 * GET 하나가 `REQUEST_TIMEOUT_MS`(120s)를 더 쓸 수 있고, 그 최악값이 `BATCH_MAX_TARGETS` 만큼
 * 곱해지면 한 실행이 시간 단위로 산다.
 *
 * **겹침을 예약 주기로 막지 않는다.** 이 마감(15분)은 주기(2분)보다 길고 그것이 의도다 —
 * 주기를 마감보다 길게 두면 막히는 것은 겹침이 아니라 **운영자 브리핑**이다. 주기 30분이던
 * 시절의 실측(2026-09-11 stg): 분석완료 13:30:53 → 배치 선정 13:41:37, 판정이 아니라 차례를
 * 기다린 시간만 10분 44초. 화면은 그동안 「판정 결과가 아직 준비되지 않았어요」를 세운다.
 *
 * 겹쳐도 두 배치가 같은 행을 집지 못한다 — 행이 없으면 `insertRequested` 의 `uq_ddv_odd_id` 가,
 * 이미 있으면 `markSent` 의 조건부 전이가 0행 받은 쪽을 물러나게 한다(`'duplicate'`).
 * **그러므로 겹친 실행을 봤다고 주기를 늘리지 마라.** 고장이 아니라 이 설계의 정상 동작이다.
 *
 * **몇 겹까지가 그 「정상」인가**: 마감 ÷ 주기이므로 15분 ÷ 2분 = 최대 8 실행이다. 정확성은 위
 * 두 장치가 지키고, 값은 낭비다 — 뒤따라온 배치는 `selectTargets` 가 준 같은 목록에 대해 전건
 * `duplicate` 왕복을 돈다. 커넥션은 안 샌다: 배치 루프가 `await` 순차라 실행 하나가 실제로 쓰는
 * 것은 풀 상한 20 이 아니라 1~2 이고, 8 겹쳐도 공용 `max_connections` 60 안에 선다. 이 셈이
 * 깨지는 경우는 하나뿐이고 형제 `ddx-verdict.schedule.ts` 가 그 자리를 적는다 — 태스크가 안
 * 죽고 누적되는 매달림이다.
 */
const BATCH_DEADLINE_MS = 15 * 60 * 1000;

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export type DdxVerdictOutcome =
  | DdxVerdictState
  | 'duplicate'
  | 'skipped_no_retry';

/**
 * 적재 허용 키(계약 `.claude/plans/drug-diag-api-m1.contract.json` 에서 유도).
 *
 * **blacklist 만으로는 저장 경계가 되지 않는다** — `DdxAnalyzeResponse` 는 `[key: string]: unknown`
 * 으로 미지 필드를 그대로 통과시키므로, 제공자가 이름을 담은 필드를 하나 추가하면 그것이 30일
 * 스윕까지 적재본에 산다. 아는 두 자리를 지운 **뒤** 아는 키만 남긴다.
 */
const STORED_TOP_KEYS = [
  'analysis_id',
  'external_ref',
  'status',
  'stats',
  'groups',
  //* 투약 30일 미만 상병(수정요청5). 화면에는 쓰지 않는다(제공자 가이드) — 그래도 계약 필드이고 적재 직후
  //* 제공자 사본을 지우므로 여기서 버리면 되찾을 곳이 없다(아래 `STORED_DRUG_KEYS` 주석의 원칙). 그룹
  //* 정제를 그대로 통과시켜 `person` 은 지운다.
  'under_30_days',
  'drug_classes',
  //* 30일 투약 탭 안내 문구(수정요청5). 스칼라라 아래 형 검사를 거친다.
  'notice',
] as const;

const STORED_GROUP_KEYS = [
  'dx_code',
  'dx_name',
  'dx_chapter_name',
  'is_administrative',
  'n_episodes',
  //* 상병별 투약일수 — 같은 날 처방은 한 번(수정요청5). 스칼라라 아래 형 검사를 거친다.
  'medication_days',
  'diag_drugs',
  'pending_drugs',
] as const;

/**
 * 적재 정제기 판번. `stripPersonNames` 가 최종 최상위 pick **뒤에** 적재본에 쓴다 — 제공자가 같은 이름의
 * 키를 보내도 whitelist 에 없으므로 살아남지 못한다.
 *
 * **이 표지가 없는 적재본은 이 판 이전 정제기를 거쳤다.** 그 정제기는 `drug_classes` 를 통째로 두고
 * `medication_days` · `notice` · `under_30_days` 를 버렸다. 그래서 표지 없는 적재본에서 그 키들의 부재는
 * 「제공자가 안 보냈다」가 아니라 「미상」이다(계획서 D7). 정제 규칙을 바꾸면 이 수를 올린다.
 */
export const DDX_INGEST_REV = 2;

/** 약효분류 원소(수정요청5 `DrugClassGroup`)의 스칼라. `drugs` · `by_date` 는 배열이라 따로 내려간다. */
const STORED_DRUG_CLASS_SCALAR_KEYS = [
  'name',
  'atc_code',
  'source',
  'total_days',
  'period_start',
  'period_end',
  'n_prescriptions',
  'n_diagnoses',
] as const;

/** 약효분류의 일자별 묶음(`DateEntry`) 스칼라. `provider` 는 처방 회차 `provider` 와 같은 등급(기관명)이다. */
const STORED_DATE_ENTRY_SCALAR_KEYS = ['date', 'provider', 'days'] as const;

/** 일자 묶음의 진단 목록. 문자열 원소만 남긴다. */
const STORED_DATE_ENTRY_STRING_LIST_KEYS = ['dx_codes', 'dx_names'] as const;

/** 일자 묶음의 약(`DateDrug`). */
const STORED_DATE_DRUG_SCALAR_KEYS = [
  'drug_name',
  'indication',
  'days_supplied',
] as const;

/** 그룹 안에서 약 배열을 담는 두 키. 원소까지 내려가지 않으면 whitelist 가 2단계에서 멈춘다. */
const STORED_DRUG_ARRAY_KEYS = ['diag_drugs', 'pending_drugs'] as const;

/**
 * 약 원소의 허용 키 — 계약 `ClassifiedDrug` 의 required 전건을 담는다.
 *
 * **`STORED_GROUP_KEYS` 는 배열을 키로만 통과시킨다.** 원소 안을 보지 않으면 제공자가 약마다
 * 이름을 담은 필드를 하나 더하는 날 그것이 그대로 적재되고 30일 스윕까지 산다 — 이 함수가
 * 막으려던 바로 그것이다. 뷰가 안 읽어서 화면에 안 보이는 것과 적재되지 않는 것은 다르다.
 *
 * **읽기 경로가 지금 읽는 집합이 아니라 계약이 기준이다.** 적재 직후 제공자 쪽 원본을 지우므로
 * (D10) 여기서 뺀 필드는 되찾을 곳이 없다. `confidence` 와 `source` 는 `ddx-view.ts` 가 아직
 * 읽지 않지만 계약 `ClassifiedDrug` 의 required 이고, 특히 `source` 는 그 문장이 규칙에서
 * 나왔는지 언어모델에서 나왔는지를 가르는 유일한 값이다 — 그 배지 없이 인사이트를 내는 것이
 * 이 제품이 해서는 안 되는 한 가지다(`DESIGN.md` §Components — The Honest Badge Rule).
 * 화면이 그것을 붙이는 날 이미 적재된 판정에 근거가 없으면 그 자리는 영영 못 연다.
 *
 * **그 「영영 못 연다」가 실제로 일어났다.** M5 가 읽기 뷰(`ddx-view.ts`)를 넓혀 `dx_code` ·
 * `dx_name` · `indication` · `n_rows` · `prescriptions` 를 읽게 했는데 이 목록은 안 넓혔다.
 * 그 결과 상세 12행 중 진료시작일 · 병·의원 · 효능효과 · 1회투약량 · 1회투여횟수 다섯이
 * 전건 `-` 로 떨어졌다 — 라우트는 값을 줬고 적재가 버렸다. 아래 다섯이 그 복구다.
 * 검사는 `stored-drug-survives-view-read`(합성 경계)가 진다.
 */
const STORED_DRUG_KEYS = [
  'drug_name',
  'ingredient',
  'drug_class',
  //* 이 약이 **어느 상병으로 청구됐는지**. 약효분류 축에서는 그룹 키가 `drug_class` 라
  //* 약마다 다르고, 그룹 레벨에서 복원할 방법이 없다(`ddx-view.ts` `DdxDrugView.dxCode`).
  'dx_code',
  'dx_name',
  'bucket',
  'role',
  'label',
  //* 허가 효능효과 문면. 표본에서 제공자가 `null` 을 주는 일이 있으나 그것은 **값의 부재**이고,
  //* 여기서 빼는 것은 **경로의 부재**다 — 후자는 제공자가 값을 주기 시작해도 안 열린다.
  'indication',
  'rationale',
  'days_supplied',
  //* 같은 약·같은 상병의 처방 회차 수. 화면이 회차 하나만 펴고 나머지를 계수로 말하므로
  //* (`DrugDetailCards` 의 각주) 이 값이 없으면 그 각주가 영영 안 선다.
  'n_rows',
  'prescriptions',
  'confidence',
  'source',
] as const;

/**
 * 처방 회차 원소의 허용 키.
 *
 * **whitelist 가 3단계에서 멈추지 않게 하는 자리다.** `STORED_DRUG_KEYS` 는 `prescriptions` 를
 * 배열 **키로만** 통과시키므로, 원소 안을 보지 않으면 제공자가 회차마다 이름을 담은 필드를
 * 하나 더하는 날 그것이 그대로 적재되고 30일 스윕까지 산다 — 약 원소에 대해 이미 한 번
 * 내린 판단(위 `STORED_DRUG_KEYS` 주석)과 같은 판단이고, 배열이 한 겹 더 생겼으니 겹도 하나
 * 더 필요할 뿐이다.
 *
 * 집합은 `ddx-view.ts` `DdxPrescriptionView` 의 다섯과 같다. 그 파일이 회차에 실리는 값의
 * 등급을 이미 판정했다: `provider`·`treatment_start_date` 는 형제 탭(약효분류별)이 같은
 * 화면에서 이미 내보내는 값이고, 실명(`person`·`person_name`)은 여기에 없다.
 */
const STORED_PRESCRIPTION_KEYS = [
  'treatment_start_date',
  'provider',
  'dose_per_administration',
  'administrations_per_day',
  'days_supplied',
] as const;

const pick = (
  src: Record<string, unknown>,
  keys: readonly string[],
): Record<string, unknown> => {
  const out: Record<string, unknown> = {};
  for (const k of keys) if (k in src) out[k] = src[k];
  return out;
};

const isObject = (v: unknown): v is Record<string, unknown> =>
  !!v && typeof v === 'object' && !Array.isArray(v);

/** 스칼라 — 문자열 · 유한수 · `null`. 허용 키 이름만 맞춘 객체가 실명을 싣고 들어오는 자리를 막는다. */
const isScalar = (v: unknown): boolean =>
  v === null ||
  typeof v === 'string' ||
  (typeof v === 'number' && Number.isFinite(v));

/**
 * 스칼라 키만 고른다. **형 검사의 주인은 여기 한 곳이다** — `pick` 은 키만 고른다. 수정요청5 로 새로 적재하는
 * 스칼라(`notice` · `medication_days` · 약효분류 · 일자 묶음의 칸)가 이 길을 탄다. 기존 키의 적재 동작은
 * 바꾸지 않는다(이 배치의 규칙 — 기존 키의 값 형 미검사는 backlog).
 */
const pickScalars = (
  src: Record<string, unknown>,
  keys: readonly string[],
): Record<string, unknown> => {
  const out: Record<string, unknown> = {};
  for (const k of keys) if (k in src && isScalar(src[k])) out[k] = src[k];
  return out;
};

/**
 * 객체 배열 정제 — **전함수**다. 배열이 아니면 `undefined`(키째 뺀다), 객체가 아닌 원소는 뺀다.
 *
 * 두 가지를 막는다(Implement-Codex F1 · security-reviewer HIGH/MEDIUM, 2026-10-07). (1) 배열 자리에 객체가 오면
 * `pick` 이 키 이름만 보고 값을 통째로 옮긴다 — `prescriptions: { person_name }` 이 적재된다. (2) 원소가 원시값이면
 * `'k' in 5` 가 던지고, 이 함수는 `analysisId` 를 기록하기 **전에** 불리므로 제공자 사본이 DELETE 열쇠 없이 남는다.
 *
 * 원소를 하나라도 버렸는데 남은 것이 없으면 `undefined` 다 — 전량 폐기를 「비었다」(`[]`)와 구분한다.
 */
const sanitizeObjects = (
  v: unknown,
  each: (x: Record<string, unknown>) => Record<string, unknown>,
): Record<string, unknown>[] | undefined => {
  if (!Array.isArray(v)) return undefined;
  const kept = v.filter(isObject);
  if (kept.length === 0 && v.length > 0) return undefined;
  return kept.map(each);
};

/** 문자열 목록 정제 — 규칙은 `sanitizeObjects` 와 같다(배열 아님 · 전량 폐기 → `undefined`). */
const sanitizeStrings = (v: unknown): string[] | undefined => {
  if (!Array.isArray(v)) return undefined;
  const kept = v.filter((x): x is string => typeof x === 'string');
  if (kept.length === 0 && v.length > 0) return undefined;
  return kept;
};

/** 정제 결과를 키에 싣거나, `undefined` 면 키째 뺀다. 원본에 키가 없던 자리에는 아무것도 만들지 않는다. */
const setOrDrop = (
  target: Record<string, unknown>,
  key: string,
  value: unknown,
): void => {
  if (value === undefined) delete target[key];
  else target[key] = value;
};

/**
 * 약 원소 — 약 키 whitelist 다음 처방 회차 원소까지 한 겹 더 내려간다. `prescriptions` 는 약 whitelist 를
 * 배열 **키로만** 통과하므로, 여기서 멈추면 회차 안의 미지 필드가 그대로 적재된다.
 * `groups[].diag_drugs` · `pending_drugs` · `under_30_days[]` 의 같은 자리 · `drug_classes[].drugs` 가 이 함수 하나를 쓴다.
 */
const sanitizeDrug = (d: Record<string, unknown>): Record<string, unknown> => {
  const drug = pick(d, STORED_DRUG_KEYS);
  if ('prescriptions' in drug)
    setOrDrop(
      drug,
      'prescriptions',
      sanitizeObjects(drug.prescriptions, (p) =>
        pick(p, STORED_PRESCRIPTION_KEYS),
      ),
    );
  return drug;
};

/** 상병 그룹 — `groups[]` 와 `under_30_days[]` 가 같은 형(`DiagnosisGroup`)이라 함께 쓴다. */
const sanitizeGroup = (g: Record<string, unknown>): Record<string, unknown> => {
  const copy = { ...g };
  //* 그룹마다 실린 실명 — 의도 표명. 아래 whitelist 가 이미 이 키를 떨군다.
  delete copy.person;
  const group = pick(copy, STORED_GROUP_KEYS);
  for (const key of STORED_DRUG_ARRAY_KEYS)
    if (key in group)
      setOrDrop(group, key, sanitizeObjects(group[key], sanitizeDrug));
  if ('medication_days' in group && !isScalar(group.medication_days))
    delete group.medication_days;
  return group;
};

/** 약효분류의 일자별 묶음(`DateEntry`). */
const sanitizeDateEntry = (
  e: Record<string, unknown>,
): Record<string, unknown> => {
  const entry = pickScalars(e, STORED_DATE_ENTRY_SCALAR_KEYS);
  for (const key of STORED_DATE_ENTRY_STRING_LIST_KEYS)
    if (key in e) setOrDrop(entry, key, sanitizeStrings(e[key]));
  if ('drugs' in e)
    setOrDrop(
      entry,
      'drugs',
      sanitizeObjects(e.drugs, (d) =>
        pickScalars(d, STORED_DATE_DRUG_SCALAR_KEYS),
      ),
    );
  return entry;
};

/** 약효분류 원소(`DrugClassGroup`). 이 판 전에는 원소가 통째로 적재됐다 — 읽는 자리가 처음 생겨 경계를 같이 닫는다. */
const sanitizeDrugClass = (
  c: Record<string, unknown>,
): Record<string, unknown> => {
  const cls = pickScalars(c, STORED_DRUG_CLASS_SCALAR_KEYS);
  if ('drugs' in c)
    setOrDrop(cls, 'drugs', sanitizeObjects(c.drugs, sanitizeDrug));
  if ('by_date' in c)
    setOrDrop(cls, 'by_date', sanitizeObjects(c.by_date, sanitizeDateEntry));
  return cls;
};

/**
 * 적재 직전 실명 제거. **저장 경계에서 지운다** — 클라이언트 응답에서만 빼면 적재본에 남고
 * 적재본은 30일 스윕까지 산다. 여기서 지우면 읽기 경로가 구조적으로 실명을 실을 수 없다.
 *
 * 경계를 **지는 것은 whitelist 일곱**이다(최상위 · 그룹 · 약 원소 · 처방 회차 원소 · 약효분류 원소 · 일자 묶음 ·
 * 일자 약). 계약이 이름을 준 실명 2자리의 명시 삭제는 그 whitelist 에 이미 포섭되며 관측 가능한 효과가 없다 —
 * 남겨 둔 것은 **의도 표명**이자, 누가 그 키를 whitelist 에 더하는 날 이 두 줄이 함께 눈에 띄게 하기 위해서다.
 *
 * 배열 자리는 배열일 때만 남는다(`sanitizeObjects`). 그래서 잘못된 형상의 입력은 「그대로 적재」가 아니라
 * 「키째 버림」이다. `groups` · `under_30_days` 의 정상 입력 적재 결과는 이 변경 전과 같다(`stored-drug-*` 검사).
 * `drug_classes[].drugs[]` 는 통째 적재에서 약 whitelist 로 **좁아졌다** — 계약의 선언 제외 다섯 키(`evidence` ·
 * `evidence_basis` · `item_seq` · `edi_code` · `atc_code`)는 적재되지 않는다(읽는 곳 0).
 */
export function stripPersonNames(
  response: DdxAnalyzeResponse,
): DdxAnalyzeResponse {
  const out: Record<string, unknown> = { ...(response as object) };
  //* (1) 최상위 실명 — 의도 표명. 아래 (4) 의 whitelist 가 이미 이 키를 떨군다.
  delete out.person_name;
  //* (2) 상병 그룹 — 30일 이상(`groups`)과 미만(`under_30_days`)이 같은 형이다. 없던 배열을 새로 만들지 않는다.
  for (const key of ['groups', 'under_30_days'])
    if (key in out)
      setOrDrop(out, key, sanitizeObjects(out[key], sanitizeGroup));
  //* (3) 약효분류 — 원소 · 일자 묶음 · 그 약까지 내려간다.
  if ('drug_classes' in out)
    setOrDrop(
      out,
      'drug_classes',
      sanitizeObjects(out.drug_classes, sanitizeDrugClass),
    );
  //* (4) 미지 필드 차단. 계약이 이름을 준 키만 적재한다.
  const stored = pick(out, STORED_TOP_KEYS);
  if ('notice' in stored && !isScalar(stored.notice)) delete stored.notice;
  //* (5) 정제기 판번 — whitelist 뒤에 쓰므로 제공자가 보낸 같은 이름의 키는 이미 떨어졌다.
  stored.ingest_rev = DDX_INGEST_REV;
  return stored as DdxAnalyzeResponse;
}

/**
 * 정제기가 최상위에서 버린 자리의 **이름만** 낸다(값은 싣지 않는다). 적재 직후 제공자 사본을 지우므로 버린
 * 값은 되찾을 곳이 없다 — 이름이라도 남아야 「제공자가 안 보냈다」 와 「우리가 버렸다」 가 갈린다.
 * 계약의 최상위 키는 `STORED_TOP_KEYS` 와 `person_name` 뿐이라, 여기 서는 이름은 형이 틀려 버린 키이거나
 * 제공자가 새로 더한 키다.
 *
 * ponytail: 최상위 키와 배열 원소 수만 본다. 그룹 · 약 원소 안쪽의 버림까지 보려면 정제기 함수들에
 * 수집기를 내려보낸다.
 */
export function ingestDrops(
  raw: unknown,
  stored: Record<string, unknown>,
): string[] {
  const out: string[] = [];
  for (const [key, value] of Object.entries(isObject(raw) ? raw : {})) {
    if (key === 'person_name') continue;
    if (!(key in stored)) out.push(key);
    else if (
      Array.isArray(value) &&
      Array.isArray(stored[key]) &&
      (stored[key] as unknown[]).length < value.length
    )
      out.push(`${key}[]`);
  }
  return out;
}

@Injectable()
export class DdxVerdictService {
  constructor(
    private readonly repository: DdxVerdictRepository,
    private readonly client: DdxClient,
    private readonly medicationStandardRepository: MedicationStandardRepository,
  ) {}

  /**
   * 배치 1회. 대상 상한만큼 순차 처리한다.
   *
   * **설정 결손을 대상 선정보다 먼저 낸다.** `DDX_BASE_URL` 이 비면 url 이 상대경로가 되어 axios
   * 가 던지고, 그것이 `status: 0` 으로 접혀 `failed_unknown` — 즉 자동 재시도 대상이 아닌 종결
   * 상태 — 로 귀속된다. 설정이 빠진 배포 한 번이 실행당 `limit` 만큼의 행을 수동 복구 대상으로
   * 만든다. 도달이 **구조적으로 불가능한** 결손은 그 전에 갈라내야 한다.
   *
   * **한 행의 예외가 배치를 끝내지 않는다.** 항목별로 잡지 않으면 던지는 행 하나가 나머지 대상과
   * 집계를 통째로 가져가고, 그 행이 정렬상 앞에 있으면 매 실행이 같은 자리에서 죽는다.
   */
  async runBatch(limit: number): Promise<Record<string, number>> {
    this.client.assertConfigured();

    const targets = await this.repository.selectTargets(limit);
    const deadline = Date.now() + BATCH_DEADLINE_MS;
    const tally: Record<string, number> = {};
    for (const oddId of targets) {
      //* 남은 대상은 다음 실행이 다시 집는다 — 상태를 남기지 않았으므로 선정 조건이 그대로다.
      if (Date.now() >= deadline) {
        tally['deadline_skipped'] = (tally['deadline_skipped'] ?? 0) + 1;
        continue;
      }
      let outcome: DdxVerdictOutcome | 'error';
      try {
        outcome = await this.processOne(oddId);
      } catch (e) {
        //* **문면은 싣지 않는다** — DB 오류 객체가 바인딩 값을 담는다(실명·상병코드). 대신
        //* 값이 될 수 없는 두 가지만 남긴다: 오류 종류와 드라이버 코드(`ER_*`). 이것마저 없으면
        //* tally 의 `error: N` 하나로는 설정·DB·제공자 중 무엇이 고장인지 가를 수 없다.
        //*
        //* **상태는 손대지 않고 시도 횟수만 올린다.** 예외가 `markSent` 뒤에 났다면 그 행은 이미
        //* `sent` 이고, 그것을 `failed_retryable` 로 내리면 다음 실행이 판정을 다시 요청한다(UI2
        //* 위반). 상태를 그대로 두면 `sent` 는 재선정되지 않고, `requested` 는 재선정되되 상한이
        //* 그 반복을 끝낸다 — 상한을 올리지 않으면 같은 행이 매 실행 자리를 차지한다.
        console.error('[ddx] processOne 실패', {
          oddId,
          kind: (e as { constructor?: { name?: string } })?.constructor?.name,
          code: (e as { code?: unknown })?.code,
        });
        await this.repository.bumpAttempts(oddId).catch(() => undefined);
        outcome = 'error';
      }
      tally[outcome] = (tally[outcome] ?? 0) + 1;
    }
    return tally;
  }

  async processOne(oddId: number): Promise<DdxVerdictOutcome> {
    const existing = await this.repository.findByOddId(oddId);

    //* 정리만 남은 행 — DELETE 만 재시도한다. 판정 요청을 다시 보내지 않는다.
    if (existing?.ddvState === DDX_VERDICT_STATE.CLEANUP_PENDING) {
      return await this.settleCleanup(oddId, existing.ddvAnalysisId);
    }

    //* 도달 여부를 모르는 실패 — 자동 재시도하지 않는다(D2). 해소는 운영자 항목이다.
    if (existing?.ddvState === DDX_VERDICT_STATE.FAILED_UNKNOWN) {
      return 'skipped_no_retry';
    }

    if (!existing) {
      //* (1) 마커 선행 INSERT. 중복 키면 다른 프로세스가 이미 맡았다는 뜻이라 물러난다.
      const claimed = await this.repository.insertRequested(oddId);
      if (!claimed) return 'duplicate';
    }

    const groups =
      //* 분류 컷 없이(0) 5년 처방 전부를 보낸다 — 30일은 상병 누적으로 판정 서버가 자른다(2026-10-13 고객사
      //* 「하나의 상병 내에서 30일 이상」). 약효분류 합계 30일로 미리 거르면 여러 분류에 나뉜 상병이 빠진다.
      (await this.medicationStandardRepository.getDosing30DayGroups(
        oddId,
        undefined,
        undefined,
        0,
      )) as Dosing30DayGroupInput[];

    //* (2) 보낼 것이 없으면 POST 하지 않고 행만 닫는다 — 행이 없으면 배치가 매번 다시 집는다.
    if (!groups?.length) {
      await this.repository.setState(oddId, DDX_VERDICT_STATE.EMPTY, {
        completed: true,
      });
      return DDX_VERDICT_STATE.EMPTY;
    }

    const personName = await this.repository.findPersonName(oddId);
    if (!personName) {
      await this.repository.setState(
        oddId,
        DDX_VERDICT_STATE.FAILED_RETRYABLE,
        {
          errorCode: 'owner_missing',
          errorMessage: '분석 소유자를 찾지 못했다',
          bumpAttempts: true,
        },
      );
      return DDX_VERDICT_STATE.FAILED_RETRYABLE;
    }

    const body = buildAnalyzeRequest({
      personName,
      groups,
      externalRef: EXTERNAL_REF,
      store: true,
    });

    //* (3) POST **직전에** sent 로 전이한다. 이 쓰기 한 번이 *안 보냈다* 와 *보냈다* 를 가른다.
    //*
    //* **조건부 전이이며 재개 경로의 배타 마커를 겸한다.** `insertRequested` 의 유니크 키는 행이
    //* 없을 때만 겹침을 가르므로, `requested`·`failed_retryable` 로 남은 행은 배치 둘이 동시에
    //* 뽑아 둘 다 claim 을 건너뛰고 둘 다 POST 할 수 있었다. 0행을 받은 쪽이 물러난다(UI2).
    const claimedForSend = await this.repository.markSent(oddId);
    if (!claimedForSend) return 'duplicate';

    //* (4) 유일한 판정 요청. 이 파일에서 `analyze` 를 부르는 자리는 여기 하나뿐이다.
    const posted = await this.client.analyze(body, 0);

    const failure = this.classifyFailure(posted);
    if (failure) {
      await this.repository.setState(oddId, failure.state, {
        errorCode: failure.code,
        errorMessage: failure.message,
        bumpAttempts: true,
      });
      return failure.state;
    }

    const analysisId = posted.data?.analysis_id;
    if (!analysisId) {
      //* 2xx 인데 id 가 없다 — 판정이 만들어졌는지 알 수 없으므로 재시도 갈래가 아니다.
      await this.repository.setState(oddId, DDX_VERDICT_STATE.FAILED_UNKNOWN, {
        errorCode: 'analysis_id_missing',
        errorMessage: '2xx 응답에 analysis_id 가 없다',
        bumpAttempts: true,
      });
      return DDX_VERDICT_STATE.FAILED_UNKNOWN;
    }

    const settled = await this.poll(analysisId, posted.data);
    const stored = stripPersonNames(settled);
    const dropped = ingestDrops(settled, stored as Record<string, unknown>);
    if (dropped.length > 0)
      console.warn('[ddx] 적재 정제기가 값을 버렸다', {
        oddId,
        analysisId,
        dropped,
      });
    const state =
      settled?.status === 'ready'
        ? DDX_VERDICT_STATE.READY
        : DDX_VERDICT_STATE.PARTIAL;

    //* (5) 적재와 정리 대기 전이를 **한 번의 쓰기로** 한다. 둘로 나누면 그 사이에서 죽은 행이
    //* `ready`/`partial` 로 남는데 그 둘은 재선정 대상이 아니다 — DELETE 가 영영 불리지 않고
    //* 실명·상병 데이터가 제공자 쪽에 남는다. D10 이 막으려던 자리가 바로 그 틈이다.
    //*
    //* `ready`/`partial` 구분은 적재본 `status` 가 진다. 이 둘은 어차피 DELETE 직전까지만 사는
    //* 과도 상태였고, 지속되는 경우는 크래시뿐이었다.
    await this.repository.setState(oddId, DDX_VERDICT_STATE.CLEANUP_PENDING, {
      analysisId,
      result: stored,
      completed: true,
    });

    await this.settleCleanup(oddId, analysisId);
    return state;
  }

  /**
   * 실패 귀속.
   *
   * **`5xx` 는 재시도 갈래가 아니다** — upstream 이 POST 를 처리한 *뒤* 직렬화나 프록시 단계에서도
   * 난다. 응답이 왔다는 사실은 판정이 만들어지지 않았다는 증거가 아니다. `4xx` 만 요청 자체가
   * 거절된 것이라 안전하게 재시도할 수 있다.
   */
  private classifyFailure(
    out: DdxOut<DdxAnalyzeResponse>,
  ): { state: DdxVerdictState; code: string; message: string } | null {
    const transport = (out.data as Record<string, unknown> | undefined)?.[
      '_transportError'
    ];

    //* 귀속 1 — 타임아웃. 요청이 도달했는지 알 수 없다.
    if (
      out.status === 0 &&
      /timeout|ETIMEDOUT|ECONNABORTED/i.test(String(transport ?? ''))
    ) {
      return {
        state: DDX_VERDICT_STATE.FAILED_UNKNOWN,
        code: 'timeout',
        message: '요청 타임아웃 — 도달 여부 불명',
      };
    }

    //* 귀속 2 — 전송오류. 같은 이유로 도달 여부가 불명이다.
    if (out.status === 0) {
      return {
        state: DDX_VERDICT_STATE.FAILED_UNKNOWN,
        code: 'transport',
        message: '전송오류 — 도달 여부 불명',
      };
    }

    //* 귀속 3 — 5xx. upstream 이 POST 를 처리한 뒤에도 난다.
    if (out.status >= 500) {
      return {
        state: DDX_VERDICT_STATE.FAILED_UNKNOWN,
        code: 'upstream_' + out.status,
        message: 'upstream ' + out.status + ' — 판정 생성 여부 불명',
      };
    }

    //* 4xx — 요청 자체가 거절됐다. 여기만 재시도 가능하다.
    if (out.status >= 400) {
      return {
        state: DDX_VERDICT_STATE.FAILED_RETRYABLE,
        code: 'rejected_' + out.status,
        message: '요청 거절 ' + out.status,
      };
    }

    return null;
  }

  /** 상한 있는 폴링. 재조회는 GET 만 쓴다 — 이 경로에 판정 요청이 없다. */
  private async poll(
    analysisId: string,
    initial: DdxAnalyzeResponse,
  ): Promise<DdxAnalyzeResponse> {
    let response = initial;
    const deadline = Date.now() + POLL_DEADLINE_MS;

    for (
      let attempt = 1;
      response?.status === 'partial' && attempt <= POLL_MAX_ATTEMPTS;
      attempt++
    ) {
      if (Date.now() >= deadline) break;
      await sleep(POLL_INTERVAL_MS);
      const got = await this.client.getAnalysis(analysisId, { waitSec: 10 });
      if (got.status === 200) response = got.data;
    }

    return response;
  }

  /**
   * 정리 종결. `2xx` 와 `404` 를 둘 다 종결로 본다 — 이미 지워진 것도 목적 달성이다.
   *
   * 그 밖의 결과에서는 행을 `cleanup_pending` 으로 **남긴다.** 다음 배치가 그 상태를 다시 집어
   * DELETE 만 재시도한다. 여기서 done 으로 닫으면 실명·상병 데이터가 제공자 쪽에 남는다.
   */
  private async settleCleanup(
    oddId: number,
    analysisId: string | null,
  ): Promise<DdxVerdictOutcome> {
    if (!analysisId) {
      await this.repository.setState(oddId, DDX_VERDICT_STATE.DONE, {
        completed: true,
      });
      return DDX_VERDICT_STATE.DONE;
    }

    const removed = await this.client.deleteAnalysis(analysisId);
    const settledOk =
      (removed.status >= 200 && removed.status < 300) || removed.status === 404;

    if (!settledOk) {
      //* **쓰기 없이 반환하면 이 실패에 흔적이 없다.** `ddv_updated_at` 도 `ddv_attempts` 도
      //* 움직이지 않아 몇 번째 실패인지 알 수 없고, 상한이 없으니 이 행이 자리를 영구히 문다.
      await this.repository.setState(oddId, DDX_VERDICT_STATE.CLEANUP_PENDING, {
        analysisId,
        errorCode: 'cleanup_' + removed.status,
        errorMessage:
          'DELETE 미종결 ' + removed.status + ' — 제공자 쪽 원본이 남아 있다',
        bumpAttempts: true,
      });
      return DDX_VERDICT_STATE.CLEANUP_PENDING;
    }

    //* (6) DELETE 종결 뒤에만 done 을 찍는다.
    await this.repository.setState(oddId, DDX_VERDICT_STATE.DONE, {
      completed: true,
    });
    return DDX_VERDICT_STATE.DONE;
  }
}
