import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import {
  BackendRequestError,
  backendFetch,
  type BackendResult,
} from '@/lib/http/backend-fetch';
import type {
  StudentGuardian,
  StudentMember,
} from '@/lib/domain/student-member';
import type {
  StudentMemberQuery,
  StudentMemberSearchField,
} from '@/lib/domain/student-member-query';

/**
 * 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
 * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
 * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
 *
 *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
 *   → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
 *              grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages }
 *
 * 아래 내용은 백엔드 저장소(edupay-backend, develop 5fa9d34)의 실제 구현을 읽고 확인한 것이다
 * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
 *
 * - 보호자는 `TB_COM_ADBK`를 LEFT JOIN해 `adbkList`(`parentNm`·`parentTelNo`)로 온다.
 *   한 학생에 여러 명이 붙을 수 있어 화면도 목록·팝업 모두 줄을 나눠 보여준다.
 * - **학생 번호·가입일은 여전히 응답에 없다** — 테이블에 컬럼 자체가 없다. 화면은 `-`다.
 * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY rnum DESC`가 하드코딩돼 있어 순서를 지정할 수
 *   없다. 그 고정 순서가 곧 가입일 최신순이라(rnum은 최초등록일시 기준 행번호) 가입일순은 그대로
 *   쓰고, 이름순만 전체를 받아 서버에서 정렬한다(`fetchByName`).
 * - **사용자 유형을 걸러 주지 않는다** — 조회 대상이 TB_COM_USER 전체라 student 외
 *   teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다.
 *   화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다.
 *
 * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
 * 꺼내 Bearer로 붙인다 — 관리자 로그인 응답의 토큰이 그대로 통한다(JWT의 `userType`=ADMIN 클레임을
 * 백엔드가 ROLE_ADMIN 권한으로 변환한다).
 *
 * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
 */

const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user';

/**
 * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
 * `<choose>` 분기에서 그대로 가져온 것이다(1=USER_NM, 2=LOGIN_ID, 3=USER_TELNO).
 *
 * 목록에 없는 값을 보내면 백엔드가 **검색 조건 없이 전체를 반환**하므로(분기 미매칭 시
 * WHERE가 비는 구조) 화면의 검색 대상 선택지도 이 세 가지로 맞춰 두었다.
 */
const SEARCH_CONDITION_BY_FIELD: Record<StudentMemberSearchField, string> = {
  name: '1',
  loginId: '2',
  phoneNumber: '3',
};

/**
 * 역할 표기. 백엔드 응답에 USER_TYPE이 없어 이 화면(학생 회원 목록)의 전제를 그대로 적는 상수다
 * — 백엔드가 사용자 유형으로 필터링하지도, 유형을 내려주지도 않으므로 **보장된 값이 아니다.**
 * 백엔드가 userType을 노출하면 그 값을 매핑해 이 상수를 없애야 한다.
 */
const STUDENT_ROLE_LABEL = '학생';

/**
 * 이름순 정렬을 위해 한 번에 받아올 최대 행 수. 백엔드가 정렬을 지원하지 않아 우리가 정렬하려면
 * 전체를 받아와야 하는데, 상한 없이 요청하면 백엔드가 전 행을 메모리에 올리게 되므로 상한을 둔다.
 * 회원 수가 이 값을 넘으면 그 위로는 정렬 대상에서 빠지고 총건수도 하한값으로 표기된다 —
 * 그 규모가 되면 백엔드에 정렬 파라미터가 필요하다(상한 인상은 임시방편일 뿐이다).
 */
const NAME_SORT_FETCH_LIMIT = 10_000;

/**
 * 페이징 파라미터. 계약 예시에는 7종이 나열돼 있지만 **실제로 쓰이는 것은 두 개뿐**이다.
 *
 * `MngrUserServiceImpl`이 `PaginationUtil.execute(pageIndex, recordCountPerPage, ...)`로
 * offset을 직접 계산해 `firstIndex`·`recordCountPerPage`를 덮어쓰고, SQL은 그 두 값으로만
 * `LIMIT/OFFSET`을 건다. 클라이언트가 보낸 `firstIndex`·`lastIndex`·`pageUnit`·`pageSize`는
 * 읽히지 않으므로 보내지 않는다 — 보내면 "이 값이 결과에 영향을 준다"는 오해만 남는다.
 */
