import fs from 'node:fs';
import path from 'node:path';
import {
  MAX_INSIGHT_BODY_BYTES,
  insightRequestBytes,
  serializeInsightRequest,
  toInsightCoverageHints,
  toInsightRequest,
  toInsightTreatments,
  withoutInsightItemAxes,
  type InsightCoverageHintInput,
  type InsightHintVisitInput,
  type InsightNoticeInput,
  type InsightRequest,
  type InsightTreatmentInput,
  type InsightTreatmentOut,
} from './insight-payload';

/**
 * 요청 매퍼의 실행 검사.
 *
 * **검사 이름은 고정이다.** 게이트 `#3`·`#12` 가 이름의 실재와 통과를 열거하며, 총건수만
 * 세면 성질 하나가 빠져도 통과한다.
 *
 * 기대값을 인라인으로 들지 않는다 — 기록된 입력과 기대 산출은 저장소 루트의
 * `.claude/plans/ai-insight-report-m2.request-fixture.json` 이 지고, 이 스펙은 그것을 **읽어**
 * 재생한다. 인라인이면 「기록된 입력에 재생해 바이트 일치」가 자기 단언이 되고, `#3` 이 그
 * 자리를 `it` 본문에서 직접 본다.
 */

//* `.../insuniverse-server/apps/api/src/modules/ddx` → superproject 루트까지 여섯 단계.
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..', '..', '..');

