import 'server-only';
import { getSessionAccessToken } from '@/lib/auth/dal';
import { getApiBaseUrl } from '@/lib/env';
import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
import type { DecorationItem, DecorationItemType } from '@/lib/domain/decoration-item';
import type { DecorationItemEditableValues } from '@/lib/domain/decoration-item-form';
import type {
  DecorationItemQuery,
  DecorationItemSearchField,
} from '@/lib/domain/decoration-item-query';
import { FILE_MODULE } from '@/lib/domain/file-module';

/**
 * 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·
 * 파라미터·응답 매핑). 통신 규약은 `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가
 * 소유하므로 여기에 들어오지 않는다.
 *
 * ```
 * GET    /api/v1/mngr/item/pagination   목록 (ROLE_ADMIN)
 * GET    /api/v1/mngr/item/{itemSn}     단건
 * POST   /api/v1/mngr/item              등록
 * PUT    /api/v1/mngr/item/{itemSn}     수정
 * DELETE /api/v1/mngr/item/{itemSn}     삭제
 * POST   /api/v1/common/file/upload/{moduleId}   이미지 업로드(multipart)
 * ```
 *
 * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 —
 * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController.
 *
 * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은
 *   `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나
 *   415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다.
 * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
 *   무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다.
 * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그
 *   값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청).
 * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해
 *   결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다.
 * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는
 *   `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로
 *   내려 준다(`mapUnderscoreToCamelCase`).
 * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `<if test="itemExplan != ''">` 가드가 빈 문자열을
 *   건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다.
 * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한
 *   `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어
 *   하한값으로 보정한다.
 *
 * 캐시: 조회는 `no-store` — 개인화 데이터는 아니지만 관리 화면이라 신선도가 우선이고 검색 조건이
 * 매 요청 달라진다. 쓰기는 캐시 대상이 아니며 호출부(Server Action)가 `revalidatePath`로 목록을
 * 재검증한다.
 */

const DECORATION_ITEM_PATH = '/api/v1/mngr/item';
const FILE_UPLOAD_PATH = '/api/v1/common/file/upload';
const FILE_IMAGE_PATH = '/api/v1/common/file/image';

/** 화면·URL의 유형 값 ↔ 백엔드 `itemType` 코드값(사용자 확정 사항). */
const ITEM_TYPE_CODE: Record<DecorationItemType, string> = {
  individual: 'N',
  set: 'S',
};

const ITEM_TYPE_BY_CODE: Record<string, DecorationItemType> = {
  N: 'individual',
  S: 'set',
};

/**
 * 화면의 "검색 대상" → 백엔드 `searchCondition` 값.
 *
 * **백엔드에 구현된 분기는 `"1"`(아이템명) 하나뿐이다.** 아이템ID 검색은 사용자 지시로 화면
 * 선택지를 유지한 채 백엔드에 추가를 요청하기로 했고, 그때 쓸 값으로 `"2"`를 예약해 둔다
 * (학생 목록이 1=이름·2=아이디·3=휴대전화를 쓰는 관례와 같은 순번). **백엔드가 이 값을 모르는
 * 동안에는 검색어가 무시되고 전체 목록이 반환된다.**
 */
const SEARCH_CONDITION_BY_FIELD: Record<DecorationItemSearchField, string> = {
  name: '1',
  itemId: '2',
};

/** 이름순 정렬이 아니라 "전체 건수를 알기 위해" 한 번에 받아올 최대 행 수(아래 주석 참조). */
const TYPE_TOTAL_COUNT_FETCH_LIMIT = 1_000;

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

function readString(source: Record<string, unknown>, key: string): string | null {
  const value = source[key];
  return typeof value === 'string' && value.length > 0 ? value : null;
}

function readNumber(source: Record<string, unknown>, key: string): number | null {
  const value = source[key];
  return typeof value === 'number' ? value : null;
}

