import { ClassValidatorPipe } from '@api/pipes/validation.pipe';
import { AuthGuardService } from '@api/modules/auth/user/auth-guard.service';
import { DrizzleService } from '@lib/drizzle/src';
import { Global, INestApplication, Logger, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { APP_PIPE, NestFactory } from '@nestjs/core';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';
import { MySqlDialect } from 'drizzle-orm/mysql-core';
import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  statSync,
  writeFileSync,
} from 'fs';
import { AddressInfo } from 'net';
import { tmpdir } from 'os';
import { join } from 'path';
import type { CaseReportService as ServiceType } from './case-report.service';
import type { CaseReportWorker as WorkerType } from './case-report.worker';

//* 실제 모듈의 두 컨트롤러 · 서비스 · 파일 저장소 · 하위 프로세스 실행기를 띄우고 HTTP 로 시험한다.
//* 워커는 dev/fake-worker(표준 라이브러리 python3), DB 는 [rows, fields] 튜플을 돌려주는 가짜 execute 다(명세 8.2 절).
const SECRET = 'case-report-http-spec';
const PASSWORD = 'synthetic-pw-Zx9q';
const WRONG_PASSWORD = 'synthetic-wrong-4Kd';
const ME = 9001;
const OTHER = 9002;
const FAKE_DIR = join(__dirname, 'dev', 'fake-worker');
const jwt = new JwtService({ secret: SECRET });
const tokenOf = (memId: number) =>
  jwt.sign({ id: 'spec', type: '일반', memId });
const MY = tokenOf(ME);
const THEIRS = tokenOf(OTHER);

// ───── 가짜 DB ─────
const basicRow = (id: number, date: string) => ({
  trb_id: id,
  odd_id: 9001,
  trb_treat_start_date: date,
  trb_hospital_name: '합성한방병원',
  trb_department: '한방내과',
  trb_treat_type: '입원',
  trb_disease_code: 'M545',
  trb_disease_name: '(한방)요통',
  trb_visit_days: 6,
  trb_total_amount: 500000,
  trb_public_charge: 350000,
  trb_deductible_amt: 150000,
  trb_hospital_code: null,
  trb_etc: '',
  trb_updated_at: null,
  trb_created_at: '2026-10-20 10:00:05',
});
const ORDERS = [
  {
    odd_id: 9001,
    mem: ME,
    usr_id: 9001,
    name: '김합성',
    birth: '19800101',
    tt_treat_basic: [basicRow(1, '2025-04-20'), basicRow(2, '2025-05-01')],
    tt_treat_detail: [
      {
        trd_id: 1,
        odd_id: 9001,
        trd_treat_start_date: '2025-04-20',
        trd_hospital_name: '합성한방병원',
        trd_treat_type: '입원',
        trd_code_name: '추나요법',
        trd_one_dose: 1,
        trd_daily_doses_number: 1,
        trd_total_dosing_days: 1,
        trd_created_at: 'x',
      },
    ],
    tt_treat_prescribe: [
      {
        trp_id: 1,
        odd_id: 9001,
        trp_treat_start_date: '2025-02-19',
        trp_hospital_name: '합성약국',
        trp_treat_type: '약국',
        trp_drug_name: '합성정',
        trp_ingredients: '합성성분',
        trp_one_dose: 1,
        trp_daily_doses_number: 3,
        trp_total_dosing_days: 3,
        trp_created_at: 'x',
      },
    ],
    tn_analysis_basic: [
      {
        asb_id: 1,
        odd_id: 9001,
        asb_treat_start_date: '2025-04-20',
        asb_hospital_name: '합성한방병원',
        asb_treat_type: '입원',
        asb_disease_code: 'M545',
        asb_disease_name: '요통',
        asb_in_disease: null,
        asb_sicked: 0,
        asb_dosing_days: 0,
        asb_notice: 'x',
      },
    ],
    tn_analysis_detail: [
      {
        asd_id: 1,
        asb_id: 1,
        odd_id: 9001,
        asd_treat_start_date: '2025-04-20',
        asd_hospital_name: '합성한방병원',
        asd_code_name: '추나요법',
        asd_operation: null,
        asd_examination: null,
        asd_in_operation: '수술',
        asd_drug: 'x',
      },
    ],
  },
  {
    odd_id: 9002,
    mem: OTHER,
    usr_id: 9002,
    name: '박합성',
    birth: '19900101',
    tt_treat_basic: [basicRow(3, '2025-01-01')],
    tt_treat_detail: [],
    tt_treat_prescribe: [],
    tn_analysis_basic: [],
    tn_analysis_detail: [],
  },
  {
    odd_id: 9003,
    mem: ME,
    usr_id: 9001,
    name: '김합성',
    birth: '19800101',
    tt_treat_basic: [],
    tt_treat_detail: [],
    tt_treat_prescribe: [],
    tn_analysis_basic: [],
    tn_analysis_detail: [],
  },
];
const NTS = [
  {
    lsa_id: 31,
    mem: ME,
    usr_id: 9001,
    usr_name: '김합성',
    lsa_completed_at: '2026-10-21 09:00:00',
    lsa_available_years: '[2025]',
    lsa_total_rows: 2,
    lsa_result: '{"startYear":2021,"results":[]}',
  },
];
//* 회원이 등록한 고객(tn_user). 9004 는 진료정보분석 건이 없는 고객
const USERS = [
  { usr_id: 9001, mem: ME, usr_name: '김합성', usr_birth: '19800101' },
  { usr_id: 9004, mem: ME, usr_name: '이합성', usr_birth: '19850505' },
  { usr_id: 9002, mem: OTHER, usr_name: '박합성', usr_birth: '19900101' },
];
const dialect = new MySqlDialect();
const db = {
  ntsPresent: true,
  //* 표 조회는 SQL 의 SELECT 칸 목록으로 행을 깎는다 — 고른 칸이 명세 4.3 절과 같은지가 결과에 그대로 드러난다
  project(text: string, row: Record<string, unknown>) {
    const cols =
      /SELECT\s+([\s\S]+?)\s+FROM/i
        .exec(text)?.[1]
        .split(',')
        .map((c) => c.trim()) ?? [];
    return Object.fromEntries(cols.map((c) => [c, row[c]]));
  },
  execute: jest.fn(async (query: any) => {
    const { sql: text, params } = dialect.sqlToQuery(query);
    if (text.includes('information_schema'))
      return [[{ n: db.ntsPresent ? 1 : 0 }], []];
    if (text.includes('FROM tn_order_detail d')) {
      const memId = params[2];
      let rows = ORDERS.filter((o) => o.mem === memId);
      if (text.includes('d.odd_id = ?'))
        rows = rows.filter((o) => o.odd_id === params[params.length - 1]);
      return [
        rows.map((o) => ({
          odd_id: o.odd_id,
          odd_transaction_id: `a00000000${o.odd_id}`,
          odd_created_at: '2026-10-20 10:00:00',
          odd_state: 'ODD006',
          usr_id: o.usr_id,
          usr_name: o.name,
          usr_birth: o.birth,
          basic_count: o.tt_treat_basic.length,
          rows_created_at: o.tt_treat_basic[0]?.trb_created_at ?? null,
          remaining_days: o.tt_treat_basic.length ? 23 : 0,
          delete_on: o.tt_treat_basic.length ? '2026-11-20' : null,
          detail_count: o.tt_treat_detail.length,
          prescribe_count: o.tt_treat_prescribe.length,
        })),
        [],
      ];
    }
    const table =
      /FROM (tt_treat_basic|tt_treat_detail|tt_treat_prescribe|tn_analysis_basic|tn_analysis_detail) WHERE/.exec(
        text,
      )?.[1];
    if (table) {
      const order = ORDERS.find((o) => o.odd_id === params[0]) as any;
      return [
        (order?.[table] ?? []).map((r: Record<string, unknown>) =>
          db.project(text, r),
        ),
        [],
      ];
    }
    if (text.includes('FROM tn_user u WHERE')) {
      return [
        USERS.filter((u) => u.mem === params[2]).map(
          ({ mem: _mem, ...u }) => u,
        ),
        [],
      ];
    }
    if (text.includes('FROM tn_loss_analysis')) {
      let rows = NTS.filter((n) => n.mem === params[1]);
      if (text.includes('l.lsa_id = ?'))
        rows = rows.filter((n) => n.lsa_id === params[2]);
      if (text.includes('l.usr_id = ?'))
        rows = rows.filter((n) => n.usr_id === params[2]);
      return [rows, []];
    }
    return [[], []];
  }),
};

// ───── 앱 ─────
let app: INestApplication;
let base = '';
let root = '';
let service: ServiceType;
let worker: WorkerType;
const savedEnv = { ...process.env };
const logged: string[] = [];
const issuedTokens: string[] = [];

beforeAll(async () => {
  root = mkdtempSync(join(tmpdir(), 'cr-http-'));
  emptyContractDir = mkdtempSync(join(tmpdir(), 'cr-no-contract-'));
  Object.assign(process.env, {
    CASE_REPORT_ENABLED: '1',
    CASE_REPORT_DIR: root,
    CASE_REPORT_PYTHON: 'python3',
    CASE_REPORT_WORKER_DIR: FAKE_DIR,
    CASE_REPORT_MAX_FILE_MB: '1',
  });
  for (const m of ['log', 'error', 'warn', 'debug', 'verbose'] as const)
    jest.spyOn(Logger.prototype, m).mockImplementation((...args: unknown[]) => {
      logged.push(args.map(String).join(' '));
    });
  //* Multer 크기 한도가 데코레이터 평가 때 고정되므로 env 를 정한 뒤 모듈을 읽는다
  const { CaseReportModule } = await import('./case-report.module');
  const { CaseReportService } = await import('./case-report.service');
  const { CaseReportWorker } = await import('./case-report.worker');

  @Global()
  @Module({
    providers: [
      { provide: DrizzleService, useValue: db },
      {
        provide: ConfigService,
        useValue: {
          get: (k: string) => (k === 'aes.secret' ? 'spec-aes' : undefined),
        },
      },
      { provide: AuthGuardService, useValue: {} },
    ],
    exports: [DrizzleService, ConfigService, AuthGuardService],
  })
  class FakeGlobals {}

  @Module({
    imports: [
      FakeGlobals,
      JwtModule.register({ secret: SECRET, global: true }),
      CaseReportModule,
    ],
    providers: [{ provide: APP_PIPE, useClass: ClassValidatorPipe }],
  })
  class CaseReportHttpModule {}

  app = await NestFactory.create(CaseReportHttpModule, { logger: false });
  await app.listen(0, '127.0.0.1');
  base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
  service = app.get(CaseReportService);
  worker = app.get(CaseReportWorker);
});

afterAll(async () => {
  await app?.close();
  process.env = savedEnv;
  rmSync(root, { recursive: true, force: true });
  rmSync(emptyContractDir, { recursive: true, force: true });
});

beforeEach(() => {
  fake({});
  db.ntsPresent = true;
});

