import { MedicationStandardRepository } from '@api/modules/medication-standard/medication-standard.repository';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DrizzleModule, DrizzleService } from '@lib/drizzle/src';
import configuration from '@api/config/configuration';
import { eq } from 'drizzle-orm';
import { tnOrderDetail, tnUser } from 'drizzle/schema';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { DdxAnalyzeResponse, DdxClient } from './ddx.client';
import {
  Dosing30DayGroupInput,
  buildAnalyzeRequest,
  serializeAnalyzeRequest,
} from './ddx-payload';

//* 환자 1명 왕복 진입점 — 라우트가 아니라 독립 실행 스크립트다.
//*
//* **왕복 전용 최소 모듈**을 이 파일 안에 선언한다(선례: schedules/delete-invoice-files.schedule.ts).
//* AppModule 을 부트하면 ScheduleModule.forRoot({}) 와 PushModule 의 @Cron 이 딸려 와
//* 폴링 동안 무관한 운영 작업이 돈다(계획서 관측 6). 여기 싣는 것은 설정·DB·리포지토리·클라이언트
//* 넷뿐이다.
//*
//* **되돌릴 수 없는 호출 앞에 배타 마커를 앉힌다.** 스펙에 멱등 키가 없어(관측 2)
//* external_ref 가 재전송을 막아 주지 않으므로 중복 방지는 이쪽 책임이다. durable 만으로는
//* 부족하다 — 동시에 뜬 두 프로세스는 둘 다 마커 부재를 읽고 둘 다 POST 한다. 생성은 `wx` 다.
//*
//* **판정을 한 번이라도 보냈으면 다시 보내지 않는다.** 마커에 analysis_id 가 있으면 상태가
//* started 든 cleanup_pending 이든 남은 할 일은 정리뿐이며, 그 경로는 DELETE 만 탄다.
//*
//* 모드는 둘이다.
//*   --fixture [경로]   기본. 추적 산출물(.claude/plans/...roundtrip.json)을 쓴다. DB 를 읽지 않는다.
//*   --odd-id <n>       실환자. 산출은 gitignore 된 .claude/state/ddx-roundtrip/ 아래에만 쓴다.
//*
//* 막힌 마커를 푸는 유일한 방법은 .claude/state/ddx-roundtrip/ 아래 파일을 손으로 지우는 것이다.
//* 그 전에 제공자 쪽에 판정이 남아 있는지 확인한다 — 마커가 막는 이유가 그것이다.

//* 폴링 상한 3종 — 횟수 · 간격 · 전체 마감. 셋 다 상수여야 무한 대기가 구조적으로 불가능하다.
const POLL_MAX_ATTEMPTS = 20;
const POLL_INTERVAL_MS = 5000;
const POLL_DEADLINE_MS = 180000;

const EXTERNAL_REF = 'drug-diag-api-m1-roundtrip';
const ANALYZE_WAIT_SEC = 15;
const STATE_DIR = '.claude/state/ddx-roundtrip';
const FIXTURE_DEFAULT = '.claude/plans/drug-diag-api-m1.fixture.json';
const TRACKED_OUT = '.claude/plans/drug-diag-api-m1.roundtrip.json';
const SOURCE_FILES = [
  'insuniverse-server/apps/api/src/modules/ddx/ddx-payload.ts',
  'insuniverse-server/apps/api/src/modules/ddx/ddx.client.ts',
  'insuniverse-server/apps/api/src/modules/ddx/ddx-roundtrip.script.ts',
];

// 저장소 루트 · 파일 유틸 ---------------------------------------------------

