import fs from 'node:fs';
import path from 'node:path';
import type { InsightNoticeOut, InsightTreatmentOut } from './insight-payload';
import { resolveInsightState, toInsightView } from './insight-view';

/**
 * 뷰모델의 실행 검사.
 *
 * **검사 이름은 고정이다.** 게이트 `#4`·`#5`·`#6`·`#12`·`#17` 이 이름의 실재와 통과를
 * 열거하며, 총건수만 세면 성질 하나가 빠져도 통과한다.
 *
 * 정적 계수(`#4` 의 앞 축)는 소스에 금지 식별자 문자열이 있는지만 본다. 여기 아홉은 정적
 * 계수가 구조적으로 못 보는 것을 본다 — 표본 밖 필드가 산출에 새는지, 상태 규칙이 실제로
 * 무엇을 내는지, 되맞춤이 보낸 진료를 실제로 되찾는지.
 */

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

/** 동결 뷰모델을 만든 입력 셋. 산출물 머리의 `_produced_from` 이 같은 값을 기록한다. */
const FROZEN_ANALYSIS_AGE_DAYS = 3;
const FROZEN_DDX_STATUS = 200;
//* 기준일은 이제 뷰모델의 **인자**다(응답의 `as_of` 를 되읽지 않는다). 스펙도 한 자리에서
//* 읽어 `responseWith` 의 `as_of` 와 갈리지 않게 한다.
const FROZEN_AS_OF = '2026-09-02';

const readJson = (p: string) => JSON.parse(fs.readFileSync(p, 'utf8'));

const treatment = (
  over: Partial<InsightTreatmentOut> = {},
): InsightTreatmentOut => ({
  treated_on: '2026-05-12',
  disease_code: 'J304',
  disease_name: '알레르기성 비염',
  kind: '외래',
  department: '이비인후과',
  facility_name: '가온내과의원',
  visit_days: 1,
  deductible_amt: 24300,
  ...over,
});

const notice = (over: Partial<InsightNoticeOut> = {}): InsightNoticeOut => ({
  text: '관련 질환으로 비밸브재건술 등(비급여) 할 경우 의료비 지급이 제한 될 수 있어요',
  disease_code: 'J304',
  disease_name: '알레르기성 비염',
  occurrences: 5,
  ...over,
});

/** 좌석 하나를 세우는 최소 응답. `evidence` 는 보낸 진료와 같은 튜플이어야 되맞춤이 성립한다. */
const responseWith = (
  findings: unknown[],
  over: Record<string, unknown> = {},
) => ({
  as_of: FROZEN_AS_OF,
  ruleset_version: 'insight-v0.1',
  treatment_count: 1,
  summary: {
    '보상 찾아줘': { count: 11 },
    '병력분석': { count: 12 },
    '알릴의무': { count: 5 },
    '주의사항': { count: 5 },
  },
  sales_findings: findings,
  application_findings: [],
  ...over,
});

const finding = (
  angle: string,
  evidence: unknown[],
  over: Record<string, unknown> = {},
) => ({
  id: 'f-' + angle,
  angle,
  rule_id: 'R',
  rule_label: 'L',
  condition_ko: '조건',
  say_ko: '문장',
  why_ko: '이유',
  evidence,
  evidence_total: evidence.length,
  generated_by: 'template',
  warn: false,
  ...over,
});

const evidenceOf = (t: InsightTreatmentOut) => ({
  deductible_amt: t.deductible_amt,
  department: t.department,
  disease_code: t.disease_code,
  disease_name: t.disease_name,
  facility_name: t.facility_name,
  kind: t.kind,
  treated_on: t.treated_on,
});