// ───── 도구 ─────
//* 값에 기대는 시험은 가짜 워커의 내장 견본(명세 예시)으로 고정한다 — 계약 견본 값이 바뀌어도 흔들리지 않게.
//* 계약 견본(workers/case-report/contract) 자체는 맨 아래 「계약 견본」 묶음이 따로 돌린다.
let emptyContractDir = '';
function fake(env: Record<string, string>) {
  worker.extraEnv = { CASE_REPORT_CONTRACT_DIR: emptyContractDir, ...env };
}
interface Res {
  status: number;
  body: any;
  headers: Headers;
}
async function api(
  method: string,
  path: string,
  opts: { token?: string | null; body?: unknown; form?: FormData } = {},
): Promise<Res> {
  const headers: Record<string, string> = {};
  const token = opts.token === undefined ? MY : opts.token;
  if (token) headers.authorization = `Bearer ${token}`;
  let body: BodyInit | undefined;
  if (opts.form) body = opts.form;
  else if (opts.body !== undefined) {
    headers['content-type'] = 'application/json';
    body = JSON.stringify(opts.body);
  }
  const res = await fetch(`${base}/case-report${path}`, {
    method,
    headers,
    body,
  });
  const text = await res.text();
  let parsed: unknown = text;
  try {
    parsed = JSON.parse(text);
  } catch {
    /* 본문이 JSON 이 아니다(PDF) */
  }
  return { status: res.status, body: parsed, headers: res.headers };
}
let seq = 0;
const pdf = (tag = `${++seq}`) =>
  Buffer.from(`%PDF-1.4\n% synthetic ${tag} ${Date.now()}\n`);
function form(
  files: [string, Buffer][],
  fields: Record<string, string> = {},
  field = 'files',
) {
  const fd = new FormData();
  for (const [name, buf] of files)
    fd.append(field, new Blob([buf], { type: 'application/pdf' }), name);
  for (const [k, v] of Object.entries(fields)) fd.append(k, v);
  return fd;
}
async function newJob(
  body: Record<string, unknown> = { customerName: '김합성' },
) {
  const res = await api('POST', '/jobs', { body });
  expect(res.status).toBe(201);
  return res.body;
}
async function upload(
  jobId: string,
  files: [string, Buffer][],
  fields: Record<string, string> = {},
) {
  const res = await api('POST', `/jobs/${jobId}/files`, {
    form: form(files, fields),
  });
  expect(res.status).toBe(201);
  return res.body;
}
const getJob = async (jobId: string) =>
  (await api('GET', `/jobs/${jobId}`)).body;
const jobJsonPath = (jobId: string) => join(root, 'jobs', jobId, 'job.json');
const readJobJson = (jobId: string) =>
  JSON.parse(readFileSync(jobJsonPath(jobId), 'utf8'));
const writeJobJson = (jobId: string, job: unknown) =>
  writeFileSync(jobJsonPath(jobId), JSON.stringify(job));
const sha = (p: string) =>
  createHash('sha256').update(readFileSync(p)).digest('hex');
function allFiles(dir: string): string[] {
  if (!existsSync(dir)) return [];
  return readdirSync(dir).flatMap((n) => {
    const p = join(dir, n);
    return statSync(p).isDirectory() ? allFiles(p) : [p];
  });
}
async function withEnv<T>(env: Record<string, string>, fn: () => Promise<T>) {
  const before = Object.fromEntries(
    Object.keys(env).map((k) => [k, process.env[k]]),
  );
  Object.assign(process.env, env);
  try {
    return await fn();
  } finally {
    for (const [k, v] of Object.entries(before))
      if (v === undefined) delete process.env[k];
      else process.env[k] = v;
  }
}

// ───── 시험 ─────
describe('기능 스위치 · 인증 · 오류 모양', () => {
  it('CASE_REPORT_ENABLED 가 꺼지면 토큰이 있든 없든 404 CR_DISABLED(공개 경로 포함)', async () => {
    await withEnv({ CASE_REPORT_ENABLED: '0' }, async () => {
      for (const token of [MY, null]) {
        const res = await api('GET', '/meta', { token });
        expect([res.status, res.body.code]).toEqual([404, 'CR_DISABLED']);
      }
      const pub = await api('GET', `/public/${'a'.repeat(32)}`, {
        token: null,
      });
      expect([pub.status, pub.body.code]).toEqual([404, 'CR_DISABLED']);
    });
  });

  it('CASE_REPORT_DIR 이 없으면 503 CR_NOT_CONFIGURED', async () => {
    await withEnv({ CASE_REPORT_DIR: '' }, async () => {
      const res = await api('GET', '/meta');
      expect([res.status, res.body.code]).toEqual([503, 'CR_NOT_CONFIGURED']);
    });
  });

  it('토큰이 없으면 401 이고 본문은 E0001 그대로(사이트 로그아웃 동작 유지)', async () => {
    const res = await api('GET', '/jobs', { token: null });
    expect([res.status, res.body.code]).toEqual([401, 'E0001']);
  });

  it('검증 실패는 400 CR_INVALID, 주민번호 꼴 입력은 저장하지 않고 400 CR_INVALID(detail.field)', async () => {
    const long = await api('POST', '/jobs', {
      body: { customerName: '김합성', title: 'x'.repeat(61) },
    });
    expect([long.status, long.body.code]).toEqual([400, 'CR_INVALID']);
    const unknown = await api('POST', '/jobs', {
      body: { customerName: '김합성', extra: 1 },
    });
    expect(unknown.body.code).toBe('CR_INVALID');
    const rrn = await api('POST', '/jobs', {
      body: { customerName: '800101-1234567' },
    });
    expect([rrn.status, rrn.body.code, rrn.body.detail?.field]).toEqual([
      400,
      'CR_INVALID',
      'customerName',
    ]);
    const noName = await api('POST', '/jobs', { body: {} });
    expect(noName.body.code).toBe('CR_INVALID');
  });

  it('meta 는 워커 version 결과와 한도·국세청 감지를 옮긴다', async () => {
    const res = await api('GET', '/meta');
    expect(res.status).toBe(200);
    expect(res.body.workerReady).toBe(true);
    expect(res.body.preciseInsurers.length).toBeGreaterThan(0);
    for (const p of res.body.preciseInsurers)
      expect(Object.keys(p).sort()).toEqual(['code', 'label']);
    expect(res.body.limits).toEqual({
      maxFileMb: 1,
      maxFilesPerJob: 40,
      maxPages: 60,
      maxPagesPolicy: 2500,
    });
    expect(res.body.ntsFeature).toEqual({ tablePresent: true });
  });
});