//* 이 파일은 insuniverse-server 서브모듈 안에 있고 산출물은 상위 저장소에 쓴다.
//* 상대 단수는 **소스 트리 실행에서만** 맞다 — dist 로 컴파일되면 층이 달라져 조용히 엉뚱한
//* 경로에 쓴다. 그래서 유도한 루트가 실제 저장소인지 단언하고, 아니면 아무것도 쓰지 않고 죽는다.
let cachedRoot: string | null = null;
function repoRoot(): string {
  if (cachedRoot) return cachedRoot;
  const root = path.resolve(__dirname, '../../../../../..');
  if (!fs.existsSync(path.join(root, '.claude'))) {
    throw new Error('저장소 루트 유도 실패 — ' + root + ' 에 .claude 가 없다 (소스 트리에서 실행하라)');
  }
  cachedRoot = root;
  return root;
}
const at = (rel: string) => path.join(repoRoot(), rel);

//* EOL 정규화 후 해시한다 — core.autocrlf 체크아웃에서 raw 바이트 스탬프는 전건 어긋난다.
function sourceDigest(rel: string): string {
  const text = fs.readFileSync(at(rel), 'utf8').replace(/\r\n/g, '\n');
  return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}

function writeJsonDurable(file: string, value: unknown): void {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  const fd = fs.openSync(file, 'w');
  try {
    fs.writeSync(fd, JSON.stringify(value, null, 2) + '\n');
    fs.fsyncSync(fd);
  } finally {
    fs.closeSync(fd);
  }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

const trimmedOrEmpty = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');

//* 죽은 주인이 남긴 잠금을 회수하기 위한 생존 확인. EPERM 은 "있는데 신호를 못 보낸다" 이므로 생존이다.
function pidAlive(pid: unknown): boolean {
  if (!Number.isSafeInteger(pid) || (pid as number) <= 0) return false;
  try {
    process.kill(pid as number, 0);
    return true;
  } catch (e: any) {
    return e?.code === 'EPERM';
  }
}

// 마커 -----------------------------------------------------------------------
//*
//* **완료 판정의 권한은 원장이 아니라 마커다.** 원장은 시도의 불변 기록이고, "정리가 끝났는가" 는
//* 마커 상태 하나가 답한다 — 재시도가 몇 행을 남기든, 동시 재개가 2xx 와 404 를 함께 남기든
//* 게이트가 붉어지지 않는 이유다.

type MarkerState = 'started' | 'cleanup_pending' | 'closed';

interface Marker {
  state: MarkerState;
  mode: 'fixture' | 'real';
  external_ref: string;
  opened_at: string;
  analysis_id: string | null;
  recorded_at: string | null;
  closed_at: string | null;
}

const markerPath = (mode: string) => at(STATE_DIR + '/marker-' + mode + '.json');
const claimPath = (mode: string) => at(STATE_DIR + '/marker-' + mode + '.claim');

function readMarker(mode: string): Marker | null {
  try {
    return JSON.parse(fs.readFileSync(markerPath(mode), 'utf8'));
  } catch {
    return null;
  }
}

/**
 * POST 앞의 마커는 **배타 생성**이다. 이미 있으면 다른 실행이 쥐고 있다는 뜻이므로 물러난다 —
 * 덮어쓰기로 만들면 동시에 뜬 두 프로세스가 둘 다 판정을 보낸다.
 */
function createMarkerExclusive(mode: string, marker: Marker): boolean {
  fs.mkdirSync(at(STATE_DIR), { recursive: true });
  let fd: number;
  try {
    fd = fs.openSync(markerPath(mode), 'wx');
  } catch {
    return false;
  }
  try {
    fs.writeSync(fd, JSON.stringify(marker, null, 2) + '\n');
    fs.fsyncSync(fd);
  } finally {
    fs.closeSync(fd);
  }
  return true;
}

function readClaim(mode: string): { pid?: unknown } | null {
  try {
    return JSON.parse(fs.readFileSync(claimPath(mode), 'utf8'));
  } catch {
    return null;
  }
}

function tryCreateClaim(mode: string): boolean {
  try {
    const fd = fs.openSync(claimPath(mode), 'wx');
    fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
    fs.closeSync(fd);
    return true;
  } catch {
    return false;
  }
}

/**
 * DELETE 전 배타 claim. 잡지 못하면 다른 프로세스가 정리 중이라는 뜻이므로 물러난다.
 *
 * **주인이 죽은 claim 은 회수한다.** 하드킬은 해제 finally 를 건너뛰고, 그렇게 남은 잠금은
 * 이후 모든 실행을 물러나게 해 정리가 영구히 막힌다 — 제공자 쪽에 실명·상병이 남은 채로.
 * 잠금 파일이 이미 pid 를 담고 있으므로 생존 확인 한 번이면 그 상태가 사라진다.
 */
function acquireCleanupClaim(mode: string): boolean {
  fs.mkdirSync(at(STATE_DIR), { recursive: true });
  if (tryCreateClaim(mode)) return true;
  const holder = readClaim(mode);
  if (holder && pidAlive(holder.pid)) return false;
  try {
    fs.unlinkSync(claimPath(mode));
  } catch {
    return false;
  }
  return tryCreateClaim(mode);
}

function releaseCleanupClaim(mode: string): void {
  try {
    fs.unlinkSync(claimPath(mode));
  } catch {
    /* 이미 없으면 목적 달성 */
  }
}

// 원장 -----------------------------------------------------------------------

interface LedgerEntry {
  at: string;
  method: 'POST' | 'GET' | 'DELETE';
  path: string;
  http_status: number;
  ms: number;
  note: string | null;
}

const ledger: LedgerEntry[] = [];

//* 실패한 시도를 빼지 않는다 — 감사 기록이 훼손된다.
function ledgerPush(
  method: LedgerEntry['method'],
  p: string,
  out: { status: number; ms: number },
  note: string | null = null,
): void {
  ledger.push({
    at: new Date().toISOString(),
    method,
    path: p,
    http_status: out.status,
    ms: out.ms,
    note,
  });
}

// 왕복 전용 최소 모듈 ---------------------------------------------------------

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      cache: true,
      load: [configuration],
      envFilePath: [path.join(process.cwd(), '.env')],
    }),
    DrizzleModule,
  ],
  providers: [MedicationStandardRepository, DdxClient],
})
class DdxRoundtripModule {}