/** 업로드된 파일 ID로 백엔드 이미지 URL을 만든다 — 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */
function buildImageUrl(imageFileId: string | null): string | null {
  if (!imageFileId) {
    return null;
  }

  const base = getApiBaseUrl().replace(/\/+$/, '');
  const url = new URL(`${base}${FILE_IMAGE_PATH}`);
  url.searchParams.set('atchFileId', imageFileId);
  // 아이템은 파일을 1개만 등록하므로 첨부 순번은 항상 1이다(백엔드도 미지정 시 1로 기본값 처리).
  url.searchParams.set('fileSn', '1');
  return url.toString();
}

/**
 * 백엔드 응답 1건 → 도메인 타입. 식별자·이름 등 목록의 존재 이유인 값이 없으면 예외로 끊는다
 * (fail-fast) — 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
 *
 * `rnum`은 담지 않는다 — 표의 "번호"는 현재 페이지 기준 표시 순번이고 표 컴포넌트가 계산한다.
 */
function toDecorationItem(raw: unknown): DecorationItem {
  if (!isRecord(raw)) {
    throw new Error('아이템 응답 항목의 형식이 올바르지 않습니다.');
  }

  const itemSn = readNumber(raw, 'itemSn');
  if (itemSn === null) {
    throw new Error('아이템 응답에 itemSn이 없습니다.');
  }

  const imageFileId = readString(raw, 'atchFileId');

  return {
    itemSn,
    // 코드값이 비었거나 모르는 값이면 화면 기본 유형으로 떨어뜨리지 않고 개별로 둔다 —
    // 목록은 유형별로 조회하므로 실제로는 요청한 유형의 행만 온다.
    itemType: ITEM_TYPE_BY_CODE[readString(raw, 'itemType') ?? ''] ?? 'individual',
    name: readString(raw, 'itemNm') ?? '',
    categoryCode: readString(raw, 'itemCateCd') ?? '',
    categoryName: readString(raw, 'itemCateNm'),
    points: readNumber(raw, 'itemAmount') ?? 0,
    description: readString(raw, 'itemExplan'),
    isActive: readString(raw, 'useYn') === 'Y',
    sortOrder: readNumber(raw, 'sortOrdr') ?? 0,
    imageFileId,
    imageUrl: buildImageUrl(imageFileId),
    updatedAt: readString(raw, 'lastMdfcnDtStr'),
  };
}

function buildListParams(
  query: DecorationItemQuery,
  pageIndex: number,
  recordCountPerPage: number
): Record<string, string | number> {
  const keyword = query.keyword.trim();

  return {
    searchItemType: ITEM_TYPE_CODE[query.itemType],
    searchCondition: keyword ? SEARCH_CONDITION_BY_FIELD[query.searchField] : '',
    searchKeyword: keyword,
    // 유효한 페이징 파라미터는 이 둘뿐이다 — 서버가 offset을 직접 계산해 firstIndex·
    // recordCountPerPage를 덮어쓰고 나머지(pageUnit·pageSize·lastIndex)는 읽지 않는다.
    pageIndex,
    recordCountPerPage,
  };
}