describe('심평원 · 국세청 출처', () => {
  it('심평원 목록은 내 분석번호만, 0행은 available:false, 남은 일수 숫자', async () => {
    const res = await api('GET', '/sources/hira');
    expect(res.body.items.map((i: any) => i.oddId)).toEqual([9001, 9003]);
    const [first, empty] = res.body.items;
    expect(first).toMatchObject({
      available: true,
      remainingDays: 23,
      rowCounts: { basic: 2, detail: 1, prescribe: 1 },
      deleteOn: '2026-11-20',
      state: 'READY',
    });
    expect(empty).toMatchObject({
      available: false,
      remainingDays: 0,
      deleteOn: null,
      state: 'EXPIRED',
    });
  });

  it('고객 목록은 로그인 회원이 등록한 고객만(진료정보분석 건 없는 고객 포함), 다른 회원 고객은 안 보인다', async () => {
    const mine = await api('GET', '/customers');
    expect(mine.status).toBe(200);
    expect(mine.body).toEqual({
      items: [
        { usrId: 9001, name: '김합성', birth: '19800101' },
        { usrId: 9004, name: '이합성', birth: '19850505' },
      ],
    });
    const theirs = await api('GET', '/customers', { token: THEIRS });
    expect(theirs.body.items.map((i: any) => i.usrId)).toEqual([9002]);
    expect((await api('GET', '/customers', { token: null })).status).toBe(401);
    const long = await api('GET', `/customers?search=${'가'.repeat(31)}`);
    expect([long.status, long.body.code]).toEqual([400, 'CR_INVALID']);
  });

  it('다른 회원 분석번호 404 CR_HIRA_NOT_FOUND · 0행 409 CR_HIRA_EMPTY', async () => {
    const other = await api('POST', '/jobs', { body: { hiraOddId: 9002 } });
    expect([other.status, other.body.code]).toEqual([404, 'CR_HIRA_NOT_FOUND']);
    const empty = await api('POST', '/jobs', { body: { hiraOddId: 9003 } });
    expect([empty.status, empty.body.code]).toEqual([409, 'CR_HIRA_EMPTY']);
  });

  it('내 분석번호를 고르면 명세 4.3 칸 그대로 스냅샷을 쓰고 고객 값을 채운다(구독·건수 조건 없음)', async () => {
    const job = await newJob({ hiraOddId: 9001 });
    expect(job.customer).toEqual({
      name: '김합성',
      birth: '1980-01-01',
      source: 'HIRA_DB',
      usrId: 9001,
    });
    expect(job.sources.hiraDb.rowCounts).toEqual({
      basic: 2,
      detail: 1,
      prescribe: 1,
      analysisBasic: 1,
      analysisDetail: 1,
    });
    expect(job.derived.canAnalyze).toBe(true);
    const snap = JSON.parse(
      readFileSync(
        join(root, 'jobs', job.jobId, 'sources', 'hira-db.json'),
        'utf8',
      ),
    );
    expect(Object.keys(snap).sort()).toEqual(
      [
        'schema',
        'odd_id',
        'transaction_id',
        'odd_created_at',
        'snapshot_at',
        'customer',
        'tt_treat_basic',
        'tt_treat_detail',
        'tt_treat_prescribe',
        'tn_analysis_basic',
        'tn_analysis_detail',
      ].sort(),
    );
    expect(snap.customer).toEqual({
      usr_id: 9001,
      name: '김합성',
      birth: '19800101',
    });
    expect(Object.keys(snap.tt_treat_basic[0])).toEqual([
      'trb_id',
      'trb_treat_start_date',
      'trb_hospital_name',
      'trb_department',
      'trb_treat_type',
      'trb_disease_code',
      'trb_disease_name',
      'trb_visit_days',
      'trb_total_amount',
      'trb_public_charge',
      'trb_deductible_amt',
      'trb_hospital_code',
      'trb_etc',
      'trb_created_at',
    ]);
    expect(Object.keys(snap.tt_treat_detail[0])).toEqual([
      'trd_id',
      'trd_treat_start_date',
      'trd_hospital_name',
      'trd_treat_type',
      'trd_code_name',
      'trd_one_dose',
      'trd_daily_doses_number',
      'trd_total_dosing_days',
    ]);
    expect(Object.keys(snap.tt_treat_prescribe[0])).toEqual([
      'trp_id',
      'trp_treat_start_date',
      'trp_hospital_name',
      'trp_treat_type',
      'trp_drug_name',
      'trp_ingredients',
      'trp_one_dose',
      'trp_daily_doses_number',
      'trp_total_dosing_days',
    ]);
    expect(Object.keys(snap.tn_analysis_basic[0])).toEqual([
      'asb_id',
      'asb_treat_start_date',
      'asb_hospital_name',
      'asb_treat_type',
      'asb_disease_code',
      'asb_disease_name',
      'asb_in_disease',
      'asb_sicked',
      'asb_dosing_days',
    ]);
    expect(Object.keys(snap.tn_analysis_detail[0])).toEqual([
      'asd_id',
      'asb_id',
      'asd_treat_start_date',
      'asd_hospital_name',
      'asd_code_name',
      'asd_operation',
      'asd_examination',
      'asd_in_operation',
    ]);
    //* 심평원 출처가 있으면 이름 변경은 400, 떼면 고객 정보는 남기고 USER
    const rename = await api('PATCH', `/jobs/${job.jobId}`, {
      body: { customerName: '이합성' },
    });
    expect([rename.status, rename.body.code]).toEqual([400, 'CR_INVALID']);
    const detached = await api('DELETE', `/jobs/${job.jobId}/sources/hira`);
    expect(detached.body.customer).toMatchObject({
      name: '김합성',
      source: 'USER',
    });
    expect(detached.body.sources.hiraDb).toBeNull();
    expect(
      existsSync(join(root, 'jobs', job.jobId, 'sources', 'hira-db.json')),
    ).toBe(false);
  });

  it('진료정보분석 건으로 만든 작업은 같은 고객의 국세청 건을 스스로 붙인다 — 표가 없거나 다른 고객 건이면 없이 만든다', async () => {
    const job = await newJob({ hiraOddId: 9001 });
    expect(job.sources.nts).toMatchObject({ lsaId: 31, usrId: 9001 });
    expect(
      existsSync(join(root, 'jobs', job.jobId, 'sources', 'nts-db.json')),
    ).toBe(true);
    db.ntsPresent = false;
    expect((await newJob({ hiraOddId: 9001 })).sources.nts).toBeNull();
    db.ntsPresent = true;
    NTS[0].usr_id = 9009;
    try {
      expect((await newJob({ hiraOddId: 9001 })).sources.nts).toBeNull();
    } finally {
      NTS[0].usr_id = 9001;
    }
    expect((await newJob()).sources.nts).toBeNull(); // 이름으로 만든 작업은 고객을 몰라 붙이지 않는다
  });

  it('작업을 만든 뒤 끝난 국세청 건은 분석을 시작할 때 붙인다', async () => {
    db.ntsPresent = false;
    const job = await newJob({ hiraOddId: 9001 });
    expect(job.sources.nts).toBeNull();
    db.ntsPresent = true;
    const res = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(res.status).toBe(201);
    expect((await getJob(job.jobId)).sources.nts).toMatchObject({ lsaId: 31 });
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v1.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.nts_snapshot_path).toBe(
      join(root, 'jobs', job.jobId, 'results', 'v1.inputs', 'nts-db.json'),
    );
  });

  it('작업 목록 항목에 진료정보분석 건 번호와 마지막 결과 시각을 싣는다(없으면 null)', async () => {
    const byHira = await newJob({ hiraOddId: 9001 });
    const byName = await newJob();
    await api('PUT', `/jobs/${byName.jobId}/manual-payments`, {
      body: {
        items: [{ insurer: '삼성화재', start: '2025-02-19', amount: 30000 }],
      },
    });
    await api('POST', `/jobs/${byName.jobId}/analyze`, { body: {} });
    await api('POST', `/jobs/${byName.jobId}/analyze`, { body: {} });
    const results = (await api('GET', `/jobs/${byName.jobId}/results`)).body
      .items;
    expect(results.map((r: any) => r.version)).toEqual([1, 2]);
    const items = (await api('GET', '/jobs')).body.items;
    const of = (id: string) => items.find((i: any) => i.jobId === id);
    expect([of(byHira.jobId).hiraOddId, of(byName.jobId).hiraOddId]).toEqual([
      9001,
      null,
    ]);
    expect([
      of(byHira.jobId).latestResultAt,
      of(byName.jobId).latestResultAt,
    ]).toEqual([null, results[1].createdAt]);
  });

  it('국세청은 테이블이 있으면 목록·스냅샷, 없으면 TABLE_ABSENT · 409 CR_NTS_UNAVAILABLE', async () => {
    const list = await api('GET', '/sources/nts?usrId=9001');
    expect(list.body).toMatchObject({ available: true, reason: null });
    expect(list.body.items[0]).toMatchObject({
      lsaId: 31,
      years: [2025],
      totalRows: 2,
    });
    const job = await newJob();
    const put = await api('PUT', `/jobs/${job.jobId}/sources/nts`, {
      body: { lsaId: 31 },
    });
    expect(put.body.sources.nts).toMatchObject({ lsaId: 31, usrId: 9001 });
    const snap = JSON.parse(
      readFileSync(
        join(root, 'jobs', job.jobId, 'sources', 'nts-db.json'),
        'utf8',
      ),
    );
    expect(snap).toMatchObject({
      schema: 'case-report.nts-db-snapshot/1',
      lsa_id: 31,
      lsa_result: { startYear: 2021 },
    });

    db.ntsPresent = false;
    expect((await api('GET', '/sources/nts')).body).toEqual({
      available: false,
      reason: 'TABLE_ABSENT',
      items: [],
    });
    const absent = await api('PUT', `/jobs/${job.jobId}/sources/nts`, {
      body: { lsaId: 31 },
    });
    expect([absent.status, absent.body.code]).toEqual([
      409,
      'CR_NTS_UNAVAILABLE',
    ]);
    expect((await getJob(job.jobId)).sources.ntsFeature).toEqual({
      tablePresent: false,
    });
  });
});

describe('소유자 확인 · 경로 검사', () => {
  it('다른 회원의 작업은 모든 경로에서 없는 것과 같게 404 CR_NOT_FOUND', async () => {
    const job = await newJob();
    const id = job.jobId;
    const calls: [string, string, Record<string, unknown>?][] = [
      ['GET', `/jobs/${id}`],
      ['PATCH', `/jobs/${id}`, { title: 'x' }],
      ['PUT', `/jobs/${id}/manual-payments`, { items: [] }],
      ['POST', `/jobs/${id}/analyze`, {}],
      ['GET', `/jobs/${id}/results`],
      ['POST', `/jobs/${id}/links`, {}],
      ['DELETE', `/jobs/${id}`],
    ];
    for (const [method, path, body] of calls) {
      const res = await api(method, path, { token: THEIRS, body });
      expect([method, path, res.status, res.body.code]).toEqual([
        method,
        path,
        404,
        'CR_NOT_FOUND',
      ]);
    }
    const up = await api('POST', `/jobs/${id}/files`, {
      token: THEIRS,
      form: form([['a.pdf', pdf()]]),
    });
    expect([up.status, up.body.code]).toEqual([404, 'CR_NOT_FOUND']);
    const theirList = await api('GET', '/jobs', { token: THEIRS });
    expect(theirList.body.items.some((i: any) => i.jobId === id)).toBe(false);
    expect(existsSync(jobJsonPath(id))).toBe(true);
  });

  it('잘못된 UUID · 경로 이탈은 404', async () => {
    const job = await newJob();
    for (const path of [
      '/jobs/not-a-uuid',
      `/jobs/${job.jobId}/files/..%2F..%2Fjob.json/extraction`,
      `/jobs/${job.jobId}/results/abc`,
    ]) {
      const res = await api('GET', path);
      expect([
        path,
        res.status >= 400 && res.status < 500,
        res.body.code?.startsWith('CR_'),
      ]).toEqual([path, true, true]);
    }
  });
});