// 실행 -----------------------------------------------------------------------

interface Args {
  mode: 'fixture' | 'real';
  oddId: number | null;
  fixturePath: string;
}

function parseArgs(argv: string[]): Args {
  const out: Args = { mode: 'fixture', oddId: null, fixturePath: FIXTURE_DEFAULT };
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--odd-id') {
      out.mode = 'real';
      out.oddId = Number(argv[++i]);
    } else if (argv[i] === '--fixture') {
      out.mode = 'fixture';
      const next = argv[i + 1];
      if (next && !next.startsWith('--')) out.fixturePath = argv[++i];
    }
  }
  if (out.mode === 'real' && !Number.isSafeInteger(out.oddId as number)) {
    throw new Error('--odd-id 값이 정수가 아니다');
  }
  return out;
}

/**
 * 앞선 실행이 남긴 마커를 읽어 이번 실행이 할 일을 정한다.
 *
 * **analysis_id 를 가진 마커는 상태와 무관하게 정리 경로로 간다.** started 는 "POST 는 됐고
 * 기록 전에 죽었다" 는 뜻이지 "다시 보내도 된다" 가 아니다 — 재전송이 곧 중복 판정이다.
 */
type Resume =
  | { kind: 'fresh' }
  | { kind: 'done'; closedAt: string | null }
  | { kind: 'cleanup'; marker: Marker; analysisId: string }
  | { kind: 'blocked'; reason: string };

