import fs from 'node:fs';
import path from 'node:path';

import {
  DDX_INGEST_REV,
  DdxVerdictService,
  ingestDrops,
  stripPersonNames,
} from './ddx-verdict.service';
import {
  DDX_SWEEPABLE_STATES,
  DDX_VERDICT_STATE,
} from './ddx-verdict.repository';
import { toDiagnosisGroupsView } from './ddx-view';

//* 생산 규약의 실행 검사.
//*
//* **이름이 정해진 검사 4건**(`dup-key-no-post` · `cleanup-pending-no-post` ·
//* `failed-unknown-no-post` · `new-request-order`)이 이 파일의 계약이다 — 게이트 `#7` 이 그 이름의
//* 실재와 통과를 열거한다. 총건수만 세면 이 네 성질이 빠져도 통과한다.
//*
//* 정적 계수는 실행 순서를 증명하지 못한다. 아래 검사들은 mock 호출 순서를 assertion 으로 문다.

type Call = { name: string; args: unknown[] };

const harness = (opts: {
  existing?: Record<string, unknown> | null;
  claimed?: boolean;
  sendClaimed?: boolean;
  groups?: unknown[];
  personName?: string | null;
  analyze?: { status: number; data: Record<string, unknown> };
  del?: { status: number };
  targets?: number[];
  unconfigured?: boolean;
}) => {
  const calls: Call[] = [];
  const track =
    (name: string, ret: unknown = undefined) =>
    (...args: unknown[]) => {
      calls.push({ name, args });
      return Promise.resolve(typeof ret === 'function' ? (ret as any)() : ret);
    };

  const repository = {
    findByOddId: track('findByOddId', opts.existing ?? null),
    insertRequested: track('insertRequested', opts.claimed ?? true),
    findPersonName: track('findPersonName', opts.personName ?? '홍길동'),
    //* 조건부 전이다 — `true` 가 "이 프로세스가 가져갔다" 이고 `false` 면 물러난다.
    markSent: track('markSent', opts.sendClaimed ?? true),
    setState: track('setState'),
    bumpAttempts: track('bumpAttempts'),
    selectTargets: track('selectTargets', opts.targets ?? []),
  };
  const client = {
    assertConfigured: (...args: unknown[]) => {
      calls.push({ name: 'assertConfigured', args });
      if (opts.unconfigured) throw new Error('ddx 설정 결손: DDX_BASE_URL');
    },
    analyze: track('analyze', {
      status: opts.analyze?.status ?? 200,
      ms: 1,
      data: opts.analyze?.data ?? { analysis_id: 'an_x', status: 'ready' },
    }),
    getAnalysis: track('getAnalysis', { status: 200, ms: 1, data: {} }),
    deleteAnalysis: track('deleteAnalysis', {
      status: opts.del?.status ?? 204,
      ms: 1,
      data: {},
    }),
  };
  const medicationStandardRepository = {
    getDosing30DayGroups: track('getDosing30DayGroups', opts.groups ?? [
      { categoryName: '해열,진통,소염제', records: [] },
    ]),
  };

  const service = new DdxVerdictService(
    repository as never,
    client as never,
    medicationStandardRepository as never,
  );
  return { service, calls, names: () => calls.map((c) => c.name) };
};