describe('업로드 · 판독', () => {
  it('PDF 아님·중복은 파일별로 거절하고 나머지는 받는다, 한글 파일 이름을 되살린다', async () => {
    const job = await newJob();
    const same = pdf('same');
    const res = await upload(job.jobId, [
      ['가입내역.pdf', same],
      ['note.pdf', Buffer.from('plain text, not a pdf')],
      ['다시.pdf', same],
    ]);
    expect(res.rejected.map((r: any) => r.code)).toEqual([
      'CR_NOT_PDF',
      'CR_DUPLICATE_FILE',
    ]);
    expect(res.job.files.map((f: any) => f.name)).toEqual(['가입내역.pdf']);
    await service.queue.drain();
    const again = await upload(job.jobId, [['또.pdf', same]]);
    expect(again.rejected.map((r: any) => r.code)).toEqual([
      'CR_DUPLICATE_FILE',
    ]);
  });

  it('크기 초과는 요청 전체 413 CR_TOO_LARGE, 칸 이름 files[] 는 400 CR_INVALID, 11개는 400 CR_LIMIT_FILES', async () => {
    const job = await newJob();
    const big = Buffer.concat([pdf(), Buffer.alloc(1.2 * 1024 * 1024)]);
    const tooBig = await api('POST', `/jobs/${job.jobId}/files`, {
      form: form([['big.pdf', big]]),
    });
    expect([tooBig.status, tooBig.body.code]).toEqual([413, 'CR_TOO_LARGE']);
    const bracket = await api('POST', `/jobs/${job.jobId}/files`, {
      form: form([['a.pdf', pdf()]], {}, 'files[]'),
    });
    expect([bracket.status, bracket.body.code]).toEqual([400, 'CR_INVALID']);
    const many = await api('POST', `/jobs/${job.jobId}/files`, {
      form: form(
        Array.from(
          { length: 11 },
          (_, i) => [`${i}.pdf`, pdf()] as [string, Buffer],
        ),
      ),
    });
    expect([many.status, many.body.code]).toEqual([400, 'CR_LIMIT_FILES']);
    expect((await getJob(job.jobId)).files).toEqual([]);
  });

  it('대기 → 판독 완료: 요약은 봉투 summary·timing 에서, 승격 뒤 work 폴더는 빈다', async () => {
    const job = await newJob();
    const up = await upload(job.jobId, [['가입내역.pdf', pdf()]], {
      readMode: 'auto',
      ocrDpi: '200',
    });
    expect(up.job.files[0]).toMatchObject({
      status: 'QUEUED',
      readMode: 'auto',
      ocrDpi: 200,
      uploadedBy: 'USER',
      attempt: 1,
    });
    expect(up.job.derived.processing).toBe(true);
    await service.queue.drain();
    const done = await getJob(job.jobId);
    const f = done.files[0];
    expect(f.status).toBe('EXTRACTED');
    expect(f.extraction).toMatchObject({
      kind: 'CREDIT_INFO',
      status: 'OK',
      timing: { textPages: 2, ocrPages: 1 },
    });
    expect(f.editCount).toBe(0);
    expect(f).not.toHaveProperty('edits');
    expect(f).not.toHaveProperty('passwordPending');
    expect(done).not.toHaveProperty('memId');
    expect(done.derived).toMatchObject({
      processing: false,
      canAnalyze: true,
      cannotAnalyzeReason: null,
    });
    const dir = join(root, 'jobs', job.jobId);
    expect(existsSync(join(dir, 'extracted', `${f.fileId}.json`))).toBe(true);
    expect(
      existsSync(join(dir, 'extracted', `${f.fileId}.effective.json`)),
    ).toBe(true);
    expect(readdirSync(join(dir, 'work'))).toEqual([]);
    const ext = await api(
      'GET',
      `/jobs/${job.jobId}/files/${f.fileId}/extraction`,
    );
    expect(ext.body.extraction.schema).toBe('case-report.extract/1');
    expect(ext.body.edits).toEqual([]);
  });

  it('약관 종류 힌트와 약관 쪽 수 한도를 워커에 넘긴다(2차)', async () => {
    const copy = join(root, 'extract-input-copy.json');
    fake({ FAKE_INPUT_COPY: copy });
    const job = await newJob();
    await upload(job.jobId, [['약관.pdf', pdf()]], { kindHint: 'POLICY' });
    await service.queue.drain();
    const inp = JSON.parse(readFileSync(copy, 'utf8'));
    expect([inp.kind_hint, inp.limits]).toEqual([
      'POLICY',
      { max_pages: 60, max_pages_policy: 2500, page_timeout_s: 120 },
    ]);
    expect(inp.llm).toEqual({ mode: 'off' });
    expect((await getJob(job.jobId)).files[0].extraction.kind).toBe('POLICY');
  });

  it('보장 내용 종류 힌트(COVERAGE)를 올리기·다시 읽기에서 받아 워커에 넘긴다(15차 D4)', async () => {
    const copy = join(root, 'extract-input-copy-coverage.json');
    fake({ FAKE_INPUT_COPY: copy });
    const job = await newJob();
    const up = await upload(job.jobId, [['보장.pdf', pdf()]], {
      kindHint: 'COVERAGE',
    });
    await service.queue.drain();
    expect(JSON.parse(readFileSync(copy, 'utf8')).kind_hint).toBe('COVERAGE');
    const fileId = up.job.files[0].fileId;
    const again = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/extract`,
      { body: { kindHint: 'COVERAGE' } },
    );
    expect(again.status).toBe(201);
    expect(again.body.files[0].kindHint).toBe('COVERAGE');
    await service.queue.drain();
    const bad = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/extract`,
      { body: { kindHint: 'NOT_A_KIND' } },
    );
    expect([bad.status, bad.body.code]).toEqual([400, 'CR_INVALID']);
  });

  it('AI 읽기 응답 캐시는 공유 OCR 캐시가 있어도 그 파일 폴더에 두고, 파일을 지우면 함께 지운다(r9 교차 검토 P1)', async () => {
    const copy = join(root, 'extract-input-copy-llm.json');
    const shared = join(root, 'shared-ocr-cache');
    fake({ FAKE_INPUT_COPY: copy });
    await withEnv(
      { CASE_REPORT_LLM_MODE: 'intake', CASE_REPORT_OCR_CACHE_DIR: shared },
      async () => {
        const job = await newJob();
        const up = await upload(job.jobId, [['보장.pdf', pdf()]]);
        const fileId = up.job.files[0].fileId;
        await service.queue.drain();
        const inp = JSON.parse(readFileSync(copy, 'utf8'));
        expect(inp.llm).toEqual({
          mode: 'intake',
          customer_name: '김합성',
          file_usd: 0.5,
          daily_usd: 10,
          ledger_dir: join(root, 'llm-ledger'),
          cache_dir: join(root, 'jobs', job.jobId, 'cache', fileId, 'llm'),
          forms_dir: join(root, 'llm-forms'),
        });
        mkdirSync(inp.llm.cache_dir, { recursive: true });
        writeFileSync(join(inp.llm.cache_dir, 'answer.json'), '{}');
        const del = await api('DELETE', `/jobs/${job.jobId}/files/${fileId}`);
        expect(del.status).toBe(200);
        expect(existsSync(join(root, 'jobs', job.jobId, 'cache', fileId))).toBe(
          false,
        );
        expect(existsSync(join(shared, 'llm'))).toBe(false);
      },
    );
  });

  it('양식 표본 자리는 작업 밖이라 작업을 지워도 남고, 원본 지우기 작업에는 주지 않는다', async () => {
    const copy = join(root, 'extract-input-copy-forms.json');
    fake({ FAKE_INPUT_COPY: copy });
    await withEnv({ CASE_REPORT_LLM_MODE: 'intake' }, async () => {
      const job = await newJob();
      await upload(job.jobId, [['보장.pdf', pdf()]]);
      await service.queue.drain();
      const forms = JSON.parse(readFileSync(copy, 'utf8')).llm.forms_dir;
      mkdirSync(forms, { recursive: true });
      writeFileSync(join(forms, 'sample.json'), '{}'); // 워커가 남긴 표본 대신
      expect((await api('DELETE', `/jobs/${job.jobId}`)).status).toBe(200);
      expect(existsSync(join(forms, 'sample.json'))).toBe(true);

      const purge = await newJob();
      await api('PATCH', `/jobs/${purge.jobId}`, {
        body: { deleteOriginalAfterExtract: true },
      });
      await upload(purge.jobId, [['보장.pdf', pdf()]]);
      await service.queue.drain();
      const llm = JSON.parse(readFileSync(copy, 'utf8')).llm;
      expect([llm.mode, 'forms_dir' in llm]).toEqual(['intake', false]);
    });
    fake({ FAKE_INPUT_COPY: copy });
    await withEnv({ CASE_REPORT_LLM_MODE: 'policy' }, async () => {
      const job = await newJob(); // 약관 AI(policy)는 더 받지 않는다 — 끈 것과 같다
      await upload(job.jobId, [['약관.pdf', pdf()]]);
      await service.queue.drain();
      expect(JSON.parse(readFileSync(copy, 'utf8')).llm).toEqual({
        mode: 'off',
      });
    });
  });

  it('판독 중에 원본 지우기를 켜면 그 파일의 양식 표본을 지운다(r14 Codex 9)', async () => {
    fake({ FAKE_SLEEP_MS: '600' });
    await withEnv({ CASE_REPORT_LLM_MODE: 'intake' }, async () => {
      const job = await newJob();
      await upload(job.jobId, [['보장.pdf', pdf()]]);
      await new Promise((r) => setTimeout(r, 200)); // 워커는 「지우기 꺼짐」으로 시작했다
      expect((await getJob(job.jobId)).files[0].status).toBe('EXTRACTING');
      const forms = join(root, 'llm-forms');
      const sample = join(forms, `${'0'.repeat(16)}.json`); // 가짜 워커 봉투의 sha256 앞 16자
      const other = join(forms, `${'1'.repeat(16)}.json`);
      mkdirSync(forms, { recursive: true });
      writeFileSync(sample, '{}');
      writeFileSync(other, '{}');
      await api('PATCH', `/jobs/${job.jobId}`, {
        body: { deleteOriginalAfterExtract: true },
      });
      await service.queue.drain();
      expect([existsSync(sample), existsSync(other)]).toEqual([false, true]);
    });
  });

  it.each([
    [
      '업무 NEEDS_PASSWORD',
      { FAKE_MODE: 'NEEDS_PASSWORD' },
      'NEEDS_PASSWORD',
      'PDF_PASSWORD_REQUIRED',
    ],
    ['업무 FAILED', { FAKE_MODE: 'FAILED' }, 'FAILED', 'PARSE_ERROR'],
    ['업무 UNSUPPORTED', { FAKE_MODE: 'UNSUPPORTED' }, 'UNSUPPORTED', null],
    ['업무 PARTIAL', { FAKE_MODE: 'PARTIAL' }, 'PARTIAL', null],
    ['종료 코드 2', { FAKE_EXIT: '2' }, 'FAILED', 'WORKER_INPUT_INVALID'],
    ['종료 코드 3', { FAKE_EXIT: '3' }, 'FAILED', 'INTERNAL'],
    ['종료 코드 9', { FAKE_EXIT: '9' }, 'FAILED', 'WORKER_CRASHED'],
    ['출력 없음', { FAKE_EXIT: 'NO_OUT' }, 'FAILED', 'WORKER_BAD_OUTPUT'],
    ['깨진 JSON', { FAKE_EXIT: 'BAD_JSON' }, 'FAILED', 'WORKER_BAD_OUTPUT'],
    ['schema 다름', { FAKE_EXIT: 'BAD_SCHEMA' }, 'FAILED', 'WORKER_BAD_OUTPUT'],
  ])('파일 상태 대응: %s → %s', async (_name, env, status, code) => {
    fake(env);
    const job = await newJob();
    await upload(job.jobId, [['x.pdf', pdf()]]);
    await service.queue.drain();
    const f = (await getJob(job.jobId)).files[0];
    expect([f.status, f.error?.code ?? null]).toEqual([status, code]);
    expect(readdirSync(join(root, 'jobs', job.jobId, 'work'))).toEqual([]);
  });

  it('시간 초과면 FAILED/TIMEOUT 이고 work 폴더가 남지 않는다', async () => {
    fake({ FAKE_EXIT: 'SLEEP' });
    const job = await newJob();
    await withEnv({ CASE_REPORT_EXTRACT_TIMEOUT_MS: '700' }, async () => {
      await upload(job.jobId, [['x.pdf', pdf()]]);
      await service.queue.drain();
    });
    const f = (await getJob(job.jobId)).files[0];
    expect([f.status, f.error.code]).toEqual(['FAILED', 'TIMEOUT']);
    //* 판독 결과 없이 끝난 시도는 실패 시각을 남겨 화면이 결과 뒤 실패를 알아챈다
    expect(Number.isNaN(Date.parse(f.error.at))).toBe(false);
    expect(readdirSync(join(root, 'jobs', job.jobId, 'work'))).toEqual([]);
  });

  it('판독 처리 중 예외가 나면 「읽는 중」에 머물지 않고 FAILED/INTERNAL', async () => {
    const job = await newJob();
    const spy = jest
      .spyOn((service as any).store, 'makeTaskDir')
      .mockRejectedValueOnce(new Error('합성 디스크 오류'));
    try {
      await upload(job.jobId, [['x.pdf', pdf()]]);
      await service.queue.drain();
    } finally {
      spy.mockRestore();
    }
    const f = (await getJob(job.jobId)).files[0];
    expect([f.status, f.error.code]).toEqual(['FAILED', 'INTERNAL']);
  });

  it('대기열은 동시 1개 · 대기 순번 1·2 · 순서대로', async () => {
    const callLog = join(root, 'calls.log');
    fake({ FAKE_SLEEP_MS: '200', FAKE_CALL_LOG: callLog });
    const job = await newJob();
    const up = await upload(job.jobId, [
      ['a.pdf', pdf()],
      ['b.pdf', pdf()],
      ['c.pdf', pdf()],
    ]);
    const ids = up.job.files.map((f: any) => f.fileId);
    expect(up.job.files.map((f: any) => f.queuePosition)).toEqual([null, 1, 2]);
    await service.queue.drain();
    const lines = readFileSync(callLog, 'utf8').trim().split('\n');
    expect(lines).toEqual(
      ids.flatMap((id: string) => [`start extract ${id}`, `end extract ${id}`]),
    );
  });

  it('판독 중 파일마다 결정형 진행(완료 쪽 / 전체 쪽 · 서버 시각)을 싣고 끝나면 지운다', async () => {
    fake({ FAKE_SLEEP_MS: '600' });
    const job = await newJob();
    const up = await upload(job.jobId, [['a.pdf', pdf()]]);
    let f: any;
    for (let i = 0; i < 40 && !f?.progress; i++) {
      await new Promise((r) => setTimeout(r, 50));
      f = (await getJob(job.jobId)).files[0];
    }
    expect(f.status).toBe('EXTRACTING');
    expect(f.progress).toEqual({
      stage: 'OCR',
      done: 3,
      total: 3,
      startedAt: expect.any(String),
      updatedAt: expect.any(String),
      //* 같은 단계에서 완료 쪽이 늘 때 잰 한 쪽 처리 시간(가짜 워커는 줄을 한 번에 내 0 에 가깝다)
      paceMs: expect.any(Number),
    });
    expect(Date.parse(f.progress.updatedAt)).toBeGreaterThanOrEqual(
      Date.parse(f.progress.startedAt),
    );
    expect(Date.parse(f.progress.startedAt)).toBeGreaterThanOrEqual(
      Date.parse(up.job.createdAt),
    );
    await service.queue.drain();
    expect((await getJob(job.jobId)).files[0].progress).toBeNull();
  });

  it('한 쪽 시간은 최근 쪽에 30% 무게를 두어 고른다 — 쪽마다 들쭉날쭉해도 남은 시간이 널뛰지 않게', () => {
    const pace = (service as any).pace as Map<string, number>;
    const before = new Map(pace);
    pace.clear();
    (service as any).recordPace('OCR', 10_000);
    (service as any).recordPace('OCR', 0);
    expect(pace.get('OCR')).toBe(7_000);
    pace.clear();
    for (const [k, v] of before) pace.set(k, v);
  });

  it('끝난 상태를 쓰기 전 조회에도 진행이 남는다 — 완료 직전에 0% 로 돌아가지 않게(Codex 2)', async () => {
    fake({});
    const job = await newJob();
    let release!: () => void;
    const gate = new Promise<void>((r) => (release = r));
    let entered!: () => void;
    const inside = new Promise<void>((r) => (entered = r));
    const real = (service as any).applyExtract.bind(service);
    const spy = jest
      .spyOn(service as any, 'applyExtract')
      .mockImplementation(async (...args: any[]) => {
        entered();
        await gate;
        return real(...args);
      });
    await upload(job.jobId, [['a.pdf', pdf()]]);
    await inside;
    const mid = (await getJob(job.jobId)).files[0];
    release();
    await service.queue.drain();
    spy.mockRestore();
    expect(mid.status).toBe('EXTRACTING');
    expect(mid.progress).toMatchObject({ stage: 'OCR', done: 3, total: 3 });
    const end = (await getJob(job.jobId)).files[0];
    expect(end.status).not.toBe('EXTRACTING');
    expect(end.progress).toBeNull();
  });

  it('비밀번호: 표준입력으로만 가고, 틀리면 NEEDS_PASSWORD(INCORRECT), 다시 넣으면 판독, 어디에도 남지 않는다', async () => {
    fake({ FAKE_EXPECT_PASSWORD: PASSWORD });
    const job = await newJob();
    const up = await upload(job.jobId, [['심평원.pdf', pdf()]], {
      password: WRONG_PASSWORD,
    });
    const fileId = up.job.files[0].fileId;
    await service.queue.drain();
    let f = (await getJob(job.jobId)).files[0];
    expect([f.status, f.error.code]).toEqual([
      'NEEDS_PASSWORD',
      'PDF_PASSWORD_INCORRECT',
    ]);
    const retry = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/password`,
      { body: { password: PASSWORD } },
    );
    expect(retry.body.files[0]).toMatchObject({ status: 'QUEUED', attempt: 2 });
    await service.queue.drain();
    f = (await getJob(job.jobId)).files[0];
    expect(f.status).toBe('EXTRACTED');
    const notNeeded = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/password`,
      { body: { password: PASSWORD } },
    );
    expect([notNeeded.status, notNeeded.body.code]).toEqual([
      400,
      'CR_INVALID',
    ]);
    for (const file of allFiles(root)) {
      const text =
        readFileSync(file).toString('latin1') + readFileSync(file, 'utf8');
      expect([
        file,
        text.includes(PASSWORD) || text.includes(WRONG_PASSWORD),
      ]).toEqual([file, false]);
    }
  });

  it('원본 삭제 옵션: 판독 뒤 원본이 없고 원문 보기·다시 판독은 410', async () => {
    const job = await newJob();
    const plainUp = await upload(job.jobId, [['원본.pdf', pdf()]]);
    await service.queue.drain();
    const content = await api(
      'GET',
      `/jobs/${job.jobId}/files/${plainUp.job.files[0].fileId}/content`,
    );
    expect(content.status).toBe(200);
    expect(content.headers.get('content-type')).toContain('application/pdf');
    expect(content.headers.get('cache-control')).toBe('no-store');

    await api('PATCH', `/jobs/${job.jobId}`, {
      body: { deleteOriginalAfterExtract: true },
    });
    const up = await upload(job.jobId, [['지움.pdf', pdf()]]);
    const fileId = up.job.files[1].fileId;
    await service.queue.drain();
    const f = (await getJob(job.jobId)).files[1];
    expect([f.status, f.originalAvailable]).toEqual(['EXTRACTED', false]);
    expect(
      existsSync(join(root, 'jobs', job.jobId, 'files', `${fileId}.pdf`)),
    ).toBe(false);
    expect(existsSync(join(root, 'jobs', job.jobId, 'cache', fileId))).toBe(
      false,
    );
    const gone = await api('GET', `/jobs/${job.jobId}/files/${fileId}/content`);
    expect([gone.status, gone.body.code]).toEqual([410, 'CR_ORIGINAL_DELETED']);
    const reread = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/extract`,
      { body: {} },
    );
    expect([reread.status, reread.body.code]).toEqual([
      410,
      'CR_ORIGINAL_DELETED',
    ]);
  });

  it('다시 판독은 attempt 를 올리고 수정 목록을 비운다, 판독 중이면 409', async () => {
    const job = await newJob();
    const up = await upload(job.jobId, [['a.pdf', pdf()]]);
    const fileId = up.job.files[0].fileId;
    const running = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/extract`,
      { body: {} },
    );
    expect([running.status, running.body.code]).toEqual([
      409,
      'CR_EXTRACT_RUNNING',
    ]);
    await service.queue.drain();
    await api('PUT', `/jobs/${job.jobId}/files/${fileId}/edits`, {
      body: {
        edits: [{ op: 'replace', path: '/data/subject/name', value: '김합성' }],
      },
    });
    const again = await api(
      'POST',
      `/jobs/${job.jobId}/files/${fileId}/extract`,
      {
        body: { readerHint: 'GENERIC', ocrDpi: 300 },
      },
    );
    expect(again.body.files[0]).toMatchObject({
      status: 'QUEUED',
      attempt: 2,
      editCount: 0,
      readerHint: 'GENERIC',
    });
    await service.queue.drain();
    expect((await getJob(job.jobId)).files[0]).toMatchObject({
      status: 'PARTIAL',
      extraction: { reader: 'GENERIC' },
    });
  });
});

