import {
  SUPER_ADMIN_PROTECTED,
  SUPER_ADMIN_UNDELETABLE,
} from '@api/constants/admin.constant';
import { mysqlErrorWrapper } from '@lib/drizzle/src/drizzle.utill';
import { ForbiddenException, HttpException, Injectable } from '@nestjs/common';
import { AdminIdentity } from '../auth/admin/admin-identity';
import { isAccountOwner } from '../auth/admin/admin-session';
import {
  CreateAdminDto,
  DeleteAdminDto,
  GetAdminListDto,
  UpdateAdminDto,
} from './admin.dto';
import { AuditContext } from '../audit/audit.entity';
import { AdminRepository } from './admin.repository';
import {
  SuperAdminDesignation,
  SuperAdminService,
} from './super-admin.service';
import { protectedIds, type SuperGrantState } from './super-admin.query';

@Injectable()
export class AdminService {
  constructor(
    private adminRepository: AdminRepository,
    private superAdmin: SuperAdminService,
  ) {}

  /**
   * 최고관리자 계정 보호 **한 자리**. 수정·삭제 쓰기가 전부 이 자리를 지난다(`#9` 가 범위로 잰다) —
   * 없으면 내부 관리자 누구나 최고관리자의 비밀번호를 바꿔 전권을 넘겨받는다.
   * 보호 집합(지정 root + GRANTED)은 **그 계정 본인만** 고친다 — 승인 최고관리자끼리 · root 를 서로 차지하지 못한다(노트 84 T3 · T8).
   *
   * 지정 행이 없거나 표시 칸이 없는 환경은 **통과**다 — 보호할 최고관리자가 없으므로 넘겨받을 전권도
   * 없고, DDL 이 실패한 환경에서 관리자 관리가 멈추면 안 된다(UI9). 그 상태는 운영 알림이 드러낸다.
   *
   * 지정 조회가 칸 없음 밖의 이유로 실패하면 **던진다** — 판정을 못 하는 동안 쓰기를 통과시키지 않는다.
   */
  private async assertMayTouch(
    requester: AdminIdentity | undefined,
    targets: Array<number | string>,
  ): Promise<{ d: SuperAdminDesignation; guarded: Set<number> }> {
    //* 경로 매개변수는 문자열로 온다 — `'1' === 1` 이 거짓이라 맞추지 않으면 보호가 조용히 빗나간다.
    const ids = targets.map(Number);
    const d = await this.superAdmin.readDesignation();
    const guarded = protectedIds(d, await this.superAdmin.readGrants());
    if (ids.some((id) => guarded.has(id) && !isAccountOwner(requester, 'admin', id))) {
      throw new ForbiddenException(SUPER_ADMIN_PROTECTED);
    }
    //* 읽은 지정 · 보호 집합을 돌려준다 — 삭제 차단(`deleteAdmin`)이 이 읽기를 그대로 쓴다.
    return { d, guarded };
  }

  // 지정이 살아 있으면 요청자가 최고관리자여야 한다. 생성 · 삭제가 같은 조건을 쓴다 (리뷰 11번).
  private async assertSuperWhenDesignated(
    d: SuperAdminDesignation,
    requester: AdminIdentity | undefined,
  ): Promise<void> {
    if (
      d.state === 'designated' &&
      !(await this.superAdmin.isSuperAdmin(requester))
    ) {
      throw new ForbiddenException(SUPER_ADMIN_PROTECTED);
    }
  }

  /**
   * 표시용 지정 행. 표시는 화장이므로 조회가 실패하면 전부 거짓으로 싣고 목록은 선다 —
   * 보호(`assertMayTouch`)와 달리 여기서 던지면 지정 조회 하나가 관리자 목록 화면을 통째로 닫는다.
   */
  private async designationForDisplay(): Promise<SuperAdminDesignation> {
    try {
      return await this.superAdmin.readDesignation();
    } catch {
      return { state: 'none' };
    }
  }

  //* 신청 표도 표시용이라 실패하면 빈 Map 이다(`designationForDisplay` 와 같다)
  private async protectionForDisplay() {
    const d = await this.designationForDisplay();
    const grants = await this.superAdmin
      .readGrants()
      .catch(() => new Map<number, SuperGrantState>());
    return { guarded: protectedIds(d, grants), grants };
  }

  //* 응답에 칸 둘을 더한다 — 엔티티·DTO 에는 넣지 않는다(생성·수정 입력에 따라 들어간다, UI4).
  //*   `plainToInstance` 는 여분 칸을 그대로 통과시킨다. admIsSuper = 보호 집합, admSuperGrant = 신청 표 상태(root 는 null).
  private flag<T extends { admin: { admId: number } }>(
    row: T,
    p: { guarded: Set<number>; grants: ReadonlyMap<number, SuperGrantState> },
  ) {
    return {
      ...row,
      admin: {
        ...row.admin,
        admIsSuper: p.guarded.has(row.admin.admId),
        admSuperGrant: p.grants.get(row.admin.admId) ?? null,
      },
    };
  }

  async getAdminList(dto: GetAdminListDto) {
    const where = await this.adminRepository.whereClause(dto);
    const orderBy = await this.adminRepository.orderByClause(dto);
    const list = await this.adminRepository.getList(where, orderBy, dto.page);
    const count = await this.adminRepository.count(where);
    const p = await this.protectionForDisplay();

    return {
      count,
      list: list.map((row) => this.flag(row, p)),
    };
  }

