// 관리자 RBAC M8 · 관리자 access 토큰의 권한 버전 (pv) 복원 판정.
// 발급 네 지점과 두 가드(필수 · 선택)가 이 파일을 함께 쓴다. 판정 순서는 restoreAdminIdentity 한 곳이다.
import type { DrizzleService } from '@lib/drizzle/src/drizzle.service';
import { Logger, ServiceUnavailableException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import {
  tnAdmin,
  tnAgency,
  tnAgencyOrganization,
  tnMember,
  tnSalesAgency,
} from 'drizzle/schema';
import {
  PERMISSION_VERSION_UNAVAILABLE,
  PermissionVersionUnavailable,
  readGlobalVersion,
  type PermissionReadDb,
} from '../permission/permission-version';
import type { AdminKind } from '../principal/principal';
import { accountKeyOf, readPending, type SessionRow } from './admin-session';
import { nowSec } from './signin-lock';

export type AdminClaims = Record<string, unknown> & {
  principalKind: AdminKind;
};

// 드라이버가 주는 모양 그대로다 (nullable 칸은 null 이다). 자격 판정은 toAdminClaims 가 한다.
export type AdminSourceRow =
  | { admId: number; admLoginId: string | null }
  | { sagId: number; sagLoginId: string | null }
  | { agcId: number; agcLoginId: string | null }
  | {
      memId: number;
      memType: string | null;
      memIsOrgManager: number | null;
      memIsResigned: number | null;
      agcId: number | null;
      agcLoginId: string | null;
      orgId: string | null;
      orgPath: string | null;
    };

// 원천 행을 키로만 읽는다 (자격 조건 없음). 자격 판정은 toAdminClaims 한 곳이다.
export async function loadSourceRow(
  db: PermissionReadDb,
  kind: AdminKind,
  payload: Record<string, unknown>,
): Promise<AdminSourceRow | null> {
  switch (kind) {
    case 'admin': {
      if (!Number.isInteger(payload.admId)) return null;
      const [row] = await db
        .select({ admId: tnAdmin.admId, admLoginId: tnAdmin.admLoginId })
        .from(tnAdmin)
        .where(eq(tnAdmin.admId, payload.admId as number))
        .limit(1);
      return row ?? null;
    }
    case 'sales': {
      if (!Number.isInteger(payload.sagId)) return null;
      const [row] = await db
        .select({
          sagId: tnSalesAgency.sagId,
          sagLoginId: tnSalesAgency.sagLoginId,
        })
        .from(tnSalesAgency)
        .where(eq(tnSalesAgency.sagId, payload.sagId as number))
        .limit(1);
      return row ?? null;
    }
    case 'corp-super': {
      if (!Number.isInteger(payload.agcId)) return null;
      const [row] = await db
        .select({
          agcId: tnAgency.agcId,
          agcLoginId: tnAgency.agcLoginId,
        })
        .from(tnAgency)
        .where(eq(tnAgency.agcId, payload.agcId as number))
        .limit(1);
      return row ?? null;
    }
    case 'org-manager': {
      if (!Number.isInteger(payload.memId)) return null;
      const [row] = await db
        .select({
          memId: tnMember.memId,
          memType: tnMember.memType,
          memIsOrgManager: tnMember.memIsOrgManager,
          memIsResigned: tnMember.memIsResigned,
          agcId: tnMember.agcId,
          agcLoginId: tnAgency.agcLoginId,
          orgId: tnMember.orgId,
          orgPath: tnAgencyOrganization.orgPath,
        })
        .from(tnMember)
        .innerJoin(tnAgency, eq(tnMember.agcId, tnAgency.agcId))
        .leftJoin(
          tnAgencyOrganization,
          eq(tnMember.orgId, tnAgencyOrganization.orgId),
        )
        .where(eq(tnMember.memId, payload.memId as number))
        .limit(1);
      return row ?? null;
    }
  }
}

/**
 * 원천 행 → 클레임. 순수 함수다 (DB 를 읽지 않는다).
 * 자격 조건은 여기서만 판정한다. org-manager 는 로그인 조회 (admin.repository getOrgManagerByCorporateCode)
 * 와 같은 조건이다. 행이 없으면 null 이다.
 */
export function toAdminClaims(
  kind: AdminKind,
  row: AdminSourceRow | null,
): AdminClaims | null {
  if (!row) return null;
  switch (kind) {
    // 행 모양이 종류와 다르면 자격이 없다 (null). 빈 클레임 (undefined 칸) 을 만들지 않는다 (리뷰 R1).
    case 'admin': {
      if (!('admId' in row)) return null;
      return {
        role: 'admin',
        admId: row.admId,
        admLoginId: row.admLoginId,
        principalKind: 'admin',
      };
    }
    case 'sales': {
      if (!('sagId' in row)) return null;
      return {
        role: 'sales',
        sagId: row.sagId,
        sagLoginId: row.sagLoginId,
        principalKind: 'sales',
      };
    }
    case 'corp-super': {
      if (!('agcId' in row) || 'memId' in row) return null;
      return {
        role: 'corp',
        corpScope: 'SUPER',
        agcId: row.agcId,
        agcLoginId: row.agcLoginId,
        orgId: null,
        orgPath: null,
        principalKind: 'corp-super',
      };
    }
    case 'org-manager': {
      if (!('memId' in row)) return null;
      const eligible =
        row.memIsOrgManager === 1 &&
        row.memType === 'MEM002' &&
        row.memIsResigned === 0 &&
        row.agcId !== null &&
        typeof row.orgPath === 'string' &&
        row.orgPath !== '';
      if (!eligible) return null;
      return {
        role: 'corp',
        corpScope: 'ORG',
        agcId: row.agcId,
        memId: row.memId,
        agcLoginId: row.agcLoginId,
        orgId: row.orgId,
        orgPath: row.orgPath,
        principalKind: 'org-manager',
      };
    }
  }
}

// 두 가드가 쓰는 입출력. 테스트는 이 객체를 가짜로 바꾼다 (기본값은 실제 저장소).
export type AdminTokenIo = {
  readGlobalVersion: (db: PermissionReadDb) => Promise<number | null>;
  loadSourceRow: (
    db: PermissionReadDb,
    kind: AdminKind,
    payload: Record<string, unknown>,
  ) => Promise<AdminSourceRow | null>;
  readSession: (db: PermissionReadDb, sid: string) => Promise<SessionRow | null>;
};

const logger = new Logger('AdminTokenVersion');

export const DEFAULT_ADMIN_TOKEN_IO: AdminTokenIo = {
  readGlobalVersion,
  loadSourceRow,
  //* 세 호출자가 이미 이 예외를 「닫음」 으로 낸다(필수 503 · 선택 익명 · 전역 503). 원인은 코드만 남긴다(메시지에 SQL 이 실린다)
  // shortcut: 세션 읽기 실패를 권한 버전 판정 불가 예외로 싣는다, 503 본문 표지를 가르게 되면 전용 예외를 둔다
  readSession: (db, sid) =>
    readPending(db, sid).catch((e) => {
      logger.error(`admin session read failed: ${(e as any)?.code ?? (e as any)?.errno ?? 'unknown'}`);
      throw new PermissionVersionUnavailable('session-read');
    }),
};

// 요청에 싣는 클레임 (재발급이면 DB 행에서 온 값, 아니면 payload 그대로일 수 있다).
// 재발급이면 pv 는 숫자다 (판별 유니온으로 컴파일러가 막는다).
export type RestoredAdminIdentity =
  | { claims: Record<string, unknown>; pv: number | null; reissue: false }
  | { claims: Record<string, unknown>; pv: number; reissue: true };

/**
 * 관리자 토큰 하나의 신원을 복원한다. 두 가드가 같은 순서로 부른다.
 *  (0) 세션 행을 먼저 대조한다(SD-1 · AZ-1). sid 가 없거나, 행이 없거나 · 끊김 · 만료 · ACTIVE 아님 ·
 *      종류 다름 · 계정 키 다름이면 거부다. 빠른 통과 · 이행기 · 재발급 · 전역 선복원 · 선택 가드 모두 이 줄을 지난다.
 *  (a) 전역 버전을 먼저 읽는다. 판정 불가면 PermissionVersionUnavailable 을 올린다.
 *  (b) 이행기 (null): admin 은 payload 를 그대로 준다 (재발급 없음). 세 범위 종류는 원천을 그래도 대조한다.
 *  (c) 버전 일치 admin 은 DB 를 읽지 않는다. 그 밖에는 원천 행으로 클레임을 세운다.
 *      범위 종류는 클레임과 payload 가 같으면 재발급하지 않고, 하나라도 다르면 재발급한다.
 * 결과가 null 이면 거부다.
 */
export async function restoreAdminIdentity(
  db: PermissionReadDb,
  kind: AdminKind,
  payload: Record<string, unknown>,
  io: AdminTokenIo = DEFAULT_ADMIN_TOKEN_IO,
): Promise<RestoredAdminIdentity | null> {
  const sid = payload.sid;
  if (typeof sid !== 'string' || sid === '') return null; //* D1: sid 없는 토큰은 즉시 거부
  const s = await io.readSession(db, sid);
  const key = accountKeyOf(kind, payload);
  if (
    !s ||
    s.state !== 'ACTIVE' ||
    s.revokedAt !== null ||
    s.expiresAt <= nowSec() ||
    s.kind !== kind ||
    key === null ||
    s.accountId !== key
  ) {
    return null;
  }

  const version = await io.readGlobalVersion(db);
  const scoped = kind !== 'admin';

  if (version === null) {
    //* 이행기: 버전 표 · 칸이 없어 관리자 토큰을 버전으로 대조하지 못한다. 요청마다 흔적을 남긴다 (리뷰 R1).
    logger.warn(`admin token pv check skipped (transition): kind=${kind}`);
    if (!scoped) return { claims: { ...payload }, pv: null, reissue: false };
    const claims = toAdminClaims(
      kind,
      await io.loadSourceRow(db, kind, payload),
    );
    return claims ? { claims: { ...claims, sid }, pv: null, reissue: false } : null;
  }

  if (!scoped && payload.pv === version) {
    return { claims: { ...payload }, pv: version, reissue: false };
  }

  const claims = toAdminClaims(kind, await io.loadSourceRow(db, kind, payload));
  if (!claims) return null;
  if (!scoped) return { claims: { ...claims, sid }, pv: version, reissue: true };

  //* 「같음」 은 sid 를 더하기 전 클레임으로 본다 — 원천 행에는 sid 가 없다
  const same =
    payload.pv === version &&
    Object.keys(claims).every((k) => payload[k] === claims[k]);
  return { claims: { ...claims, sid }, pv: version, reissue: !same };
}

/**
 * 발급 시점의 전역 버전. 판정 불가면 로그인을 503 으로 막는다 (pv 없는 토큰을 남기지 않는다).
 * null 이면 이행기라 pv 클레임을 넣지 않는다.
 */
export async function signinVersion(
  db: DrizzleService,
): Promise<number | null> {
  try {
    return await readGlobalVersion(db);
  } catch (e) {
    if (e instanceof PermissionVersionUnavailable) {
      throw new ServiceUnavailableException(PERMISSION_VERSION_UNAVAILABLE);
    }
    throw e;
  }
}