describe('insight-payload.toInsightRequest', () => {
  it('request-is-byte-stable', () => {
    const fixturePath = path.join(
      REPO_ROOT,
      '.claude',
      'plans',
      'ai-insight-report-m2.request-fixture.json',
    );
    const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as {
      input: {
        asOf: string;
        treatments: InsightTreatmentInput[];
        notices: InsightNoticeInput[];
      };
      expected: unknown;
    };

    const produced = toInsightRequest(fixture.input);

    //* 바이트 비교. 키 순서까지 계약이므로 `toEqual` 로는 부족하다 — 순서가 흔들려도 통과한다.
    expect(serializeInsightRequest(produced)).toBe(
      serializeInsightRequest(fixture.expected),
    );
  });

  it('request-carries-no-person-name', () => {
    const produced = toInsightRequest({
      asOf: '2026-09-02',
      treatments: [
        {
          trbTreatStartDate: '2026-05-12',
          trbDiseaseCode: 'J304',
          trbDiseaseName: '알레르기성 비염',
          trbTreatType: '외래',
          trbDepartment: '이비인후과',
          trbHospitalName: '가온내과의원',
          trbVisitDays: 1,
          trbDeductibleAmt: 24300,
        },
      ],
      notices: [],
    });

    //* 키 자체가 없어야 한다 — `undefined` 로 두면 직렬화에서 사라지지만 형이 그 자리를 남긴다.
    expect(Object.keys(produced).sort()).toEqual([
      'as_of',
      'notices',
      'treatments',
    ]);
    const wire = serializeInsightRequest(produced);
    //* `"name"` 을 통째로 금칙어에 둘 수 없다 — 확장 본문의 `drugs[]`·`tests[]`·`procedures[]`
    //* 가 그 키를 **정당하게** 싣는다(제공자 스펙 required). 그래서 사람을 가리키는 키 셋으로
    //* 좁히고, `name` 은 아래에서 **자리**로 막는다: 진료 객체 자신에는 없어야 한다.
    for (const forbidden of ['person', 'external_ref', 'patient_name']) {
      expect(wire).not.toContain(`"${forbidden}"`);
    }
    for (const treatment of produced.treatments) {
      expect(Object.keys(treatment)).not.toContain('name');
    }
  });

  //* 경계 축. fixture 의 `_axes` 와 같은 목록이며 여기서 성질 단위로 다시 본다 —
  //* 바이트 비교 하나만 두면 어느 규칙이 깨졌는지 실패 문면이 말해 주지 않는다.
  it('drops treatments the spec cannot accept', () => {
    const produced = toInsightRequest({
      asOf: '2026-09-02',
      treatments: [
        {
          trbTreatStartDate: null,
          trbDiseaseCode: 'J304',
          trbDiseaseName: null,
          trbTreatType: null,
          trbDepartment: null,
          trbHospitalName: null,
          trbVisitDays: 1,
          trbDeductibleAmt: 1,
        },
        {
          trbTreatStartDate: '2020/01/05',
          trbDiseaseCode: 'J304',
          trbDiseaseName: null,
          trbTreatType: null,
          trbDepartment: null,
          trbHospitalName: null,
          trbVisitDays: 1,
          trbDeductibleAmt: 1,
        },
        {
          trbTreatStartDate: '2026-05-12T09:00:00',
          trbDiseaseCode: 'J304',
          trbDiseaseName: null,
          trbTreatType: null,
          trbDepartment: null,
          trbHospitalName: null,
          trbVisitDays: null,
          trbDeductibleAmt: null,
        },
      ],
      notices: [],
    });

    expect(produced.treatments).toHaveLength(1);
    //* 시각이 붙어 와도 앞 10자만 취한다. 모양이 아니면 관대하게 자르지 않고 버린다.
    expect(produced.treatments[0].treated_on).toBe('2026-05-12');
    //* `visit_days` 는 nullable 이 아니다 — 결측은 스펙 기본값 1.
    expect(produced.treatments[0].visit_days).toBe(1);
    //* `kind` 도 nullable 이 아니다 — `TreatmentIn` 8필드 중 이 자리만 `anyOf [T, null]` 이
    //* 아니다. `null` 을 보내면 요청 전체가 422 이고, 그 422 는 「비200 → unavailable」로 접혀
    //* 진료형태 결측 한 행이 인사이트 전체를 **영구히** 「일시적 오류」로 만든다.
    //* `trb_treat_type` 은 nullable 이라 도달 가능한 입력이다.
    expect(produced.treatments[0].kind).toBe('외래');
    //* 위 단언이 표본 하나에만 걸리지 않게 — 살아남은 전 행이 그 성질을 갖는다.
    expect(
      produced.treatments.filter((t) => typeof t.kind !== 'string'),
    ).toEqual([]);
    //* `deductible_amt` 는 nullable 이다 — 결측을 0 으로 채우면 «본인부담 0원» 이 된다.
    expect(produced.treatments[0].deductible_amt).toBeNull();
  });

  it('normalizes notices to the spec bounds', () => {
    const produced = toInsightRequest({
      asOf: '2026-09-02',
      treatments: [],
      notices: [
        { text: '   ', diseaseCode: 'J304', diseaseName: null, occurrences: 3 },
        {
          text: '줄기세포치료 할 경우 보장이 제한될 수 있어요.',
          diseaseCode: 'M171',
          diseaseName: '무릎관절증',
          occurrences: 0,
        },
      ],
    });

    expect(produced.notices).toHaveLength(1);
    //* `minimum: 1` — 0 을 보내면 422 이고 그것이 화면에서 «일시적 오류» 로 표기된다.
    expect(produced.notices[0].occurrences).toBe(1);
  });

  it('request-replays-extended-fixture', () => {
    const fixturePath = path.join(
      REPO_ROOT,
      '.claude',
      'plans',
      'ai-insight-payload.request-fixture.json',
    );
    const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as {
      input: {
        asOf: string;
        treatments: InsightTreatmentInput[];
        notices: InsightNoticeInput[];
        coverageHints: InsightCoverageHintInput[];
      };
      expected: unknown;
    };

    const produced = toInsightRequest(fixture.input);

    //* 바이트 비교. 확장 축은 키 **유무**가 계약이라 `toEqual` 로는 부족하다 —
    //* `undefined` 와 키 부재가 같아지고, 중첩 키 순서도 통과한다.
    expect(serializeInsightRequest(produced)).toBe(
      serializeInsightRequest(fixture.expected),
    );
    //* 확장 입력은 확장 산출을 낸다 — 재생이 8필드로 조용히 좁혀지지 않았다.
    expect(produced.treatments[0].insurance_type).toBe('건강보험');
    expect(produced.coverage_hints).toHaveLength(2);
  });

  it('request-top-level-keys-are-declared', () => {
    //* 선언(`InsightRequest`)을 소스에서 읽어 **산출과 대조**한다. 타입은 런타임에 사라지므로
    //* 손으로 적은 목록을 쓰면 선언이 늘어도 검사가 따라오지 않는다.
    const source = fs.readFileSync(
      path.join(__dirname, 'insight-payload.ts'),
      'utf8',
    );
    const block = /export interface InsightRequest\s*\{([\s\S]*?)\n\}/.exec(
      source,
    );
    expect(block).not.toBeNull();
    const declared = [
      ...(block as RegExpExecArray)[1].matchAll(/^\s{2}(\w+)\??\s*:/gm),
    ].map((match) => match[1]);
    expect(declared).toEqual([
      'as_of',
      'treatments',
      'notices',
      'coverage_hints',
    ]);

    const fixturePath = path.join(
      REPO_ROOT,
      '.claude',
      'plans',
      'ai-insight-payload.request-fixture.json',
    );
    const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')) as {
      input: Parameters<typeof toInsightRequest>[0];
    };
    const produced = toInsightRequest(fixture.input);
    const keys = Object.keys(produced);
    //* 선언 밖 키가 없고, 순서가 선언을 따른다.
    expect(keys.filter((key) => !declared.includes(key))).toEqual([]);
    expect(keys).toEqual(
      keys.slice().sort((a, b) => declared.indexOf(a) - declared.indexOf(b)),
    );
  });

  it('legacy-input-is-byte-identical-to-m2', () => {
    //* 확장 전 입력(8필드·노출명 없음)은 m2 판과 **바이트가 같아야** 한다. 기대값을 여기에
    //* 적지 않고 m2 fixture 를 외부 기준으로 읽는다 — 자기 단언이 아니다.
    const m2Path = path.join(
      REPO_ROOT,
      '.claude',
      'plans',
      'ai-insight-report-m2.request-fixture.json',
    );
    const m2 = JSON.parse(fs.readFileSync(m2Path, 'utf8')) as {
      input: {
        asOf: string;
        treatments: InsightTreatmentInput[];
        notices: InsightNoticeInput[];
      };
      expected: unknown;
    };

    const produced = toInsightRequest(m2.input);

    expect(serializeInsightRequest(produced)).toBe(
      serializeInsightRequest(m2.expected),
    );
    //* 확장 축이 입력에 없으면 산출에도 없다 — 키가 하나라도 늘면 위 바이트 비교가 먼저 깨진다.
    expect('coverage_hints' in produced).toBe(false);
    for (const treatment of produced.treatments) {
      expect(Object.keys(treatment)).toHaveLength(8);
    }
  });

  it('over-budget-body-drops-item-axes-not-truncates', () => {
    const treatment: InsightTreatmentInput = {
      trbTreatStartDate: '2026-06-30',
      trbDiseaseCode: 'M171',
      trbDiseaseName: '무릎관절증',
      trbTreatType: '외래',
      trbDepartment: '내과',
      trbHospitalName: '가온내과의원',
      trbVisitDays: 1,
      trbDeductibleAmt: 16100,
      insuranceType: '건강보험',
      surgery: false,
      duplicated: false,
      tests: Array.from({ length: 40 }, (_, i) => ({
        name: `합성검사${i}`,
        count: 1,
        category: null,
      })),
      drugs: [{ name: '합성정10밀리그램', ingredient: null, days: 5 }],
      procedures: [{ name: '합성처치' }],
      prescriptionDays: 5,
    };
    const request = toInsightRequest({
      treatments: Array.from({ length: 400 }, () => treatment),
      notices: [],
      asOf: '2026-06-30',
    });

    expect(insightRequestBytes(request)).toBeGreaterThan(
      MAX_INSIGHT_BODY_BYTES,
    );

    const reduced = withoutInsightItemAxes(request);

    //* 절삭이 아니다 — 진료는 한 건도 잃지 않는다. 절삭하면 「이만큼이 전부」가 거짓이 된다.
    expect(reduced.treatments).toHaveLength(request.treatments.length);
    expect(insightRequestBytes(reduced)).toBeLessThan(MAX_INSIGHT_BODY_BYTES);
    for (const row of reduced.treatments) {
      //* 「안 보냄(모름)」이지 `[]`(진짜 없음)가 아니다.
      expect('tests' in row).toBe(false);
      expect('drugs' in row).toBe(false);
      expect('procedures' in row).toBe(false);
      //* 스칼라 축과 키 순서는 그대로다 — 게이트 `#4` 의 계약이 뺀 뒤에도 성립한다.
      expect(row.prescription_days).toBe(5);
      expect(Object.keys(row)).toEqual([
        'treated_on',
        'disease_code',
        'disease_name',
        'kind',
        'department',
        'facility_name',
        'visit_days',
        'deductible_amt',
        'insurance_type',
        'surgery',
        'duplicated',
        'prescription_days',
      ]);
    }
  });
});