describe('DdxVerdictService 생산 규약', () => {
  it('dup-key-no-post — UNIQUE 중복이면 판정을 요청하지 않는다', async () => {
    const h = harness({ existing: null, claimed: false });
    const outcome = await h.service.processOne(1);

    expect(outcome).toBe('duplicate');
    expect(h.names()).toContain('insertRequested');
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(0);
    expect(h.names().filter((n) => n === 'markSent')).toHaveLength(0);
  });

  it('resume-claim-no-post — 재개 행을 다른 프로세스가 먼저 가져갔으면 판정을 요청하지 않는다', async () => {
    //* 행이 이미 있으므로 `insertRequested` 의 유니크 키는 겹침을 가르지 못한다. 배타는
    //* `markSent` 의 조건부 전이가 지고, 0행(=false)을 받은 쪽은 POST 전에 물러나야 한다.
    const h = harness({
      existing: { ddvState: DDX_VERDICT_STATE.REQUESTED, ddvAnalysisId: null },
      sendClaimed: false,
    });
    const outcome = await h.service.processOne(11);

    expect(outcome).toBe('duplicate');
    expect(h.names().filter((n) => n === 'markSent')).toHaveLength(1);
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(0);
  });

  it('재개 행을 자기가 가져가면 판정을 요청한다 — 위 검사가 모든 재개를 막는 것이 아니다', async () => {
    const h = harness({
      existing: { ddvState: DDX_VERDICT_STATE.REQUESTED, ddvAnalysisId: null },
      sendClaimed: true,
    });
    await h.service.processOne(12);

    expect(h.names().filter((n) => n === 'insertRequested')).toHaveLength(0);
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(1);
  });

  it('cleanup-pending-no-post — 정리 대기 행의 재개는 DELETE 만 재시도한다', async () => {
    const h = harness({
      existing: {
        ddvState: DDX_VERDICT_STATE.CLEANUP_PENDING,
        ddvAnalysisId: 'an_pending',
      },
    });
    const outcome = await h.service.processOne(2);

    expect(outcome).toBe(DDX_VERDICT_STATE.DONE);
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(0);
    expect(h.names().filter((n) => n === 'deleteAnalysis')).toHaveLength(1);
  });

  it('failed-unknown-no-post — 도달 여부 불명 행은 자동 재시도하지 않는다', async () => {
    const h = harness({
      existing: { ddvState: DDX_VERDICT_STATE.FAILED_UNKNOWN, ddvAnalysisId: null },
    });
    const outcome = await h.service.processOne(3);

    expect(outcome).toBe('skipped_no_retry');
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(0);
  });

  it('new-request-order — 마커 INSERT 와 sent 전이가 판정 요청보다 먼저 불린다', async () => {
    const h = harness({ existing: null, claimed: true });
    await h.service.processOne(4);

    const order = h.names();
    const insertAt = order.indexOf('insertRequested');
    const sentAt = order.indexOf('markSent');
    const postAt = order.indexOf('analyze');

    expect(insertAt).toBeGreaterThanOrEqual(0);
    expect(sentAt).toBeGreaterThanOrEqual(0);
    expect(postAt).toBeGreaterThanOrEqual(0);
    expect(insertAt).toBeLessThan(postAt);
    expect(sentAt).toBeLessThan(postAt);
    expect(order.filter((n) => n === 'analyze')).toHaveLength(1);
  });

  it('5xx 는 failed_unknown 이다 — 응답의 존재가 무판정의 증거가 아니다', async () => {
    const h = harness({ analyze: { status: 502, data: {} } });
    const outcome = await h.service.processOne(5);
    expect(outcome).toBe(DDX_VERDICT_STATE.FAILED_UNKNOWN);
  });

  it('전송오류(status 0)는 failed_unknown 이다', async () => {
    const h = harness({ analyze: { status: 0, data: { _transportError: 'ECONNRESET' } } });
    const outcome = await h.service.processOne(6);
    expect(outcome).toBe(DDX_VERDICT_STATE.FAILED_UNKNOWN);
  });

  it('타임아웃은 failed_unknown 이다', async () => {
    const h = harness({
      analyze: { status: 0, data: { _transportError: 'timeout of 120000ms exceeded' } },
    });
    const outcome = await h.service.processOne(7);
    expect(outcome).toBe(DDX_VERDICT_STATE.FAILED_UNKNOWN);
  });

  it('4xx 만 failed_retryable 이다 — 요청 자체가 거절된 자리', async () => {
    const h = harness({ analyze: { status: 422, data: {} } });
    const outcome = await h.service.processOne(8);
    expect(outcome).toBe(DDX_VERDICT_STATE.FAILED_RETRYABLE);
  });

  it('30일 그룹이 비면 empty 로 닫고 POST 하지 않는다', async () => {
    const h = harness({ groups: [] });
    const outcome = await h.service.processOne(9);
    expect(outcome).toBe(DDX_VERDICT_STATE.EMPTY);
    expect(h.names().filter((n) => n === 'analyze')).toHaveLength(0);
  });

  it('DELETE 가 종결하지 못하면 cleanup_pending 으로 남는다', async () => {
    const h = harness({ del: { status: 503 } });
    await h.service.processOne(10);
    const states = h.calls
      .filter((c) => c.name === 'setState')
      .map((c) => c.args[1]);
    expect(states).toContain(DDX_VERDICT_STATE.CLEANUP_PENDING);
    expect(states).not.toContain(DDX_VERDICT_STATE.DONE);
  });

  it('적재 직전에 실명 2자리를 지운다', () => {
    const stored = stripPersonNames({
      analysis_id: 'an_x',
      person_name: '홍길동',
      groups: [{ person: '홍길동', dx_code: 'E119', diag_drugs: [] }],
    } as never);

    expect(JSON.stringify(stored)).not.toContain('홍길동');
    expect(stored).not.toHaveProperty('person_name');
    expect((stored.groups as Record<string, unknown>[])[0]).not.toHaveProperty(
      'person',
    );
    expect((stored.groups as Record<string, unknown>[])[0].dx_code).toBe('E119');
  });

  it('계약에 없는 필드는 적재하지 않는다 — 미지 필드가 이름을 담아도 통과하지 못한다', () => {
    const stored = stripPersonNames({
      analysis_id: 'an_x',
      status: 'ready',
      patient_full_name: '홍길동',
      groups: [
        { dx_code: 'E119', diag_drugs: [], guardian_name: '홍길동' },
      ],
    } as never);

    expect(JSON.stringify(stored)).not.toContain('홍길동');
    expect(stored).not.toHaveProperty('patient_full_name');
    expect((stored.groups as Record<string, unknown>[])[0]).not.toHaveProperty(
      'guardian_name',
    );
  });

  it('약 원소의 미지 필드도 적재하지 않는다 — whitelist 가 2단계에서 멈추지 않는다', () => {
    const stored = stripPersonNames({
      analysis_id: 'an_x',
      status: 'ready',
      groups: [
        {
          dx_code: 'E119',
          diag_drugs: [
            { drug_name: '메트포르민', prescriber_name: '홍길동', days_supplied: 30 },
          ],
          pending_drugs: [{ drug_name: '아스피린', patient: '홍길동' }],
        },
      ],
    } as never);

    expect(JSON.stringify(stored)).not.toContain('홍길동');
    const group = (stored.groups as Record<string, unknown>[])[0];
    const diag = (group.diag_drugs as Record<string, unknown>[])[0];
    const pending = (group.pending_drugs as Record<string, unknown>[])[0];
    expect(diag).not.toHaveProperty('prescriber_name');
    expect(pending).not.toHaveProperty('patient');
    //* 아는 키는 남는다 — 전부 지우는 것이 아니라 whitelist 라는 것.
    expect(diag.drug_name).toBe('메트포르민');
    expect(diag.days_supplied).toBe(30);
  });

  it('groups 가 없던 응답에 빈 배열을 만들지 않는다', () => {
    const stored = stripPersonNames({ analysis_id: 'an_x', status: 'ready' } as never);
    expect(stored).not.toHaveProperty('groups');
  });

  //* 적재 직후 제공자 사본을 지우므로 정제기가 버린 값은 되찾을 곳이 없다 — 이름이라도 남아야
  //* 「제공자가 안 보냈다」 와 「우리가 버렸다」 가 갈린다. 값은 싣지 않는다.
  it('ingest-drops-are-named-without-values', () => {
    const raw = {
      analysis_id: 'an_x',
      status: 'ready',
      person_name: '합성이름',
      groups: { person_name: '합성이름' },
      drug_classes: [{ name: '합성분류' }, 3],
      notice: { text: '형이 틀린 안내' },
      provider_new_key: '합성값',
    };
    const stored = stripPersonNames(raw as never) as Record<
      string,
      unknown
    >;
    const dropped = ingestDrops(raw, stored);
    //* 실명은 의도한 삭제라 빼고, 형이 틀려 버린 키 · 줄어든 배열 · 계약 밖 새 키만 이름으로 선다.
    expect(dropped).toEqual([
      'groups',
      'drug_classes[]',
      'notice',
      'provider_new_key',
    ]);
    expect(JSON.stringify(dropped)).not.toContain('합성');
    //* 정상 입력은 아무것도 버리지 않는다.
    const normal = {
      analysis_id: 'an_y',
      status: 'ready',
      groups: [],
      drug_classes: [{ name: '합성분류' }],
    };
    expect(
      ingestDrops(normal, stripPersonNames(normal as never) as never),
    ).toEqual([]);
  });

  //* 이름이 정해진 검사 `sweepable-states-exclude-provider-copies`.
  //*
  //* 30일 스윕(`analyze.repository.ts` `delete()`)은 이 집합의 행만 통째로 지운다. 제공자 쪽에
  //* 원본이 남아 있을 수 있는 상태가 여기 들어오면 유일한 열쇠인 `ddv_analysis_id` 가 함께
  //* 사라져 수동 DELETE 가 불가능해진다 — 실명·상병이 제공자 쪽에 영구히 남는다.
  //*
  //* **위험 집합을 여기서 독립 재열거한다.** 생산 코드의 목록을 되읽어 비교하면 그 목록이
  //* 틀렸을 때 검사도 함께 틀린다.
  it('sweepable-states-exclude-provider-copies — 제공자 사본이 남을 수 있는 상태는 스윕 대상이 아니다', () => {
    //* POST 가 도달했을 수 있어 제공자 쪽 사본을 배제할 수 없는 자리들.
    const providerCopyPossible: string[] = [
      DDX_VERDICT_STATE.SENT,
      DDX_VERDICT_STATE.READY,
      DDX_VERDICT_STATE.PARTIAL,
      DDX_VERDICT_STATE.CLEANUP_PENDING,
      DDX_VERDICT_STATE.FAILED_UNKNOWN,
    ];
    const sweepable: string[] = [...DDX_SWEEPABLE_STATES];

    expect(sweepable.filter((s) => providerCopyPossible.includes(s))).toEqual(
      [],
    );

    //* 어휘 전건이 둘 중 하나로 분류된다 — 새 상태가 조용히 어느 쪽도 아닌 채 남지 않는다.
    const all: string[] = Object.values(DDX_VERDICT_STATE);
    const classified = [...sweepable, ...providerCopyPossible];
    expect(all.filter((s) => !classified.includes(s))).toEqual([]);
    expect(classified).toHaveLength(all.length);
  });

  //* 이름이 정해진 검사 `stored-drug-keeps-contract-required`.
  //*
  //* 적재 직후 제공자 쪽 원본을 지우므로(D10) whitelist 에서 뺀 필드는 되찾을 곳이 없다.
  //* 특히 `source` 는 그 문장이 규칙에서 나왔는지 언어모델에서 나왔는지를 가르는 유일한 값이고,
  //* 그 배지 없이 인사이트를 내는 것은 이 제품이 해서는 안 되는 한 가지다.
  it('stored-drug-keeps-contract-required — 계약 ClassifiedDrug required 가 전건 적재된다', () => {
    const stored = stripPersonNames({
      analysis_id: 'an_x',
      status: 'ready',
      groups: [
        {
          dx_code: 'E119',
          diag_drugs: [
            {
              drug_name: '메트포르민',
              drug_class: '당뇨병용제',
              label: '해당',
              confidence: 0.92,
              rationale: '허가 효능효과에 해당 상병이 있다',
              source: 'rule',
              prescriber_name: '홍길동',
            },
          ],
        },
      ],
    } as never);

    const diag = (
      (stored.groups as Record<string, unknown>[])[0]
        .diag_drugs as Record<string, unknown>[]
    )[0];
    //* 계약 `ClassifiedDrug.required` 6종 전건.
    expect(diag.drug_name).toBe('메트포르민');
    expect(diag.drug_class).toBe('당뇨병용제');
    expect(diag.label).toBe('해당');
    expect(diag.confidence).toBe(0.92);
    expect(diag.rationale).toBe('허가 효능효과에 해당 상병이 있다');
    expect(diag.source).toBe('rule');
    //* whitelist 는 여전히 whitelist 다 — 계약 밖 필드는 그대로 떨어진다.
    expect(diag).not.toHaveProperty('prescriber_name');
    expect(JSON.stringify(stored)).not.toContain('홍길동');
  });

  //* 이름이 정해진 검사 `stored-drug-survives-view-read`.
  //*
  //* **이 파일과 `ddx-view.spec.ts` 는 각자 옳으면서 함께 틀릴 수 있었다.** 저쪽은 원응답을
  //* `toDiagnosisGroupsView` 에 **직접** 먹여 `stripPersonNames` 를 건너뛰고, 이쪽 whitelist
  //* 검사는 실명이 **빠지는지**만 봤다. 그래서 「뷰가 읽는 키가 적재를 통과하는가」는 두 파일
  //* 사이의 빈 자리였고, M5 가 뷰를 넓히면서 whitelist 를 안 넓혔을 때 아무 검사도 안 붉었다 —
  //* 상세 12행 중 다섯이 전건 `-` 로 나가는 동안.
  //*
  //* 그래서 이 검사는 **합성 경계**에 선다: 적재 → 읽기를 실제로 이어 붙이고, 뷰가 읽는 값이
  //* 반대편에서 살아 나오는지 본다. 표본 하나를 외우는 대신 **뷰가 읽는 자리마다 고유한 파수
  //* 값**을 넣어, 어느 한 칸이 적재에서 떨어지면 그 칸의 assertion 이 정확히 그 이름으로 붉는다.
  it('stored-drug-survives-view-read — 뷰가 읽는 칸이 적재를 통과한다', () => {
    const stored = stripPersonNames({
      analysis_id: 'an_x',
      status: 'ready',
      groups: [
        {
          person: '홍길동',
          dx_code: 'E1120',
          dx_name: '2형 당뇨병',
          is_administrative: false,
          n_episodes: 3,
          diag_drugs: [
            {
              drug_name: '글루코파지',
              ingredient: '메트포르민염산염',
              drug_class: '당뇨병용제',
              dx_code: 'E1120',
              dx_name: '2형 당뇨병',
              bucket: '주치료',
              role: '주치료',
              label: '해당',
              indication: '당뇨병 혈당 조절',
              rationale: '허가사항에 해당 상병이 있다',
              days_supplied: 527,
              n_rows: 9,
              confidence: 1,
              source: 'rule',
              prescriptions: [
                {
                  treatment_start_date: '2026-04-13',
                  provider: '좋은문화병원',
                  dose_per_administration: 1,
                  administrations_per_day: 2,
                  days_supplied: 90,
                  //* 회차 원소의 미지 필드 — 4단째 whitelist 가 없으면 여기가 샌다.
                  prescriber_name: '홍길동',
                },
              ],
            },
          ],
          pending_drugs: [],
        },
      ],
    } as never);

    //* 적재본에는 실명이 없다. 회차가 한 겹 더 깊어졌어도 그 성질은 그대로다.
    expect(JSON.stringify(stored)).not.toContain('홍길동');

    const view = toDiagnosisGroupsView(stored as never);
    const group = view.groups[0];
    const drug = group.buckets.find((b) => b.bucket === '주치료')!.drugs[0];
    const rx = drug.prescriptions[0];

    //* 화면 상세 12행이 읽는 자리 전건. 이름은 `ANS003BDiagnosis.tsx` `DrugDetailCards` 의 헤더다.
    expect(rx.treatmentStartDate).toBe('2026-04-13'); //* 진료시작일
    expect(rx.provider).toBe('좋은문화병원'); //* 병·의원
    expect(drug.dxCode).toBe('E1120'); //* 진단코드
    expect(drug.dxName).toBe('2형 당뇨병'); //* 진단명
    expect(drug.drugName).toBe('글루코파지'); //* 약품명
    expect(drug.ingredient).toBe('메트포르민염산염'); //* 성분명
    expect(drug.indication).toBe('당뇨병 혈당 조절'); //* 효능효과
    expect(rx.dosePerAdministration).toBe(1); //* 1회투약량
    expect(rx.administrationsPerDay).toBe(2); //* 1회투여횟수
    expect(drug.daysSupplied).toBe(527); //* 총 투약일수
    expect(drug.role).toBe('주치료'); //* 판정 (배지 1)
    expect(drug.label).toBe('해당'); //* 판정 (배지 2)
    expect(drug.rationale).toBe('허가사항에 해당 상병이 있다'); //* 판정 근거
    //* 각주 「같은 약·같은 상병으로 N회 처방」의 모수.
    expect(drug.nRows).toBe(9);
    //* 약효분류별 탭이 피벗 키로 쓰는 축 — 이것이 없으면 그 탭에서 약이 통째로 사라진다.
    expect(drug.drugClass).toBe('당뇨병용제');
    //* 카드의 기간 줄.
    expect(group.startDate).toBe('2026-04-13');
    expect(group.endDate).toBe('2026-04-13');

    //* whitelist 는 여전히 whitelist 다 — 회차 원소의 계약 밖 필드는 떨어진다.
    expect(rx).not.toHaveProperty('prescriberName');
    expect(
      (
        (
          (stored.groups as Record<string, unknown>[])[0]
            .diag_drugs as Record<string, unknown>[]
        )[0].prescriptions as Record<string, unknown>[]
      )[0],
    ).not.toHaveProperty('prescriber_name');
  });

  it('적재와 정리 대기 전이는 한 번의 쓰기다 — 그 사이에서 죽는 창이 없다', async () => {
    const h = harness({ del: { status: 503 } });
    await h.service.processOne(11);

    const stores = h.calls.filter(
      (c) => c.name === 'setState' && (c.args[2] as Record<string, unknown>)?.result !== undefined,
    );
    expect(stores).toHaveLength(1);
    expect(stores[0].args[1]).toBe(DDX_VERDICT_STATE.CLEANUP_PENDING);
  });

  it('DELETE 미종결은 시도 횟수를 올리고 문면을 남긴다', async () => {
    const h = harness({ del: { status: 503 } });
    await h.service.processOne(12);

    const bumped = h.calls.filter(
      (c) =>
        c.name === 'setState' &&
        (c.args[2] as Record<string, unknown>)?.bumpAttempts === true &&
        String((c.args[2] as Record<string, unknown>)?.errorCode).startsWith('cleanup_'),
    );
    expect(bumped).toHaveLength(1);
  });

  it('runBatch 는 설정 결손을 대상 선정보다 먼저 낸다', async () => {
    const h = harness({ unconfigured: true });
    await expect(h.service.runBatch(50)).rejects.toThrow(/DDX_BASE_URL/);
    expect(h.names()).toEqual(['assertConfigured']);
  });

  it('runBatch 는 한 행의 예외로 배치를 끝내지 않는다', async () => {
    const h = harness({ targets: [1, 2] });
    let first = true;
    (h.service as unknown as { processOne: unknown }).processOne = async (
      oddId: number,
    ) => {
      if (first) {
        first = false;
        throw new Error('boom');
      }
      return DDX_VERDICT_STATE.READY;
    };

    const tally = await h.service.runBatch(50);
    expect(tally).toEqual({ error: 1, ready: 1 });
    expect(h.names().filter((n) => n === 'bumpAttempts')).toHaveLength(1);
  });
});