function resolveResume(mode: string): Resume {
  const prior = readMarker(mode);
  if (!prior) return { kind: 'fresh' };
  if (prior.state === 'closed') return { kind: 'done', closedAt: prior.closed_at };

  const analysisId = trimmedOrEmpty(prior.analysis_id);
  if (analysisId) return { kind: 'cleanup', marker: prior, analysisId };

  return {
    kind: 'blocked',
    reason:
      '[ddx-roundtrip] 앞선 실행이 POST 와 기록 사이에서 죽었고 analysis_id 를 모른다 (state=' +
      prior.state +
      ') — 두 번째 POST 를 거부한다. 제공자 쪽에 판정이 남았는지 수동으로 확인한 뒤 ' +
      STATE_DIR +
      ' 아래 마커를 지워라',
  };
}

/** 입력 수집. 픽스처 모드는 DB 를 읽지 않는다. */
async function collectInput(
  app: Awaited<ReturnType<typeof NestFactory.createApplicationContext>>,
  args: Args,
): Promise<{
  groups: Dosing30DayGroupInput[];
  personName: string;
  inputProvenance: Record<string, unknown>;
}> {
  if (args.mode === 'fixture') {
    const fx = JSON.parse(fs.readFileSync(at(args.fixturePath), 'utf8'));
    return {
      groups: fx.groups,
      personName: fx.person.name,
      inputProvenance: { provenance: 'fixture', fixture_path: args.fixturePath },
    };
  }

  const repo = app.get(MedicationStandardRepository);
  const drizzle = app.get(DrizzleService);
  //* 배치와 같은 입력 — 분류 컷 없이(0). `ddx-verdict.service` 주석.
  const groups = (await repo.getDosing30DayGroups(args.oddId as number, undefined, undefined, 0)) as Dosing30DayGroupInput[];
  //* 30일 그룹 산출에 이름이 없다(관측 5). 실명 출처는 tnOrderDetail.usrId -> tnUser.usrName
  //* 하나이며 이 자리가 유일한 결박이다. 값은 어떤 출력문에도 싣지 않는다(UI11 · UI1).
  const [ownerRow] = await drizzle
    .select({ personName: tnUser.usrName })
    .from(tnOrderDetail)
    .innerJoin(tnUser, eq(tnUser.usrId, tnOrderDetail.usrId))
    .where(eq(tnOrderDetail.oddId, args.oddId as number));
  if (!ownerRow?.personName) throw new Error('환자 이름을 찾지 못했다 — oddId 소유자 미확인');
  return {
    groups,
    personName: ownerRow.personName,
    inputProvenance: { provenance: 'real', odd_id: args.oddId },
  };
}

/**
 * 상한 있는 폴링. 재조회는 GET 만 쓴다.
 *
 * `getReachable` 은 **한 번이라도 200 이었는가**로 누적한다. 마지막 시도만 보면 중간에 도달했던
 * 사실이 마지막 실패 하나로 지워지고, 마일스톤 2 가 그 값을 보고 잘못 추측한다.
 */
async function pollUntilSettled(
  client: DdxClient,
  analysisId: string,
  initial: DdxAnalyzeResponse,
): Promise<{ response: DdxAnalyzeResponse; getReachable: boolean }> {
  let response = initial;
  let getReachable: boolean | null = null;
  const deadline = Date.now() + POLL_DEADLINE_MS;

  for (let attempt = 1; response?.status === 'partial' && attempt <= POLL_MAX_ATTEMPTS; attempt++) {
    if (Date.now() >= deadline) {
      console.error('[ddx-roundtrip] 폴링 전체 마감 초과 — partial 로 종료한다');
      break;
    }
    await sleep(POLL_INTERVAL_MS);
    const got = await client.getAnalysis(analysisId, { waitSec: 10 });
    ledgerPush('GET', '/analyses/{analysis_id}', got, 'attempt=' + attempt);
    getReachable = (getReachable ?? false) || got.status === 200;
    if (got.status === 200) response = got.data;
  }

  if (getReachable === null) {
    //* partial 이 아니어서 폴링을 안 돌았어도 GET 도달 가능 여부를 남긴다 — 마일스톤 2 가 추측하지 않게.
    const probe = await client.getAnalysis(analysisId, { waitSec: 0 });
    ledgerPush('GET', '/analyses/{analysis_id}', probe, 'reachability probe');
    getReachable = probe.status === 200;
    if (probe.status === 200) response = probe.data;
  }

  return { response, getReachable };
}

