import { OrderDetailState } from '@api/modules/order/order-detail/order-detail.entity';
import { DrizzleService } from '@lib/drizzle/src';
import { Injectable } from '@nestjs/common';
import dayjs from 'dayjs';
import {
  and,
  asc,
  eq,
  gte,
  inArray,
  isNotNull,
  isNull,
  lt,
  or,
  sql,
} from 'drizzle-orm';
import { tnOrderDetail, tnUser, ttDdxVerdict } from 'drizzle/schema';

/**
 * 판정 행의 상태 어휘. 계획서 D1·D2·D10 이 정의한 상태 머신이고 `#5`·`#7` 이 이 집합을 센다.
 *
 * `requested` 와 `sent` 를 가르는 것이 핵심이다 — `requested` 는 행만 만들고 아직 아무것도 보내지
 * 않은 상태라 죽어도 POST 가 안전하고, `sent` 는 이미 보낸 상태라 `analysis_id` 가 없으면 도달
 * 여부를 알 수 없다.
 */
export const DDX_VERDICT_STATE = {
  REQUESTED: 'requested',
  SENT: 'sent',
  READY: 'ready',
  PARTIAL: 'partial',
  CLEANUP_PENDING: 'cleanup_pending',
  DONE: 'done',
  EMPTY: 'empty',
  FAILED_RETRYABLE: 'failed_retryable',
  FAILED_UNKNOWN: 'failed_unknown',
} as const;

export type DdxVerdictState =
  (typeof DDX_VERDICT_STATE)[keyof typeof DDX_VERDICT_STATE];

/**
 * 배치가 다시 집어야 하는 상태들.
 *
 * **행 부재만 보면 한 번 실패한 분석이 영구히 선택되지 않는다** — 정확히 1회가 아니라 최대 1회가
 * 되고 그 유실은 조용하다. `failed_unknown` 은 여기 없다: 도달 여부를 모르는 요청의 재전송은
 * 두 번째 판정이 될 수 있어 자동 재시도 대상이 아니다(D2 · UI2).
 *
 * **여기 담긴 상태는 배타 마커의 사정거리 밖이다.** `insertRequested` 의 `uq_ddv_odd_id` 는 행이
 * 없을 때만 겹침을 가르므로, 이미 행이 있는 재개 대상은 `markSent` 의 조건부 전이가 대신 가른다
 * (`DDX_CLAIMABLE_STATES`).
 */
export const DDX_RESUMABLE_STATES: DdxVerdictState[] = [
  DDX_VERDICT_STATE.REQUESTED,
  DDX_VERDICT_STATE.CLEANUP_PENDING,
  DDX_VERDICT_STATE.FAILED_RETRYABLE,
];

/**
 * `markSent` 가 `sent` 로 뒤집을 수 있는 출발 상태.
 *
 * `cleanup_pending` 은 없다 — 그 상태의 재개는 DELETE 만 타고 POST 를 부르지 않는다(UI2).
 */
export const DDX_CLAIMABLE_STATES: DdxVerdictState[] = [
  DDX_VERDICT_STATE.REQUESTED,
  DDX_VERDICT_STATE.FAILED_RETRYABLE,
];

/**
 * 30일 스윕이 **행째로 지워도 되는** 상태 — 제공자 쪽에 원본이 없다고 말할 수 있는 자리들.
 *
 * `requested` 는 아직 아무것도 보내지 않았고, `empty` 는 보낼 것이 없어 POST 를 건너뛰었으며,
 * `failed_retryable` 은 4xx — 요청 자체가 거절돼 판정이 만들어지지 않았다. `done` 은 DELETE 가
 * 2xx·404 로 종결된 뒤에만 찍힌다.
 *
 * **여기 없는 셋(`sent`·`cleanup_pending`·`failed_unknown`)은 제공자 쪽에 원본이 남아 있을 수
 * 있다.** 그 행을 30일째 통째로 지우면 유일한 열쇠인 `ddv_analysis_id` 가 함께 사라져 아래 운영
 * 메모가 지시하는 수동 DELETE 가 불가능해진다 — 실명·상병이 제공자 쪽에 영구히 남고 그 사실을
 * 아는 기록이 어디에도 없다. 스윕은 그 행의 `ddv_result` 만 비우고 열쇠는 남긴다
 * (`analyze.repository.ts` `delete()`).
 */
export const DDX_SWEEPABLE_STATES: DdxVerdictState[] = [
  DDX_VERDICT_STATE.REQUESTED,
  DDX_VERDICT_STATE.EMPTY,
  DDX_VERDICT_STATE.FAILED_RETRYABLE,
  DDX_VERDICT_STATE.DONE,
];

