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

import { toInsightReportV2 } from './insight-report-v2';

/**
 * `insight-report-v2.ts` 실행 검사.
 *
 * **검사 이름은 고정이다.** 계획 게이트 `#2` 가 이름의 실재·통과·단언 수를 열거한다.
 *
 * fixture 는 2026-09-23 ddx 합성 요청 2건의 실측 응답이다(`rich` 22건 · `s11` 6건). `bait` 는 `rich.report`
 * 사본에 미끼 `__LEAK__` 12자리와 3상태 `null` 4자리를 심은 판본이다.
 *
 * `v05` 는 손으로 지은 합성 `insight-v0.5` report 다(키·형만 2026-10-01 stg 실호출에서 따왔다). `v05bait` 는 그 사본에
 * 새 블록마다 미끼와 제외 필드 값, 3상태 `null` 을 심은 판본이다. 실데이터 전건 대조는 계획 게이트 `#5` 가 따로 본다.
 */

//* `.../insuniverse-server/apps/api/src/modules/ddx` → 서버 루트 다섯 단계 · superproject 루트 여섯 단계.
const SERVER_ROOT = path.resolve(__dirname, '..', '..', '..', '..', '..');
const ROOT = path.resolve(SERVER_ROOT, '..');
const fixture = JSON.parse(
  fs.readFileSync(
    path.join(
      ROOT,
      '.claude',
      'plans',
      'ai-insight-report-v2-m1.report-fixture.json',
    ),
    'utf8',
  ),
) as {
  rich: { response: { report: Record<string, unknown> } };
  s11: { response: { report: Record<string, unknown> } };
  bait: { report: Record<string, unknown> };
};
const v05Fixture = JSON.parse(
  fs.readFileSync(
    path.join(
      ROOT,
      '.claude',
      'plans',
      'ai-insight-v05-projection.report-fixture.json',
    ),
    'utf8',
  ),
) as {
  v05: { report: Record<string, unknown> };
  bait: { report: Record<string, unknown> };
};
//* v0.6 — v0.5 합성본에 수정요청5 키를 더한 합성 report. 원응답 실측은 0건이다(계획서 Risks 1행).
const v06Fixture = JSON.parse(
  fs.readFileSync(
    path.join(
      ROOT,
      '.claude',
      'plans',
      'ai-insight-cr2-projection.report-fixture.json',
    ),
    'utf8',
  ),
) as {
  v06: { report: Record<string, unknown> };
  bait: { report: Record<string, unknown> };
};
//* visit_ids — 우리 진료 id 를 돌려받은 제공자 원응답(J3 응답 B, stg ddx 합성 요청 · 실환자 0).
const visitIdsFixture = JSON.parse(
  fs.readFileSync(
    path.join(
      ROOT,
      '.claude',
      'plans',
      'ai-insight-cr2-visit-ids.response-fixture.json',
    ),
    'utf8',
  ),
) as {
  reference: { rowPaths: string[]; evidenceWithVisitIds: number };
  report: Record<string, unknown>;
};
const CASES = {
  rich: fixture.rich.response.report,
  s11: fixture.s11.response.report,
  bait: fixture.bait.report,
  v05: v05Fixture.v05.report,
  v05bait: v05Fixture.bait.report,
  v06: v06Fixture.v06.report,
  v06bait: v06Fixture.bait.report,
};

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

/** 잎 목록. 빈 배열·빈 객체도 잎이다 — 「비었다」도 원본과 대조할 값이다. */
function leaves(v: unknown, at: string[] = []): Array<[string[], unknown]> {
  if (Array.isArray(v) ? v.length === 0 : !isObj(v)) return [[at, v]];
  const keys = Array.isArray(v)
    ? v.map((_, i) => String(i))
    : Object.keys(v as object);
  return keys.flatMap((k) =>
    leaves((v as Record<string, unknown>)[k], [...at, k]),
  );
}

/** 산출의 배열마다 [경로, 길이]. 잎 대조는 산출 → 원본 한 방향이라 원소 탈락을 못 본다. */
function arrayLengths(
  v: unknown,
  at: string[] = [],
): Array<[string[], number]> {
  if (Array.isArray(v))
    return [
      [at, v.length],
      ...v.flatMap((x, i) => arrayLengths(x, [...at, String(i)])),
    ];
  if (!isObj(v)) return [];
  return Object.entries(v).flatMap(([k, x]) => arrayLengths(x, [...at, k]));
}

function get(v: unknown, at: string[]): unknown {
  return at.reduce<unknown>(
    (cur, k) =>
      cur && typeof cur === 'object'
        ? (cur as Record<string, unknown>)[k]
        : undefined,
    v,
  );
}

/**
 * 경로 한 줄 → 값 목록. `[*]` 는 배열 전 원소, `[type=자동차보험]` 은 그 필드가 그 값인 원소다.
 * 원소마다 형상이 다른 자리(`coverageFound.items[].rows`)를 변형별로 부르려고 선택자를 둔다.
 */
function valuesAt(v: unknown, spec: string): unknown[] {
  let cur: unknown[] = [v];
  for (const part of spec.split('.')) {
    const m = /^([^[]+)(?:\[(\*|[^=\]]+=[^\]]+)\])?$/.exec(part);
    if (!m) throw new Error('경로 형식: ' + spec);
    cur = cur.map((x) => (isObj(x) ? x[m[1]] : undefined));
    if (m[2] === '*') cur = cur.flatMap((x) => (Array.isArray(x) ? x : []));
    else if (m[2]) {
      const [k, want] = m[2].split('=');
      cur = cur
        .flatMap((x) => (Array.isArray(x) ? x : []))
        .filter((x) => isObj(x) && x[k] === want);
    }
  }
  return cur;
}