function buildPaginationParams(
  pageIndex: number,
  recordCountPerPage: number
): Record<string, string | number> {
  return { pageIndex, recordCountPerPage };
}

/**
 * 검색 파라미터. 검색어가 비어 있으면 조건도 함께 빈 값으로 보낸다 — 백엔드도 `searchKeyword`가
 * 비면 조건 분기 자체를 타지 않으므로(전체 조회) 결과는 같고, 의미 없는 조건을 실어 보내지 않는다.
 */
function buildSearchParams(
  query: StudentMemberQuery
): Record<string, string> {
  const keyword = query.keyword.trim();

  if (!keyword) {
    return { searchCondition: '', searchKeyword: '' };
  }

  return {
    searchCondition: SEARCH_CONDITION_BY_FIELD[query.searchField],
    searchKeyword: keyword,
  };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === 'object';
}

function readRequiredString(
  source: Record<string, unknown>,
  key: string
): string {
  const value = source[key];
  if (typeof value !== 'string' || value.length === 0) {
    throw new Error(`학생 회원 응답에 ${key}가 없습니다.`);
  }
  return value;
}

/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */
function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
  const value = raw[key];
  if (typeof value === 'string') {
    return value.trim() === '' ? null : value;
  }
  return typeof value === 'number' ? String(value) : null;
}

/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */
function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
  const text = readOptionalString(raw, key);
  if (text === null) {
    return null;
  }
  const parsed = Number(text);
  return Number.isFinite(parsed) ? parsed : null;
}

/**
 * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
 * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
 *
 * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 현재 페이지 기준 표시 순번
 * (`(page-1)*pageSize + 행 인덱스 + 1`)이고, 그 계산은 이미 표 컴포넌트가 한다.
 *
 * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
 * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
 */
/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */
function readUseYn(raw: Record<string, unknown>): boolean | null {
  const value = readOptionalString(raw, 'useYn');
  if (value === null) {
    return null;
  }
  return value.toUpperCase() === 'Y';
}

/**
 * `adbkList` → 보호자 목록. 백엔드가 `notNullColumn="parent_nm"`으로 걸러 주지만, 보호자가
 * 없는 학생에게 빈 항목이 오는 경우도 있어 이름·연락처가 모두 없는 행은 버린다.
 */
function toGuardians(raw: unknown): StudentGuardian[] {
  if (!Array.isArray(raw)) {
    return [];
  }

  return raw.flatMap((entry) => {
    if (!isRecord(entry)) {
      return [];
    }
    const name = readOptionalString(entry, 'parentNm');
    const phoneNumber = readOptionalString(entry, 'parentTelNo');
    if (name === null && phoneNumber === null) {
      return [];
    }
    return [{ name, phoneNumber }];
  });
}

function toStudentMember(raw: unknown): StudentMember {
  if (!isRecord(raw)) {
    throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
  }

  const userId = readRequiredString(raw, 'userId');

  return {
    id: userId,
    name: readRequiredString(raw, 'userNm'),
    loginId: readRequiredString(raw, 'loginId'),
    phoneNumber: readOptionalString(raw, 'userTelNo'),
    email: readOptionalString(raw, 'userEmlAddr'),
    role: STUDENT_ROLE_LABEL,
    schoolName: readOptionalString(raw, 'schNm'),
    grade: readOptionalNumber(raw, 'grade'),
    classNumber: readOptionalNumber(raw, 'clsNo'),
    // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다.
    studentNumber: null,
    guardians: toGuardians(raw.adbkList),
    // 가입일은 아직 응답에 없다.
    joinedAt: null,
    birthDate: readOptionalString(raw, 'birth'),
    isActive: readUseYn(raw),
  };
}

export type StudentMemberPage = {
  items: StudentMember[];
  /** 전체 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */
  totalCount: number;
  /** 위 값이 확정된 전체 건수인지. false면 화면이 "N명 이상"으로 표기하고 다음 페이지를 열어 둔다. */
  isTotalCountExact: boolean;
};