/** 신규 왕복 1회 — 마커 · POST · 폴링 · 기록 · 정리. */
async function runFreshRoundtrip(
  app: Awaited<ReturnType<typeof NestFactory.createApplicationContext>>,
  client: DdxClient,
  args: Args,
  outPath: string,
): Promise<number> {
  const { groups, personName, inputProvenance } = await collectInput(app, args);

  const body = buildAnalyzeRequest({
    personName,
    groups,
    externalRef: EXTERNAL_REF,
    store: true, //* false 면 partial 이 왔을 때 GET 이 가리킬 대상이 없어 ready 에 못 간다
  });
  const normalized = serializeAnalyzeRequest(body);

  // (1) POST 앞에 배타 마커
  const marker: Marker = {
    state: 'started',
    mode: args.mode,
    external_ref: EXTERNAL_REF,
    opened_at: new Date().toISOString(),
    analysis_id: null,
    recorded_at: null,
    closed_at: null,
  };
  if (!createMarkerExclusive(args.mode, marker)) {
    console.error('[ddx-roundtrip] 마커를 배타 생성하지 못했다 — 다른 실행이 진행 중이거나 미완 마커가 남아 있다');
    return 1;
  }

  // (2) 판정 요청 1회
  const posted = await client.analyze(body, ANALYZE_WAIT_SEC);
  ledgerPush('POST', '/analyze', posted, 'wait=' + ANALYZE_WAIT_SEC);
  if (posted.status !== 200) {
    console.error('[ddx-roundtrip] POST /analyze HTTP ' + posted.status + ' — 중단한다');
    return 1;
  }

  //* **analysis_id 는 관용으로 받지 않는다.** 스펙상 AnalyzeResponse required 이고 type: string 이다.
  //* 없는 채로 진행하면 이후 경로가 문자열 "null" 을 id 로 써서 DELETE /analyses/null 을 부르고,
  //* 그 404 를 정리 완료로 읽어 마커를 닫는다 — store:true 로 보낸 실명·상병이 제공자 쪽에 남은 채로.
  const analysisId = trimmedOrEmpty(posted.data?.analysis_id);
  if (!analysisId) {
    console.error(
      '[ddx-roundtrip] POST /analyze 가 200 인데 analysis_id 가 없다 (스펙 required 위반) — ' +
        '판정이 제공자 쪽에 남았을 수 있다. 마커는 재실행을 막은 채로 둔다',
    );
    return 1;
  }
  marker.analysis_id = analysisId;
  writeJsonDurable(markerPath(args.mode), marker);

  const { response, getReachable } = await pollUntilSettled(client, analysisId, posted.data);

  // (3) 기록 -> 마커를 cleanup_pending 으로
  marker.state = 'cleanup_pending';
  marker.recorded_at = new Date().toISOString();
  writeJsonDurable(markerPath(args.mode), marker);

  const artifact: Record<string, unknown> = {
    _purpose: '왕복 1회의 호출 원장 · 매퍼 입력 · 정규화 요청 · 응답 · 소스 digest 3종',
    ...inputProvenance,
    recorded_at: marker.recorded_at,
    external_ref: EXTERNAL_REF,
    analysis_id: analysisId,
    get_reachable: getReachable,
    source_digests: Object.fromEntries(SOURCE_FILES.map((f) => [f, sourceDigest(f)])),
    mapper_input: { person: { name: personName }, groups },
    normalized_request: normalized,
    response,
    ledger,
    marker: { ...marker },
  };
  //* 실환자 모드의 outPath 는 STATE_DIR 아래이며 추적 경로에는 아무것도 만들지 않는다(관측 7).
  writeJsonDurable(outPath, artifact);

  // (4) DELETE -> 2xx 또는 404 뒤에만 마커를 닫는다
  const cleaned = await runCleanup(client, args.mode, analysisId, marker);
  artifact.ledger = ledger;
  artifact.marker = readMarker(args.mode);
  writeJsonDurable(outPath, artifact);
  return cleaned ? 0 : 1;
}