  async getDetail(admId: number) {
    //* 권한 버전은 상세보다 **먼저** 읽는다. 뒤에 읽으면 그 사이의 회수가 낡은 athId 에 새 버전을 붙여
    //*   낡은 화면 저장이 버전 검사를 통과한다. 먼저 읽으면 중간 변경은 옛 버전이라 저장이 409 로 막힌다.
    //*   읽지 못하면 null 이고, 그 저장은 버전 검사에서 막힌다.
    const permissionVersion = await this.adminRepository.getPermissionVersion();
    const detail = await this.adminRepository.getDetail(admId);
    if (!detail) return detail;
    return { ...this.flag(detail, await this.protectionForDisplay()), permissionVersion };
  }

  //* 요청자 신원(`requester`)과 감사 맥락(`ctx`)은 다른 것을 답한다 — 수정·삭제와 같다. 신원은
  //*   "이 쓰기를 해도 되는가", 맥락은 "누가 했다고 기록하는가" 다. 감사 맥락은 **선택 인자가 아니다**
  //*   (계획 D11 · `audit-actor.ts` 의 `AuditContext` 주석) — 선택으로 두면 호출부가 빠뜨렸을 때
  //*   감사 행이 조용히 행위자 없이 남거나 아예 빠진다.
  async createAdmin(
    dto: CreateAdminDto,
    requester: AdminIdentity | undefined,
    ctx: AuditContext,
  ) {
    //* 생성은 지킬 **기존 대상**이 없어 `assertMayTouch`(대상이 지정 행일 때만 막는다)가 안 맞는다.
    //*   수정·삭제와 같은 의미로 맞춘다 — 최고관리자가 **지정된** 환경에서만 "최고관리자만 관리자
    //*   계정을 만든다"로 잠그고, 지정 행이 없거나 표시 칸이 없는 환경은 통과시킨다(UI9, 단일 공용
    //*   관리자 모델 재검토는 #75 범위). 아니면 수정·삭제가 던지는 ForbiddenException(SUPER_ADMIN_PROTECTED)다.
    await this.assertSuperWhenDesignated(
      await this.superAdmin.readDesignation(),
      requester,
    );
    return await this.adminRepository.create(dto, requester, ctx).catch((error) => {
      //* 판정 거부 (403) · 버전 충돌 (409) 은 그대로 올린다 — 상태 코드를 500 으로 바꾸지 않는다 (R2 L3 MEDIUM).
      if (error instanceof HttpException) throw error;
      mysqlErrorWrapper(error);
    });
  }

  //* 요청자 신원(`requester`)과 감사 맥락(`ctx`)은 다른 것을 답한다 — 앞은 "이 쓰기를 해도 되는가",
  //*   뒤는 "누가 했다고 기록하는가" 다. 둘 다 필수다: 신원이 빠지면 보호가 안 돌고(hardening M4 DD7),
  //*   맥락이 빠지면 감사 행이 조용히 사라진다(감사 D11).
  async updateAdmin(
    admId: number,
    dto: UpdateAdminDto,
    requester: AdminIdentity | undefined,
    ctx: AuditContext,
  ) {
    //* 쓰기 **전에** 판정한다.
    await this.assertMayTouch(requester, [admId]);
    const setterIsSuper = dto.admPassword
      ? await this.superAdmin.isSuperAdmin(requester)
      : false;
    return await this.adminRepository.update(admId, dto, requester, ctx, setterIsSuper).catch((error) => {
      if (error instanceof HttpException) throw error;
      mysqlErrorWrapper(error);
    });
  }

  /**
   * 삭제. 지정이 살아 있으면 **최고관리자만** 지운다(E0827, PRD R2) — 지정 밖 계정도 마찬가지다.
   * 지정 행은 **요청자가 최고관리자여도** 지우지 못한다(E0828) — 최고관리자는 공용 계정 하나라
   * "최고관리자만 지울 수 있다" 가 곧 "최고관리자가 자기를 지운다" 이고, 그러면 지정이 사라진 채
   * 되살릴 화면 경로가 없다(기동 seed 는 대상 계정 자체가 없어 `target-absent` 로 멈춘다).
   *
   * 지정 행이 없거나 칸이 없는 환경은 생성과 같게 통과다(UI9). 그 복구 절차는 백로그 2131 의 잔여다.
   */
  async deleteAdmin(
    dto: DeleteAdminDto,
    requester: AdminIdentity | undefined,
    ctx: AuditContext,
  ) {
    //* 섞어 삭제도 막는다 — 목록 하나만 최고관리자여도 요청 전체가 거부된다.
    //* 보호와 **같은 읽기**로 판정한다 — 두 번 물으면 그 사이에 지정이 바뀔 수 있다.
    const { d, guarded } = await this.assertMayTouch(requester, dto.ids);
    await this.assertSuperWhenDesignated(d, requester);
    //* 보호 집합은 지우지 못한다 — 승인 계정은 해제한 뒤 지운다(노트 84 T12)
    if (dto.ids.map(Number).some((id) => guarded.has(id))) {
      throw new ForbiddenException(SUPER_ADMIN_UNDELETABLE);
    }
    return await this.adminRepository.delete(dto.ids, ctx);
  }
}