/**
 * 재시도 상한. **`ddv_attempts` 는 세는 것으로 끝나면 상한이 아니다** — 대상 선정이 그 값을
 * 읽지 않으면 지속 실패 행이 매 실행 다시 뽑히고, `limit` 만큼의 자리를 영구히 차지해 신규
 * 분석이 선택되지 않는다. 그 유실은 조용하다.
 */
export const DDX_MAX_ATTEMPTS = 5;

/**
 * 선정이 보는 「분석이 끝났다」의 정의.
 *
 * 리터럴 `'ODD006'` 을 적지 않고 `OrderDetailState` 를 인용한다 — 그 열거가 이 값의 정본이고,
 * 상태를 옮기는 두 자리(`treat.event-handler.ts` · `treat.service.ts`)가 같은 열거를 쓴다.
 * 여기에 문자열을 박으면 그 셋이 따로 움직일 수 있고, 어긋나도 컴파일러가 말해 주지 않는다.
 */
export const DDX_ELIGIBLE_ORDER_STATE = OrderDetailState.분석완료;

//* 사람이 봐야 하는 자리 — 이 배치는 아래 셋을 **스스로 해소하지 않는다.**
//* (아래 `RETENTION_DAYS` 와 무관한 운영 메모다. 어느 선언에도 묶이지 않게 `//*` 로 둔다.)
//*
//* 1. `ddv_state = 'failed_unknown'` — 도달 여부 불명. 재전송이 두 번째 판정이 될 수 있어
//*    자동 재시도 대상이 아니다(D2 · UI2).
//* 2. `ddv_state = 'sent'` 로 오래 남은 행 — 응답을 받기 전에 프로세스가 죽은 자리다. 같은
//*    이유로 재시도 대상이 아니며, `ddv_sent_at` 이 그 시점을 남긴다.
//* 3. `ddv_attempts >= DDX_MAX_ATTEMPTS` — 상한 소진. `cleanup_pending` 에서 소진했다면
//*    제공자 쪽에 원본이 남아 있다는 뜻이라 수동 DELETE 가 필요하다(`ddv_analysis_id`).

/** 대상 선정이 보는 분석 나이 상한. 30일 스윕이 `usrId` 를 끊기 전까지만 요청을 만들 수 있다. */
const RETENTION_DAYS = 30;

const now = () => dayjs().format('YYYY-MM-DD HH:mm:ss');

//* DB 안에서 더한다 — 읽고 더해 쓰면 겹친 두 프로세스가 증가 하나를 잃는다.
const attemptsPlusOne = () => sql<number>`${ttDdxVerdict.ddvAttempts} + 1`;

/**
 * mysql2 드라이버가 낸 변경 행수.
 *
 * drizzle 의 mysql UPDATE 는 `[ResultSetHeader, FieldPacket[]]` 을 낸다. **모르는 형상에서 0 을
 * 내지 않는다** — 조건부 전이의 반환값이 곧 "다른 프로세스가 가져갔다" 이므로, 형상 오독이
 * 0 으로 접히면 정상 요청이 전부 조용히 중복 판정으로 분류되고 판정이 하나도 나가지 않는다.
 * 읽을 수 없으면 던진다.
 */
const affectedRows = (result: unknown): number => {
  const head = Array.isArray(result) ? result[0] : result;
  const n = (head as { affectedRows?: unknown } | undefined)?.affectedRows;
  if (typeof n !== 'number') {
    throw new Error('ddx: UPDATE 결과에서 affectedRows 를 읽지 못했다');
  }
  return n;
};

export interface DdxVerdictRow {
  ddvId: number;
  oddId: number;
  ddvState: string;
  ddvAnalysisId: string | null;
  ddvResult: unknown;
  ddvAttempts: number;
}

@Injectable()
export class DdxVerdictRepository {
  constructor(private readonly drizzle: DrizzleService) {}

