import {
  Injectable,
  Logger,
  OnModuleDestroy,
  OnModuleInit,
} from '@nestjs/common';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import { basename, join } from 'path';
import { caseReportConfig, LINK_MAX_FILES } from './case-report.config';
import {
  AnalysisOptionsDto,
  CreateJobDto,
  CreateLinkDto,
  EditDto,
  ExtractDto,
  HospitalAliasDto,
  ManualContractDto,
  ManualPaymentDto,
  OutcomeDto,
  OverrideDto,
  PatchJobDto,
  UploadDto,
} from './case-report.dto';
import { editRejection } from './case-report.edits';
import { crError } from './case-report-exception.filter';
import { ExtractQueue, QueueItem } from './case-report.queue';
import { CaseReportRepository } from './case-report.repository';
import { assertId, CaseReportStore, isMissing } from './case-report.store';
import {
  ErrorInfo,
  FileExtraction,
  FileStatus,
  JobFile,
  JobLink,
  JobRecord,
  Progress,
  WorkerProgress,
} from './case-report.types';
import {
  CaseReportWorker,
  WorkerOutcome,
  WorkerRun,
} from './case-report.worker';

const RRN = /\d{6}-?[1-4]\d{6}/;
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
const RUNNING: FileStatus[] = ['QUEUED', 'EXTRACTING'];
const USABLE: FileStatus[] = ['EXTRACTED', 'PARTIAL'];
//* 워커 봉투 status → 파일 status (명세 4.8 절 대응표, 서버 한 곳)
const FILE_STATUS: Record<string, FileStatus> = {
  OK: 'EXTRACTED',
  PARTIAL: 'PARTIAL',
  UNSUPPORTED: 'UNSUPPORTED',
  NEEDS_PASSWORD: 'NEEDS_PASSWORD',
  FAILED: 'FAILED',
};
const FAILURE_TEXT: Record<string, string> = {
  WORKER_INPUT_INVALID: '파일을 읽지 못했어요.',
  INTERNAL: '파일을 읽다가 오류가 났어요.',
  WORKER_CRASHED: '파일을 읽다가 멈췄어요.',
  WORKER_BAD_OUTPUT: '읽은 결과를 확인하지 못했어요.',
  TIMEOUT: '파일을 읽는 데 시간이 너무 걸렸어요.',
};
export const DEFAULT_ANALYSIS_OPTIONS = {
  hira_source: 'AUTO',
  show_claim_deadline: true,
  deadline_years: 3,
  use_nts: 'AUTO',
  link_window_days: 0,
  unlinked_block_days: 30,
  pharmacy_follows_prescriber: true,
  generic_unconfirmed_policy: 'REVIEW',
  identity_policy: 'BLOCK',
  require_complete_pages: true,
  require_contract_scope_all: true,
  include_out_of_coverage: true,
  estimate_payout: true,
  estimate_silson_coverage: true,
  benchmarks: {
    cancer_diagnosis: 30000000,
    ischemic_heart_diagnosis: 20000000,
    disease_surgery: 1000000,
    death_injury_ratio: 3,
    dementia_age: 40,
    daily_benefit_excess: 200000,
  },
};

const iso = () => new Date().toISOString();
const seoulToday = () =>
  new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul' }).format(
    new Date(),
  );
const sha256 = (data: Buffer | string) =>
  createHash('sha256').update(data).digest('hex');
const plain = <T>(v: T): T =>
  v === undefined ? v : JSON.parse(JSON.stringify(v));
const notFound = () => crError(404, 'CR_NOT_FOUND', '찾을 수 없어요.');
//* 2월 30일 같은 날짜는 Date 가 다음 달로 넘기므로 되돌려 같은지 본다
const isCalendarDate = (s: string) => {
  const d = new Date(`${s}T00:00:00Z`);
  return !isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
};
const maskName = (name: string) =>
  name.length <= 1
    ? '*'
    : name.length === 2
      ? `${name[0]}*`
      : `${name[0]}${'*'.repeat(name.length - 2)}${name[name.length - 1]}`;