describe('편집(revalidate)', () => {
  async function extractedJob() {
    const job = await newJob();
    const up = await upload(job.jobId, [['가입내역.pdf', pdf()]]);
    await service.queue.drain();
    return { jobId: job.jobId, fileId: up.job.files[0].fileId };
  }

  it('경로 모양이 틀리면 400 CR_EDIT_INVALID + editIndex, 주민번호 꼴 값은 400 CR_INVALID', async () => {
    const { jobId, fileId } = await extractedJob();
    const bad = await api('PUT', `/jobs/${jobId}/files/${fileId}/edits`, {
      body: {
        edits: [
          { op: 'replace', path: '/data/subject/name', value: '김합성' },
          { op: 'replace', path: '/data/contracts/0/insurer', value: 'x' },
        ],
      },
    });
    expect([bad.status, bad.body.code, bad.body.detail.editIndex]).toEqual([
      400,
      'CR_EDIT_INVALID',
      1,
    ]);
    const rrn = await api('PUT', `/jobs/${jobId}/files/${fileId}/edits`, {
      body: {
        edits: [
          {
            op: 'replace',
            path: '/data/subject/name',
            value: '800101-1234567',
          },
        ],
      },
    });
    expect([rrn.status, rrn.body.code]).toEqual([400, 'CR_INVALID']);
  });

  it('워커 EDIT_INVALID 는 400 이고 편집 목록·적용본이 그대로다', async () => {
    const { jobId, fileId } = await extractedJob();
    const effectivePath = join(
      root,
      'jobs',
      jobId,
      'extracted',
      `${fileId}.effective.json`,
    );
    const before = sha(effectivePath);
    fake({ FAKE_MODE: 'EDIT_INVALID' });
    const res = await api('PUT', `/jobs/${jobId}/files/${fileId}/edits`, {
      body: {
        edits: [
          { op: 'replace', path: '/data/subject/birth', value: '1980/01' },
        ],
      },
    });
    expect([
      res.status,
      res.body.code,
      res.body.detail.editIndex,
      res.body.detail.reason,
    ]).toEqual([400, 'CR_EDIT_INVALID', 0, 'INVALID_VALUE']);
    expect(sha(effectivePath)).toBe(before);
    expect(
      (await api('GET', `/jobs/${jobId}/files/${fileId}/extraction`)).body
        .edits,
    ).toEqual([]);
    expect((await getJob(jobId)).files[0].editCount).toBe(0);
  });

  it('성공하면 적용본을 승격하고 파일 요약이 적용본 summary 로 바뀐다', async () => {
    const { jobId, fileId } = await extractedJob();
    const before = (await getJob(jobId)).files[0].extraction;
    const res = await api('PUT', `/jobs/${jobId}/files/${fileId}/edits`, {
      body: {
        edits: [{ op: 'replace', path: '/data/subject/name', value: '김합성' }],
      },
    });
    expect(res.status).toBe(200);
    expect(res.body.file.extraction.reviewCount).toBe(before.reviewCount - 1);
    expect(res.body.file.extraction.revalidatedAt).toEqual(expect.any(String));
    expect(res.body.file.extraction.extractedAt).toBe(before.extractedAt);
    expect(res.body.file.editCount).toBe(1);
    expect(res.body.edits[0]).toMatchObject({
      op: 'replace',
      path: '/data/subject/name',
      value: '김합성',
      at: expect.any(String),
    });
    const ext = (await api('GET', `/jobs/${jobId}/files/${fileId}/extraction`))
      .body;
    expect(ext.effective.review_items.length).toBe(
      ext.extraction.review_items.length - 1,
    );
  });

  it('판독 중에는 409 CR_EXTRACT_RUNNING', async () => {
    fake({ FAKE_SLEEP_MS: '600' });
    const job = await newJob();
    const up = await upload(job.jobId, [['a.pdf', pdf()]]);
    const res = await api(
      'PUT',
      `/jobs/${job.jobId}/files/${up.job.files[0].fileId}/edits`,
      {
        body: { edits: [] },
      },
    );
    expect([res.status, res.body.code]).toEqual([409, 'CR_EXTRACT_RUNNING']);
    await service.queue.drain();
  });
});