//* ── 수정요청5(v0.6) 적재 경계 ─────────────────────────────────────────
//* 제공자 openapi 동결 사본(부모 `.claude/plans/`, sha256 은 계획 게이트 `#4` 가 본다). 서버 루트 다섯 단계 · superproject 여섯 단계.
const OPENAPI = JSON.parse(
  fs.readFileSync(
    path.join(
      __dirname,
      '..',
      '..',
      '..',
      '..',
      '..',
      '..',
      '.claude',
      'plans',
      'ai-insight-cr2-projection.openapi.json',
    ),
    'utf8',
  ),
) as {
  components: { schemas: Record<string, { properties?: Record<string, any> }> };
};

/** 계약 속성 중 **의도해서** 적재하지 않는 것. 스키마에 실재해야 한다(낡은 제외 0 — 아래 검사가 본다). */
const DECLARED_EXCLUSIONS: Record<string, string[]> = {
  AnalyzeResponse: ['person_name'],
  DiagnosisGroup: ['person'],
  ClassifiedDrug: [
    'item_seq',
    'edi_code',
    'atc_code',
    'evidence',
    'evidence_basis',
  ],
  PendingDrug: ['item_seq', 'edi_code', 'atc_code'],
};

describe('수정요청5 적재 경계', () => {
  const v06Response = () => ({
    analysis_id: 'an_v06',
    person_name: '홍길동',
    status: 'ready',
    notice: '합성 안내 — 참고용으로 보세요',
    groups: [
      {
        person: '홍길동',
        dx_code: 'K760',
        dx_name: '합성 간질환',
        medication_days: 60,
        diag_drugs: [
          {
            drug_name: '합성약가',
            drug_class: '간장질환용제',
            bucket: '주치료',
            days_supplied: 60,
          },
        ],
        pending_drugs: [],
      },
    ],
    under_30_days: [
      {
        person: '홍길동',
        dx_code: 'J209',
        dx_name: '합성 기관지염',
        medication_days: 10,
        diag_drugs: [],
        pending_drugs: [],
      },
    ],
    drug_classes: [
      {
        name: '간장질환용제',
        source: 'site',
        atc_code: 'A05',
        total_days: 60,
        period_start: '2022-07-04',
        period_end: '2022-07-04',
        n_prescriptions: 2,
        n_diagnoses: 1,
        drugs: [
          {
            drug_name: '합성약가',
            drug_class: '간장질환용제',
            prescriptions: [
              {
                treatment_start_date: '2022-07-04',
                provider: '합성의원',
                days_supplied: 60,
              },
            ],
          },
        ],
        by_date: [
          {
            date: '2021-01-01',
            provider: '합성의원',
            dx_codes: ['K760'],
            dx_names: ['합성 간질환'],
            days: 30,
            drugs: [
              { drug_name: '합성약나', indication: null, days_supplied: 30 },
            ],
          },
          {
            date: '2022-07-04',
            provider: '합성병원',
            dx_codes: ['K760', 'I109'],
            dx_names: ['합성 간질환', '합성 고혈압'],
            days: 60,
            drugs: [
              {
                drug_name: '합성약가',
                indication: '합성 효능',
                days_supplied: 60,
              },
            ],
          },
        ],
      },
    ],
  });

  it('stored-drug-class-survives-view-read — 뷰가 읽는 약효분류·일자 칸이 적재를 통과한다', () => {
    const view = toDiagnosisGroupsView(
      stripPersonNames(v06Response() as never) as never,
    );
    expect([view.ingestRevision, view.notice]).toEqual([
      DDX_INGEST_REV,
      '합성 안내 — 참고용으로 보세요',
    ]);
    expect(view.groups[0].medicationDays).toBe(60);
    const c = view.drugClasses[0];
    //* 약효분류 9칸 — 화면이 읽는 자리 전건.
    expect([
      c.name,
      c.source,
      c.atcCode,
      c.totalDays,
      c.periodStart,
      c.periodEnd,
      c.nPrescriptions,
      c.nDiagnoses,
    ]).toEqual([
      '간장질환용제',
      'site',
      'A05',
      60,
      '2022-07-04',
      '2022-07-04',
      2,
      1,
    ]);
    expect(c.byDate).toHaveLength(2);
    //* 일자 6칸 · 일자 약 3칸 — 최근 날짜 먼저.
    const [newer, older] = c.byDate!;
    expect([
      newer.date,
      newer.provider,
      newer.dxCodes,
      newer.dxNames,
      newer.days,
    ]).toEqual([
      '2022-07-04',
      '합성병원',
      ['K760', 'I109'],
      ['합성 간질환', '합성 고혈압'],
      60,
    ]);
    expect(newer.drugs).toEqual([
      { drugName: '합성약가', indication: '합성 효능', daysSupplied: 60 },
    ]);
    expect([older.date, older.dxCodes, older.days]).toEqual([
      '2021-01-01',
      ['K760'],
      30,
    ]);
    expect(older.drugs).toEqual([
      { drugName: '합성약나', indication: null, daysSupplied: 30 },
    ]);
  });

  it('drug-classes-whitelist-descends — 약효분류·일자·회차 원소의 미지 필드와 실명이 적재되지 않는다', () => {
    const raw = v06Response() as Record<string, any>;
    const cls = raw.drug_classes[0];
    cls.prescriber_name = '홍길동';
    cls.__unknown__ = '미지';
    cls.drugs[0].prescriber_name = '홍길동';
    cls.drugs[0].prescriptions[0].prescriber_name = '홍길동';
    cls.by_date[0].person_name = '홍길동';
    cls.by_date[0].__unknown__ = '미지';
    cls.by_date[0].drugs[0].prescriber_name = '홍길동';
    raw.under_30_days[0].prescriber_name = '홍길동';
    //* 표지 위조 — 제공자가 보낸 `ingest_rev` 는 whitelist 에 없어 떨어지고, 정제기가 자기 판번을 쓴다.
    raw.ingest_rev = 99;
    const stored = stripPersonNames(raw as never) as Record<string, any>;
    const text = JSON.stringify(stored);
    expect(text).not.toContain('홍길동');
    expect(text).not.toContain('__unknown__');
    expect(stored.ingest_rev).toBe(DDX_INGEST_REV);

    //* 배열 자리에 배열이 아닌 값 — 키째 빠진다(Implement-Codex F1 · security HIGH). 그대로 옮기면 이름이 적재된다.
    const bad = v06Response() as Record<string, any>;
    bad.under_30_days = { person_name: '홍길동' };
    bad.groups[0].diag_drugs = { person_name: '홍길동' };
    bad.drug_classes[0].drugs[0].prescriptions = { person_name: '홍길동' };
    bad.drug_classes[0].by_date[0].drugs = { person_name: '홍길동' };
    bad.drug_classes[0].by_date[1].dx_codes = { person_name: '홍길동' };
    const s2 = stripPersonNames(bad as never) as Record<string, any>;
    expect(JSON.stringify(s2)).not.toContain('홍길동');
    expect('under_30_days' in s2).toBe(false);
    expect('diag_drugs' in s2.groups[0]).toBe(false);
    expect('prescriptions' in s2.drug_classes[0].drugs[0]).toBe(false);
    expect('drugs' in s2.drug_classes[0].by_date[0]).toBe(false);
    expect('dx_codes' in s2.drug_classes[0].by_date[1]).toBe(false);

    //* 전함수 — 모든 겹에 객체가 아닌 원소를 심어도 던지지 않는다(던지면 analysisId 기록 전이라 제공자 사본이 남는다).
    const junk = v06Response() as Record<string, any>;
    junk.groups.push(5);
    junk.groups[0].diag_drugs.push('x');
    junk.groups[0].diag_drugs[0].prescriptions = [
      null,
      { treatment_start_date: '2026-01-01' },
    ];
    junk.drug_classes.push([]);
    junk.drug_classes[0].by_date[0].drugs.push(1);
    junk.drug_classes[0].by_date[1].dx_codes = [{}, 'K760'];
    let s3: Record<string, any> = {};
    expect(() => {
      s3 = stripPersonNames(junk as never) as Record<string, any>;
    }).not.toThrow();
    expect(s3.groups).toHaveLength(1);
    expect(s3.groups[0].diag_drugs).toHaveLength(1);
    expect(s3.groups[0].diag_drugs[0].prescriptions).toEqual([
      { treatment_start_date: '2026-01-01' },
    ]);
    expect(s3.drug_classes).toHaveLength(1);
    expect(s3.drug_classes[0].by_date[0].drugs).toHaveLength(1);
    expect(s3.drug_classes[0].by_date[1].dx_codes).toEqual(['K760']);
    //* 원소를 전부 버린 배열은 「비었다」(`[]`)가 아니라 키째 빠진다.
    const allJunk = v06Response() as Record<string, any>;
    allJunk.drug_classes[0].by_date = [1, 'x'];
    expect(
      'by_date' in
        (stripPersonNames(allJunk as never) as Record<string, any>)
          .drug_classes[0],
    ).toBe(false);
  });

  it('under-30-days-stored-through-group-strip — person 은 지워지고 계약 키만 남는다', () => {
    const raw = v06Response() as Record<string, any>;
    raw.under_30_days[0].__unknown__ = '미지';
    raw.under_30_days[0].diag_drugs = [
      { drug_name: '합성약다', prescriber_name: '홍길동' },
    ];
    const stored = stripPersonNames(raw as never) as Record<string, any>;
    const g = stored.under_30_days[0];
    expect(Object.keys(g).sort()).toEqual(
      [
        'diag_drugs',
        'dx_code',
        'dx_name',
        'medication_days',
        'pending_drugs',
      ].sort(),
    );
    expect([g.dx_code, g.medication_days, g.diag_drugs]).toEqual([
      'J209',
      10,
      [{ drug_name: '합성약다' }],
    ]);
    expect(JSON.stringify(stored)).not.toContain('홍길동');
  });

  it('new-scalar-keys-drop-non-primitive — 새 스칼라 키에 객체가 오면 적재하지 않는다', () => {
    const raw = v06Response() as Record<string, any>;
    raw.notice = { person_name: '홍길동' };
    raw.groups[0].medication_days = { person_name: '홍길동' };
    raw.drug_classes[0].name = { person_name: '홍길동' };
    raw.drug_classes[0].by_date[0].provider = { person_name: '홍길동' };
    raw.drug_classes[0].by_date[0].drugs[0].indication = ['홍길동'];
    raw.drug_classes[0].by_date[1].days = Infinity;
    const stored = stripPersonNames(raw as never) as Record<string, any>;
    expect(JSON.stringify(stored)).not.toContain('홍길동');
    expect('notice' in stored).toBe(false);
    expect('medication_days' in stored.groups[0]).toBe(false);
    expect('name' in stored.drug_classes[0]).toBe(false);
    expect('provider' in stored.drug_classes[0].by_date[0]).toBe(false);
    expect('indication' in stored.drug_classes[0].by_date[0].drugs[0]).toBe(
      false,
    );
    expect('days' in stored.drug_classes[0].by_date[1]).toBe(false);
    //* 반대 방향 — 문자열 · 수 · null 은 그대로 남는다.
    const ok = stripPersonNames({
      ...v06Response(),
      notice: null,
    } as never) as Record<string, any>;
    expect(['notice' in ok, ok.notice, ok.groups[0].medication_days]).toEqual([
      true,
      null,
      60,
    ]);
  });

  it('stored-matches-provider-schema — 동결 openapi 의 계약 속성이 선언 제외 밖에서 전건 적재된다', () => {
    const S = OPENAPI.components.schemas;
    let n = 0;
    type Spec = { ref?: string; array?: Spec; scalar?: string };
    const specOf = (p: any): Spec => {
      if (!p) return { scalar: 'string' };
      if (p.$ref) return { ref: String(p.$ref).split('/').pop() };
      if (Array.isArray(p.anyOf))
        return specOf(p.anyOf.find((x: any) => x.type !== 'null'));
      if (p.type === 'array') return { array: specOf(p.items) };
      return {
        scalar: Array.isArray(p.type)
          ? p.type.find((x: string) => x !== 'null')
          : p.type,
      };
    };
    const isObjSchema = (name?: string) => !!name && !!S[name]?.properties;
    const valueOf = (spec: Spec, at: string): unknown => {
      if (spec.ref && isObjSchema(spec.ref)) return build(spec.ref);
      if (spec.array) return [valueOf(spec.array, at)];
      if (spec.scalar === 'integer' || spec.scalar === 'number') return ++n;
      if (spec.scalar === 'boolean') return true;
      return '파수:' + at;
    };
    const build = (name: string): Record<string, unknown> =>
      Object.fromEntries(
        Object.entries(S[name].properties ?? {}).map(([k, p]) => [
          k,
          valueOf(specOf(p), name + '.' + k),
        ]),
      );
    const raw = build('AnalyzeResponse');
    const stored = stripPersonNames(raw as never) as Record<string, unknown>;

    //* 양방향 대조 — 속성마다 「적재됨(값 그대로)」 또는 「선언 제외」 중 정확히 하나. 적재본에 스키마 밖 키는 우리 표지뿐이다.
    const checked: string[] = [];
    const walk = (
      name: string,
      r: Record<string, any>,
      s: Record<string, any>,
    ) => {
      const props = S[name].properties ?? {};
      const excl = DECLARED_EXCLUSIONS[name] ?? [];
      for (const [k, p] of Object.entries(props)) {
        const spec = specOf(p);
        checked.push(name + '.' + k);
        if (excl.includes(k)) {
          expect([name + '.' + k, k in s]).toEqual([name + '.' + k, false]);
          continue;
        }
        expect([name + '.' + k, k in s]).toEqual([name + '.' + k, true]);
        if (spec.ref && isObjSchema(spec.ref)) walk(spec.ref, r[k], s[k]);
        else if (spec.array?.ref && isObjSchema(spec.array.ref))
          walk(spec.array.ref, r[k][0], s[k][0]);
        else expect([name + '.' + k, s[k]]).toEqual([name + '.' + k, r[k]]);
      }
      const extra = Object.keys(s).filter(
        (k) =>
          !(k in props) && !(name === 'AnalyzeResponse' && k === 'ingest_rev'),
      );
      expect([name, extra]).toEqual([name, []]);
    };
    walk('AnalyzeResponse', raw, stored);
    //* 대조가 실제로 수정요청5 스키마와 기존 `Stats` 까지 내려갔다.
    for (const must of [
      'AnalyzeResponse.notice',
      'AnalyzeResponse.under_30_days',
      'DiagnosisGroup.medication_days',
      'DrugClassGroup.by_date',
      'DateEntry.dx_codes',
      'DateDrug.indication',
      'Prescription_.provider',
      'Stats.drugs',
    ])
      expect([must, checked.includes(must)]).toEqual([must, true]);
    //* 선언 제외는 스키마에 실재한다 — 이름이 바뀐 제외가 조용히 남지 않게.
    for (const [name, keys] of Object.entries(DECLARED_EXCLUSIONS))
      for (const k of keys)
        expect([name + '.' + k, k in (S[name].properties ?? {})]).toEqual([
          name + '.' + k,
          true,
        ]);
  });
});