const birthOf = (raw: string | null) =>
  raw && /^\d{8}$/.test(raw)
    ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6)}`
    : raw || null;

function hasRrn(value: unknown): boolean {
  if (typeof value === 'string') return RRN.test(value);
  if (Array.isArray(value)) return value.some(hasRrn);
  if (value && typeof value === 'object')
    return Object.values(value).some(hasRrn);
  return false;
}

//* 사용자 입력 글자 칸의 주민번호 꼴은 가려 저장하지 않고 거절한다(무엇이 저장됐는지 사용자가 알 수 있게)
function rejectRrn(field: string, value: unknown) {
  if (hasRrn(value))
    throw crError(
      400,
      'CR_INVALID',
      '주민등록번호 형식의 값은 저장할 수 없어요.',
      {
        field,
      },
    );
}

//* fax-user.controller 방식 — multer 가 latin1 로 읽은 한글 이름을 utf8 로 되돌린다
function decodeName(raw: string): string {
  let name = raw;
  try {
    const decoded = Buffer.from(raw, 'latin1').toString('utf8');
    if (decoded && !decoded.includes('�')) name = decoded;
  } catch {
    /* 그대로 둔다 */
  }
  name = basename(name.replace(/\\/g, '/')).trim().slice(0, 200);
  return name || 'file.pdf';
}

interface Running {
  fileId: string | null;
  run: WorkerRun;
}

interface UploadOptions extends UploadDto {
  uploadedBy: 'USER' | 'CUSTOMER_LINK';
  linkId: string | null;
}

@Injectable()
export class CaseReportService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger('CaseReport');
  readonly queue = new ExtractQueue((item) => this.runExtract(item));
  //* 비밀번호는 메모리에만 — 판독을 시작할 때 꺼내 표준입력으로 넘기고 지운다
  private readonly passwords = new Map<string, string>();
  private readonly progress = new Map<string, Progress>();
  private readonly running = new Map<string, Set<Running>>();
  private readonly analyzing = new Set<string>();
  //* 단계별 한 쪽 처리 시간(ms, 서버가 잰 값을 최근 쪽에 무게를 두어 고른 값) — 첫 쪽이 끝나기 전에도 남은 시간을 셀 수 있게. 서버를 다시 켜면 빈다
  private readonly pace = new Map<WorkerProgress['stage'], number>();
  private versionPromise: Promise<Record<string, any> | null> | null = null;
  private revalidateSeq = 0;

  constructor(
    private readonly store: CaseReportStore,
    private readonly worker: CaseReportWorker,
    private readonly repo: CaseReportRepository,
  ) {}

  // ───────────────────────── 기동 · 종료 ─────────────────────────

  async onModuleInit() {
    const config = caseReportConfig();
    if (!config.enabled || !config.dir) return;
    //* 복구 실패가 서버 전체 기동을 막지 않게 한다 — 이 메뉴만 영향받는다
    await this.recover().catch((e) =>
      this.logger.error(`recover failed ${(e as Error)?.name}`),
    );
  }

  onModuleDestroy() {
    for (const set of this.running.values()) for (const r of set) r.run.kill();
  }

  //* 지난 실행의 잔재를 지우고(쪽 이미지 등 원본 글자가 남아 있을 수 있다) 대기열을 되살린다
  async recover() {
    await this.store.ensureRoot();
    await this.clearDir(join(this.store.root(), 'work'));
    for (const jobId of await this.store.listJobIds()) {
      const requeue: string[] = [];
      try {
        await this.clearDir(this.store.path(jobId, 'work'));
        await this.store.lock(jobId, async () => {
          const job = await this.store.readJob(jobId);
          if (!job) return;
          const before = JSON.stringify(job);
          for (const f of job.files) {
            if (!RUNNING.includes(f.status)) continue;
            if (f.passwordPending) {
              f.status = 'NEEDS_PASSWORD';
              f.passwordPending = false;
              f.error = {
                code: 'PDF_PASSWORD_REQUIRED',
                message: '서버가 다시 시작되어 비밀번호를 다시 넣어 주세요.',
              };
            } else {
              f.status = 'QUEUED';
              requeue.push(f.fileId);
            }
          }
          const kept: JobRecord['results'] = [];
          for (const r of job.results)
            if (
              await this.exists(
                this.store.path(jobId, 'results', `v${r.version}.json`),
              )
            )
              kept.push(r);
          job.results = kept;
          const recorded = new Set(kept.map((r) => r.version));
          for (const name of await fs.readdir(
            this.store.path(jobId, 'results'),
          )) {
            const m = /^v(\d+)(\.json|\.inputs)$/.exec(name);
            if (m && !recorded.has(Number(m[1])))
              await fs.rm(this.store.path(jobId, 'results', name), {
                recursive: true,
                force: true,
              });
          }
          if (JSON.stringify(job) !== before) await this.store.writeJob(job);
        });
      } catch (e) {
        this.logger.warn(`recover job=${jobId} failed ${(e as Error)?.name}`);
      }
      for (const fileId of requeue) this.queue.enqueue({ jobId, fileId });
    }
  }

  // ───────────────────────── 조회 ─────────────────────────

  async meta() {
    const info = await this.versionInfo();
    const config = caseReportConfig();
    return {
      workerReady: !!info,
      workerVersion: info?.worker_version ?? null,
      easyocrModelsPresent: !!info?.ocr?.models_present,
      preciseInsurers: this.preciseInsurers(info),
      limits: {
        maxFileMb: config.maxFileMb,
        maxFilesPerJob: config.maxFilesPerJob,
        maxPages: config.maxPages,
        maxPagesPolicy: config.maxPagesPolicy,
      },
      ntsFeature: { tablePresent: await this.ntsPresent() },
    };
  }

  async listHiraSources(memId: number, search?: string) {
    return {
      items: await this.repo.listHiraOrders(memId, search?.trim() || undefined),
    };
  }

  async listCustomers(memId: number, search?: string) {
    return {
      items: await this.repo.listCustomers(memId, search?.trim() || undefined),
    };
  }

  async listNtsSources(memId: number, usrId?: number) {
    if (!(await this.ntsPresent()))
      return { available: false, reason: 'TABLE_ABSENT', items: [] };
    const items = await this.repo.listNts(memId);
    //* 같은 고객의 건을 위로(안정 정렬)
    if (usrId)
      items.sort(
        (a, b) => Number(b.usrId === usrId) - Number(a.usrId === usrId),
      );
    return { available: true, reason: null, items };
  }

  async listJobs(memId: number) {
    const items: {
      jobId: string;
      title: string;
      customerName: string;
      createdAt: string;
      updatedAt: string;
      fileCount: number;
      latestVersion: number | null;
      latestRound: number | null;
      latestResultAt: string | null;
      processing: boolean;
      hiraOddId: number | null;
    }[] = [];
    for (const jobId of await this.store.listJobIds()) {
      const job = await this.store.readJob(jobId).catch(() => null);
      if (!job || job.memId !== memId) continue;
      const latest = this.latestResult(job);
      items.push({
        jobId: job.jobId,
        title: job.title,
        customerName: job.customer.name,
        createdAt: job.createdAt,
        updatedAt: job.updatedAt,
        fileCount: job.files.length,
        latestVersion: latest?.version ?? null,
        latestRound: latest?.round ?? null,
        latestResultAt: latest?.createdAt ?? null,
        processing: job.files.some((f) => RUNNING.includes(f.status)),
        hiraOddId: job.sources.hiraDb?.oddId ?? null,
      });
    }
    items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
    return { items };
  }

  async getJob(memId: number, jobId: string) {
    return this.view(await this.owned(memId, jobId));
  }

  async getExtraction(memId: number, jobId: string, fileId: string) {
    const job = await this.owned(memId, jobId);
    const f = this.file(job, fileId);
    return {
      extraction: await this.store.readJson(
        this.store.path(jobId, 'extracted', `${f.fileId}.json`),
      ),
      effective: await this.store.readJson(
        this.store.path(jobId, 'extracted', `${f.fileId}.effective.json`),
      ),
      edits: f.edits,
    };
  }

  async contentPath(memId: number, jobId: string, fileId: string) {
    const job = await this.owned(memId, jobId);
    const f = this.file(job, fileId);
    const path = this.store.path(jobId, 'files', `${f.fileId}.pdf`);
    if (!f.originalAvailable || !(await this.exists(path)))
      throw crError(410, 'CR_ORIGINAL_DELETED', '원본 PDF 가 지워졌어요.');
    return path;
  }

  async listResults(memId: number, jobId: string) {
    const job = await this.owned(memId, jobId);
    return {
      items: job.results.map(
        ({ version, createdAt, round, status, counts, blocked }) => ({
          version,
          createdAt,
          round,
          status,
          counts,
          blocked,
        }),
      ),
    };
  }

  async getResult(memId: number, jobId: string, version: string) {
    const job = await this.owned(memId, jobId);
    if (version !== 'latest' && !/^\d+$/.test(version))
      throw crError(400, 'CR_INVALID', '판본 번호가 올바르지 않아요.');
    const entry =
      version === 'latest'
        ? this.latestResult(job)
        : job.results.find((r) => r.version === Number(version));
    const result = entry
      ? await this.store.readJson(
          this.store.path(jobId, 'results', `v${entry.version}.json`),
        )
      : null;
    if (!result) throw notFound();
    return result;
  }

  // ───────────────────────── 작업 ─────────────────────────

  async createJob(memId: number, dto: CreateJobDto) {
    rejectRrn('title', dto.title);
    rejectRrn('customerName', dto.customerName);
    if (!dto.hiraOddId && !dto.customerName)
      throw crError(400, 'CR_INVALID', '고객 이름을 넣어 주세요.', {
        field: 'customerName',
      });
    const hira = dto.hiraOddId
      ? await this.loadHira(memId, dto.hiraOddId)
      : null;
    const nts = dto.ntsLsaId
      ? await this.loadNts(memId, dto.ntsLsaId)
      : hira
        ? await this.autoNts(memId, hira.customer.usrId)
        : null;
    const jobId = randomUUID();
    const now = iso();
    const customer = hira
      ? hira.customer
      : {
          name: dto.customerName as string,
          birth: dto.customerBirth ?? null,
          source: 'USER' as const,
          usrId: null,
        };
    const job: JobRecord = {
      schema: 'case-report.job/1',
      jobId,
      memId,
      title: dto.title || `${customer.name} 님 보장·청구 분석`,
      createdAt: now,
      updatedAt: now,
      customer,
      sources: { hiraDb: hira?.source ?? null, nts: nts?.source ?? null },
      options: {
        analysis: (plain(dto.analysisOptions) as Record<string, unknown>) ?? {},
        refDate: null,
        deleteOriginalAfterExtract: false,
      },
      files: [],
      manualPayments: [],
      overrides: [],
      hospitalAliases: [],
      manualContracts: [],
      outcomes: [],
      links: [],
      results: [],
    };
    await this.store.createJobDirs(jobId);
    if (hira)
      await this.store.writeJsonAtomic(
        this.store.path(jobId, 'sources', 'hira-db.json'),
        hira.snapshot,
      );
    if (nts)
      await this.store.writeJsonAtomic(
        this.store.path(jobId, 'sources', 'nts-db.json'),
        nts.snapshot,
      );
    await this.store.writeJob(job);
    this.logger.log(`job created job=${jobId} hira=${!!hira} nts=${!!nts}`);
    return this.view(job);
  }

  async patchJob(memId: number, jobId: string, dto: PatchJobDto) {
    rejectRrn('title', dto.title);
    rejectRrn('customerName', dto.customerName);
    const job = await this.store.mutate(jobId, (job) => {
      this.assertOwner(job, memId);
      const changesCustomer =
        (dto.customerName !== undefined &&
          dto.customerName !== job.customer.name) ||
        (dto.customerBirth !== undefined &&
          dto.customerBirth !== job.customer.birth);
      if (changesCustomer && job.sources.hiraDb)
        throw crError(
          400,
          'CR_INVALID',
          '심평원 자료를 붙인 작업은 고객 이름·생년월일을 바꿀 수 없어요.',
          {
            field: 'customerName',
          },
        );
      if (dto.title !== undefined) job.title = dto.title;
      if (dto.customerName !== undefined) job.customer.name = dto.customerName;
      if (dto.customerBirth !== undefined)
        job.customer.birth = dto.customerBirth;
      if (dto.analysisOptions !== undefined)
        job.options.analysis = this.mergeOptions(job, dto.analysisOptions);
      if (dto.refDate !== undefined) job.options.refDate = dto.refDate;
      if (dto.deleteOriginalAfterExtract !== undefined)
        job.options.deleteOriginalAfterExtract = dto.deleteOriginalAfterExtract;
      return job;
    });
    return this.view(job);
  }

  async deleteJob(memId: number, jobId: string) {
    await this.store.lock(jobId, async () => {
      const job = await this.store.readJob(jobId);
      this.assertOwner(job, memId);
      this.queue.remove(jobId);
      //* 실행 중인 워커 그룹을 끊고 종료를 확인한 뒤 폴더를 지운다
      await this.killRunning(jobId);
      for (const f of (job as JobRecord).files) this.forget(jobId, f.fileId);
      await fs.rm(this.store.jobDir(jobId), { recursive: true, force: true });
      for (const l of (job as JobRecord).links)
        await fs.rm(this.store.linkPath(l.tokenHash), { force: true });
    });
    this.logger.log(`job deleted job=${jobId}`);
    return { deleted: true };
  }

  async setHiraSource(memId: number, jobId: string, oddId: number) {
    await this.owned(memId, jobId);
    const hira = await this.loadHira(memId, oddId);
    const job = await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      await this.store.writeJsonAtomic(
        this.store.path(jobId, 'sources', 'hira-db.json'),
        hira.snapshot,
      );
      job.sources.hiraDb = hira.source;
      job.customer = hira.customer;
      return job;
    });
    return this.view(job);
  }

  async clearHiraSource(memId: number, jobId: string) {
    const job = await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      await fs.rm(this.store.path(jobId, 'sources', 'hira-db.json'), {
        force: true,
      });
      job.sources.hiraDb = null;
      job.customer = { ...job.customer, source: 'USER', usrId: null };
      return job;
    });
    return this.view(job);
  }

  async setNtsSource(memId: number, jobId: string, lsaId: number) {
    await this.owned(memId, jobId);
    const nts = await this.loadNts(memId, lsaId);
    const job = await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      await this.store.writeJsonAtomic(
        this.store.path(jobId, 'sources', 'nts-db.json'),
        nts.snapshot,
      );
      job.sources.nts = nts.source;
      return job;
    });
    return this.view(job);
  }

  async clearNtsSource(memId: number, jobId: string) {
    const job = await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      await fs.rm(this.store.path(jobId, 'sources', 'nts-db.json'), {
        force: true,
      });
      job.sources.nts = null;
      return job;
    });
    return this.view(job);
  }

  async putManualPayments(
    memId: number,
    jobId: string,
    items: ManualPaymentDto[],
  ) {
    items.forEach((p, i) => {
      rejectRrn(`items[${i}].note`, p.note);
      rejectRrn(`items[${i}].hospital`, p.hospital);
      rejectRrn(`items[${i}].coverage`, p.coverage);
    });
    return this.update(memId, jobId, (job) => {
      job.manualPayments = plain(items).map((p) => ({
        ...p,
        id: p.id || `m${randomBytes(4).toString('hex')}`,
      }));
    });
  }

  async putOverrides(memId: number, jobId: string, items: OverrideDto[]) {
    items.forEach((o, i) => rejectRrn(`items[${i}].note`, o.note));
    return this.update(memId, jobId, (job) => {
      job.overrides = items.map((o) => ({
        itemKey: o.itemKey,
        insurer: o.insurer,
        status: o.status,
        note: o.note ?? null,
      }));
    });
  }

  async putHospitalAliases(
    memId: number,
    jobId: string,
    items: HospitalAliasDto[],
  ) {
    return this.update(memId, jobId, (job) => {
      job.hospitalAliases = items.map((a) => ({
        hiraName: a.hiraName,
        statementName: a.statementName,
      }));
    });
  }

  async putManualContracts(
    memId: number,
    jobId: string,
    items: ManualContractDto[],
  ) {
    items.forEach((c, i) => {
      const field = `contracts[${i}]`;
      rejectRrn(`${field}.insurer`, c.insurer);
      if (!c.insurer.trim())
        throw crError(400, 'CR_INVALID', '보험사 이름을 넣어 주세요.', {
          field: `${field}.insurer`,
        });
      if (!isCalendarDate(c.start))
        throw crError(400, 'CR_INVALID', '가입일이 날짜가 아니에요.', {
          field: `${field}.start`,
        });
      if (c.end && (!isCalendarDate(c.end) || c.end < c.start))
        throw crError(
          400,
          'CR_INVALID',
          '끝난 날은 가입일과 같거나 뒤여야 해요.',
          { field: `${field}.end` },
        );
    });
    return this.update(memId, jobId, (job) => {
      job.manualContracts = items.map((c) => ({
        id: `mc_${randomBytes(4).toString('hex')}`,
        insurer: c.insurer.trim(),
        start: c.start,
        end: c.end ?? null,
      }));
    });
  }

  async putOutcomes(memId: number, jobId: string, items: OutcomeDto[]) {
    items.forEach((o, i) => rejectRrn(`items[${i}].note`, o.note));
    const now = iso();
    return this.update(memId, jobId, (job) => {
      job.outcomes = items.map((o) => ({
        itemKey: o.itemKey,
        insurer: o.insurer ?? null,
        outcome: o.outcome,
        note: o.note ?? null,
        resultVersion: o.resultVersion,
        at: now,
      }));
    });
  }

  // ───────────────────────── 파일 ─────────────────────────

  async uploadFiles(
    memId: number,
    jobId: string,
    files: Express.Multer.File[],
    dto: UploadDto,
  ) {
    await this.owned(memId, jobId);
    const { rejected } = await this.addFiles(
      jobId,
      files ?? [],
      { ...dto, uploadedBy: 'USER', linkId: null },
      (job) => this.assertOwner(job, memId),
    );
    return { job: await this.view(await this.owned(memId, jobId)), rejected };
  }

  async deleteFile(memId: number, jobId: string, fileId: string) {
    const job = await this.removeFile(jobId, fileId, (job) =>
      this.assertOwner(job, memId),
    );
    return this.view(job);
  }

  async reExtract(
    memId: number,
    jobId: string,
    fileId: string,
    dto: ExtractDto,
  ) {
    assertId(fileId);
    const job = await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      const f = this.file(job, fileId);
      if (RUNNING.includes(f.status))
        throw crError(409, 'CR_EXTRACT_RUNNING', '파일을 읽는 중이에요.');
      if (!f.originalAvailable)
        throw crError(
          410,
          'CR_ORIGINAL_DELETED',
          '원본 PDF 가 지워져 다시 읽을 수 없어요.',
        );
      if (dto.readMode !== undefined)
        f.readMode = dto.readMode as JobFile['readMode'];
      if (dto.ocrDpi !== undefined) f.ocrDpi = dto.ocrDpi as JobFile['ocrDpi'];
      if (dto.kindHint !== undefined) f.kindHint = dto.kindHint;
      if (dto.readerHint !== undefined)
        f.readerHint = dto.readerHint as JobFile['readerHint'];
      //* 다시 판독하면 그 파일의 수정 목록은 비운다(명세 2.4 절)
      f.edits = [];
      f.extraction = null;
      await this.clearExtracted(jobId, fileId);
      this.requeue(jobId, f, dto.password);
      return job;
    });
    this.queue.enqueue({ jobId, fileId });
    return this.view(job);
  }

  async submitPassword(
    memId: number,
    jobId: string,
    fileId: string,
    password: string,
  ) {
    const job = await this.passwordRetry(jobId, fileId, password, (job) =>
      this.assertOwner(job, memId),
    );
    return this.view(job);
  }

  async putEdits(
    memId: number,
    jobId: string,
    fileId: string,
    edits: EditDto[],
  ) {
    assertId(fileId);
    return this.store.lock(jobId, async () => {
      const job = (await this.store.readJob(jobId)) as JobRecord;
      this.assertOwner(job, memId);
      const f = this.file(job, fileId);
      if (RUNNING.includes(f.status))
        throw crError(409, 'CR_EXTRACT_RUNNING', '파일을 읽는 중이에요.');
      edits.forEach((e, i) => {
        const reason = editRejection(f.extraction?.kind, e);
        if (reason)
          throw crError(400, 'CR_EDIT_INVALID', '고칠 수 없는 칸이에요.', {
            editIndex: i,
            reason,
          });
        rejectRrn(`edits[${i}].value`, e.value);
      });
      const original = this.store.path(jobId, 'extracted', `${fileId}.json`);
      if (!(await this.exists(original)))
        throw crError(400, 'CR_INVALID', '읽은 결과가 없어 고칠 수 없어요.');

      const task = `revalidate-${fileId}-${++this.revalidateSeq}`;
      const taskDir = await this.store.makeTaskDir(jobId, task);
      if (!taskDir) throw notFound();
      try {
        const clean = edits.map(({ op, path, value }) => ({ op, path, value }));
        //* 작업 사슬 안에서 돈다 — 다시 판독과 같은 원본을 동시에 바꾸지 않는다(명세 11 절 S5)
        const outcome = await this.runTracked(jobId, fileId, {
          cmd: 'revalidate',
          taskDir,
          input: {
            schema: 'case-report.revalidate-input/1',
            file_id: fileId,
            extraction_path: original,
            edits: clean,
          },
          timeoutMs: caseReportConfig().analyzeTimeoutMs,
          stderrFile: this.store.path(jobId, 'logs', `${task}.stderr.txt`),
        });
        const env = outcome.envelope;
        this.logger.log(
          `revalidate job=${jobId} file=${fileId} status=${env?.status ?? outcome.failure} ms=${outcome.ms}`,
        );
        if (outcome.failure === 'TIMEOUT')
          throw crError(
            504,
            'CR_WORKER_TIMEOUT',
            '다시 계산 시간이 초과됐어요.',
          );
        if (!env)
          throw crError(502, 'CR_WORKER_FAILED', '다시 계산하지 못했어요.', {
            workerCode: outcome.failure,
          });
        if (env.status === 'FAILED') {
          const detail = (env.error?.detail ?? {}) as Record<string, unknown>;
          if (env.error?.code === 'EDIT_INVALID')
            throw crError(
              400,
              'CR_EDIT_INVALID',
              //* 워커 문구(「…습니다」)를 화면에 넘기지 않는다 — 까닭은 detail.reason 이 싣는다
              '고친 값의 형식이 맞지 않아요.',
              {
                ...detail,
                editIndex: detail.edit_index,
                reason: detail.reason,
              },
            );
          throw crError(502, 'CR_WORKER_FAILED', '다시 계산하지 못했어요.', {
            workerCode: env.error?.code ?? null,
          });
        }
        //* 성공했을 때만 편집 목록을 저장하고 적용본을 승격한다
        await fs.rename(
          outcome.outPath,
          this.store.path(jobId, 'extracted', `${fileId}.effective.json`),
        );
        const now = iso();
        f.edits = clean.map((e) => ({ ...e, at: now })) as JobFile['edits'];
        f.status = FILE_STATUS[env.status];
        f.extraction = this.summarize(
          env,
          f.extraction?.extractedAt ?? now,
          now,
        );
        job.updatedAt = now;
        await this.store.writeJob(job);
        return {
          effective: env,
          edits: f.edits,
          file: this.fileView(jobId, f),
        };
      } finally {
        await fs.rm(taskDir, { recursive: true, force: true });
      }
    });
  }

  // ───────────────────────── 분석 ─────────────────────────

  async analyze(memId: number, jobId: string) {
    const config = caseReportConfig();
    const prep = await this.store.lock(jobId, async () => {
      const job = (await this.store.readJob(jobId)) as JobRecord;
      this.assertOwner(job, memId);
      if (this.analyzing.has(jobId))
        throw crError(409, 'CR_ANALYZE_RUNNING', '분석이 이미 실행 중이에요.');
      if (!this.canAnalyze(job))
        throw crError(400, 'CR_NOTHING_TO_ANALYZE', '분석할 자료가 없어요.');
      //* 국세청 분석이 작업을 만든 뒤에 끝났을 수 있다 — 분석마다 같은 고객 건을 다시 찾는다
      if (job.sources.hiraDb && !job.sources.nts) {
        const nts = await this.autoNts(memId, job.customer.usrId);
        if (nts) {
          await this.store.writeJsonAtomic(
            this.store.path(jobId, 'sources', 'nts-db.json'),
            nts.snapshot,
          );
          job.sources.nts = nts.source;
          await this.store.writeJob(job);
        }
      }
      const version = Math.max(0, ...job.results.map((r) => r.version)) + 1;
      //* 시작할 때 쓸 입력을 판본 폴더로 복사해 고정한다 — 분석 도중 편집·출처 교체가 섞이지 않는다
      const inputsDir = this.store.path(jobId, 'results', `v${version}.inputs`);
      await fs.rm(inputsDir, { recursive: true, force: true });
      await fs.rm(this.store.path(jobId, 'results', `v${version}.json`), {
        force: true,
      });
      await fs.mkdir(inputsDir);
      const manifest: Record<string, unknown>[] = [];
      const pin = async (
        src: string,
        name: string,
        role: string,
        fileId?: string,
      ) => {
        const dst = join(inputsDir, name);
        await fs.copyFile(src, dst);
        manifest.push({
          role,
          ...(fileId ? { file_id: fileId } : {}),
          path: dst,
          sha256: sha256(await fs.readFile(dst)),
        });
        return dst;
      };
      const hiraPath = job.sources.hiraDb
        ? await pin(
            this.store.path(jobId, 'sources', 'hira-db.json'),
            'hira-db.json',
            'HIRA_DB',
          )
        : null;
      const ntsPath = job.sources.nts
        ? await pin(
            this.store.path(jobId, 'sources', 'nts-db.json'),
            'nts-db.json',
            'NTS_DB',
          )
        : null;
      const documents: Record<string, unknown>[] = [];
      //* 진료정보분석 건이 붙은 작업은 올린 심평원 · 국세청 PDF 를 쓰지 않는다(DB 자료가 정본) — 판독 상태는 그대로 둔다
      const unusedPdf = (x: JobFile) =>
        !!job.sources.hiraDb && /^(HIRA_|NTS$)/.test(x.extraction?.kind ?? '');
      for (const f of job.files.filter(
        (x) => USABLE.includes(x.status) && !unusedPdf(x),
      )) {
        const path = await pin(
          this.store.path(jobId, 'extracted', `${f.fileId}.effective.json`),
          `doc-${f.fileId}.json`,
          'DOCUMENT',
          f.fileId,
        );
        documents.push({
          file_id: f.fileId,
          name: f.name,
          uploaded_by: f.uploadedBy,
          uploaded_at: f.uploadedAt,
          extraction_path: path,
          edit_count: f.edits.length,
          edited_paths: [...new Set(f.edits.map((e) => e.path))],
        });
      }
      const previous = job.results
        .filter((r) => r.status === 'OK')
        .sort((a, b) => a.version - b.version)
        .pop();
      const input = {
        schema: 'case-report.analyze-input/1',
        job_id: jobId,
        version,
        ref_date: job.options.refDate ?? seoulToday(),
        now: iso(),
        customer: { name: job.customer.name, birth: job.customer.birth },
        options: this.effectiveOptions(job),
        hira_db_snapshot_path: hiraPath,
        nts_snapshot_path: ntsPath,
        documents,
        inputs_manifest: manifest,
        pending_documents: job.files
          .filter((f) => !USABLE.includes(f.status))
          .map((f) => ({ file_id: f.fileId, name: f.name, status: f.status })),
        manual_payments: job.manualPayments,
        overrides: job.overrides.map((o) => ({
          item_key: o.itemKey,
          insurer: o.insurer,
          status: o.status,
          note: o.note,
        })),
        hospital_aliases: job.hospitalAliases.map((a) => ({
          hira_name: a.hiraName,
          statement_name: a.statementName,
        })),
        //* 15차 D16: 직접 넣기 화면이 없어져 보이지 않는 입력이 된다 — 저장값(job.manualContracts)은 되돌림 대비로 남긴다
        manual_contracts: [],
        previous_result_path: previous
          ? this.store.path(jobId, 'results', `v${previous.version}.json`)
          : null,
      };
      await this.store.writeJsonAtomic(
        join(inputsDir, 'analyze-input.json'),
        input,
      );
      this.analyzing.add(jobId);
      return { version, input, inputsDir };
    });

    try {
      const task = `analyze-v${prep.version}`;
      const taskDir = await this.store.makeTaskDir(jobId, task);
      if (!taskDir) throw notFound();
      try {
        const outcome = await this.runTracked(jobId, null, {
          cmd: 'analyze',
          taskDir,
          input: prep.input,
          timeoutMs: config.analyzeTimeoutMs,
          stderrFile: this.store.path(jobId, 'logs', `${task}.stderr.txt`),
        });
        const env = outcome.envelope;
        this.logger.log(
          `analyze job=${jobId} v=${prep.version} status=${env?.status ?? outcome.failure} ms=${outcome.ms}`,
        );
        return await this.store.lock(jobId, async () => {
          const job = await this.store.readJob(jobId);
          if (!job) throw notFound();
          if (!env || env.status === 'FAILED') {
            await fs.rm(prep.inputsDir, { recursive: true, force: true }); //* 판본 번호 반납
            if (outcome.failure === 'TIMEOUT')
              throw crError(
                504,
                'CR_WORKER_TIMEOUT',
                '분석 시간이 초과됐어요.',
              );
            throw crError(502, 'CR_WORKER_FAILED', '분석하지 못했어요.', {
              workerCode: env?.error?.code ?? outcome.failure,
            });
          }
          await fs.rename(
            outcome.outPath,
            this.store.path(jobId, 'results', `v${prep.version}.json`),
          );
          const entry = {
            version: prep.version,
            createdAt: env.created_at ?? prep.input.now,
            round: env.round,
            status: env.status,
            counts: env.summary?.counts ?? {},
            blocked: env.blocked ?? null,
          };
          job.results.push(entry);
          job.updatedAt = iso();
          await this.store.writeJob(job);
          const { createdAt: _createdAt, ...response } = entry;
          return response;
        });
      } finally {
        await fs.rm(taskDir, { recursive: true, force: true });
      }
    } finally {
      this.analyzing.delete(jobId);
    }
  }

  // ───────────────────────── 고객 링크 ─────────────────────────

  async createLink(memId: number, jobId: string, dto: CreateLinkDto) {
    rejectRrn('note', dto.note);
    const token = randomBytes(24).toString('base64url');
    const tokenHash = sha256(token);
    const now = new Date();
    const link: JobLink = {
      linkId: randomUUID(),
      tokenHash,
      createdAt: now.toISOString(),
      expiresAt: new Date(
        now.getTime() + (dto.expiresInHours ?? 72) * 3600_000,
      ).toISOString(),
      revokedAt: null,
      note: dto.note ?? null,
      uploadCount: 0,
    };
    await this.store.mutate(jobId, async (job) => {
      this.assertOwner(job, memId);
      await this.store.writeJsonAtomic(this.store.linkPath(tokenHash), {
        jobId,
        linkId: link.linkId,
      });
      job.links.push(link);
    });
    //* 토큰은 이때 한 번만 준다 — 서버는 sha256 만 저장한다
    return { linkId: link.linkId, token, expiresAt: link.expiresAt };
  }

  async revokeLink(memId: number, jobId: string, linkId: string) {
    assertId(linkId);
    return this.update(memId, jobId, (job) => {
      const link = job.links.find((l) => l.linkId === linkId);
      if (!link)
        throw crError(404, 'CR_LINK_NOT_FOUND', '링크를 찾을 수 없어요.');
      link.revokedAt ??= iso();
    });
  }

  async publicInfo(token: string) {
    const { job, link } = await this.resolveLink(token);
    const info = await this.versionInfo();
    return {
      customerName: maskName(job.customer.name),
      expiresAt: link.expiresAt,
      note: link.note,
      requested: await this.requested(job),
      uploaded: job.files
        .filter((f) => f.linkId === link.linkId)
        .map((f) => ({
          fileId: f.fileId,
          name: f.name,
          uploadedAt: f.uploadedAt,
          status: f.status,
          // 6.2 절 「비밀번호가 맞지 않습니다」를 처음 연 화면에서도 가리기 위한 상태 코드(추출값 아님)
          errorCode: f.status === 'NEEDS_PASSWORD' ? (f.error?.code ?? null) : null,
        })),
      limits: {
        maxFileMb: caseReportConfig().maxFileMb,
        maxFiles: LINK_MAX_FILES,
        remainingFiles: Math.max(0, LINK_MAX_FILES - link.uploadCount),
      },
      preciseInsurers: this.preciseInsurers(info),
    };
  }

  async publicUpload(
    token: string,
    files: Express.Multer.File[],
    password?: string,
  ) {
    const { job, link } = await this.resolveLink(token);
    const { accepted, rejected } = await this.addFiles(
      job.jobId,
      files ?? [],
      { password, uploadedBy: 'CUSTOMER_LINK', linkId: link.linkId },
      (fresh) => this.liveLink(fresh, link.linkId),
    );
    return {
      uploaded: accepted.map((f) => ({
        fileId: f.fileId,
        name: f.name,
        status: f.status,
      })),
      rejected,
    };
  }

  async publicPassword(token: string, fileId: string, password: string) {
    const { job, link } = await this.resolveLink(token);
    const fresh = await this.passwordRetry(job.jobId, fileId, password, (j) => {
      this.liveLink(j, link.linkId);
      if (this.file(j, fileId).linkId !== link.linkId) throw notFound();
    });
    const f = this.file(fresh, fileId);
    return { fileId: f.fileId, name: f.name, status: f.status };
  }

  async publicDeleteFile(token: string, fileId: string) {
    const { job, link } = await this.resolveLink(token);
    await this.removeFile(job.jobId, fileId, (j) => {
      this.liveLink(j, link.linkId);
      if (this.file(j, fileId).linkId !== link.linkId) throw notFound();
    });
    return { deleted: true };
  }

  // ───────────────────────── 내부 ─────────────────────────

  private async owned(memId: number, jobId: string): Promise<JobRecord> {
    const job = await this.store.readJob(jobId);
    this.assertOwner(job, memId);
    return job as JobRecord;
  }

  //* 다른 회원의 작업은 없는 것과 같게 404
  private assertOwner(job: JobRecord | null, memId: number) {
    if (!job || job.memId !== memId) throw notFound();
  }

  private file(job: JobRecord, fileId: string): JobFile {
    assertId(fileId);
    const f = job.files.find((x) => x.fileId === fileId);
    if (!f) throw notFound();
    return f;
  }

  private async update(
    memId: number,
    jobId: string,
    fn: (job: JobRecord) => void,
  ) {
    const job = await this.store.mutate(jobId, (job) => {
      this.assertOwner(job, memId);
      fn(job);
      return job;
    });
    return this.view(job);
  }

  private async view(job: JobRecord) {
    const { memId: _memId, ...rest } = job;
    return {
      ...rest,
      manualContracts: job.manualContracts ?? [],
      files: job.files.map((f) => this.fileView(job.jobId, f)),
      links: job.links.map(({ tokenHash: _hash, ...l }) => l),
      sources: {
        ...job.sources,
        ntsFeature: { tablePresent: await this.ntsPresent() },
      },
      derived: this.derived(job),
    };
  }

  private fileView(jobId: string, f: JobFile) {
    const { edits, passwordPending: _pending, ...rest } = f;
    return {
      ...rest,
      editCount: edits.length,
      progress: this.progressView(jobId, f),
      queuePosition: this.queue.position({ jobId, fileId: f.fileId }),
    };
  }

  //* 쪽마다 들쭉날쭉한 시간을 고른다 — 새 값 30% · 앞 값 70%
  private recordPace(stage: WorkerProgress['stage'], ms: number) {
    const before = this.pace.get(stage);
    this.pace.set(stage, before === undefined ? ms : before * 0.7 + ms * 0.3);
  }

  //* 읽는 중인 파일만 진행을 싣는다 — 끝난 상태를 쓴 뒤 진행을 지우기 전 사이에 끝난 파일에 진행이 붙어 보이지 않게
  private progressView(jobId: string, f: JobFile) {
    const p = f.status === 'EXTRACTING' ? this.progress.get(`${jobId}/${f.fileId}`) : undefined;
    return p ? { ...p, paceMs: this.pace.get(p.stage) ?? null } : null;
  }

  private derived(job: JobRecord) {
    const analyzing = this.analyzing.has(job.jobId);
    const nothing = !this.canAnalyze(job);
    const now = Date.now();
    return {
      processing: job.files.some((f) => RUNNING.includes(f.status)),
      hasActiveLink: job.links.some(
        (l) => !l.revokedAt && Date.parse(l.expiresAt) > now,
      ),
      queueLength: this.queue.length,
      analyzing,
      canAnalyze: !analyzing && !nothing,
      cannotAnalyzeReason: analyzing
        ? 'ANALYZE_RUNNING'
        : nothing
          ? 'NOTHING_TO_ANALYZE'
          : null,
      latestVersion: this.latestResult(job)?.version ?? null,
    };
  }

  private canAnalyze(job: JobRecord) {
    return (
      !!job.sources.hiraDb ||
      job.files.some((f) => USABLE.includes(f.status)) ||
      job.manualPayments.length > 0
    );
  }

  private latestResult(job: JobRecord) {
    return [...job.results].sort((a, b) => a.version - b.version).pop() ?? null;
  }

  private mergeOptions(job: JobRecord, next: AnalysisOptionsDto) {
    const current = job.options.analysis as Record<string, any>;
    const incoming = plain(next) as Record<string, any>;
    return {
      ...current,
      ...incoming,
      ...(incoming.benchmarks
        ? {
            benchmarks: {
              ...(current.benchmarks ?? {}),
              ...incoming.benchmarks,
            },
          }
        : {}),
    };
  }

  //* 없는 키는 기본값(명세 4.5 절)
  private effectiveOptions(job: JobRecord) {
    const stored = job.options.analysis as Record<string, any>;
    return {
      ...DEFAULT_ANALYSIS_OPTIONS,
      ...stored,
      benchmarks: {
        ...DEFAULT_ANALYSIS_OPTIONS.benchmarks,
        ...(stored.benchmarks ?? {}),
      },
    };
  }

  private async loadHira(memId: number, oddId: number) {
    const order = await this.repo.findOwnedOrder(memId, oddId);
    if (!order)
      throw crError(
        404,
        'CR_HIRA_NOT_FOUND',
        '진료정보분석 건을 찾을 수 없어요.',
      );
    if (!order.available)
      throw crError(
        409,
        'CR_HIRA_EMPTY',
        '진료정보분석 자료가 이미 지워졌어요.',
      );
    const tables = await this.repo.hiraTables(oddId);
    if (!tables.tt_treat_basic.length)
      throw crError(
        409,
        'CR_HIRA_EMPTY',
        '진료정보분석 자료가 이미 지워졌어요.',
      );
    const snapshotAt = iso();
    return {
      snapshot: {
        schema: 'case-report.hira-db-snapshot/1',
        odd_id: order.oddId,
        transaction_id: order.transactionId,
        odd_created_at: order.createdAt,
        snapshot_at: snapshotAt,
        customer: {
          usr_id: order.customer.usrId,
          name: order.customer.name,
          birth: order.customer.birth,
        },
        ...tables,
      },
      source: {
        oddId: order.oddId,
        transactionId: order.transactionId,
        snapshotAt,
        rowCounts: {
          basic: tables.tt_treat_basic.length,
          detail: tables.tt_treat_detail.length,
          prescribe: tables.tt_treat_prescribe.length,
          analysisBasic: tables.tn_analysis_basic.length,
          analysisDetail: tables.tn_analysis_detail.length,
        },
        rowsCreatedAt: order.rowsCreatedAt,
      },
      customer: {
        name: order.customer.name || '이름 없음',
        birth: birthOf(order.customer.birth),
        source: 'HIRA_DB' as const,
        usrId: order.customer.usrId,
      },
    };
  }

  private async loadNts(memId: number, lsaId: number) {
    if (!(await this.ntsPresent()))
      throw crError(
        409,
        'CR_NTS_UNAVAILABLE',
        '이 서버에서는 국세청 자료를 쓸 수 없어요.',
      );
    const row = await this.repo.findNts(memId, lsaId);
    if (!row) throw notFound();
    const snapshotAt = iso();
    // shortcut: 국세청 스냅샷을 거르지 않고 복사한다, 운영 전 고객 행만 남기는 스냅샷으로
    return {
      snapshot: {
        schema: 'case-report.nts-db-snapshot/1',
        lsa_id: row.lsa_id,
        usr_id: row.usr_id,
        completed_at: row.completed_at,
        snapshot_at: snapshotAt,
        lsa_result: row.lsa_result,
      },
      source: {
        lsaId: row.lsa_id,
        usrId: row.usr_id,
        completedAt: row.completed_at,
        snapshotAt,
      },
    };
  }

  //* 같은 고객(진료정보분석 건의 회원)의 가장 최근 국세청 분석 건. 없거나 실패하면 null — 작업 만들기 · 분석을 막지 않는다
  private async autoNts(memId: number, usrId: number | null) {
    try {
      if (!usrId || !(await this.ntsPresent())) return null;
      const [row] = await this.repo.listNts(memId, usrId);
      return row ? await this.loadNts(memId, row.lsaId) : null;
    } catch (e) {
      this.logger.warn(`auto nts failed ${(e as Error)?.name}`);
      return null;
    }
  }

  private async ntsPresent(): Promise<boolean> {
    try {
      return await this.repo.ntsTablePresent();
    } catch {
      return false;
    }
  }

  private preciseInsurers(info: Record<string, any> | null) {
    return (
      (info?.precise_insurers ?? []) as { code: string; label: string }[]
    ).map(({ code, label }) => ({ code, label }));
  }

  //* 서버 시작 뒤 첫 성공을 받아 둔다. 실패는 담지 않아 다음 호출이 다시 묻는다
  private versionInfo(): Promise<Record<string, any> | null> {
    this.versionPromise ??= this.runVersion().then((info) => {
      if (!info) this.versionPromise = null;
      return info;
    });
    return this.versionPromise;
  }

  private async runVersion(): Promise<Record<string, any> | null> {
    try {
      await this.store.ensureRoot();
      const taskDir = await this.store.makeTaskDir(
        null,
        `version-${randomUUID()}`,
      );
      if (!taskDir) return null;
      try {
        const out = await this.worker.start({
          cmd: 'version',
          taskDir,
          timeoutMs: 30_000,
        }).done;
        return out.envelope?.status === 'OK' ? out.envelope : null;
      } finally {
        await fs.rm(taskDir, { recursive: true, force: true });
      }
    } catch {
      return null;
    }
  }

  private async addFiles(
    jobId: string,
    files: Express.Multer.File[],
    opts: UploadOptions,
    check: (job: JobRecord) => void,
  ) {
    if (!files.length)
      throw crError(400, 'CR_INVALID', '올릴 파일이 없어요.', {
        field: 'files',
      });
    const config = caseReportConfig();
    const rejected: { name: string; code: string; message: string }[] = [];
    const candidates: { name: string; buffer: Buffer; sha: string }[] = [];
    for (const file of files) {
      const name = decodeName(file.originalname ?? '');
      if (!file.buffer?.subarray(0, 1024).includes('%PDF-')) {
        rejected.push({
          name,
          code: 'CR_NOT_PDF',
          message: 'PDF 로 열 수 없는 파일이에요. 손상됐거나 다른 형식일 수 있어요.',
        });
        continue;
      }
      candidates.push({ name, buffer: file.buffer, sha: sha256(file.buffer) });
    }
    const accepted: JobFile[] = [];
    if (candidates.length) {
      await this.store.mutate(jobId, async (job) => {
        check(job);
        const link = opts.linkId
          ? job.links.find((l) => l.linkId === opts.linkId)
          : null;
        for (const c of candidates) {
          //* 같은 작업 안 같은 sha256 은 거절 — 지급 합계가 두 배가 되는 것을 막는다
          if (job.files.some((f) => f.sha256 === c.sha)) {
            rejected.push({
              name: c.name,
              code: 'CR_DUPLICATE_FILE',
              message: '이미 올린 파일이에요.',
            });
            continue;
          }
          if (
            job.files.length >= config.maxFilesPerJob ||
            (link && link.uploadCount >= LINK_MAX_FILES)
          ) {
            rejected.push({
              name: c.name,
              code: 'CR_LIMIT_FILES',
              message: '올릴 수 있는 파일 수를 넘었어요.',
            });
            continue;
          }
          const fileId = randomUUID();
          await fs.writeFile(
            this.store.path(jobId, 'files', `${fileId}.pdf`),
            c.buffer,
          );
          const f: JobFile = {
            fileId,
            name: c.name,
            sizeBytes: c.buffer.length,
            sha256: c.sha,
            uploadedAt: iso(),
            uploadedBy: opts.uploadedBy,
            linkId: opts.linkId,
            status: 'QUEUED',
            attempt: 1,
            readMode: (opts.readMode as JobFile['readMode']) ?? 'auto',
            ocrDpi: (opts.ocrDpi as JobFile['ocrDpi']) ?? 300,
            kindHint: opts.kindHint ?? null,
            readerHint: (opts.readerHint as JobFile['readerHint']) ?? null,
            extraction: null,
            edits: [],
            error: null,
            originalAvailable: true,
            passwordPending: !!opts.password,
          };
          if (opts.password)
            this.passwords.set(`${jobId}/${fileId}`, opts.password);
          job.files.push(f);
          if (link) link.uploadCount += 1;
          accepted.push(f);
        }
      });
    }
    for (const f of accepted) this.queue.enqueue({ jobId, fileId: f.fileId });
    this.logger.log(
      `upload job=${jobId} by=${opts.uploadedBy} accepted=${accepted.length} rejected=${rejected.length}`,
    );
    return { accepted, rejected };
  }

  private async removeFile(
    jobId: string,
    fileId: string,
    check: (job: JobRecord) => void,
  ) {
    assertId(fileId);
    return this.store.mutate(jobId, async (job) => {
      check(job);
      this.file(job, fileId);
      this.queue.remove(jobId, fileId);
      await this.killRunning(jobId, fileId);
      this.forget(jobId, fileId);
      await fs.rm(this.store.path(jobId, 'files', `${fileId}.pdf`), {
        force: true,
      });
      await this.clearExtracted(jobId, fileId);
      await this.clearCache(jobId, fileId);
      job.files = job.files.filter((f) => f.fileId !== fileId);
      return job;
    });
  }

  private async passwordRetry(
    jobId: string,
    fileId: string,
    password: string,
    check: (job: JobRecord) => void,
  ) {
    assertId(fileId);
    const job = await this.store.mutate(jobId, (job) => {
      check(job);
      const f = this.file(job, fileId);
      if (f.status !== 'NEEDS_PASSWORD')
        throw crError(400, 'CR_INVALID', '비밀번호가 필요한 파일이 아니에요.');
      if (!f.originalAvailable)
        throw crError(410, 'CR_ORIGINAL_DELETED', '원본 PDF 가 지워졌어요.');
      this.requeue(jobId, f, password);
      return job;
    });
    this.queue.enqueue({ jobId, fileId });
    return job;
  }

  //* 새 시도 — attempt 를 올려 앞 시도의 늦은 결과를 거절한다
  private requeue(jobId: string, f: JobFile, password?: string) {
    f.attempt += 1;
    f.status = 'QUEUED';
    f.error = null;
    f.passwordPending = !!password;
    if (password) this.passwords.set(`${jobId}/${f.fileId}`, password);
    else this.passwords.delete(`${jobId}/${f.fileId}`);
  }

  private async runExtract({ jobId, fileId }: QueueItem): Promise<void> {
    const key = `${jobId}/${fileId}`;
    const prep = await this.store
      .lock(jobId, async () => {
        const job = await this.store.readJob(jobId);
        const f = job?.files.find((x) => x.fileId === fileId);
        if (!job || !f || f.status !== 'QUEUED') return null;
        f.status = 'EXTRACTING';
        job.updatedAt = iso();
        await this.store.writeJob(job);
        return {
          ...f,
          deleteOriginal: job.options.deleteOriginalAfterExtract,
          customerName: job.customer.name,
        };
      })
      .catch(() => null);
    if (!prep) return;
    const password = this.passwords.get(key);
    this.passwords.delete(key);
    const task = `extract-${fileId}-a${prep.attempt}`;
    const taskDir = await this.store.makeTaskDir(jobId, task).catch(() => null);
    if (!taskDir) {
      await this.failStuck(jobId, fileId, prep.attempt);
      return;
    }
    try {
      const config = caseReportConfig();
      const cacheDir =
        config.ocrCacheDir ?? this.store.path(jobId, 'cache', fileId);
      await fs
        .mkdir(cacheDir, { recursive: !!config.ocrCacheDir })
        .catch(() => undefined);
      const outcome = await this.runTracked(jobId, fileId, {
        cmd: 'extract',
        taskDir,
        input: {
          schema: 'case-report.extract-input/1',
          file_id: fileId,
          pdf_path: this.store.path(jobId, 'files', `${fileId}.pdf`),
          password_stdin: password !== undefined,
          read_mode: prep.readMode,
          ocr_dpi: prep.ocrDpi,
          kind_hint: prep.kindHint,
          reader_hint: prep.readerHint,
          cache_dir: cacheDir,
          tmp_dir: join(taskDir, 'tmp'),
          limits: {
            max_pages: config.maxPages,
            max_pages_policy: config.maxPagesPolicy,
            page_timeout_s: 120,
          },
          llm:
            config.llmMode === 'intake' && config.llmLedgerDir
              ? {
                  mode: 'intake',
                  //* AI 로 보내기 전에 이 이름을 자리표로 바꾼다(서류에 칸 없이 나오는 자리까지)
                  customer_name: prep.customerName,
                  file_usd: config.llmFileUsd,
                  daily_usd: config.llmDailyUsd,
                  ledger_dir: config.llmLedgerDir,
                  //* 공유 OCR 캐시와 무관하게 파일 폴더에 둬야 작업·원본 삭제가 함께 지운다
                  cache_dir: this.store.path(jobId, 'cache', fileId, 'llm'),
                  //* 양식 표본(가린 글자 + AI 출력)은 작업 밖에 남긴다(서식 스크립트화 재료). 「판독 직후 원본 지우기」 작업은 남기지 않는다
                  ...(prep.deleteOriginal
                    ? {}
                    : { forms_dir: join(this.store.root(), 'llm-forms') }),
                }
              : { mode: 'off' },
        },
        password,
        timeoutMs: config.extractTimeoutMs,
        onProgress: (p) => {
          const now = iso();
          const prev = this.progress.get(key);
          //* 같은 단계에서 완료 쪽이 늘면 그 사이 시간 ÷ 늘어난 쪽 = 한 쪽 처리 시간. 파일의 첫 쪽은 글자 인식 엔진을 띄우는 시간이 섞여 빼고 잰다
          if (prev?.stage === p.stage && prev.done > 0 && p.done > prev.done)
            this.recordPace(
              p.stage,
              (Date.parse(now) - Date.parse(prev.updatedAt)) /
                (p.done - prev.done),
            );
          this.progress.set(key, {
            ...p,
            startedAt: prev?.startedAt ?? now,
            updatedAt: now,
          });
        },
        stderrFile: this.store.path(jobId, 'logs', `${task}.stderr.txt`),
      });
      //* AI 읽기는 요청 한 번이라 끝난 때에야 잰다 — 쪽당 시간으로 남긴다. 진행은 끝난 상태를 쓴 뒤(finally) 지운다(Codex 2)
      const last = this.progress.get(key);
      if (last?.stage === 'AI' && outcome.envelope && last.total > 0)
        this.recordPace(
          'AI',
          (Date.now() - Date.parse(last.updatedAt)) / last.total,
        );
      if (outcome.failure === 'KILLED') return;
      const status = await this.store.lock(jobId, async () => {
        const job = await this.store.readJob(jobId);
        const f = job?.files.find((x) => x.fileId === fileId);
        //* 늦은 결과 거절: 작업·파일이 없어졌거나 시도 번호가 바뀌었으면 승격하지 않는다
        if (
          !job ||
          !f ||
          f.attempt !== prep.attempt ||
          f.status !== 'EXTRACTING'
        )
          return 'DISCARDED';
        await this.applyExtract(job, f, outcome);
        //* 판독 중에 「원본 지우기」를 켰으면 워커가 시작 때 값으로 남긴 양식 표본도 지운다(켜진 작업은 표본을 남기지 않는다)
        const sha = outcome.envelope?.file?.sha256;
        if (
          !prep.deleteOriginal &&
          job.options.deleteOriginalAfterExtract &&
          typeof sha === 'string' &&
          /^[0-9a-f]{16}/.test(sha)
        )
          await fs.rm(
            join(this.store.root(), 'llm-forms', `${sha.slice(0, 16)}.json`),
            { force: true },
          );
        job.updatedAt = iso();
        await this.store.writeJob(job);
        return f.status;
      });
      this.logger.log(
        `extract job=${jobId} file=${fileId} a=${prep.attempt} status=${status} ms=${outcome.ms} code=${
          outcome.envelope?.error?.code ?? outcome.failure ?? '-'
        }`,
      );
    } catch (e) {
      this.logger.error(
        `extract job=${jobId} file=${fileId} error=${(e as Error)?.name}`,
      );
      await this.failStuck(jobId, fileId, prep.attempt);
    } finally {
      this.progress.delete(key);
      await fs.rm(taskDir, { recursive: true, force: true });
    }
  }

  //* 판독 처리 중 예외로 끝나면 「읽는 중」에 머물지 않게 실패로 내린다(같은 시도이고 아직 판독 중일 때만)
  private async failStuck(jobId: string, fileId: string, attempt: number) {
    await this.store
      .lock(jobId, async () => {
        const job = await this.store.readJob(jobId);
        const f = job?.files.find((x) => x.fileId === fileId);
        if (!job || !f || f.attempt !== attempt || f.status !== 'EXTRACTING')
          return;
        f.status = 'FAILED';
        f.error = { code: 'INTERNAL', message: FAILURE_TEXT.INTERNAL, at: iso() };
        f.passwordPending = false;
        job.updatedAt = iso();
        await this.store.writeJob(job);
      })
      .catch(() => undefined);
  }

  private async applyExtract(
    job: JobRecord,
    f: JobFile,
    outcome: WorkerOutcome,
  ) {
    const jobId = job.jobId;
    const env = outcome.envelope;
    f.passwordPending = false;
    if (!env) {
      const code = outcome.failure as string;
      f.status = 'FAILED';
      //* 판독 결과 없이 끝난 시도는 extraction 시각이 없어 실패 시각을 따로 남긴다(결과 뒤 다시 판독 실패를 화면이 알아채게)
      f.error = { code, message: FAILURE_TEXT[code] ?? '파일을 읽지 못했어요.', at: iso() };
      f.extraction = null;
      return;
    }
    f.status = FILE_STATUS[env.status];
    //* 미지원도 이유(쓸 수 없는 서류 등)를 싣는다 — 화면이 이유 줄을 그린다
    f.error =
      env.status === 'FAILED' ||
      env.status === 'NEEDS_PASSWORD' ||
      (env.status === 'UNSUPPORTED' && env.error)
        ? this.workerError(env.error)
        : null;
    if (['OK', 'PARTIAL', 'UNSUPPORTED'].includes(env.status)) {
      const original = this.store.path(jobId, 'extracted', `${f.fileId}.json`);
      await fs.rename(outcome.outPath, original);
      await fs.copyFile(
        original,
        this.store.path(jobId, 'extracted', `${f.fileId}.effective.json`),
      );
    }
    f.extraction = this.summarize(env, iso(), null);
    if (USABLE.includes(f.status) && job.options.deleteOriginalAfterExtract) {
      await fs.rm(this.store.path(jobId, 'files', `${f.fileId}.pdf`), {
        force: true,
      });
      await this.clearCache(jobId, f.fileId);
      f.originalAvailable = false;
    }
  }

  private workerError(error: any): ErrorInfo {
    return {
      code: typeof error?.code === 'string' ? error.code : 'INTERNAL',
      message:
        typeof error?.message === 'string'
          ? error.message
          : '파일을 읽지 못했어요.',
    };
  }

  //* 파일 요약은 적용본의 summary·file·timing 에서만 만든다(data 를 읽지 않는다)
  private summarize(
    env: Record<string, any>,
    extractedAt: string,
    revalidatedAt: string | null,
  ): FileExtraction {
    const s = env.summary ?? {};
    const pages: { mode?: string }[] = env.timing?.pages ?? [];
    return {
      kind: env.kind ?? null,
      status: env.status,
      reader: s.reader ?? null,
      form: s.form ?? null,
      insurer: s.insurer ?? null,
      insurers: Array.isArray(s.insurers) ? s.insurers : null,
      variant: s.variant ?? null,
      docScopeKind: s.doc_scope_kind ?? null,
      pagesComplete: s.pages_complete ?? null,
      contractScope: s.contract_scope ?? null,
      subjectName: s.subject_name ?? null,
      pages: env.file?.pages ?? null,
      checks: {
        total: s.checks_total ?? 0,
        failed: s.checks_failed ?? 0,
        blockingFailed: s.blocking_failed ?? 0,
      },
      reviewCount: s.review_count ?? 0,
      warningCodes: ((env.warnings ?? []) as { code?: unknown }[])
        .map((w) => w?.code)
        .filter((c): c is string => typeof c === 'string'),
      timing: {
        totalMs: env.timing?.total_ms ?? null,
        textPages: pages.filter((p) => p.mode === 'text').length,
        ocrPages: pages.filter((p) => p.mode === 'ocr').length,
      },
      extractedAt,
      revalidatedAt,
      workerVersion: env.engine?.worker_version ?? null,
    };
  }

  private async runTracked(
    jobId: string,
    fileId: string | null,
    opts: Parameters<CaseReportWorker['start']>[0],
  ): Promise<WorkerOutcome> {
    const entry: Running = { fileId, run: this.worker.start(opts) };
    const set = this.running.get(jobId) ?? new Set<Running>();
    set.add(entry);
    this.running.set(jobId, set);
    try {
      return await entry.run.done;
    } finally {
      set.delete(entry);
      if (!set.size && this.running.get(jobId) === set)
        this.running.delete(jobId);
    }
  }

  private async killRunning(jobId: string, fileId?: string) {
    const targets = [...(this.running.get(jobId) ?? [])].filter(
      (r) => fileId === undefined || r.fileId === fileId,
    );
    for (const r of targets) r.run.kill();
    await Promise.all(targets.map((r) => r.run.done.catch(() => undefined)));
  }

  private forget(jobId: string, fileId: string) {
    this.passwords.delete(`${jobId}/${fileId}`);
    this.progress.delete(`${jobId}/${fileId}`);
  }

  private async clearExtracted(jobId: string, fileId: string) {
    await fs.rm(this.store.path(jobId, 'extracted', `${fileId}.json`), {
      force: true,
    });
    await fs.rm(
      this.store.path(jobId, 'extracted', `${fileId}.effective.json`),
      { force: true },
    );
  }

  //* 파일 폴더(OCR 쪽 글자 · LLM 응답)는 항상 지운다. 공유 OCR 캐시(CASE_REPORT_OCR_CACHE_DIR, 개발 전용)는 이 폴더 밖이라 남는다
  private async clearCache(jobId: string, fileId: string) {
    await fs.rm(this.store.path(jobId, 'cache', fileId), {
      recursive: true,
      force: true,
    });
  }

  private async resolveLink(
    token: string,
  ): Promise<{ job: JobRecord; link: JobLink }> {
    const missing = () =>
      crError(404, 'CR_LINK_NOT_FOUND', '링크를 찾을 수 없어요.');
    if (typeof token !== 'string' || !TOKEN_RE.test(token)) throw missing();
    const tokenHash = sha256(token);
    const index = await this.store.readJson<{ jobId: string; linkId: string }>(
      this.store.linkPath(tokenHash),
    );
    if (!index) throw missing();
    const job = await this.store.readJob(index.jobId).catch(() => null);
    const link = job?.links.find(
      (l) => l.linkId === index.linkId && l.tokenHash === tokenHash,
    );
    if (!job || !link) throw missing();
    this.liveLink(job, link.linkId);
    return { job, link };
  }

  private liveLink(job: JobRecord, linkId: string) {
    const link = job.links.find((l) => l.linkId === linkId);
    if (!link)
      throw crError(404, 'CR_LINK_NOT_FOUND', '링크를 찾을 수 없어요.');
    if (link.revokedAt)
      throw crError(
        410,
        'CR_LINK_REVOKED',
        '링크 사용 기간이 지났어요. 담당 설계사에게 새 링크를 요청해 주세요.',
      );
    if (Date.parse(link.expiresAt) <= Date.now())
      throw crError(
        410,
        'CR_LINK_EXPIRED',
        '링크 사용 기간이 지났어요. 담당 설계사에게 새 링크를 요청해 주세요.',
      );
  }

  private async requested(job: JobRecord) {
    const latestOk = job.results
      .filter((r) => r.status === 'OK')
      .sort((a, b) => a.version - b.version)
      .pop();
    //* 통합조회 결과서는 고객이 받을 수 없어 요청 서류에서 뺀다(사용자 결정 10-29). 심평원 · 국세청은 PDF 로 받지 않는다(r14 D7)
    if (!latestOk)
      return [
        {
          kind: 'STATEMENT',
          label: '보험금 지급내역(가입한 보험사)',
          insurer: null,
        },
        {
          kind: 'COVERAGE',
          label: '가입 담보(보험사별 보장 목록 · 보험증권)',
          insurer: null,
        },
      ];
    const result = await this.store
      .readJson<any>(
        this.store.path(job.jobId, 'results', `v${latestOk.version}.json`),
      )
      .catch(() => null);
    const out: { kind: string; label: string; insurer: string | null }[] = (
      (result?.guides?.statement_requests?.items ?? []) as {
        insurer?: string;
      }[]
    ).map((i) => ({
      kind: 'STATEMENT',
      label: '보험금 지급내역',
      insurer: i.insurer ?? null,
    }));
    return out;
  }

  private async exists(path: string) {
    try {
      await fs.access(path);
      return true;
    } catch {
      return false;
    }
  }

  private async clearDir(dir: string) {
    try {
      for (const name of await fs.readdir(dir))
        await fs.rm(join(dir, name), { recursive: true, force: true });
    } catch (e) {
      if (!isMissing(e)) throw e;
    }
  }
}