  /**
   * 배치 대상 선정.
   *
   * 30일 이내이고 `usrId` 가 살아 있는 분석만 고른다 — 30일 스윕이 `usrId` 를 `null` 로 만들면
   * 실명 출처가 끊겨 요청 자체를 만들 수 없다.
   *
   * **분석이 끝난 것만 고른다**(`odd_state = ODD006`). 이 조건이 없으면 수집·분석 중인 분석이
   * 선정되고, 그 시점의 30일 투약 그룹은 아직 0건이라 `processOne` 이 행을 `empty` 로 닫는다.
   * `empty` 는 `DDX_RESUMABLE_STATES` 에 없는 **종결** 상태라 그 분석은 자료가 다 들어온 뒤에도
   * 영영 다시 선정되지 않는다. 화면에는 「판정 결과가 아직 준비되지 않았어요」가 그대로 남는데
   * (`toDiagnosisGroupsView` 가 적재본 부재를 `status: 'none'` 으로 접는다) 그것은 「아직」이
   * 아니라 「영원히」다 — 조기 종결과 미처리가 화면에서 구분되지 않는다.
   *
   * 실측(2026-09-10 stg): 판정 행 22건 중 `empty` 가 8건이었다. 배치는 30분 주기이고 수집은 분
   * 단위라, 주기마다 그 창에 걸린 분석이 조용히 종결됐다.
   *
   * `분석완료`(`ODD006`)가 그 경계다 — `treat.event-handler.ts` · `treat.service.ts` 가 투약
   * 자료를 다 적재한 뒤 `oddCompletedAt` 과 함께 이 상태로 옮긴다. 그 전 상태(`자료분석중` ·
   * `인증요청중`)에서는 그룹이 비어 있는 것이 정상이고, 그 정상을 판정 부재로 오독하면 안 된다.
   *
   * **지운 분석은 고르지 않는다**(`odd_deletion = 0`). 판정 요청 바디에는 실명이 실리므로
   * (`buildAnalyzeRequest`) 사용자가 지운 분석의 실명을 외부 제공자에 새로 보내는 일이 없어야
   * 한다. 이웃 질의(`alimtalk.repository.ts` · `order-detail.repository.ts`)와 같은 조건이다.
   *
   * **재시도는 `DDX_MAX_ATTEMPTS` 에서 멈춘다.** 상한 없이 `failed_retryable` 을 다시 무는 선정은
   * 지속 실패 행에게 `limit` 자리를 영구히 내준다.
   *
   * **정렬이 없으면 그 자리 다툼이 DB 의 반환 순서에 맡겨진다.** 미시도(판정 행 없음 → `attempts`
   * 가 `NULL` → MySQL asc 에서 맨 앞)를 항상 먼저 놓고, 그다음 시도 횟수가 적은 순으로 간다.
   */
  async selectTargets(limit: number): Promise<number[]> {
    const since = dayjs()
      .subtract(RETENTION_DAYS, 'day')
      .format('YYYY-MM-DD HH:mm:ss');

    const rows = await this.drizzle
      .select({ oddId: tnOrderDetail.oddId })
      .from(tnOrderDetail)
      .leftJoin(ttDdxVerdict, eq(ttDdxVerdict.oddId, tnOrderDetail.oddId))
      .where(
        and(
          gte(tnOrderDetail.oddCreatedAt, since),
          isNotNull(tnOrderDetail.usrId),
          //* 분석이 끝난 것만. 그 전에는 30일 투약 그룹이 비어 있고, 그 빈 것을 판정 부재로
          //* 읽으면 행이 `empty` 로 종결돼 되살아나지 못한다.
          eq(tnOrderDetail.oddState, DDX_ELIGIBLE_ORDER_STATE),
          //* 지운 분석의 실명을 외부 제공자에 새로 보내지 않는다.
          eq(tnOrderDetail.oddDeletion, 0),
          or(
            isNull(ttDdxVerdict.ddvId),
            and(
              inArray(ttDdxVerdict.ddvState, DDX_RESUMABLE_STATES),
              lt(ttDdxVerdict.ddvAttempts, DDX_MAX_ATTEMPTS),
            ),
          ),
        ),
      )
      .orderBy(asc(ttDdxVerdict.ddvAttempts), asc(tnOrderDetail.oddId))
      .limit(limit);

    return rows.map((r) => r.oddId);
  }

  /** 실명 조회. `tnOrderDetail.usrId` -> `tnUser.usrName` 이 유일한 출처다. */
  async findPersonName(oddId: number): Promise<string | null> {
    const [row] = await this.drizzle
      .select({ personName: tnUser.usrName })
      .from(tnOrderDetail)
      .innerJoin(tnUser, eq(tnUser.usrId, tnOrderDetail.usrId))
      .where(eq(tnOrderDetail.oddId, oddId));
    return row?.personName ?? null;
  }

  async findByOddId(oddId: number): Promise<DdxVerdictRow | null> {
    const [row] = await this.drizzle
      .select({
        ddvId: ttDdxVerdict.ddvId,
        oddId: ttDdxVerdict.oddId,
        ddvState: ttDdxVerdict.ddvState,
        ddvAnalysisId: ttDdxVerdict.ddvAnalysisId,
        ddvResult: ttDdxVerdict.ddvResult,
        ddvAttempts: ttDdxVerdict.ddvAttempts,
      })
      .from(ttDdxVerdict)
      .where(eq(ttDdxVerdict.oddId, oddId));
    return (row as DdxVerdictRow) ?? null;
  }