/**
 * 노출명 힌트의 `visit_ids` 와 진료 `id` (계획 `ai-insight-cr2-visit-ids`).
 *
 * J3 기준과 정책 사례는 `.claude/plans/ai-insight-cr2-visit-ids.request-fixture.json` 이 지고 이
 * 스펙은 그것을 읽어 재생한다. 아래 인라인 사례 둘은 구현 착수 전 정책 판정(AP3)이 정한 자리다.
 */
describe('insight-payload.visit_ids', () => {
  type Tuple = [
    string,
    string | null,
    string | null,
    string,
    string | null,
    number,
  ];
  type Expect = {
    dropped?: boolean;
    absent?: boolean;
    tuples?: Tuple[];
    ids?: string[];
    unresolved: number;
  };
  type Case = {
    name?: string;
    input: Parameters<typeof toInsightRequest>[0] & {
      coverageHints: InsightCoverageHintInput[];
    };
    expect: Expect[];
  };
  const fixture = JSON.parse(
    fs.readFileSync(
      path.join(
        REPO_ROOT,
        '.claude',
        'plans',
        'ai-insight-cr2-visit-ids.request-fixture.json',
      ),
      'utf8',
    ),
  ) as {
    j3: Case & {
      reference: {
        hintText: string;
        visitTuples: Tuple[];
        excludedTuples: Tuple[];
      };
    };
    cases: Case[];
  };

  const tupleOf = (t: InsightTreatmentOut): Tuple => [
    t.treated_on,
    t.facility_name,
    t.disease_code,
    t.kind,
    t.department,
    t.visit_days,
  ];
  const hintKey = (h: { tab: string | null; text: string | null }) =>
    `${(h.tab ?? '').trim()}|${(h.text ?? '').trim()}`;
  const pointed = (request: InsightRequest, visitIds: string[]) =>
    visitIds.map((id) => {
      const hit = request.treatments.find((t) => t.id === id);
      expect(hit).toBeDefined();
      return hit as InsightTreatmentOut;
    });
  const health = (
    over: Partial<InsightTreatmentInput> = {},
  ): InsightTreatmentInput => ({
    trbTreatStartDate: '2026-09-25',
    trbDiseaseCode: 'J209',
    trbDiseaseName: '합성 상병명',
    trbTreatType: '외래',
    trbDepartment: '내과',
    trbHospitalName: '가온내과의원',
    trbVisitDays: 1,
    trbDeductibleAmt: 1000,
    insuranceType: '건강보험',
    ...over,
  });
  const visit = (
    over: Partial<InsightHintVisitInput> = {},
  ): InsightHintVisitInput => ({
    treatStartDate: '2026-09-25',
    hospitalName: '가온내과의원',
    diseaseCode: 'J209',
    treatType: '외래',
    department: '내과',
    visitDays: 1,
    ...over,
  });
  const hint = (
    text: string,
    visits: InsightHintVisitInput[],
  ): InsightCoverageHintInput => ({
    tab: '기타',
    text,
    diseaseCode: visits[0]?.diseaseCode ?? null,
    diseaseName: '합성 상병명',
    occurrences: visits.length,
    visits,
  });

  it('visit-ids-follow-j3-reference — 응급실 힌트가 J3 가 검증한 다섯 내원만 가리킨다', () => {
    const { j3 } = fixture;
    const request = toInsightRequest(j3.input);
    const hints = request.coverage_hints ?? [];
    const er = hints.find((h) => h.text === j3.reference.hintText);
    expect(er).toBeDefined();
    const erTuples = pointed(request, er?.visit_ids ?? []).map(tupleOf);
    //* J3 가 제공자에게 보내 5건을 받은 그 집합이다 — 01-23 일반 재진이 빠졌다.
    expect(erTuples).toEqual(j3.reference.visitTuples);
    for (const excluded of j3.reference.excludedTuples) {
      expect(erTuples).not.toContainEqual(excluded);
    }
    j3.input.coverageHints.forEach((input, i) => {
      const out = hints.find((h) => hintKey(h) === hintKey(input));
      expect(out).toBeDefined();
      //* 분석 행 수 그대로 — 풀린 수로 바꾸지 않는다.
      expect(out?.occurrences).toBe(input.occurrences);
      if (j3.expect[i].absent) {
        //* `visits` 가 없는 힌트는 지금처럼 키 없이 간다.
        expect(out && 'visit_ids' in out).toBe(false);
      } else {
        expect(out?.visit_ids).toEqual(j3.expect[i].ids);
      }
    });
  });

  it('visit-ids-policy-cases — 정책 사례가 fixture 의 기대와 정확히 같다', () => {
    expect(fixture.cases.length).toBeGreaterThanOrEqual(14);
    for (const c of fixture.cases) {
      const request = toInsightRequest(c.input);
      const hints = request.coverage_hints ?? [];
      //* 기대가 입력보다 길거나 짧으면 남는 쪽이 조용히 검사되지 않는다.
      expect([c.name, c.expect.length]).toEqual([
        c.name,
        c.input.coverageHints.length,
      ]);
      c.input.coverageHints.forEach((input, i) => {
        const want = c.expect[i];
        const out = hints.filter((h) => hintKey(h) === hintKey(input));
        if (want.dropped) {
          //* 하나도 안 풀린 힌트는 싣지 않는다 — 키만 빼고 남기면 제공자가 스스로 골라 붙인다.
          expect([c.name, out.length]).toEqual([c.name, 0]);
          return;
        }
        expect([c.name, out.length]).toEqual([c.name, 1]);
        const ids = out[0].visit_ids ?? [];
        expect([c.name, ids]).toEqual([c.name, want.ids]);
        expect([c.name, pointed(request, ids).map(tupleOf)]).toEqual([
          c.name,
          want.tuples,
        ]);
        expect([c.name, ids.length]).toEqual([
          c.name,
          (input.visits ?? []).length - want.unresolved,
        ]);
      });
    }
  });

  it('treatment-ids-follow-the-final-array — id 는 최종 송신 배열 순번이고 R- 가 아니다', () => {
    const request = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [
        health({ trbTreatStartDate: null, trbDiseaseCode: 'L309' }),
        health({ trbTreatStartDate: '2026-05-14', trbDiseaseCode: 'L309' }),
        health({ trbTreatStartDate: '2026-05-01', trbDiseaseCode: 'L309' }),
      ],
      coverageHints: [
        hint('합성 노출명 — 피부질환 통원', [
          visit({ treatStartDate: '2026-05-01', diseaseCode: 'L309' }),
        ]),
      ],
    });

    //* 진료일 결측 행은 송신에서 빠진다 — 그 뒤에서도 `T-0001` 부터다(거르기 전에 매기면 밀린다).
    expect(request.treatments.map((t) => t.id)).toEqual(['T-0001', 'T-0002']);
    expect(request.treatments.some((t) => /^R-/.test(t.id ?? ''))).toBe(false);
    for (const t of request.treatments) {
      expect(Object.keys(t).pop()).toBe('id');
    }
    //* 가리킴은 순번이 아니라 내용으로 맞다 — 05-01 진료다.
    const ids = request.coverage_hints?.[0].visit_ids ?? [];
    expect(pointed(request, ids).map((t) => t.treated_on)).toEqual([
      '2026-05-01',
    ]);
    expect(Object.keys(request.coverage_hints?.[0] ?? {}).pop()).toBe(
      'visit_ids',
    );
    //* id 를 달기 전 배열로 풀면 조용히 미해석으로 거르지 않고 멈춘다.
    expect(() =>
      toInsightCoverageHints(
        [hint('합성 노출명 — id 없음', [visit()])],
        toInsightTreatments([health()]),
      ),
    ).toThrow('insight 진료 id 없이 visit_ids 를 풀려 했다');
  });

  it('zero-resolved-hint-is-not-sent — 하나도 안 풀린 힌트는 보내지 않고 빈 배열도 내지 않는다', () => {
    const request = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [health()],
      coverageHints: [
        //* 다른 날 내원만 가리킨다 — 풀릴 진료가 없다.
        hint('합성 노출명 — 풀리지 않음', [
          visit({ treatStartDate: '2026-01-02' }),
        ]),
        //* `visits` 배열이 비었다 — 가리킬 내원이 0 이라 같은 규칙이다.
        { ...hint('합성 노출명 — 빈 내원', [visit()]), visits: [] },
        //* `visits` 가 없는 힌트(옛 호출부)는 지금처럼 키 없이 간다.
        {
          tab: '기타',
          text: '합성 노출명 — 내원 미전송',
          diseaseCode: 'J209',
          diseaseName: '합성 상병명',
          occurrences: 2,
        },
        hint('합성 노출명 — 풀림', [visit()]),
      ],
    });

    const hints = request.coverage_hints ?? [];
    expect(hints.map((h) => h.text)).toEqual([
      '합성 노출명 — 내원 미전송',
      '합성 노출명 — 풀림',
    ]);
    expect('visit_ids' in hints[0]).toBe(false);
    expect(hints[0].occurrences).toBe(2);
    expect(hints[1].visit_ids).toEqual(['T-0001']);
    expect(
      hints.filter((h) => Array.isArray(h.visit_ids) && !h.visit_ids.length),
    ).toEqual([]);
  });

  //* AP3 정책 판정 (가) — 여섯 칸이 같은 형제는 가를 칸이 없다. 요청 순서 첫 번째를 쓰고, 틀리면
  //* 근거의 본인부담 · 총액 · 상병명 문자열이 형제 값으로 서는 잔여를 수용했다(codex 는 미해석을
  //* 주장해 갈렸다). 형제 둘 · 내원 둘은 fixture 정책 사례가 고정한다.
  it('visit-ids-six-field-siblings-accepted-residual — 여섯 칸이 같은 진료 둘에 내원 하나면 요청 순서 첫 번째(항목 축 행)를 가리킨다(AP3 수용 잔여)', () => {
    const request = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [
        //* 같은 (진료일, 기관) 버킷의 항목 축은 첫 행에만 실린다(저장소 규칙) — 그 행을 재현한다.
        health({ tests: [{ name: '합성혈액검사', count: 1, category: null }] }),
        health({ trbDeductibleAmt: 3000 }),
      ],
      coverageHints: [hint('합성 노출명 — 급성기관지염 통원', [visit()])],
    });
    const out = request.coverage_hints?.[0];
    expect(out?.visit_ids).toEqual(['T-0001']);
    const [picked] = pointed(request, out?.visit_ids ?? []);
    expect(picked.tests).toEqual([{ name: '합성혈액검사', count: 1 }]);
    expect(picked.deductible_amt).toBe(1000);
    expect(out?.occurrences).toBe(1);

    //* 원문 공백만 다른 형제도 정규화 뒤 같은 여섯 칸이라 첫 번째다.
    const spaced = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [
        health({ trbHospitalName: ' 가온내과의원 ' }),
        health({ trbDeductibleAmt: 3000 }),
      ],
      coverageHints: [hint('합성 노출명 — 급성기관지염 통원', [visit()])],
    });
    expect(spaced.coverage_hints?.[0].visit_ids).toEqual(['T-0001']);
  });

  //* AP3 정책 판정 (나) — 진료형태는 1차 키이고 결측은 송신과 같은 `외래` 로 읽는다.
  it('visit-ids-missing-treat-type-policy — 진료형태 결측은 외래로 읽고 입원 분석 행에 붙지 않으며 보조 키가 명시적 외래를 고른다(AP3)', () => {
    //* 분석 행 `입원` · 진료 원본 결측 단일 후보 → 미해석 → 유일 내원이라 힌트를 보내지 않는다.
    const inpatient = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [health({ trbTreatType: null })],
      coverageHints: [
        hint('합성 노출명 — 입원일당', [visit({ treatType: '입원' })]),
      ],
    });
    expect(inpatient.treatments[0].kind).toBe('외래');
    expect(inpatient.coverage_hints).toEqual([]);

    //* 분석 행 `''` · 진료 원본 결측 → 둘 다 외래로 읽혀 맞는다(notNull 은 빈 문자열을 막지 않는다).
    const blank = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [health({ trbTreatType: null })],
      coverageHints: [hint('합성 노출명 — 통원', [visit({ treatType: '' })])],
    });
    expect(blank.coverage_hints?.[0].visit_ids).toEqual(['T-0001']);

    //* 결측(내원일수 3) · 명시적 외래(1) 가 1차 키로 함께 서면 보조 키가 명시적 외래를 고른다.
    const collide = toInsightRequest({
      asOf: '2026-10-07',
      notices: [],
      treatments: [
        health({ trbTreatType: null, trbVisitDays: 3 }),
        health({ trbTreatType: '외래', trbVisitDays: 1 }),
      ],
      coverageHints: [hint('합성 노출명 — 통원', [visit({ visitDays: 1 })])],
    });
    expect(collide.coverage_hints?.[0].visit_ids).toEqual(['T-0002']);
  });
});