describe('분석 · 판본', () => {
  const manual = {
    items: [
      {
        insurer: '삼성화재',
        care: 'OUTPATIENT',
        kind: 'SILSON',
        hospital: '합성의원',
        start: '2025-02-19',
        amount: 30000,
      },
    ],
  };

  it('자료가 없으면 400 CR_NOTHING_TO_ANALYZE, 수기 입력만으로 1회차 → 2회차(직전 판본 비교)', async () => {
    const job = await newJob();
    expect((await getJob(job.jobId)).derived).toMatchObject({
      canAnalyze: false,
      cannotAnalyzeReason: 'NOTHING_TO_ANALYZE',
    });
    const none = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect([none.status, none.body.code]).toEqual([
      400,
      'CR_NOTHING_TO_ANALYZE',
    ]);
    const saved = await api('PUT', `/jobs/${job.jobId}/manual-payments`, {
      body: manual,
    });
    expect(saved.body.manualPayments[0].id).toMatch(/^m[0-9a-f]{8}$/);

    const v1 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v1.body).toMatchObject({
      version: 1,
      round: 1,
      status: 'OK',
      blocked: null,
    });
    expect(Object.keys(v1.body.counts)).toEqual(
      expect.arrayContaining([
        'PAID',
        'NOT_PAID_CANDIDATE',
        'NO_DATA_CANDIDATE',
      ]),
    );
    const v2 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v2.body).toMatchObject({ version: 2, round: 2 });
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v2.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.previous_result_path).toBe(
      join(root, 'jobs', job.jobId, 'results', 'v1.json'),
    );
    expect(input.manual_payments[0]).toMatchObject({
      insurer: '삼성화재',
      start: '2025-02-19',
    });
    expect(input.options).toMatchObject({
      hira_source: 'AUTO',
      unlinked_block_days: 30,
      identity_policy: 'BLOCK',
    });
    expect(input.ref_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);

    const list = await api('GET', `/jobs/${job.jobId}/results`);
    expect(
      list.body.items.map((r: any) => [r.version, r.round, r.status]),
    ).toEqual([
      [1, 1, 'OK'],
      [2, 2, 'OK'],
    ]);
    const latest = await api('GET', `/jobs/${job.jobId}/results/latest`);
    expect([latest.body.schema, latest.body.version]).toEqual([
      'case-report.result/1',
      2,
    ]);
    expect((await api('GET', `/jobs/${job.jobId}/results/9`)).status).toBe(404);
  });

  it('판독이 끝나지 않은 파일(실패 포함)은 상태와 함께 pending_documents 로 넘긴다', async () => {
    fake({ FAKE_EXIT: 'SLEEP' });
    const job = await newJob();
    await withEnv({ CASE_REPORT_EXTRACT_TIMEOUT_MS: '700' }, async () => {
      await upload(job.jobId, [['x.pdf', pdf()]]);
      await service.queue.drain();
    });
    fake({});
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, { body: manual });
    const v1 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v1.status).toBe(201);
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v1.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.documents).toEqual([]);
    expect(input.pending_documents).toEqual([
      { file_id: expect.any(String), name: 'x.pdf', status: 'FAILED' },
    ]);
  });

  it('워커 FAILED 면 502 CR_WORKER_FAILED 이고 판본 번호를 반납한다(입력 폴더 없음)', async () => {
    const job = await newJob();
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, { body: manual });
    fake({ FAKE_MODE: 'FAILED' });
    const failed = await api('POST', `/jobs/${job.jobId}/analyze`, {
      body: {},
    });
    expect([
      failed.status,
      failed.body.code,
      failed.body.detail.workerCode,
    ]).toEqual([502, 'CR_WORKER_FAILED', 'INPUT_INVALID']);
    expect(readdirSync(join(root, 'jobs', job.jobId, 'results'))).toEqual([]);
    fake({ FAKE_EXIT: '9' });
    expect(
      (await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} })).body
        .detail.workerCode,
    ).toBe('WORKER_CRASHED');
    fake({});
    expect(
      (await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} })).body
        .version,
    ).toBe(1);
  });

  it('대상자 중단(BLOCKED)도 판본으로 남고 건수는 0, 다음 비교 기준에서는 빠진다', async () => {
    const job = await newJob();
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, { body: manual });
    fake({ FAKE_MODE: 'BLOCKED' });
    const res = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(res.body).toMatchObject({
      version: 1,
      status: 'BLOCKED',
      blocked: { code: 'IDENTITY_MISMATCH' },
    });
    expect(Object.values(res.body.counts).every((n) => n === 0)).toBe(true);
    fake({});
    await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v2.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.previous_result_path).toBeNull();
  });

  it('진료정보분석 건이 붙은 작업은 올린 심평원 · 국세청 PDF 를 분석 입력에 넣지 않는다(이름 작업은 넣는다)', async () => {
    const docsOf = async (job: any) => {
      const up = await upload(job.jobId, [['심평원.pdf', pdf()]], {
        kindHint: 'HIRA_BASIC',
      });
      await upload(job.jobId, [['국세청.pdf', pdf()]], { kindHint: 'NTS' });
      await upload(job.jobId, [['가입내역.pdf', pdf()]]);
      await service.queue.drain();
      expect(
        (await getJob(job.jobId)).files.map((f: any) => f.extraction.kind),
      ).toEqual(['HIRA_BASIC', 'NTS', 'CREDIT_INFO']);
      await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
      const input = JSON.parse(
        readFileSync(
          join(
            root,
            'jobs',
            job.jobId,
            'results',
            'v1.inputs',
            'analyze-input.json',
          ),
          'utf8',
        ),
      );
      expect(input.pending_documents).toEqual([]);
      return [up.job.files[0].fileId, input.documents.map((d: any) => d.name)];
    };
    const [, withHira] = await docsOf(await newJob({ hiraOddId: 9001 }));
    expect(withHira).toEqual(['가입내역.pdf']);
    const [, byName] = await docsOf(await newJob());
    expect(byName).toEqual(['심평원.pdf', '국세청.pdf', '가입내역.pdf']);
  });

  it('같은 작업의 분석이 겹치면 409 CR_ANALYZE_RUNNING', async () => {
    const job = await newJob();
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, { body: manual });
    fake({ FAKE_SLEEP_MS: '700' });
    const first = api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    await new Promise((r) => setTimeout(r, 200));
    expect((await getJob(job.jobId)).derived).toMatchObject({
      analyzing: true,
      canAnalyze: false,
      cannotAnalyzeReason: 'ANALYZE_RUNNING',
    });
    const second = await api('POST', `/jobs/${job.jobId}/analyze`, {
      body: {},
    });
    expect([second.status, second.body.code]).toEqual([
      409,
      'CR_ANALYZE_RUNNING',
    ]);
    expect((await first).body.version).toBe(1);
  });

  it('분석 입력은 시작 때 고정된다 — 실행 중 편집·출처 교체가 그 판본에 섞이지 않는다', async () => {
    const job = await newJob({ hiraOddId: 9001 });
    const up = await upload(job.jobId, [['가입내역.pdf', pdf()]]);
    const fileId = up.job.files[0].fileId;
    await service.queue.drain();
    fake({ FAKE_SLEEP_MS: '600' });
    const running = api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    await new Promise((r) => setTimeout(r, 150));
    fake({});
    const edit = await api('PUT', `/jobs/${job.jobId}/files/${fileId}/edits`, {
      body: {
        edits: [{ op: 'replace', path: '/data/subject/name', value: '김합성' }],
      },
    });
    expect(edit.status).toBe(200);
    await api('DELETE', `/jobs/${job.jobId}/sources/hira`);
    //* 가짜 워커는 끝날 때 manifest sha256 을 다시 대조한다 — 섞였으면 FAILED 로 502 가 된다
    expect((await running).body).toMatchObject({ version: 1, status: 'OK' });
    const inputs = join(root, 'jobs', job.jobId, 'results', 'v1.inputs');
    const input = JSON.parse(
      readFileSync(join(inputs, 'analyze-input.json'), 'utf8'),
    );
    for (const m of input.inputs_manifest) expect(sha(m.path)).toBe(m.sha256);
    expect(input.inputs_manifest.map((m: any) => m.role).sort()).toEqual([
      'DOCUMENT',
      'HIRA_DB',
      'NTS_DB', // 같은 고객 국세청 건이 저절로 붙는다(r14)
    ]);
    expect(input.documents[0].edit_count).toBe(0);
    const pinned = sha(join(inputs, `doc-${fileId}.json`));
    expect(pinned).not.toBe(
      sha(
        join(root, 'jobs', job.jobId, 'extracted', `${fileId}.effective.json`),
      ),
    );
  });

  it('수기 확인·병원 별칭·청구 결과 기록을 저장하고 분석 입력에 snake_case 로 넘긴다', async () => {
    const job = await newJob();
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, { body: manual });
    await api('PUT', `/jobs/${job.jobId}/overrides`, {
      body: {
        items: [
          {
            itemKey: 'ep_4be1a0c3d2f1',
            insurer: '롯데손보',
            status: 'NOT_PAID',
            note: '앱 확인',
          },
        ],
      },
    });
    await api('PUT', `/jobs/${job.jobId}/hospital-aliases`, {
      body: {
        items: [{ hiraName: '합성한방병원', statementName: '합성한방' }],
      },
    });
    const out = await api('PUT', `/jobs/${job.jobId}/outcomes`, {
      body: {
        items: [
          {
            itemKey: 'ep_4be1a0c3d2f1',
            insurer: '롯데손보',
            outcome: 'ALREADY_CLAIMED',
            resultVersion: 1,
          },
        ],
      },
    });
    expect(out.body.outcomes[0]).toMatchObject({
      itemKey: 'ep_4be1a0c3d2f1',
      outcome: 'ALREADY_CLAIMED',
      resultVersion: 1,
      note: null,
      at: expect.any(String),
    });
    const badOutcome = await api('PUT', `/jobs/${job.jobId}/outcomes`, {
      body: { items: [{ itemKey: 'x', outcome: 'WON', resultVersion: 1 }] },
    });
    expect(badOutcome.body.code).toBe('CR_INVALID');
    const rrnNote = await api('PUT', `/jobs/${job.jobId}/manual-payments`, {
      body: { items: [{ ...manual.items[0], note: '주민 800101-1234567' }] },
    });
    expect([rrnNote.body.code, rrnNote.body.detail.field]).toEqual([
      'CR_INVALID',
      'items[0].note',
    ]);
    await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v1.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.overrides).toEqual([
      {
        item_key: 'ep_4be1a0c3d2f1',
        insurer: '롯데손보',
        status: 'NOT_PAID',
        note: '앱 확인',
      },
    ]);
    expect(input.hospital_aliases).toEqual([
      { hira_name: '합성한방병원', statement_name: '합성한방' },
    ]);
    const patched = await api('PATCH', `/jobs/${job.jobId}`, {
      body: {
        analysisOptions: {
          unlinked_block_days: 0,
          use_nts: 'PDF',
          estimate_payout: false,
          estimate_silson_coverage: false,
          benchmarks: { dementia_age: 50 },
        },
        refDate: '2026-01-01',
      },
    });
    expect(patched.body.options).toMatchObject({
      refDate: '2026-01-01',
      analysis: { unlinked_block_days: 0, benchmarks: { dementia_age: 50 } },
    });
    await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    const input2 = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v2.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input2.ref_date).toBe('2026-01-01');
    expect(input2.options).toMatchObject({
      unlinked_block_days: 0,
      use_nts: 'PDF',
      estimate_payout: false,
      estimate_silson_coverage: false,
      link_window_days: 0,
      benchmarks: { dementia_age: 50, cancer_diagnosis: 30000000 },
    });
    const badOption = await api('PATCH', `/jobs/${job.jobId}`, {
      body: { analysisOptions: { unlinked_block_days: 91 } },
    });
    expect(badOption.body.code).toBe('CR_INVALID');
    const badNts = await api('PATCH', `/jobs/${job.jobId}`, {
      body: { analysisOptions: { use_nts: 'NONE' } },
    });
    expect(badNts.body.code).toBe('CR_INVALID');
  });
});