async function main(): Promise<number> {
  const args = parseArgs(process.argv.slice(2));
  const outPath =
    args.mode === 'fixture'
      ? at(TRACKED_OUT)
      : at(STATE_DIR + '/roundtrip-odd-' + args.oddId + '.json');

  const resume = resolveResume(args.mode);
  if (resume.kind === 'done') {
    console.log('[ddx-roundtrip] 이미 완료된 왕복이다 (marker closed at ' + resume.closedAt + ') — 아무것도 호출하지 않는다');
    return 0;
  }
  if (resume.kind === 'blocked') {
    console.error(resume.reason);
    return 1;
  }

  let app: Awaited<ReturnType<typeof NestFactory.createApplicationContext>> | null = null;
  try {
    app = await NestFactory.createApplicationContext(DdxRoundtripModule, { bufferLogs: true });
    const client = app.get(DdxClient);
    //* 설정 결손은 요청이 구조적으로 도달할 수 없는 상태다. 마커를 만들기 **전에** 갈라내지 않으면
    //* 그 실패가 "도달했을지 모르는 전송오류" 와 같은 자리에 떨어져 마커가 영구히 막힌다.
    client.assertConfigured();

    if (resume.kind === 'cleanup') {
      const done = await runCleanup(client, args.mode, resume.analysisId, resume.marker);
      mergeLedgerInto(outPath, args.mode);
      return done ? 0 : 1;
    }
    return await runFreshRoundtrip(app, client, args, outPath);
  } finally {
    if (app) await app.close();
  }
}

/** DELETE 만 재시도한다. POST 는 부르지 않는다. */
async function runCleanup(
  client: DdxClient,
  mode: string,
  analysisId: string,
  marker: Marker,
): Promise<boolean> {
  if (!acquireCleanupClaim(mode)) {
    console.error('[ddx-roundtrip] 다른 프로세스가 정리 중이다 (claim 점유) — 물러난다');
    return false;
  }
  try {
    const del = await client.deleteAnalysis(analysisId);
    ledgerPush('DELETE', '/analyses/{analysis_id}', del, null);
    const terminal = (del.status >= 200 && del.status < 300) || del.status === 404;
    if (!terminal) {
      console.error('[ddx-roundtrip] DELETE HTTP ' + del.status + ' — 마커를 cleanup_pending 으로 둔다. 재실행은 DELETE 만 재시도한다');
      return false;
    }
    const closed: Marker = {
      ...marker,
      analysis_id: analysisId,
      state: 'closed',
      closed_at: new Date().toISOString(),
    };
    writeJsonDurable(markerPath(mode), closed);
    return true;
  } finally {
    releaseCleanupClaim(mode);
  }
}

/** cleanup_pending 재개 경로에서 산출물의 원장·마커만 갱신한다. */
function mergeLedgerInto(outPath: string, mode: string): void {
  try {
    const prev = JSON.parse(fs.readFileSync(outPath, 'utf8'));
    prev.ledger = [...(prev.ledger ?? []), ...ledger];
    prev.marker = readMarker(mode);
    writeJsonDurable(outPath, prev);
  } catch {
    /* 산출물이 아직 없으면 남길 곳이 없다 — 마커가 정본이다 */
  }
}

main()
  .then((code) => process.exit(code))
  .catch((err: any) => {
    console.error('[ddx-roundtrip] 실패:', err?.message);
    process.exit(1);
  });