/** 백엔드 목록 호출 1회 — 응답 검증까지만 하고 정렬·페이징 판단은 호출부에 맡긴다. */
async function requestStudentMemberList(
  query: StudentMemberQuery,
  pageIndex: number,
  recordCountPerPage: number
): Promise<{ items: StudentMember[]; reportedTotalCount: number }> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<unknown>(STUDENT_MEMBER_PAGINATION_PATH, {
    method: 'GET',
    query: {
      ...buildSearchParams(query),
      ...buildPaginationParams(pageIndex, recordCountPerPage),
    },
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
  });

  if (!result.ok) {
    throw new BackendRequestError(result);
  }

  const data = result.data;
  if (!isRecord(data) || !Array.isArray(data.list)) {
    throw new Error('학생 회원 목록 응답의 형식이 올바르지 않습니다.');
  }

  const items = data.list.map(toStudentMember);

  return {
    items,
    reportedTotalCount:
      typeof data.totalCount === 'number' ? data.totalCount : items.length,
  };
}

/**
 * 가입일순 — 백엔드가 이미 이 순서로 내려준다.
 *
 * 백엔드 목록 SQL은 `ORDER BY rnum DESC`로 고정돼 있고 `rnum`은
 * `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, user_nm DESC)`, 즉 **최초등록일시 오름차순 순번**이다.
 * 그것을 역순으로 뒤집으니 결과는 가입일 최신순(동일 시각이면 이름 오름차순)이 된다. 그래서 이
 * 정렬은 페이지 하나만 받아오면 되고 추가 비용이 없다.
 *
 * **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가
 * 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend
 * `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로
 * 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 하한값으로 보정한다:
 *
 * - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정).
 * - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음
 *   페이지를 열어 두게 한다.
 *
 * 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가
 * 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다.
 */
async function fetchByJoinedAt(
  query: StudentMemberQuery
): Promise<StudentMemberPage> {
  const { items, reportedTotalCount } = await requestStudentMemberList(
    query,
    query.page,
    query.pageSize
  );

  const offset = (query.page - 1) * query.pageSize;
  const confirmedCount = offset + items.length;
  const reachedLastPage = items.length < query.pageSize;

  return {
    items,
    totalCount: Math.max(reportedTotalCount, confirmedCount),
    isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount,
  };
}

/**
 * 이름순 — 백엔드가 정렬을 지원하지 않아 **전체를 받아 여기서 정렬하고 페이지를 잘라낸다.**
 *
 * 받아온 한 페이지만 정렬하면 "그 페이지 안에서만 이름순"이 되어 전체 기준 정렬처럼 보이는 잘못된
 * 결과가 나온다(2페이지의 '김'이 1페이지의 '이'보다 뒤에 오는 식). 그래서 검색 조건은 백엔드에
 * 그대로 위임하되 페이지 크기만 상한까지 키워 한 번에 받아온 뒤 정렬한다.
 *
 * 전체를 손에 쥐므로 총건수는 확정이다 — 상한에 걸린 경우에만 하한값으로 표기한다.
 */
async function fetchByName(
  query: StudentMemberQuery
): Promise<StudentMemberPage> {
  const { items } = await requestStudentMemberList(
    query,
    1,
    NAME_SORT_FETCH_LIMIT
  );

  const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
  const offset = (query.page - 1) * query.pageSize;

  return {
    items: sorted.slice(offset, offset + query.pageSize),
    totalCount: sorted.length,
    isTotalCountExact: sorted.length < NAME_SORT_FETCH_LIMIT,
  };
}

/**
 * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다.
 *
 * 정렬 기준에 따라 조회 전략이 갈린다 — 백엔드에 정렬 파라미터가 없기 때문이다(목록 SQL의
 * `ORDER BY`가 하드코딩돼 있다). 가입일순은 백엔드의 고정 순서와 같아 페이지 하나만 받으면 되고,
 * 이름순은 전체를 받아 여기서 정렬한다. 두 경로의 비용 차이는 그 사실에서 온다.
 */
export async function fetchStudentMembers(
  query: StudentMemberQuery
): Promise<StudentMemberPage> {
  return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query);
}

/**
 * 사용여부 변경.
 *
 *   PUT /api/v1/mngr/user/{userId}/{useYn}   (ROLE_ADMIN 전용)
 *
 * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다.
 *
 * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로
 * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다
 * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서
 * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도
 * 그대로 동작한다.
 *
 * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의
 * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다.
 */
export async function updateStudentMemberUseYn(
  userId: string,
  isActive: boolean
): Promise<BackendResult<null>> {
  const accessToken = await getSessionAccessToken();

  return backendFetch<null>(
    `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
      isActive ? 'Y' : 'N'
    }`,
    {
      method: 'PUT',
      accessToken: accessToken ?? undefined,
      canHaveEmptyBody: true,
      canHaveNullData: true,
    }
  );
}