describe('삭제 · 늦은 결과 거절', () => {
  it('판독 중 작업을 지우면 워커를 끊고 폴더가 없어지며, 늦은 결과가 폴더를 되살리지 않는다', async () => {
    fake({ FAKE_SLEEP_MS: '800' });
    const job = await newJob();
    await upload(job.jobId, [
      ['a.pdf', pdf()],
      ['b.pdf', pdf()],
    ]);
    const link = await api('POST', `/jobs/${job.jobId}/links`, { body: {} });
    issuedTokens.push(link.body.token);
    await new Promise((r) => setTimeout(r, 150));
    const del = await api('DELETE', `/jobs/${job.jobId}`);
    expect(del.body).toEqual({ deleted: true });
    await service.queue.drain();
    await new Promise((r) => setTimeout(r, 300));
    expect(existsSync(join(root, 'jobs', job.jobId))).toBe(false);
    const linkFile = join(
      root,
      'links',
      `${createHash('sha256').update(link.body.token).digest('hex')}.json`,
    );
    expect(existsSync(linkFile)).toBe(false);
    expect((await api('GET', `/jobs/${job.jobId}`)).status).toBe(404);
  });

  it('판독 중 파일을 지우면 그 파일의 결과를 승격하지 않는다', async () => {
    fake({ FAKE_SLEEP_MS: '600' });
    const job = await newJob();
    const up = await upload(job.jobId, [['a.pdf', pdf()]]);
    const fileId = up.job.files[0].fileId;
    await new Promise((r) => setTimeout(r, 150));
    const del = await api('DELETE', `/jobs/${job.jobId}/files/${fileId}`);
    expect(del.body.files).toEqual([]);
    await service.queue.drain();
    expect(
      existsSync(join(root, 'jobs', job.jobId, 'extracted', `${fileId}.json`)),
    ).toBe(false);
    expect(
      existsSync(join(root, 'jobs', job.jobId, 'files', `${fileId}.pdf`)),
    ).toBe(false);
    expect((await getJob(job.jobId)).files).toEqual([]);
  });
});

describe('재시작 복구', () => {
  it('EXTRACTING → QUEUED 로 다시 처리, 비밀번호 대기 파일 → NEEDS_PASSWORD, work 잔재·결과 파일 없는 판본 정리', async () => {
    const job = await newJob();
    await upload(job.jobId, [
      ['a.pdf', pdf()],
      ['b.pdf', pdf()],
    ]);
    await service.queue.drain();
    const rec = readJobJson(job.jobId);
    rec.files[0].status = 'EXTRACTING';
    rec.files[1].status = 'QUEUED';
    rec.files[1].passwordPending = true;
    rec.results = [
      {
        version: 1,
        createdAt: 'x',
        round: 1,
        status: 'OK',
        counts: {},
        blocked: null,
      },
    ];
    writeJobJson(job.jobId, rec);
    const dir = join(root, 'jobs', job.jobId);
    mkdirSync(join(dir, 'results', 'v1.inputs'));
    mkdirSync(join(dir, 'results', 'v2.inputs'));
    mkdirSync(join(dir, 'work', 'extract-leftover-a1', 'tmp'), {
      recursive: true,
    });
    writeFileSync(
      join(dir, 'work', 'extract-leftover-a1', 'tmp', 'page-1.png'),
      'x',
    );

    await service.recover();
    await service.queue.drain();

    const after = await getJob(job.jobId);
    expect(after.files[0].status).toBe('EXTRACTED');
    expect([after.files[1].status, after.files[1].error.code]).toEqual([
      'NEEDS_PASSWORD',
      'PDF_PASSWORD_REQUIRED',
    ]);
    expect(after.results).toEqual([]);
    expect(readdirSync(join(dir, 'results'))).toEqual([]);
    expect(readdirSync(join(dir, 'work'))).toEqual([]);
  });
});

describe('고객 업로드 링크(공개 경로)', () => {
  async function linkedJob(body: Record<string, unknown> = {}) {
    const job = await newJob({ customerName: '김합성' });
    const link = await api('POST', `/jobs/${job.jobId}/links`, {
      body: { expiresInHours: 2, note: '지급내역을 올려 주세요', ...body },
    });
    expect(link.status).toBe(201);
    expect(link.body.token).toMatch(/^[A-Za-z0-9_-]{32}$/);
    issuedTokens.push(link.body.token);
    return { jobId: job.jobId, ...link.body };
  }
  const pub = (
    method: string,
    path: string,
    opts: { body?: unknown; form?: FormData } = {},
  ) => api(method, `/public${path}`, { token: null, ...opts });

  it('링크를 만들면 토큰은 한 번만 주고 서버는 해시만 둔다, 공개 GET 은 가린 이름·요청 서류·상태만', async () => {
    const { jobId, token, linkId } = await linkedJob();
    const job = await getJob(jobId);
    expect(job.links[0]).toMatchObject({
      linkId,
      revokedAt: null,
      uploadCount: 0,
    });
    expect(job.links[0]).not.toHaveProperty('tokenHash');
    expect(JSON.stringify(readJobJson(jobId))).not.toContain(token);
    expect(job.derived.hasActiveLink).toBe(true);

    const info = await pub('GET', `/${token}`);
    expect(info.status).toBe(200);
    expect(info.body).toMatchObject({
      customerName: '김*성',
      note: '지급내역을 올려 주세요',
      limits: { maxFileMb: 1, maxFiles: 20, remainingFiles: 20 },
    });
    expect(info.body.requested.map((r: any) => r.kind)).toEqual([
      'STATEMENT',
      'COVERAGE', // 심평원 PDF 는 받지 않는다(r14)
    ]);
    expect(info.body.preciseInsurers.length).toBeGreaterThan(0);

    const up = await pub('POST', `/${token}/files`, {
      form: form([['현대.pdf', pdf()]]),
    });
    expect(up.body.uploaded).toEqual([
      { fileId: expect.any(String), name: '현대.pdf', status: 'QUEUED' },
    ]);
    await service.queue.drain();
    const mine = await getJob(jobId);
    expect(mine.files[0]).toMatchObject({
      uploadedBy: 'CUSTOMER_LINK',
      linkId,
      status: 'EXTRACTED',
    });
    const again = await pub('GET', `/${token}`);
    expect(Object.keys(again.body.uploaded[0]).sort()).toEqual([
      'errorCode',
      'fileId',
      'name',
      'status',
      'uploadedAt',
    ]);
    expect(JSON.stringify(again.body)).not.toMatch(
      /extraction|subjectName|김합성|review/,
    );
    expect(again.body.limits.remainingFiles).toBe(19);
  });

  it('끊은 링크 410 CR_LINK_REVOKED · 만료 410 CR_LINK_EXPIRED · 없는 토큰 404 CR_LINK_NOT_FOUND', async () => {
    const a = await linkedJob();
    const revoked = await api('DELETE', `/jobs/${a.jobId}/links/${a.linkId}`);
    expect(revoked.body.links[0].revokedAt).toEqual(expect.any(String));
    expect(revoked.body.derived.hasActiveLink).toBe(false);
    const r1 = await pub('GET', `/${a.token}`);
    expect([r1.status, r1.body.code]).toEqual([410, 'CR_LINK_REVOKED']);
    const r2 = await pub('POST', `/${a.token}/files`, {
      form: form([['a.pdf', pdf()]]),
    });
    expect([r2.status, r2.body.code]).toEqual([410, 'CR_LINK_REVOKED']);

    const b = await linkedJob();
    const rec = readJobJson(b.jobId);
    rec.links[0].expiresAt = new Date(Date.now() - 1000).toISOString();
    writeJobJson(b.jobId, rec);
    const expired = await pub('GET', `/${b.token}`);
    expect([expired.status, expired.body.code]).toEqual([
      410,
      'CR_LINK_EXPIRED',
    ]);

    for (const t of ['A'.repeat(32), 'short', '..%2F..%2Fjobs']) {
      const res = await pub('GET', `/${t}`);
      expect([t, res.status, res.body.code]).toEqual([
        t,
        404,
        'CR_LINK_NOT_FOUND',
      ]);
    }
    const bad = await api('POST', `/jobs/${b.jobId}/links`, {
      body: { expiresInHours: 169 },
    });
    expect(bad.body.code).toBe('CR_INVALID');
  });

  it('공개 비밀번호 다시 넣기는 그 링크로 올린 NEEDS_PASSWORD 파일만', async () => {
    fake({ FAKE_EXPECT_PASSWORD: PASSWORD });
    const a = await linkedJob();
    const b = await api('POST', `/jobs/${a.jobId}/links`, { body: {} });
    issuedTokens.push(b.body.token);
    const up = await pub('POST', `/${a.token}/files`, {
      form: form([['심평원.pdf', pdf()]]),
    });
    const fileId = up.body.uploaded[0].fileId;
    const mineUp = await upload(a.jobId, [['내것.pdf', pdf()]]);
    await service.queue.drain();
    expect((await pub('GET', `/${a.token}`)).body.uploaded[0]).toMatchObject({
      status: 'NEEDS_PASSWORD',
      errorCode: 'PDF_PASSWORD_REQUIRED',
    });

    const otherLink = await pub(
      'POST',
      `/${b.body.token}/files/${fileId}/password`,
      { body: { password: PASSWORD } },
    );
    expect([otherLink.status, otherLink.body.code]).toEqual([
      404,
      'CR_NOT_FOUND',
    ]);
    const userFile = await pub(
      'POST',
      `/${a.token}/files/${mineUp.job.files[1].fileId}/password`,
      { body: { password: PASSWORD } },
    );
    expect([userFile.status, userFile.body.code]).toEqual([
      404,
      'CR_NOT_FOUND',
    ]);
    const ok = await pub('POST', `/${a.token}/files/${fileId}/password`, {
      body: { password: PASSWORD },
    });
    expect(ok.body).toEqual({ fileId, name: '심평원.pdf', status: 'QUEUED' });
    await service.queue.drain();
    expect((await pub('GET', `/${a.token}`)).body.uploaded[0].status).toBe(
      'EXTRACTED',
    );

    const otherDelete = await pub('DELETE', `/${b.body.token}/files/${fileId}`);
    expect(otherDelete.status).toBe(404);
    expect((await pub('DELETE', `/${a.token}/files/${fileId}`)).body).toEqual({
      deleted: true,
    });
  });

  it('링크당 20개를 넘으면 CR_LIMIT_FILES 로 파일별 거절', async () => {
    const a = await linkedJob();
    const rec = readJobJson(a.jobId);
    rec.links[0].uploadCount = 20;
    writeJobJson(a.jobId, rec);
    const res = await pub('POST', `/${a.token}/files`, {
      form: form([['a.pdf', pdf()]]),
    });
    expect(res.body).toEqual({
      uploaded: [],
      rejected: [
        { name: 'a.pdf', code: 'CR_LIMIT_FILES', message: expect.any(String) },
      ],
    });
  });

  it('분석 결과가 있으면 요청 서류는 지급내역 요청 목록을 따른다', async () => {
    const a = await linkedJob();
    await api('PUT', `/jobs/${a.jobId}/manual-payments`, {
      body: { items: [{ insurer: '삼성화재', start: '2025-02-19' }] },
    });
    await api('POST', `/jobs/${a.jobId}/analyze`, { body: {} });
    const info = await pub('GET', `/${a.token}`);
    const result = (await api('GET', `/jobs/${a.jobId}/results/latest`)).body;
    const expected = result.guides.statement_requests.items.map(
      (i: any) => i.insurer,
    );
    expect(
      info.body.requested
        .filter((r: any) => r.kind === 'STATEMENT')
        .map((r: any) => r.insurer),
    ).toEqual(expected);
    expect(info.body.requested.map((r: any) => r.kind)).not.toContain(
      'HIRA_BASIC',
    ); // 결과 뒤에도 심평원 PDF 를 요청하지 않는다
  });
});