  /**
   * 요청 마커 선행 INSERT. **판정 요청보다 먼저** 불린다.
   *
   * `uq_ddv_odd_id` 위반은 예외가 아니라 정상 경로다 — 다른 프로세스가 이미 그 분석을 맡았다는
   * 뜻이고 호출자는 물러난다. 파일 마커와 달리 프로세스가 겹쳐도 성립한다(D1).
   */
  async insertRequested(oddId: number): Promise<boolean> {
    try {
      await this.drizzle.insert(ttDdxVerdict).values({
        oddId,
        ddvState: DDX_VERDICT_STATE.REQUESTED,
        ddvRequestedAt: now(),
        ddvUpdatedAt: now(),
      });
      return true;
    } catch (e: any) {
      if (e?.code === 'ER_DUP_ENTRY' || e?.errno === 1062) return false;
      throw e;
    }
  }

  /**
   * POST 직전 전이. 이 쓰기 한 번이 *안 보냈다* 와 *보냈다* 를 가른다(D1).
   *
   * **조건부다 — 이것이 재개 경로의 배타 마커다.** `insertRequested` 의 유니크 키는 행이 없을
   * 때만 겹침을 가르므로, `requested`·`failed_retryable` 로 남은 행은 배치 둘이 동시에 뽑아
   * 둘 다 claim 을 건너뛰고 둘 다 POST 할 수 있었다. 출발 상태를 술어에 넣으면 먼저 도착한
   * 쪽만 1행을 바꾸고 나머지는 0행을 받아 물러난다 — 판정 1회 요청이 상태 머신으로 닫힌다.
   *
   * @returns 이 호출이 실제로 `sent` 로 뒤집었는가. `false` 면 다른 프로세스가 이미 가져갔다.
   */
  async markSent(oddId: number): Promise<boolean> {
    const result = await this.drizzle
      .update(ttDdxVerdict)
      .set({
        ddvState: DDX_VERDICT_STATE.SENT,
        ddvSentAt: now(),
        ddvUpdatedAt: now(),
      })
      .where(
        and(
          eq(ttDdxVerdict.oddId, oddId),
          inArray(ttDdxVerdict.ddvState, DDX_CLAIMABLE_STATES),
        ),
      );
    return affectedRows(result) > 0;
  }

  /**
   * 시도 횟수만 올린다. **상태는 손대지 않는다** — 예외가 어느 지점에서 났는지 모르는 자리에서
   * 상태를 내리면 이미 보낸 행이 재전송 대상이 될 수 있다(UI2). 이 쓰기가 없으면 던지는 행이
   * 상한에 걸리지 않아 매 실행 같은 자리를 차지한다.
   *
   * **증가는 DB 안에서 한다.** 읽고 더해 쓰면 겹친 두 프로세스가 같은 값을 읽어 증가 하나가
   * 유실되고, 그 상한이 곧 지속 실패 행을 멈추는 유일한 장치다. 질의도 둘에서 하나로 준다.
   */
  async bumpAttempts(oddId: number): Promise<void> {
    await this.drizzle
      .update(ttDdxVerdict)
      .set({ ddvAttempts: attemptsPlusOne(), ddvUpdatedAt: now() })
      .where(eq(ttDdxVerdict.oddId, oddId));
  }

  async setState(
    oddId: number,
    state: DdxVerdictState,
    patch: {
      analysisId?: string | null;
      result?: unknown;
      errorCode?: string | null;
      errorMessage?: string | null;
      completed?: boolean;
      bumpAttempts?: boolean;
    } = {},
  ): Promise<void> {
    await this.drizzle
      .update(ttDdxVerdict)
      .set({
        ddvState: state,
        ddvUpdatedAt: now(),
        ...(patch.analysisId !== undefined
          ? { ddvAnalysisId: patch.analysisId }
          : {}),
        ...(patch.result !== undefined ? { ddvResult: patch.result } : {}),
        ...(patch.errorCode !== undefined
          ? { ddvErrorCode: patch.errorCode }
          : {}),
        ...(patch.errorMessage !== undefined
          ? { ddvErrorMessage: patch.errorMessage?.slice(0, 500) ?? null }
          : {}),
        ...(patch.completed ? { ddvCompletedAt: now() } : {}),
        ...(patch.bumpAttempts ? { ddvAttempts: attemptsPlusOne() } : {}),
      })
      .where(eq(ttDdxVerdict.oddId, oddId));
  }
}