/** 산출 `summary[i]` 의 필드 → 원본 `summary[key]` 의 필드. `key` 는 원본의 키 자체다. */
const SUMMARY_FIELD: Record<string, string | null> = {
  key: null,
  count: 'count',
  detailKo: 'detail_ko',
  noteKo: 'note_ko',
};

const filled = (x: unknown) =>
  x !== null && x !== undefined && !(Array.isArray(x) && x.length === 0);

/**
 * 매핑 가이드 경로 — **투영이 빠뜨린 경로**를 보는 목록이다. 잎 대조는 산출에 있는 잎만 보므로
 * 필드를 통째로 빼먹으면 조용히 통과한다(L2 R1 test MEDIUM). 각 경로는 rich 응답에서 **어느 원소든**
 * 값이 차 있어야 한다. rich 가 전건 `null` 로 주는 자리(`checks[].applies` — 요청에 `sicked` 가 없다)는
 * 여기서 볼 수 없어 뺐다 — 3상태 검사가 그 자리를 따로 본다.
 */
const REQUIRED_PATHS = [
  'meta.coverage.from',
  'meta.coverage.to',
  'meta.coverage.recordCount',
  'meta.coverage.deductibleTotal',
  'meta.generatedAt',
  'meta.gapDays',
  'meta.rulesetVersion',
  'subTabs[*].key',
  'subTabs[*].label',
  'subTabs[*].count',
  'subTabs[*].blocks[*].name',
  'subTabs[*].blocks[*].why',
  'summary[*].detailKo',
  'records[*].recordId',
  'records[*].asbTreatStartDate',
  'records[*].asbHospitalName',
  'records[*].asbDiseaseCode',
  'records[*].trbDeductibleAmt',
  'records[*].insuranceType',
  'records[*].totalAmt',
  'records[*].tests[*].name',
  'records[*].drugs[*].name',
  'records[*].procedures[*].name',
  'findings[*].findingId',
  'findings[*].evidenceRecordCount',
  'salesTip.title',
  'salesTip.sections[*].title',
  'salesTip.sections[*].reviewedCount',
  'salesTip.sections[*].scripts[*].displayText',
  'salesTip.sections[*].scripts[*].keyword',
  'salesTip.sections[*].scripts[*].covers',
  'salesTip.sections[*].scripts[*].evidenceRecordIds',
  'salesTip.sections[*].scripts[*].criteria[*].text',
  'coverageFound.intro[*].text',
  'coverageFound.status.count',
  'coverageFound.status.waiverNote',
  'coverageFound.items[*].type',
  'coverageFound.items[*].displayText',
  'coverageFound.items[*].covers',
  'coverageFound.items[type=숨은 보험].rows[*].expiresOn',
  'coverageFound.items[type=숨은 보험].rows[*].daysLeft',
  'coverageFound.items[type=자동차보험].rows[*].facility',
  'coverageFound.items[type=자동차보험].rows[*].from',
  'coverageFound.items[type=자동차보험].rows[*].visitDays',
  'coverageFound.items[type=자동차보험].rows[*].outpatientTotalAmt',
  'coverageFound.items[type=청구 검토].rows[*].occurrences',
  'coverageFound.items[type=청구 검토].rows[*].text',
  'carRecords[*].facility',
  'carRecords[*].recordIds',
  'claimItems.items[*].occurrences',
  'caution.groups[*].displayText',
  'caution.groups[*].tracking',
  'caution.groups[*].diseases[*].count',
  'notifyDuty.gap.lastVisit',
  'notifyDuty.filter.basis',
  'notifyDuty.checks[*].label',
  'notifyDuty.checks[*].hit',
  'notifyDuty.checks[*].window.from',
  'notifyDuty.strategy[*].text',
];

/**
 * 응답 필드 계약(계획서 `ai-insight-v05-projection` 의 「응답 필드 계약」 표)의 잎 경로 — 합성 v0.5 산출에서 값이 차야 한다.
 * 장해 행의 `diagnosisNotes` 는 합성·실데이터 모두 빈 배열이라 여기 없고, 빈 배열로 서는지는 아래 형 검사가 본다.
 */
const REQUIRED_V05_PATHS = [
  'salesTip.sections[*].scripts[*].title',
  'salesTip.sections[*].scripts[*].riskLabel',
  'salesTip.sections[*].scripts[*].painPoint',
  'salesTip.sections[*].scripts[*].message',
  'salesTip.sections[*].scripts[*].category',
  'salesTip.sections[*].scripts[*].lines[*].text',
  'salesTip.sections[*].scripts[*].lines[*].recordIds',
  'salesTip.sections[*].scripts[*].lines[*].note',
  'salesTip.sections[*].scripts[*].promotions[*].item',
  'salesTip.sections[*].scripts[*].promotions[*].category',
  'salesTip.sections[*].scripts[*].promotions[*].reason',
  'salesTip.sections[*].scripts[*].promotions[*].approved',
  'records[*].diagnosisNote.ruleId',
  'records[*].diagnosisNote.text',
  'records[*].diagnosisNote.approved',
  'coverageFound.items[*].rows[*].diseases[*].name',
  'coverageFound.items[*].rows[*].diseases[*].code',
  'coverageFound.items[*].rows[*].diseases[*].visitCount',
  'coverageFound.items[*].rows[*].diseases[*].lastDate',
  'coverageFound.items[*].rows[*].diagnosisNotes[*].ruleId',
  'coverageFound.items[*].rows[*].diagnosisNotes[*].text',
  'coverageFound.items[*].rows[*].diagnosisNotes[*].approved',
  'coverageFound.disabilityRows[*].diseases[*].name',
  'coverageFound.disabilityRows[*].diseases[*].code',
  'coverageFound.disabilityRows[*].diseases[*].visitCount',
  'coverageFound.disabilityRows[*].diseases[*].lastDate',
  'caution.groups[*].matchedCount',
  'caution.groups[*].diseases[*].tracking',
  'notifyDuty.checks[*].groups[*].diseaseCode',
  'notifyDuty.checks[*].groups[*].diseaseName',
  'notifyDuty.checks[*].groups[*].visitCount',
  'notifyDuty.checks[*].groups[*].lastDate',
  'notifyDuty.checks[*].groups[*].recordIds',
  'notifyDuty.checks[*].groups[*].testCount',
  'notifyDuty.checks[*].groups[*].careDays',
  'notifyDuty.checks[*].groups[*].prescriptionDays',
  'notifyDuty.checks[*].groups[*].displayText',
];