/** 목록 조회 1회 — 응답 검증까지만 하고 건수 판단은 호출부에 맡긴다. */
async function requestItemList(
  query: DecorationItemQuery,
  pageIndex: number,
  recordCountPerPage: number
): Promise<{ items: DecorationItem[]; reportedTotalCount: number }> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<unknown>(
    `${DECORATION_ITEM_PATH}/pagination`,
    {
      method: 'GET',
      query: buildListParams(query, 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('아이템 목록 응답의 형식이 올바르지 않습니다.');
  }

  return {
    items: data.list.map(toDecorationItem),
    reportedTotalCount:
      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
  };
}

export type DecorationItemPage = {
  items: DecorationItem[];
  /** 검색어까지 적용한 결과 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */
  totalCount: number;
  isTotalCountExact: boolean;
  /**
   * 검색어와 무관하게 현재 유형(개별/셋트) 전체 등록 건수 — 등록/수정 팝업의
   * "정렬순서 (총 등록 N개)" 힌트와 신규 등록 기본 정렬순서 계산에 쓴다.
   */
  typeTotalCount: number;
};

/**
 * 검색·유형·페이징이 적용된 목록을 조회한다.
 *
 * **`totalCount` 보정**: 백엔드는 count 쿼리 없이 `list.size()`를 총건수로 내려준다
 * (`PaginationUtil.execute`). 그대로 쓰면 한 페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼
 * 2페이지 이후에 접근할 수 없으므로, 학생 목록과 동일하게 하한값으로 보정한다 — 페이지가 가득
 * 차지 않았으면 마지막 페이지이므로 `offset + 행 수`가 확정 총건수이고, 가득 찼으면 하한값만
 * 알린다. 백엔드가 진짜 count를 넣으면 `Math.max`가 자동으로 그 값을 채택한다.
 *
 * **`typeTotalCount`는 별도 조회다** — 검색 결과가 아니라 그 유형에 실제로 존재하는 전체 개수여야
 * 하는데(정렬순서 힌트·기본값의 근거) 백엔드가 총건수를 주지 않으므로, 검색어를 뺀 조건으로 큰
 * 상한을 걸어 한 번 더 조회해 길이를 센다. 백엔드에 count가 생기면 이 왕복을 없앨 수 있다.
 */
export async function fetchDecorationItems(
  query: DecorationItemQuery
): Promise<DecorationItemPage> {
  const { items, reportedTotalCount } = await requestItemList(
    query,
    query.page,
    query.pageSize
  );

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

  const typeTotal = await requestItemList(
    { ...query, keyword: '' },
    1,
    TYPE_TOTAL_COUNT_FETCH_LIMIT
  );

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

/*
 * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
 * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON
 * (파일 상단 주석 참조).
 */

/** 등록·수정이 공유하는 전송 필드. */
function buildWriteForm(
  values: DecorationItemEditableValues
): Record<string, string | number | undefined> {
  return {
    itemNm: values.name,
    itemCateCd: values.categoryCode,
    itemAmount: values.points,
    itemExplan: values.description,
    useYn: values.isActive ? 'Y' : 'N',
    sortOrdr: values.sortOrder,
    itemType: ITEM_TYPE_CODE[values.itemType],
    // 썸네일은 백엔드가 자동생성하도록 바뀔 예정이라 지금은 atchFileId만 보낸다(사용자 확정 사항).
    atchFileId: values.imageFileId,
  };
}

/**
 * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다.
 *
 * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
 */
async function sendWrite(
  path: string,
  method: 'POST' | 'PUT',
  payload: { form: Record<string, string | number | undefined> } | { body: unknown }
): Promise<void> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<null>(path, {
    method,
    ...payload,
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
    canHaveNullData: true,
  });

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

/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */
export async function createDecorationItem(
  values: DecorationItemEditableValues
): Promise<void> {
  await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
}

/**
 * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를
 * 보내지만 형식이 다르다.
 *
 * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일
 * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다.
 */
export async function updateDecorationItem(
  itemSn: number,
  values: DecorationItemEditableValues
): Promise<void> {
  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
    body: buildWriteForm(values),
  });
}

export async function deleteDecorationItem(itemSn: number): Promise<void> {
  const accessToken = await getSessionAccessToken();

  const result = await backendFetch<null>(`${DECORATION_ITEM_PATH}/${itemSn}`, {
    method: 'DELETE',
    accessToken: accessToken ?? undefined,
    cache: 'no-store',
    canHaveNullData: true,
  });

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

/**
 * 이미지 업로드 — 성공 시 `atchFileId` 문자열을 돌려준다.
 *
 * 브라우저가 백엔드에 직접 올리지 않는다(토큰이 서버에만 있다) — Server Action이 받은 File을
 * 그대로 백엔드로 중계한다.
 */
export async function uploadDecorationItemImage(file: File): Promise<string> {
  const accessToken = await getSessionAccessToken();

  const multipart = new FormData();
  multipart.set('file', file);

  const result = await backendFetch<string>(
    `${FILE_UPLOAD_PATH}/${FILE_MODULE.userItem.id}`,
    {
      method: 'POST',
      multipart,
      accessToken: accessToken ?? undefined,
      cache: 'no-store',
    }
  );

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

  if (typeof result.data !== 'string' || !result.data) {
    throw new Error('이미지 업로드 응답에 파일 ID가 없습니다.');
  }

  return result.data;
}