describe('직접 넣은 실손 가입 기간', () => {
  const put = (jobId: string, contracts: Record<string, unknown>[]) =>
    api('PUT', `/jobs/${jobId}/manual-contracts`, { body: { contracts } });

  it('원소가 객체가 아니면 400 CR_INVALID — 중첩 배열이 검사를 통과해 500 이 되지 않는다', async () => {
    const job = await newJob();
    for (const body of [[[]], [null], ['현대해상']]) {
      const res = await put(job.jobId, body as never);
      expect([res.status, res.body.code]).toEqual([400, 'CR_INVALID']);
    }
  });

  it('칸을 검사하고, 저장하면 id 를 붙여 작업 보기로 돌려준다', async () => {
    const job = await newJob();
    expect(job.manualContracts).toEqual([]);
    const bad: [Record<string, unknown>, string][] = [
      [{ insurer: '  ', start: '2015-03-01' }, 'contracts[0].insurer'],
      [
        { insurer: '현대 800101-1234567', start: '2015-03-01' },
        'contracts[0].insurer',
      ],
      [{ insurer: '현대해상', start: '2015-02-30' }, 'contracts[0].start'],
      [
        { insurer: '현대해상', start: '2015-03-01', end: '2015-02-28' },
        'contracts[0].end',
      ],
    ];
    for (const [c, field] of bad) {
      const res = await put(job.jobId, [c]);
      expect([res.status, res.body.code, res.body.detail?.field]).toEqual([
        400,
        'CR_INVALID',
        field,
      ]);
    }
    expect(
      (
        await put(job.jobId, [
          { insurer: '현대해상', start: '2015-03-01', end: '' },
        ])
      ).status,
    ).toBe(400);
    const ok = await put(job.jobId, [
      { insurer: '현대해상', start: '2015-03-01', end: null },
      { insurer: '롯데손보', start: '2020-01-01', end: '2020-01-01' },
    ]);
    expect(ok.status).toBe(200);
    expect(ok.body.manualContracts).toEqual([
      {
        id: expect.stringMatching(/^mc_[0-9a-f]{8}$/),
        insurer: '현대해상',
        start: '2015-03-01',
        end: null,
      },
      {
        id: expect.stringMatching(/^mc_[0-9a-f]{8}$/),
        insurer: '롯데손보',
        start: '2020-01-01',
        end: '2020-01-01',
      },
    ]);
  });

  it('저장값은 작업에 남기지만 분석 입력에는 싣지 않는다(15차 D16), 칸 없는 예전 작업 파일은 빈 목록으로 본다', async () => {
    const job = await newJob();
    await put(job.jobId, [
      { insurer: '현대해상', start: '2015-03-01', end: null },
    ]);
    // 자료가 없으면 분석이 안 돈다 — 수기 지급 한 줄로 분석 입력을 만든다
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, {
      body: {
        items: [{ insurer: '삼성화재', start: '2025-02-19', amount: 30000 }],
      },
    });
    await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    const input = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v1.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(input.manual_contracts).toEqual([]);
    expect(readJobJson(job.jobId).manualContracts).toEqual([
      {
        id: expect.stringMatching(/^mc_/),
        insurer: '현대해상',
        start: '2015-03-01',
        end: null,
      },
    ]);

    const rec = readJobJson(job.jobId);
    delete rec.manualContracts;
    writeJobJson(job.jobId, rec);
    expect((await getJob(job.jobId)).manualContracts).toEqual([]);
    await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    const old = JSON.parse(
      readFileSync(
        join(
          root,
          'jobs',
          job.jobId,
          'results',
          'v2.inputs',
          'analyze-input.json',
        ),
        'utf8',
      ),
    );
    expect(old.manual_contracts).toEqual([]);
  });
});

describe('계약 견본(workers/case-report/contract)으로 전 흐름', () => {
  //* 가짜 워커가 Python 담당의 견본 12개를 그대로 --out 에 복사한다 — 기대값도 같은 견본에서 읽는다(명세 4.0 절)
  const CONTRACT = join(
    __dirname,
    '..',
    '..',
    '..',
    '..',
    '..',
    'workers',
    'case-report',
    'contract',
  );
  const NAMES = [
    'version.ok.json',
    'extract.ok.credit-info.json',
    'extract.ok.statement-precise.json',
    'extract.partial.statement-generic.json',
    'extract.needs-password.json',
    'extract.failed.json',
    'extract.unsupported.json',
    'revalidate.edit-invalid.json',
    'analyze.ok.round1.json',
    'analyze.ok.round2.json',
    'analyze.blocked.json',
    'analyze.failed.json',
  ];
  const fx = (name: string) =>
    JSON.parse(readFileSync(join(CONTRACT, name), 'utf8'));
  const real = (env: Record<string, string> = {}) => (worker.extraEnv = env);

  it('견본 12개가 모두 있다(없으면 건너뛰지 않고 실패)', () => {
    expect(NAMES.filter((n) => !existsSync(join(CONTRACT, n)))).toEqual([]);
  });

  it.each([
    ['extract.ok.credit-info.json', {}, {}, 'EXTRACTED'],
    [
      'extract.ok.statement-precise.json',
      {},
      { kindHint: 'STATEMENT' },
      'EXTRACTED',
    ],
    [
      'extract.partial.statement-generic.json',
      {},
      { readerHint: 'GENERIC' },
      'PARTIAL',
    ],
    [
      'extract.needs-password.json',
      { FAKE_MODE: 'NEEDS_PASSWORD' },
      {},
      'NEEDS_PASSWORD',
    ],
    ['extract.failed.json', { FAKE_MODE: 'FAILED' }, {}, 'FAILED'],
    [
      'extract.unsupported.json',
      { FAKE_MODE: 'UNSUPPORTED' },
      {},
      'UNSUPPORTED',
    ],
  ])('%s → 파일 상태 %s·요약', async (name, env, fields, status) => {
    real(env);
    const envelope = fx(name);
    const job = await newJob();
    await upload(
      job.jobId,
      [['x.pdf', pdf()]],
      fields as Record<string, string>,
    );
    await service.queue.drain();
    const f = (await getJob(job.jobId)).files[0];
    const pages: { mode?: string }[] = envelope.timing?.pages ?? [];
    expect(f.status).toBe(status);
    //* 미지원도 이유 코드를 싣는다(r14 — 화면의 이유 줄)
    expect(f.error?.code ?? null).toBe(
      ['FAILED', 'NEEDS_PASSWORD', 'UNSUPPORTED'].includes(envelope.status)
        ? envelope.error.code
        : null,
    );
    expect(f.extraction).toMatchObject({
      kind: envelope.kind,
      status: envelope.status,
      pages: envelope.file?.pages ?? null,
      reviewCount: envelope.summary?.review_count ?? 0,
      warningCodes: (envelope.warnings ?? []).map((w: any) => w.code),
      reader: envelope.summary?.reader ?? null,
      timing: {
        totalMs: envelope.timing?.total_ms ?? null,
        textPages: pages.filter((p) => p.mode === 'text').length,
        ocrPages: pages.filter((p) => p.mode === 'ocr').length,
      },
    });
  });

  it('revalidate 견본: EDIT_INVALID 는 400 + 워커 detail, 정상은 200 으로 승격', async () => {
    real();
    const job = await newJob();
    const up = await upload(job.jobId, [['가입내역.pdf', pdf()]]);
    const fileId = up.job.files[0].fileId;
    await service.queue.drain();
    const edits = {
      edits: [{ op: 'replace', path: '/data/subject/name', value: '김합성' }],
    };
    const ok = await api('PUT', `/jobs/${job.jobId}/files/${fileId}/edits`, {
      body: edits,
    });
    expect([
      ok.status,
      ok.body.file.editCount,
      ok.body.effective.schema,
    ]).toEqual([200, 1, 'case-report.extract/1']);
    real({ FAKE_MODE: 'EDIT_INVALID' });
    const bad = await api('PUT', `/jobs/${job.jobId}/files/${fileId}/edits`, {
      body: edits,
    });
    const detail = fx('revalidate.edit-invalid.json').error.detail;
    expect([
      bad.status,
      bad.body.code,
      bad.body.detail.editIndex,
      bad.body.detail.reason,
    ]).toEqual([400, 'CR_EDIT_INVALID', detail.edit_index, detail.reason]);
    expect((await getJob(job.jobId)).files[0].editCount).toBe(1);
  });

  it('analyze 견본: 1회차 → 2회차 → BLOCKED → FAILED(502)', async () => {
    real();
    const job = await newJob();
    await api('PUT', `/jobs/${job.jobId}/manual-payments`, {
      body: { items: [{ insurer: '삼성화재', start: '2025-02-19' }] },
    });
    const r1 = fx('analyze.ok.round1.json');
    const r2 = fx('analyze.ok.round2.json');
    const blocked = fx('analyze.blocked.json');
    const v1 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v1.body).toEqual({
      version: 1,
      round: r1.round,
      status: 'OK',
      counts: r1.summary.counts,
      blocked: null,
    });
    const v2 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v2.body).toMatchObject({
      version: 2,
      round: r2.round,
      counts: r2.summary.counts,
    });
    real({ FAKE_MODE: 'BLOCKED' });
    const v3 = await api('POST', `/jobs/${job.jobId}/analyze`, { body: {} });
    expect(v3.body).toEqual({
      version: 3,
      round: blocked.round,
      status: 'BLOCKED',
      counts: blocked.summary.counts,
      blocked: blocked.blocked,
    });
    real({ FAKE_MODE: 'FAILED' });
    const failed = await api('POST', `/jobs/${job.jobId}/analyze`, {
      body: {},
    });
    expect([failed.status, failed.body.detail.workerCode]).toEqual([
      502,
      fx('analyze.failed.json').error.code,
    ]);
    const latest = await api('GET', `/jobs/${job.jobId}/results/latest`);
    expect([latest.body.version, latest.body.status]).toEqual([3, 'BLOCKED']);
  });
});

describe('로그', () => {
  it('서버 로그에 합성 토큰·합성 비밀번호·고객 이름이 0건이다', () => {
    expect(issuedTokens.length).toBeGreaterThan(0);
    expect(logged.length).toBeGreaterThan(0);
    const all = logged.join('\n');
    for (const secret of [
      ...issuedTokens,
      PASSWORD,
      WRONG_PASSWORD,
      '김합성',
      '800101',
    ])
      expect([secret.slice(0, 6), all.includes(secret)]).toEqual([
        secret.slice(0, 6),
        false,
      ]);
  });
});