/** 계획서 `ai-insight-cr2-projection` 「응답 필드 계약」 A 의 잎 경로 — 합성 v0.6 산출에서 값이 차야 한다. */
const REQUIRED_V06_PATHS = [
  'salesTip.sections[*].scripts[*].recurrenceNote.text',
  'salesTip.sections[*].scripts[*].recurrenceNote.approved',
  'records[*].asdOperationSource',
  'notifyDuty.approved',
  'notifyDuty.strategy[*].plan',
  'notifyDuty.strategy[*].approved',
  'notifyDuty.strategy[*].items[*].label',
  'notifyDuty.strategy[*].items[*].text',
  'notifyDuty.strategy[*].items[*].guide',
  'notifyDuty.strategy[*].items[*].evidenceRecordIds',
];

/**
 * 착수 전(`origin/stg` `5b45423e`) 투영의 객체별 키 목록 — 리터럴로 동결한다. 「옛 키는 그대로, 새 키만 더했다」 를
 * 이 목록 ∪ 새 키 = 산출 키 집합으로 본다. 옛 키가 빠지거나 이름이 바뀌면 붉다.
 */
const OLD_KEYS = {
  script: [
    'order',
    'findingId',
    'displayText',
    'fact',
    'keyword',
    'keywordTag',
    'visitCount',
    'covers',
    'evidenceRecordIds',
    'criteria',
    'limits',
    'product',
    'reason',
    'effect',
    'talkTrack',
    'why',
    'title',
    'riskLabel',
    'painPoint',
    'message',
    'category',
    'lines',
    'promotions',
  ],
  record: [
    'recordId',
    'asbTreatStartDate',
    'asbHospitalName',
    'asbDepartment',
    'asbTreatType',
    'asbDiseaseCode',
    'asbDiseaseName',
    'asbVisitDays',
    'trbDeductibleAmt',
    'asdExamination',
    'asdOperation',
    'asbDuplicated',
    'medicineType',
    'insuranceType',
    'totalAmt',
    'prescriptionDays',
    'dispensedBy',
    'surgery',
    'surgeryName',
    'tests',
    'drugs',
    'procedures',
    'diagnosisNote',
  ],
  strategy: ['key', 'label', 'text', 'evidenceRecordIds'],
  notifyDuty: [
    'title',
    'gap',
    'filter',
    'checks',
    'strategy',
    'points',
    'dosingNote',
    'footnoteKo',
  ],
};
const NEW_KEYS = {
  //* `tags` — insight-v0.9(수정요청 10.12). 새 필드 검사는 `insight-report-v2.revision6.spec.ts`(fixture 없이 돈다).
  //* `priorityScore` · `importance` — ddx revision7-v1. 새 필드 검사는 `insight-report-v2.revision7.spec.ts`.
  script: ['recurrenceNote', 'tags', 'priorityScore', 'importance'],
  record: ['asdOperationSource'],
  strategy: ['plan', 'items', 'approved'],
  notifyDuty: ['approved'],
};

type RawGroup = { diseases: Array<Record<string, unknown>> };
type RawScript = Record<string, unknown>;
const rawCaution = (r: Record<string, unknown>) =>
  (r.caution as { groups: RawGroup[] }).groups;
const rawSections = (r: Record<string, unknown>) =>
  (r.salesTip as { sections: Array<{ key: string; scripts: RawScript[] }> })
    .sections;