describe('insight-view', () => {
  it('view-drops-person-name-and-identifiers', () => {
    const sent = [treatment()];
    //* 응답에 금지 식별자를 **심어** 넣는다. 정적 축은 「참조가 없다」만 보므로, 통째 전개
    //* 같은 경로로 새는 것은 이 실행 축이 아니면 잡히지 않는다.
    const poisoned = responseWith(
      [finding('수술 전 경고', [evidenceOf(sent[0])])],
      {
        person_name: '홍길동',
        external_ref: 'ODD-188982',
        analysis_id: 'a-7f3c',
      },
    );
    (poisoned.sales_findings[0] as Record<string, unknown>).person_name =
      '홍길동';
    (
      (poisoned.sales_findings[0] as Record<string, unknown>)
        .evidence as Record<string, unknown>[]
    )[0].person_name = '홍길동';

    const view = toInsightView({
      response: poisoned,
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const wire = JSON.stringify(view);
    expect(wire).not.toContain('홍길동');
    expect(wire).not.toContain('ODD-188982');
    expect(wire).not.toContain('a-7f3c');
    expect(wire).not.toContain('person_name');
    expect(wire).not.toContain('external_ref');
    expect(wire).not.toContain('analysis_id');
  });

  it('view-seats-four-angles-and-lists-the-rest', () => {
    const sent = [treatment()];
    const ev = [evidenceOf(sent[0])];
    const view = toInsightView({
      response: responseWith(
        [
          finding('세일즈 포인트', [
            {
              count: 5,
              deductible_total: 68900,
              disease_code: 'J304',
              disease_name: '알레르기성 비염',
              first: '2023-06-08',
              last: '2026-05-12',
              modalities: '외래',
            },
          ]),
          finding('수술 전 경고', ev),
          finding('놓친 것', ev),
          finding('시효 임박', ev),
          finding('업코딩 가능', ev),
          //* 계약 밖 angle — 버리지 않고 이유와 함께 남는다(fail-closed).
          finding('아직 없는 규칙', ev),
        ],
        {
          application_findings: [
            finding('인수 판단', ev),
            finding('확인 후 고지', ev),
          ],
        },
      ),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    //* **좌석이 넷에서 일곱으로 늘었다(M7).** 검사 이름은 그대로 두되 단언하는 건수가
    //* 바뀐다 — 그 전이는 계획서 `## Harness Contract` 의 `SPEC_SEMANTIC_SHIFTS` 에
    //* 적혀 있고 게이트 `#12` 가 그것을 계획서와 상등 대조한다. 이름만 남기고 뜻을
    //* 조용히 뒤집는 길을 그 둘이 함께 막는다.
    //*
    //* 순서는 `SEAT_ORDER` 가 정한다 — 응답에 오는 순서가 아니다.
    expect(view.seats.map((s) => s.seat)).toEqual([
      'tip.sales',
      'cov.expired',
      'cov.soon',
      'cau.warn',
      'duty.admit5y',
      'duty.exam1y',
    ]);
    //* 「놓친 것」·「시효 임박」은 이제 좌석을 갖는다. 남는 무좌석은 클라이언트가 뺀 하나와
    //* 계약 밖 하나뿐이다.
    expect(view.unmapped).toEqual([
      { angle: '업코딩 가능', count: 1, reason: 'excluded-by-client' },
      { angle: '아직 없는 규칙', count: 1, reason: 'not-in-contract' },
    ]);
    //* 집계 좌석은 **진료 행으로 펼쳐진다.** 집계행을 그대로 실으면 `ID`·`진료일`·`병·의원`
    //* 세 열이 어떤 입력으로도 채워지지 않는다 — 그 형상에 대응 키가 아예 없기 때문이다.
    //* 여기서는 `count` 5 인데 보낸 진료가 1건뿐이라 한 행만 서고, 나머지 4는 `unmatched`
    //* 로 남는다. 조용히 0 으로 접으면 상병코드가 어긋나 통째로 못 펼친 발견이 정상으로 보인다.
    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    expect(sales?.evidenceKind).toBe('aggregate');
    expect(sales?.evidenceTotal).toBe(5);
    expect(sales?.rows).toHaveLength(1);
    expect(sales?.unmatched).toBe(4);
    expect(sales?.rows[0].id).toBe('S-0001');
    expect(sales?.rows[0].treatedOn).toBe('2026-05-12');
    expect(sales?.rows[0].facilityName).toBe('가온내과의원');
  });

  /**
   * 펼침이 **고르는지** 본다. 위 검사는 표본이 1건이라 「전부 붙였다」로도 통과한다 —
   * 상병이 다른 진료와 기간 밖 진료를 함께 넣어야 열쇠가 실제로 도는지가 드러난다.
   */
  it('view-expands-aggregate-by-code-and-window', () => {
    const sent = [
      treatment({ treated_on: '2026-05-12' }),
      //* 기간 밖 — `last` 를 넘는다.
      treatment({ treated_on: '2026-07-01' }),
      //* 상병 밖 — 같은 기간이지만 다른 코드다.
      treatment({ treated_on: '2024-01-05', disease_code: 'K210' }),
      //* 기간 안 · 상병 일치 — 붙는다.
      treatment({ treated_on: '2024-02-09' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          {
            count: 2,
            deductible_total: 48600,
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            first: '2023-06-08',
            last: '2026-05-12',
            modalities: '외래',
          },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    //* 순번은 `sent` 의 자리에서 온다 — 골라낸 뒤 다시 매기면 같은 분석이 같은 `ID` 를 내지
    //* 않는다(1번째와 4번째가 붙었으므로 `S-0001`·`S-0004`).
    expect(sales?.rows.map((r) => r.id)).toEqual(['S-0001', 'S-0004']);
    expect(sales?.rows.map((r) => r.treatedOn)).toEqual([
      '2026-05-12',
      '2024-02-09',
    ]);
    //* `count` 2 를 2행으로 다 펼쳤다 — 남는 것이 없다.
    expect(sales?.unmatched).toBe(0);
  });

  /**
   * **제공자가 센 것보다 많이 골라진 축.** 부족 축(위 검사)만 있으면 `Math.max(0, …)` 같은
   * 접기가 그대로 통과한다 — 그때 표는 토글 라벨보다 긴 행수를 그리는데 응답 어디에도
   * 그 사실이 남지 않는다.
   *
   * 여기서는 `count` 1 인데 상병·기간이 맞는 진료가 2건이다. 싣는 행은 1로 멈추고(상한),
   * 고른 수 2는 `unmatched` 의 음수로 남는다.
   */
  it('view-keeps-aggregate-overmatch-visible', () => {
    const sent = [
      treatment({ treated_on: '2026-05-12' }),
      treatment({ treated_on: '2024-02-09' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          {
            count: 1,
            deductible_total: 24300,
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            first: '2023-06-08',
            last: '2026-05-12',
            modalities: '외래',
          },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    //* 상한이 없으면 여기가 2다 — 그 상태로 토글은 「진료 1건」을 말한다.
    expect(sales?.rows).toHaveLength(1);
    expect(sales?.evidenceTotal).toBe(1);
    //* 1(센 수) − 2(고른 수). 0 으로 접으면 이 단정이 뒤집히지 않는다.
    expect(sales?.unmatched).toBe(-1);
  });

  /** `count` 가 빠진 집계행. 상한 없이 다 실으므로 총수도 고른 수다 — 0 이면 N행 표에 「진료 0건」이 선다. */
  it('view-counts-aggregate-without-count-as-matched', () => {
    const sent = [
      treatment({ treated_on: '2026-05-12' }),
      treatment({ treated_on: '2024-02-09' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          { disease_code: 'J304', disease_name: '알레르기성 비염' },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    expect(sales?.rows).toHaveLength(2);
    expect(sales?.evidenceTotal).toBe(2);
    expect(sales?.unmatched).toBe(0);
  });

  /**
   * 「검사」·「수술」 은 ddx 응답이 아니라 **우리 저장소**에서 온다. 열쇠 세 쌍으로 붙고,
   * 문면은 운영 선례대로 「◯◯ 등 N건」으로 접힌다.
   */
  it('view-attaches-examination-and-operation', () => {
    const sent = [treatment(), treatment({ treated_on: '2024-02-09' })];
    const view = toInsightView({
      response: responseWith([finding('수술 전 경고', sent.map(evidenceOf))]),
      sentTreatments: sent,
      notices: [],
      procedures: [
        {
          treatedOn: '2026-05-12',
          hospitalName: '가온내과의원',
          diseaseCode: 'J304',
          examination: '기본초음파',
          examinationCount: 43,
          operation: null,
          operationCount: 0,
        },
        {
          treatedOn: '2024-02-09',
          hospitalName: '가온내과의원',
          diseaseCode: 'J304',
          examination: '세포병리검사',
          examinationCount: 1,
          operation: '비밸브재건술',
          operationCount: 2,
        },
      ],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const rows = view.seats.find((s) => s.seat === 'cau.warn')?.rows ?? [];
    //* 건수가 1이면 접지 않는다 — 「세포병리검사 등 1건」은 운영 어디에도 없는 문면이다.
    expect(rows.map((r) => r.examination)).toEqual([
      '기본초음파 등 43건',
      '세포병리검사',
    ]);
    //* 한쪽만 있는 진료의 반대쪽은 `null` 이고, 그 자리는 화면에서 `—` 로 남는다.
    expect(rows.map((r) => r.operation)).toEqual([null, '비밸브재건술 등 2건']);
  });

  /**
   * 재료가 없어도 나머지 열은 선다. 저장소 조회가 비어도 판정 화면 전체가 내려가면 안 된다 —
   * 「검사」는 ddx 판정과 독립한 축이다.
   */
  it('view-survives-missing-procedures', () => {
    const sent = [treatment()];
    const view = toInsightView({
      response: responseWith([finding('수술 전 경고', [evidenceOf(sent[0])])]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });
    const row = view.seats.find((s) => s.seat === 'cau.warn')?.rows[0];
    expect(row?.examination).toBeNull();
    expect(row?.operation).toBeNull();
    expect(row?.id).toBe('S-0001');
  });

  /**
   * 「검사」·「수술」 은 건강보험 분석 표에서만 온다. 요청에 자동차보험 진료가 함께 실리면서
   * 같은 `(진료일, 기관, 상병)` 의 자동차보험 진료가 건강보험 문면을 물려받을 수 있게 됐다 —
   * 행 형상 좌석(되맞춤)과 집계 좌석(펼침) 두 길 모두에서 막는다.
   */
  it('view-does-not-attach-health-procedures-to-car-rows', () => {
    const sent = [
      treatment({ insurance_type: '건강보험' }),
      treatment({ insurance_type: '자동차보험', deductible_amt: undefined }),
    ];
    const procedures = [
      {
        treatedOn: '2026-05-12',
        hospitalName: '가온내과의원',
        diseaseCode: 'J304',
        examination: '기본초음파',
        examinationCount: 1,
        operation: '비밸브재건술',
        operationCount: 1,
      },
    ];
    const view = toInsightView({
      response: responseWith([
        finding('수술 전 경고', sent.map(evidenceOf)),
        finding('세일즈 포인트', [
          {
            count: 2,
            deductible_total: 24300,
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            first: '2026-05-12',
            last: '2026-05-12',
            modalities: '외래',
          },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      procedures,
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const warn = view.seats.find((s) => s.seat === 'cau.warn')?.rows ?? [];
    expect(warn.map((r) => r.id)).toEqual(['S-0001', 'S-0002']);
    expect(warn.map((r) => r.examination)).toEqual(['기본초음파', null]);
    expect(warn.map((r) => r.operation)).toEqual(['비밸브재건술', null]);

    const sales = view.seats.find((s) => s.seat === 'tip.sales')?.rows ?? [];
    expect(sales.map((r) => r.id)).toEqual(['S-0001', 'S-0002']);
    expect(sales.map((r) => r.examination)).toEqual(['기본초음파', null]);
    expect(sales.map((r) => r.operation)).toEqual(['비밸브재건술', null]);
  });

  /**
   * 되맞춤 튜플에는 보험 출처가 없다. 본인부담이 빈 건강보험 행과 자동차보험 행은 나머지 넷이
   * 같으면 한 튜플을 공유하고, 병합상 건강보험이 앞선다. 자동차보험 진료만 담긴 응답이 그
   * 순번을 가져가면 건강보험 문면이 자동차보험 근거에 선다.
   */
  it('view-leaves-insurance-ambiguous-evidence-unresolved', () => {
    const sent = [
      treatment({ insurance_type: '건강보험', deductible_amt: undefined }),
      treatment({ insurance_type: '자동차보험', deductible_amt: undefined }),
    ];
    const rowsFor = (evidence: Record<string, unknown>) =>
      toInsightView({
        response: responseWith([finding('수술 전 경고', [evidence])]),
        sentTreatments: sent,
        notices: [],
        procedures: [
          {
            treatedOn: '2026-05-12',
            hospitalName: '가온내과의원',
            diseaseCode: 'J304',
            examination: '기본초음파',
            examinationCount: 1,
            operation: null,
            operationCount: 0,
          },
        ],
        asOf: FROZEN_AS_OF,
        analysisAgeDays: 3,
        ddxStatus: 200,
      }).seats.find((s) => s.seat === 'cau.warn')?.rows ?? [];

    //* 출처를 모르면 어느 진료인지 가를 수 없다 — 순번도 문면도 비운다.
    const unknown = rowsFor(evidenceOf(sent[1]));
    expect(unknown.map((r) => [r.id, r.examination])).toEqual([[null, null]]);

    //* 응답이 출처를 주면 그것으로 후보를 거른다.
    const car = rowsFor({
      ...evidenceOf(sent[1]),
      insurance_type: '자동차보험',
    });
    expect(car.map((r) => [r.id, r.examination])).toEqual([['S-0002', null]]);
    const health = rowsFor({
      ...evidenceOf(sent[0]),
      insurance_type: '건강보험',
    });
    expect(health.map((r) => [r.id, r.examination])).toEqual([
      ['S-0001', '기본초음파'],
    ]);
  });

  it('view-derives-state-from-inputs', () => {
    //* 규칙 자체를 실행으로 본다. 나이가 유일한 판별 근거이므로 인자로 받는다.
    expect(
      resolveInsightState({
        analysisAgeDays: 3,
        treatmentCount: 12,
        ddxStatus: 200,
      }),
    ).toBe('ready');
    expect(
      resolveInsightState({
        analysisAgeDays: 31,
        treatmentCount: 0,
        ddxStatus: null,
      }),
    ).toBe('expired');
    expect(
      resolveInsightState({
        analysisAgeDays: 3,
        treatmentCount: 0,
        ddxStatus: null,
      }),
    ).toBe('no_treatments');
    expect(
      resolveInsightState({
        analysisAgeDays: 3,
        treatmentCount: 12,
        ddxStatus: 503,
      }),
    ).toBe('unavailable');
    //* 경계 — 정확히 보존기간이면 아직 만료가 아니다.
    expect(
      resolveInsightState({
        analysisAgeDays: 30,
        treatmentCount: 0,
        ddxStatus: null,
      }),
    ).toBe('no_treatments');
    //* **진료가 0건이면 30일이 지났어도 ddx 를 부르지 않는다.** 스펙의 `treatments` 가
    //* `minItems: 1` 이라 빈 배열은 계약상 422 이고, 그 422 를 `unavailable` 로 접으면
    //* 정상 상태가 일시 장애로 오표기된다(Codex Implement-R1 F1).
    expect(
      resolveInsightState({
        analysisAgeDays: 31,
        treatmentCount: 0,
        ddxStatus: 422,
      }),
    ).toBe('expired');
    //* 나이를 못 구하면 만료를 단정하지 않는다 — 정회원 라우트에서는 가드가 그 전에 끊는다.
    expect(
      resolveInsightState({
        analysisAgeDays: null,
        treatmentCount: 0,
        ddxStatus: null,
      }),
    ).toBe('no_treatments');
  });

  it('view-separates-expired-from-unavailable', () => {
    const expired = toInsightView({
      response: null,
      sentTreatments: [],
      notices: [notice()],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 31,
      ddxStatus: null,
    });
    const unavailable = toInsightView({
      response: null,
      sentTreatments: [treatment()],
      notices: [notice()],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 503,
    });

    expect(expired.state).toBe('expired');
    expect(unavailable.state).toBe('unavailable');
    //* 같은 산출이면 화면이 둘을 가를 재료가 없다.
    expect(JSON.stringify(expired)).not.toBe(JSON.stringify(unavailable));
  });

  it('view-separates-no-treatments-from-expired', () => {
    const noTreatments = toInsightView({
      response: null,
      sentTreatments: [],
      notices: [notice()],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: null,
    });
    const expired = toInsightView({
      response: null,
      sentTreatments: [],
      notices: [notice()],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 31,
      ddxStatus: null,
    });

    //* 둘 다 `tt_treat_basic` 0건으로 관측되지만 원인이 다르다. 하나로 접으면 이 계획이
    //* 미러로 든 `ANS003BDiagnosis` 의 규약을 서버에서 그대로 깬다.
    expect(noTreatments.state).toBe('no_treatments');
    expect(expired.state).toBe('expired');
    expect(noTreatments.state).not.toBe(expired.state);
  });

  it('view-rejoins-evidence-to-sent-treatments', () => {
    //* 같은 튜플 둘 — 구별 불가능하므로 앞의 미사용 것부터 배정한다.
    const twin = treatment();
    const other = treatment({
      treated_on: '2025-11-04',
      deductible_amt: 11800,
    });
    const sent = [twin, { ...twin }, other];
    //* 마지막 행은 보낸 적 없는 튜플이다 — 되찾지 못하고 `unmatched` 로 센다.
    const stranger = { ...evidenceOf(other), facility_name: '라온한방병원' };

    const view = toInsightView({
      response: responseWith([
        finding('수술 전 경고', [
          evidenceOf(twin),
          evidenceOf(twin),
          evidenceOf(other),
          stranger,
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const seat = view.seats[0];
    expect(seat.rows.map((r) => r.id)).toEqual([
      'S-0001',
      'S-0002',
      'S-0003',
      null,
    ]);
    //* 조용히 0 으로 접지 않는다.
    expect(seat.unmatched).toBe(1);
  });

  it('notices-reflect-what-was-sent', () => {
    const recent = treatment({
      treated_on: '2026-05-12',
      disease_code: 'J304',
    });
    const old = treatment({
      treated_on: '2021-08-21',
      disease_code: 'J321',
      disease_name: '만성 부비동염',
    });
    const view = toInsightView({
      response: responseWith([]),
      sentTreatments: [recent, old],
      notices: [
        notice(),
        notice({
          text: '비중격교정술(비급여 수술) 할 경우 의료비 지급이 제한 될 수 있어요',
          disease_code: 'J321',
          disease_name: '만성 부비동염',
          occurrences: 2,
        }),
      ],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    expect(view.notices.map((n) => n.text)).toEqual([
      '관련 질환으로 비밸브재건술 등(비급여) 할 경우 의료비 지급이 제한 될 수 있어요',
      '비중격교정술(비급여 수술) 할 경우 의료비 지급이 제한 될 수 있어요',
    ]);
    //* 안내 건수와 진료 건수는 다른 축이다.
    expect(view.noticeTreatmentCount).toBe(7);
    //* 「추적 중」 — 최근 2년 안에 같은 상병 진료가 있는 것만.
    expect(view.notices.map((n) => n.tracking)).toEqual([true, false]);
    expect(view.notices[0].lastTreatedOn).toBe('2026-05-12');
  });

  it('notices-follow-state-not-treatments', () => {
    const notices = [notice()];
    //* 안내의 출처는 `tn_analysis_basic` 이고 진료 원본 스윕의 대상이 아니다. 그러므로
    //* 만료 문면과 채워진 안내 표가 한 화면에 서는 것이 정상이다.
    for (const [ageDays, state] of [
      [31, 'expired'],
      [3, 'no_treatments'],
    ] as Array<[number, string]>) {
      const view = toInsightView({
        response: null,
        sentTreatments: [],
        notices,
        asOf: FROZEN_AS_OF,
        analysisAgeDays: ageDays,
        ddxStatus: null,
      });
      expect(view.state).toBe(state);
      expect(view.notices).toHaveLength(1);
      expect(view.noticeTreatmentCount).toBe(5);
    }
    //* ddx 응답이 없으면 반사할 대상 자체가 없다 — 여기서만 함께 내려간다.
    const unavailable = toInsightView({
      response: null,
      sentTreatments: [treatment()],
      notices,
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 0,
    });
    expect(unavailable.state).toBe('unavailable');
    expect(unavailable.notices).toHaveLength(0);
    expect(unavailable.noticeTreatmentCount).toBe(0);
  });

  it('view-fixture-is-what-the-server-produces', () => {
    //* 동결 뷰모델의 **입력**을 앵커에 묶는다. 응답을 인라인으로 들면 「서버가 이 뷰모델을
    //* 낸다」가 앵커 없는 자기 단언이 되고, 레퍼런스와 같아지도록 응답을 고르는 것이 `#2` 를
    //* 초록으로 만드는 가장 싼 길이 된다(L2 R3 test F2).
    const response = readJson(
      planFile('ai-insight-report-m2.response-fixture.json'),
    );
    const request = readJson(
      planFile('ai-insight-report-m2.request-fixture.json'),
    );
    const frozen = readJson(path.join(__dirname, 'insight-view.fixture.json'));

    const produced = toInsightView({
      response,
      sentTreatments: request.expected.treatments,
      notices: request.expected.notices,
      asOf: response.as_of,
      analysisAgeDays: FROZEN_ANALYSIS_AGE_DAYS,
      ddxStatus: FROZEN_DDX_STATUS,
    });

    //* 동결본이 자기 입력을 기록했는가 — 기록이 없으면 사슬이 끊긴다.
    expect(frozen._produced_from.analysis_age_days).toBe(
      FROZEN_ANALYSIS_AGE_DAYS,
    );
    expect(frozen._produced_from.ddx_status).toBe(FROZEN_DDX_STATUS);
    //* 바이트가 아니라 값으로 비교한다 — 들여쓰기는 계약이 아니고, 두 사본의 바이트 일치는
    //* `#17` 이 파일 단위로 본다.
    expect(produced).toEqual(frozen.view);
  });

  /**
   * 2026-09-23 합성 실측 — 레거시 세일즈 근거가 집계행이 아니라 **진료 행**으로 온다. 옛 판은 좌석
   * 계약(`aggregate`)대로 행마다 같은 상병 진료를 다시 펼쳐 stg 에 49행(고유 7)·「진료 0건」을 세웠다.
   * 기대 수는 fixture 에서 유도한다 — 발견마다 근거 항목 수만큼의 행이다.
   */
  it('tip.sales 행 형상 근거를 다시 펼치지 않는다', () => {
    const { rich } = readJson(
      planFile('ai-insight-report-v2-m1.report-fixture.json'),
    );
    const view = toInsightView({
      response: rich.response.insight,
      report: rich.response.report,
      sentTreatments: rich.request.treatments,
      notices: [],
      asOf: rich.request.as_of,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });
    const raw = (
      rich.response.insight.sales_findings as Array<{
        angle: string;
        evidence: unknown[];
      }>
    ).filter((f) => f.angle === '세일즈 포인트');
    const seats = view.seats.filter((s) => s.seat === 'tip.sales');
    const want = raw.map((f) => f.evidence.length);

    expect(want.length).toBeGreaterThan(0);
    expect(seats.map((s) => s.rows.length)).toEqual(want);
    expect(seats.map((s) => new Set(s.rows.map((r) => r.id)).size)).toEqual(
      want,
    );
    expect(seats.map((s) => s.evidenceTotal)).toEqual(want);
    expect(seats.map((s) => s.evidenceKind)).toEqual(want.map(() => 'row'));
    expect(seats.map((s) => s.unmatched)).toEqual(want.map(() => 0));
    expect(seats.flatMap((s) => s.rows).every((r) => r.id !== null)).toBe(true);
  });

  it('tip.sales 집계 형상 근거는 상병으로 펼친다', () => {
    const sent = [
      treatment({ treated_on: '2026-05-12' }),
      treatment({ treated_on: '2024-01-05', disease_code: 'K210' }),
      treatment({ treated_on: '2024-02-09' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          {
            count: 3,
            deductible_total: 48600,
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            first: '2023-06-08',
            last: '2026-05-12',
            modalities: '외래',
          },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    expect(sales?.evidenceKind).toBe('aggregate');
    expect(sales?.rows.map((r) => r.id)).toEqual(['S-0001', 'S-0003']);
    expect(sales?.evidenceTotal).toBe(3);
    //* 3(센 수) − 2(고른 수).
    expect(sales?.unmatched).toBe(1);
  });

  /**
   * 한 목록에 행·집계가 섞이고 모호한 항목이 끼어도 **항목마다** 제 경로로 간다. 발견 단위로 가르면
   * 집계 항목이 빈 한 행으로 줄거나(행 경로) 행 항목이 같은 진료를 다시 펼친다(집계 경로).
   */
  it('tip.sales 혼합·모호 근거를 항목마다 제 경로로 처리한다', () => {
    const t1 = treatment({ treated_on: '2026-05-12' });
    const sent = [
      t1,
      treatment({ treated_on: '2024-02-09' }),
      treatment({ treated_on: '2025-01-03', disease_code: 'K210' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          //* 행 항목 — 되맞춤으로 S-0001.
          { ...evidenceOf(t1), record_id: 'R-0001' },
          //* `treated_on: null` 은 행 표지가 아니다 — 집계로 남는다. 행 항목이 쓴 S-0001 은 다시 고르지 않는다.
          {
            count: 2,
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            first: '2023-01-01',
            last: '2026-12-31',
            treated_on: null,
          },
          //* `record_id` 만 있는 항목 — 행 경로에서 되맞춤에 실패해 `id: null` 행으로 선다(사라지지 않는다).
          { record_id: 'R-0099', disease_code: 'Z999' },
          //* 상병코드 없는 집계 — 기존 규칙대로 0행.
          { count: 1, disease_name: '코드 없음' },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    expect(sales?.rows.map((r) => r.id)).toEqual(['S-0001', 'S-0002', null]);
    //* 행 항목 2 + 집계 `count` 합 3.
    expect(sales?.evidenceTotal).toBe(5);
    //* `id: null` 행 1 + (집계 `count` 합 3 − 집계가 고른 1).
    expect(sales?.unmatched).toBe(3);
    expect(sales?.evidenceKind).toBe('row');
    //* 같은 진료가 두 번 서지 않는다.
    const ids = (sales?.rows ?? []).map((r) => r.id).filter((id) => id);
    expect(new Set(ids).size).toBe(ids.length);
  });

  //* 두 식별 필드(`treated_on`·`record_id`)를 다 잃은 행 항목. 집계로 읽으면 기간도 `count` 도 없어
  //* 같은 상병 진료 두 건을 전부 근거로 세운다 — `facility_name` 이 행 표지라 되맞춤 실패 한 행으로 선다.
  it('tip.sales 병·의원만 남은 행 항목을 상병 전체로 펼치지 않는다', () => {
    const sent = [
      treatment({ treated_on: '2026-05-12' }),
      treatment({ treated_on: '2024-02-09' }),
    ];
    const view = toInsightView({
      response: responseWith([
        finding('세일즈 포인트', [
          {
            facility_name: '가온내과의원',
            disease_code: 'J304',
            disease_name: '알레르기성 비염',
            kind: '외래',
          },
        ]),
      ]),
      sentTreatments: sent,
      notices: [],
      asOf: FROZEN_AS_OF,
      analysisAgeDays: 3,
      ddxStatus: 200,
    });

    const sales = view.seats.find((s) => s.seat === 'tip.sales');
    expect(sales?.rows.map((r) => r.id)).toEqual([null]);
    expect(sales?.unmatched).toBe(1);
    expect(sales?.evidenceKind).toBe('row');
  });

  it('reportV2 는 ready 에서만 선다', () => {
    const report = { meta: { rulesetVersion: 'insight-v0.3' } };
    const at = (
      sent: InsightTreatmentOut[],
      ageDays: number,
      ddxStatus: number | null,
      withReport = true,
    ) =>
      toInsightView({
        response: responseWith([]),
        report: withReport ? report : undefined,
        sentTreatments: sent,
        notices: [],
        asOf: FROZEN_AS_OF,
        analysisAgeDays: ageDays,
        ddxStatus,
      });

    const ready = at([treatment()], 3, 200);
    expect(ready.state).toBe('ready');
    expect(ready.reportV2?.meta.rulesetVersion).toBe('insight-v0.3');
    //* 제공자가 절을 안 주면 `null` — 「재료가 없다」(빈 블록)와 다른 값이다.
    expect(at([treatment()], 3, 200, false).reportV2).toBeNull();
    for (const [view, state] of [
      [at([], 31, null), 'expired'],
      [at([], 3, null), 'no_treatments'],
      [at([treatment()], 3, 0), 'unavailable'],
    ] as const) {
      expect(view.state).toBe(state);
      expect(view.reportV2).toBeNull();
    }
  });
});