describe('toInsightReportV2', () => {
  it('v2 — 미끼 값이 산출 어디에도 없다', () => {
    //* 금지 키 이름은 조각을 이어 만든다 — 이 파일에서 매퍼로 복사해 가지 않게.
    const forbidden = [
      '__LEAK__',
      'person' + '_name',
      'sub' + 'ject',
      'external' + 'Ref',
      'analysis' + 'Id',
      'secret' + 'Note',
      'asbPatient' + 'Name',
    ];
    for (const raw of Object.values(CASES)) {
      const text = dump(toInsightReportV2(raw));
      for (const f of forbidden) expect(text).not.toContain(f);
    }
    //* 미끼가 실제로 심겨 있어야 위 단언이 뜻을 갖는다.
    expect(
      dump(CASES.bait).split('__LEAK__').length - 1,
    ).toBeGreaterThanOrEqual(12);
  });

  it('v2 — 산출의 모든 잎이 원본의 같은 경로 값과 같다', () => {
    let compared = 0;
    for (const raw of Object.values(CASES)) {
      const out = toInsightReportV2(raw);
      for (const [at, value] of leaves(out)) {
        //* 의도된 변형 1 — `summary` 는 한글 절 이름이 키인 객체를 응답 키 순서대로 편 배열이다.
        let rawAt: unknown;
        if (at[0] === 'summary') {
          const key = (out?.summary[Number(at[1])] ?? { key: '' }).key;
          const field = SUMMARY_FIELD[at[2]];
          rawAt = field === null ? key : get(raw, ['summary', key, field]);
          if (field === null)
            expect(Object.keys(raw.summary as object)).toContain(key);
        } else rawAt = get(raw, at);
        //* 의도된 변형 2 — 표 이름이 든 caveat 는 `null` 로 간다(아래 caveat 검사가 본다).
        if (at[0] === 'emptyBlocks' && at[2] === 'caveat' && value === null)
          continue;
        if (value === null) expect(rawAt ?? null).toBeNull();
        else if (Array.isArray(value)) expect(rawAt ?? []).toEqual([]);
        else expect(rawAt).toBe(value);
        compared += 1;
      }
      //* 배열마다 길이도 원본과 같다 — `summary` 는 원본 객체의 키 수와 대조한다(의도된 변형 1).
      for (const [at, n] of arrayLengths(out)) {
        const rawAt =
          at.length === 1 && at[0] === 'summary'
            ? Object.keys(isObj(raw.summary) ? raw.summary : {})
            : get(raw, at);
        const want = Array.isArray(rawAt) ? rawAt.length : 0;
        expect([at.join('.'), n]).toEqual([at.join('.'), want]);
        compared += 1;
      }
    }
    expect(compared).toBeGreaterThan(0);
  });

  it('v2 — 매핑 가이드 경로가 채운 응답에서 선다', () => {
    const out = toInsightReportV2(CASES.rich);
    for (const p of REQUIRED_PATHS) {
      //* 실패하면 어느 경로인지 보이게 경로를 함께 싣는다.
      expect([p, valuesAt(out, p).some(filled)]).toEqual([p, true]);
    }
    expect(REQUIRED_PATHS.length).toBeGreaterThanOrEqual(40);
  });

  it('v2 — tracking·hit·applies·approved 의 null 을 false 로 접지 않는다', () => {
    const bait = toInsightReportV2(CASES.bait);
    expect(bait?.caution.groups[0].tracking).toBeNull();
    expect(bait?.notifyDuty.checks[0].hit).toBeNull();
    expect(bait?.notifyDuty.checks[0].applies).toBeNull();
    expect(bait?.salesTip.sections[1].approved).toBeNull();
    //* 반대 방향 — `false` 는 `false` 로 남는다(rich 원본의 같은 자리가 `false`).
    const rich = toInsightReportV2(CASES.rich);
    expect(rich?.caution.groups[0].tracking).toBe(false);
    expect(rich?.salesTip.sections[1].approved).toBe(false);
    //* 참·거짓이 아닌 값(문자열 'true' 등)도 `null` 이다 — 판정하지 않은 것을 판정한 것처럼 만들지 않는다.
    const odd = toInsightReportV2({
      caution: { groups: [{ tracking: 'true' }] },
      notifyDuty: {
        checks: [{ hit: 1, applies: 'false' }],
        filter: { approved: 0 },
      },
      salesTip: { sections: [{ approved: 'yes' }] },
      coverageFound: { status: { coversApproved: null } },
    });
    expect(odd?.caution.groups[0].tracking).toBeNull();
    expect(odd?.notifyDuty.checks[0].hit).toBeNull();
    expect(odd?.notifyDuty.checks[0].applies).toBeNull();
    expect(odd?.notifyDuty.filter.approved).toBeNull();
    expect(odd?.salesTip.sections[0].approved).toBeNull();
    expect(odd?.coverageFound.status.coversApproved).toBeNull();
  });

  it('v2 — 표 이름이 적힌 caveat 는 버리고 요청 사유 caveat 는 싣는다', () => {
    expect(
      toInsightReportV2(CASES.bait)?.emptyBlocks.map((b) => b.caveat),
    ).toEqual([null, null, '검사 자료(tests)가 요청에 없습니다.']);
    //* 실측 요청 사유는 그대로 간다 — 괄호 안 요청 필드 이름(`insurance_type`)을 표 이름으로 오인하지 않는다.
    const s11 = toInsightReportV2(CASES.s11);
    expect(s11?.emptyBlocks.map((b) => b.caveat)).toEqual(
      (CASES.s11.emptyBlocks as Array<{ caveat: string }>).map((b) => b.caveat),
    );
    expect(
      s11?.emptyBlocks.some((b) => b.caveat?.includes('insurance_type')),
    ).toBe(true);
    //* 저장소 스키마의 표 이름 **전부**가 걸린다 — 접두가 새로 생기면 여기가 붉다(implement 게이트 S1).
    const schema = fs.readFileSync(
      path.join(SERVER_ROOT, 'drizzle', 'schema.ts'),
      'utf8',
    );
    const tables = [
      ...schema.matchAll(/mysqlTable\(\s*['"]([a-z0-9_]+)['"]/g),
    ].map((m) => m[1]);
    expect(tables.length).toBeGreaterThan(0);
    const kept = tables.filter(
      (t) =>
        toInsightReportV2({ emptyBlocks: [{ caveat: `조회 결과(${t}) 0행` }] })
          ?.emptyBlocks[0].caveat !== null,
    );
    expect(kept).toEqual([]);
  });

  it('v2 — 빈 세일즈 절은 emptyNote·reviewedCount 를 싣는다', () => {
    const raw = (
      CASES.s11.salesTip as { sections: Array<Record<string, unknown>> }
    ).sections;
    const i = raw.findIndex(
      (s) => Array.isArray(s.scripts) && s.scripts.length === 0 && s.emptyNote,
    );
    expect(i).toBeGreaterThanOrEqual(0);
    const sec = toInsightReportV2(CASES.s11)?.salesTip.sections[i];
    expect(sec?.scripts).toEqual([]);
    expect(sec?.emptyNote).toBe(raw[i].emptyNote);
    expect(sec?.reviewedCount).toBe(raw[i].reviewedCount);
    expect(typeof sec?.reviewedCount).toBe('number');
  });

  it('v2 — 근거 id 가 records 에서 정확히 한 행으로 되찾아진다', () => {
    const out = toInsightReportV2(CASES.rich);
    const ids = [
      'salesTip.sections[*].scripts[*].evidenceRecordIds[*]',
      'coverageFound.items[*].evidenceRecordIds[*]',
      'coverageFound.items[*].rows[*].recordIds[*]',
      'coverageFound.disabilityRows[*].recordIds[*]',
      'carRecords[*].recordIds[*]',
      'caution.groups[*].evidenceRecordIds[*]',
      'notifyDuty.checks[*].evidenceRecordIds[*]',
      'notifyDuty.strategy[*].evidenceRecordIds[*]',
      'notifyDuty.points[*].evidenceRecordIds[*]',
    ].flatMap(
      (p) => valuesAt(out, p.replace(/\[\*\]$/, '')).flat() as unknown[],
    );
    expect(ids.length).toBeGreaterThan(0);
    const unresolved = [...new Set(ids)].filter(
      (id) => out?.records.filter((r) => r.recordId === id).length !== 1,
    );
    expect(unresolved).toEqual([]);
  });

  it('visit-ids-response-evidence-resolves — J3 응답의 근거 id 가 투영 뒤 records 에서 전건 되찾아진다', () => {
    const raw = visitIdsFixture.report;
    const out = toInsightReportV2(raw);
    expect(out).not.toBeNull();
    const rawIds = (raw.records as Array<{ recordId: string }>).map(
      (r) => r.recordId,
    );
    //* records 는 원응답 그대로 — 복제 · 탈락 없이 같은 id 열이다.
    expect(out?.records.map((r) => r.recordId)).toEqual(rawIds);
    expect(rawIds.some((id) => id.startsWith('R-'))).toBe(false);
    const ids = new Set(rawIds);
    const dangling: string[] = [];
    let seen = 0;
    const walk = (v: unknown): void => {
      if (Array.isArray(v)) return v.forEach(walk);
      if (!v || typeof v !== 'object') return;
      for (const [k, x] of Object.entries(v)) {
        if (/^(recordIds|evidenceRecordIds)$/.test(k) && Array.isArray(x)) {
          for (const id of x as string[]) {
            seen += 1;
            if (!ids.has(id)) dangling.push(`${k}=${id}`);
          }
        }
        walk(x);
      }
    };
    walk(out);
    expect(seen).toBeGreaterThan(0);
    expect(dangling).toEqual([]);
    //* 응급실 행: 근거가 원응답 같은 행과 열 그대로 같다(개수 5 만 보면 한 id 로 채워도 통과한다).
    const at = (root: unknown, spec: string): unknown =>
      spec.split('.').reduce<unknown>((node, seg) => {
        const m = /^(\w+)((?:\[\d+\])*)$/.exec(seg);
        if (!m || node == null) return undefined;
        let v = (node as Record<string, unknown>)[m[1]];
        for (const ix of m[2].match(/\d+/g) ?? [])
          v = (v as unknown[] | undefined)?.[Number(ix)];
        return v;
      }, root);
    const rowPath = visitIdsFixture.reference.rowPaths[0];
    const row = at(out, rowPath) as { text: string; recordIds: string[] };
    const rawRow = at(raw, rowPath) as { recordIds: string[] };
    expect(row.text).toMatch(/응급실/);
    expect(row.recordIds).toHaveLength(
      visitIdsFixture.reference.evidenceWithVisitIds,
    );
    expect(row.recordIds).toEqual(rawRow.recordIds);
  });

  it('v2 — null 과 전 블록이 비었다를 구분한다', () => {
    for (const absent of [null, undefined, [], 'report', 0]) {
      expect(toInsightReportV2(absent)).toBeNull();
    }
    //* 절은 있는데 재료가 없다 — 이름 붙은 빈 값으로 선다. 지어낸 값은 없다.
    const empty = toInsightReportV2({});
    expect(empty).not.toBeNull();
    expect(empty?.records).toEqual([]);
    expect(empty?.coverageFound.items).toEqual([]);
    expect(empty?.salesTip.title).toBeNull();
    expect(empty?.meta.coverage.recordCount).toBeNull();
    expect(empty?.summary).toEqual([]);
  });

  it('v0.5 — 새 필드가 합성 v0.5 응답에서 선다', () => {
    const out = toInsightReportV2(CASES.v05);
    for (const p of REQUIRED_V05_PATHS) {
      expect([p, valuesAt(out, p).some(filled)]).toEqual([p, true]);
    }
    expect(REQUIRED_V05_PATHS.length).toBeGreaterThanOrEqual(30);
  });

  it('v0.5 — 상병별 tracking 은 원본에 키가 있을 때만 싣는다', () => {
    //* v0.3 상병에는 키가 없다 — 「판정 불가」(`null`)로 채우지 않는다.
    const old = toInsightReportV2(CASES.rich)?.caution.groups ?? [];
    const oldDiseases = old.flatMap((g) => g.diseases);
    expect(oldDiseases.length).toBeGreaterThan(0);
    for (const d of oldDiseases) expect('tracking' in d).toBe(false);
    //* v0.5 — 상병마다 원본의 키 유무와 값이 그대로다. 한 묶음에 키 없는 상병이 섞여도 그 상병만 키가 없다.
    const raw = rawCaution(CASES.v05);
    const out = toInsightReportV2(CASES.v05)?.caution.groups ?? [];
    const seen: unknown[] = [];
    raw.forEach((g, i) =>
      g.diseases.forEach((d, k) => {
        const o = out[i].diseases[k];
        expect(['tracking' in o, o.tracking]).toEqual([
          'tracking' in d,
          d.tracking,
        ]);
        seen.push('tracking' in d ? d.tracking : 'absent');
      }),
    );
    //* 입력이 세 값과 키 부재를 다 가져야 위 단언이 뜻을 갖는다.
    expect(new Set(seen)).toEqual(new Set([true, false, null, 'absent']));
  });

  it('v0.5 — 3상태와 수치의 null 을 접지 않는다', () => {
    const v05 = toInsightReportV2(CASES.v05);
    const bait = toInsightReportV2(CASES.v05bait);
    const sales = (o: typeof v05) =>
      o?.salesTip.sections.find((x) => x.key === 'sales')?.scripts[0];
    const note = (o: typeof v05) =>
      o?.records.find((r) => r.diagnosisNote !== null)?.diagnosisNote;
    expect(sales(v05)?.promotions[0].approved).toBe(false);
    expect(sales(bait)?.promotions[0].approved).toBeNull();
    expect(note(v05)?.approved).toBe(false);
    expect(note(bait)?.approved).toBeNull();
    //* 진단 메모가 없는 행은 `null` 이다 — 빈 객체를 지어내지 않는다.
    expect(v05?.records.some((r) => r.diagnosisNote === null)).toBe(true);
    //* 0 과 `null` 은 다른 값이다 — 0 으로 접으면 화면이 「검사 0건」을 그린다.
    const counts = (v05?.notifyDuty.checks ?? [])
      .flatMap((c) => c.groups)
      .map((g) => g.testCount);
    expect(counts).toContain(0);
    expect(counts).toContain(null);
    //* 원본이 `null` 로 보낸 배열은 `[]` 로 선다.
    const crmRaw = rawSections(CASES.v05).find((x) => x.key === 'crm_item')
      ?.scripts[0];
    expect([crmRaw?.lines, crmRaw?.promotions]).toEqual([null, null]);
    const crm = v05?.salesTip.sections.find((x) => x.key === 'crm_item')
      ?.scripts[0];
    expect([crm?.lines, crm?.promotions]).toEqual([[], []]);
  });

  it('v0.5 — 새 근거 id 가 records 에서 정확히 한 행으로 되찾아진다', () => {
    const out = toInsightReportV2(CASES.v05);
    const lists = [
      'salesTip.sections[*].scripts[*].lines[*].recordIds',
      'notifyDuty.checks[*].groups[*].recordIds',
      'coverageFound.items[*].rows[*].recordIds',
      'coverageFound.disabilityRows[*].recordIds',
    ].map((p) => valuesAt(out, p).flat() as unknown[]);
    for (const ids of lists) expect(ids.length).toBeGreaterThan(0);
    const unresolved = [...new Set(lists.flat())].filter(
      (id) => out?.records.filter((r) => r.recordId === id).length !== 1,
    );
    expect(unresolved).toEqual([]);
  });

  it('v0.5 — 싣지 않기로 한 필드는 산출에 없다', () => {
    //* 원본에는 그 필드가 있어야 아래 단언이 뜻을 갖는다.
    const rawScript = rawSections(CASES.v05)[0].scripts[0];
    for (const k of ['strength', 'organ', 'fixed'])
      expect(rawScript).toHaveProperty(k);
    expect(rawCaution(CASES.v05)[0].diseases[0]).toHaveProperty('findingId');
    for (const raw of [CASES.v05, CASES.v05bait]) {
      const out = toInsightReportV2(raw);
      const scripts = [
        ...(out?.salesTip.sections.flatMap((x) => x.scripts) ?? []),
        ...(out?.notifyDuty.points ?? []),
      ];
      expect(scripts.length).toBeGreaterThan(0);
      for (const t of scripts)
        for (const k of ['strength', 'organ', 'fixed'])
          expect([k, k in t]).toEqual([k, false]);
      for (const d of out?.caution.groups.flatMap((g) => g.diseases) ?? [])
        expect('findingId' in d).toBe(false);
    }
    expect(
      dump(CASES.v05bait).split('__LEAK__').length - 1,
    ).toBeGreaterThanOrEqual(10);
  });

  it('v0.5 — 형이 맞지 않는 새 필드는 null·빈 배열로 선다', () => {
    const odd = toInsightReportV2({
      records: [
        { recordId: 'R-1', diagnosisNote: '문자열 메모' },
        { recordId: 'R-2', diagnosisNote: ['배열'] },
      ],
      salesTip: {
        sections: [{ scripts: [{ title: 3, lines: {}, promotions: 'x' }] }],
      },
      caution: {
        groups: [
          {
            matchedCount: '3',
            //* 셋째는 상속된 키뿐이다 — 원본을 병합·복사하는 층이 생겨도 자기 속성만 「있다」로 읽는다.
            diseases: [
              { tracking: 'true' },
              {},
              Object.create({ tracking: true }),
            ],
          },
        ],
      },
      notifyDuty: {
        checks: [{ groups: [{ testCount: '3', recordIds: 'R-1' }] }],
      },
      coverageFound: {
        items: [{ rows: [{ diseases: {}, diagnosisNotes: null }] }],
        disabilityRows: [{ diseases: 'x' }],
      },
    });
    expect(odd?.records.map((r) => r.diagnosisNote)).toEqual([null, null]);
    const t = odd?.salesTip.sections[0].scripts[0];
    expect([t?.title, t?.lines, t?.promotions]).toEqual([null, [], []]);
    //* 키는 원본에 있으니 서고, 참·거짓이 아닌 값은 `null` 이다. 키가 없는 상병은 키가 없다.
    const ds = odd?.caution.groups[0].diseases ?? [];
    expect(ds[0]).toHaveProperty('tracking', null);
    expect('tracking' in ds[1]).toBe(false);
    expect('tracking' in ds[2]).toBe(false);
    expect(odd?.caution.groups[0].matchedCount).toBeNull();
    const g = odd?.notifyDuty.checks[0].groups[0];
    expect([g?.testCount, g?.recordIds]).toEqual([null, []]);
    const row = odd?.coverageFound.items[0].rows[0];
    expect([row?.diseases, row?.diagnosisNotes]).toEqual([[], []]);
    expect(odd?.coverageFound.disabilityRows[0].diseases).toEqual([]);
  });

  it('v0.5 — 객체 배열의 객체가 아닌 원소는 빠지고 장해 행 메모는 값 그대로다', () => {
    //* 전-`null` 객체를 지어내면 화면이 빈 상병 줄과 유령 「외 1개」를 세운다.
    const junk = [1, 'x', null, ['배열']];
    const dz = {
      name: '합성상병',
      code: 'A01',
      visitCount: 2,
      lastDate: '2026-01-02',
    };
    const dz2 = { ...dz, code: 'A02' };
    const note = { ruleId: 'r-1', text: '합성 메모', approved: true };
    const line = { text: '합성 줄', recordIds: ['R-1'], note: null };
    const promo = {
      item: '합성 물품',
      category: null,
      reason: null,
      approved: false,
    };
    const out = toInsightReportV2({
      salesTip: {
        sections: [
          {
            key: 'sales',
            scripts: [{ lines: [...junk, line], promotions: [...junk, promo] }],
          },
        ],
      },
      notifyDuty: {
        checks: [{ groups: [...junk, { diseaseCode: 'A01', testCount: 0 }] }],
      },
      coverageFound: {
        items: [
          {
            rows: [
              { diseases: [...junk, dz], diagnosisNotes: [...junk, note] },
            ],
          },
        ],
        disabilityRows: [
          { diseases: [...junk, dz, dz2], diagnosisNotes: [...junk, note] },
        ],
      },
    });
    const t = out?.salesTip.sections[0].scripts[0];
    expect([t?.lines, t?.promotions]).toEqual([[line], [promo]]);
    expect(out?.notifyDuty.checks[0].groups).toEqual([
      {
        diseaseCode: 'A01',
        diseaseName: null,
        visitCount: null,
        lastDate: null,
        recordIds: [],
        testCount: 0,
        careDays: null,
        prescriptionDays: null,
        displayText: null,
      },
    ]);
    const row = out?.coverageFound.items[0].rows[0];
    expect([row?.diseases, row?.diagnosisNotes]).toEqual([[dz], [note]]);
    const dis = out?.coverageFound.disabilityRows[0];
    expect([dis?.diseases, dis?.diagnosisNotes]).toEqual([[dz, dz2], [note]]);
  });

  it('v0.5 — 빈 객체 원소의 잎은 null 이다 — 0 · 빈 문자열로 접지 않는다', () => {
    const out = toInsightReportV2({
      records: [{ recordId: 'R-1', diagnosisNote: {} }],
      salesTip: {
        sections: [
          { key: 'sales', scripts: [{ lines: [{}], promotions: [{}] }] },
        ],
      },
      caution: { groups: [{}] },
      notifyDuty: { checks: [{ groups: [{}] }] },
      coverageFound: {
        items: [{ rows: [{ diseases: [{}], diagnosisNotes: [{}] }] }],
        disabilityRows: [{ diseases: [{}], diagnosisNotes: [{}] }],
      },
    });
    const nullNote = { ruleId: null, text: null, approved: null };
    const nullDz = { name: null, code: null, visitCount: null, lastDate: null };
    expect(out?.records[0].diagnosisNote).toEqual(nullNote);
    const t = out?.salesTip.sections[0].scripts[0];
    expect([
      t?.title,
      t?.riskLabel,
      t?.painPoint,
      t?.message,
      t?.category,
    ]).toEqual([null, null, null, null, null]);
    expect(t?.lines).toEqual([{ text: null, recordIds: [], note: null }]);
    expect(t?.promotions).toEqual([
      { item: null, category: null, reason: null, approved: null },
    ]);
    expect(out?.caution.groups[0].matchedCount).toBeNull();
    expect(out?.notifyDuty.checks[0].groups).toEqual([
      {
        diseaseCode: null,
        diseaseName: null,
        visitCount: null,
        lastDate: null,
        recordIds: [],
        testCount: null,
        careDays: null,
        prescriptionDays: null,
        displayText: null,
      },
    ]);
    for (const r of [
      out?.coverageFound.items[0].rows[0],
      out?.coverageFound.disabilityRows[0],
    ])
      expect([r?.diseases, r?.diagnosisNotes]).toEqual([[nullDz], [nullNote]]);
  });

  it('v0.6 — 새 필드가 합성 v0.6 응답에서 선다', () => {
    const out = toInsightReportV2(CASES.v06);
    for (const p of REQUIRED_V06_PATHS) {
      expect([
        p,
        valuesAt(out, p).some(
          (x) =>
            x !== null &&
            x !== undefined &&
            !(Array.isArray(x) && x.length === 0),
        ),
      ]).toEqual([p, true]);
    }
    expect(REQUIRED_V06_PATHS.length).toBeGreaterThanOrEqual(10);
    //* 원본 값이 그대로 선다 — 형만 맞고 값이 바뀐 투영을 막는다.
    const simp = out?.notifyDuty.strategy.find((s) => s.key === 'simplified');
    expect(simp?.plan).toBe('3.2.5');
    expect(simp?.items.map((i) => i.label)).toEqual([
      '3개월',
      '입원/수술',
      '5년 중대질병',
    ]);
    expect(simp?.items.map((i) => i.guide !== null)).toEqual([
      false,
      true,
      false,
    ]);
    expect(
      out?.notifyDuty.strategy.find((s) => s.key === 'standard')?.text,
    ).toBeNull();
    expect(out?.records.map((r) => r.asdOperationSource)).toEqual([
      'request',
      'procedure',
      'flag',
      null,
      null,
      null,
    ]);
  });

  it('v0.6 — 행 approved 와 notifyDuty.approved 는 3상태로 따로 선다', () => {
    const v06 = toInsightReportV2(CASES.v06);
    const bait = toInsightReportV2(CASES.v06bait);
    const simp = (o: typeof v06) =>
      o?.notifyDuty.strategy.find((s) => s.key === 'simplified');
    const sales = (o: typeof v06) =>
      o?.salesTip.sections.find((x) => x.key === 'sales')?.scripts[0];
    expect([
      v06?.notifyDuty.approved,
      simp(v06)?.approved,
      sales(v06)?.recurrenceNote?.approved,
    ]).toEqual([false, false, false]);
    expect([
      bait?.notifyDuty.approved,
      simp(bait)?.approved,
      sales(bait)?.recurrenceNote?.approved,
    ]).toEqual([null, null, null]);
    //* 승인 여부가 없는 행은 `null` 이다 — 블록 값 `false` 를 물려받지 않는다(상속을 지어내지 않는다).
    const others = (v06?.notifyDuty.strategy ?? []).filter(
      (s) => s.key !== 'simplified',
    );
    expect(others.length).toBe(2);
    for (const s of others) expect(s.approved).toBeNull();
    //* 반대 방향 — 블록 값이 없을 때 행 `false` 가 블록으로 번지지 않는다.
    const rowOnly = toInsightReportV2({
      notifyDuty: { strategy: [{ key: 'simplified', approved: false }] },
    });
    expect([
      rowOnly?.notifyDuty.approved,
      rowOnly?.notifyDuty.strategy[0].approved,
    ]).toEqual([null, false]);
  });

  it('v0.6 — 간편심사 items 의 근거 id 가 records 에서 정확히 한 행으로 되찾아진다', () => {
    const out = toInsightReportV2(CASES.v06);
    const ids = valuesAt(
      out,
      'notifyDuty.strategy[*].items[*].evidenceRecordIds',
    ).flat() as unknown[];
    expect(ids.length).toBeGreaterThan(0);
    const unresolved = [...new Set(ids)].filter(
      (id) => out?.records.filter((r) => r.recordId === id).length !== 1,
    );
    expect(unresolved).toEqual([]);
  });

  it('v0.6 — 형이 맞지 않는 새 필드는 null·빈 배열로 선다', () => {
    const odd = toInsightReportV2({
      records: [{ recordId: 'R-1', asdOperationSource: 3 }],
      salesTip: {
        sections: [
          {
            scripts: [
              { recurrenceNote: '문자열' },
              { recurrenceNote: ['배열'] },
              { recurrenceNote: {} },
            ],
          },
        ],
      },
      notifyDuty: {
        approved: 'false',
        strategy: [
          { plan: 3, approved: 'false', items: {} },
          {
            items: [
              1,
              'x',
              null,
              { label: '합성', guide: 7, evidenceRecordIds: ['R-1', 2] },
            ],
          },
        ],
      },
    });
    expect(odd?.records[0].asdOperationSource).toBeNull();
    expect(
      odd?.salesTip.sections[0].scripts.map((t) => t.recurrenceNote),
    ).toEqual([null, null, null]);
    expect(odd?.notifyDuty.approved).toBeNull();
    const [a, b] = odd?.notifyDuty.strategy ?? [];
    expect([a?.plan, a?.approved, a?.items]).toEqual([null, null, []]);
    expect(b?.items).toEqual([
      { label: '합성', text: null, guide: null, evidenceRecordIds: ['R-1'] },
    ]);
  });

  it('v0.6 — 옛 키는 그대로이고 새 키만 더해졌다', () => {
    const rich = toInsightReportV2(CASES.rich);
    const sorted = (xs: string[]) => [...xs].sort();
    const shapes: Array<[keyof typeof OLD_KEYS, object[]]> = [
      [
        'script',
        [
          ...(rich?.salesTip.sections.flatMap((x) => x.scripts) ?? []),
          ...(rich?.notifyDuty.points ?? []),
        ],
      ],
      ['record', rich?.records ?? []],
      ['strategy', rich?.notifyDuty.strategy ?? []],
      ['notifyDuty', rich ? [rich.notifyDuty] : []],
    ];
    for (const [kind, objs] of shapes) {
      expect([kind, objs.length > 0]).toEqual([kind, true]);
      for (const o of objs)
        expect([kind, sorted(Object.keys(o))]).toEqual([
          kind,
          sorted([...OLD_KEYS[kind], ...NEW_KEYS[kind]]),
        ]);
    }
    //* v0.3 응답에는 새 키의 원본이 없다 — 빈 값으로 선다.
    for (const t of rich?.salesTip.sections.flatMap((x) => x.scripts) ?? [])
      expect(t.recurrenceNote).toBeNull();
    for (const r of rich?.records ?? [])
      expect(r.asdOperationSource).toBeNull();
    for (const s of rich?.notifyDuty.strategy ?? [])
      expect([s.plan, s.items, s.approved]).toEqual([null, [], null]);
    expect(rich?.notifyDuty.approved).toBeNull();
  });
});
