import 'server-only';
import { getApiBaseUrl } from '@/lib/env';

/**
 * 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더,
 * 공통 응답 봉투 `{ success, code, message, data }` 파싱을 여기서만 다룬다. Repository는 이
 * 함수를 통해서만 백엔드를 호출하고, 직접 `fetch()`를 호출하지 않는다.
 *
 * ⚠️ 백엔드는 비즈니스 실패(예: 로그인 실패)도 HTTP 200으로 응답하고 응답 봉투의 `success`
 * 필드로만 성공/실패를 구분한다(실제 호출로 확인됨: POST .../admin/login에 잘못된 계정을
 * 넣어도 status 200 + `{success:false, code:300, ...}`가 온다). 그래서 이 모듈은 HTTP status가
 * 아니라 `success` 필드를 성공/실패의 유일한 판정 기준으로 삼는다. status가 2xx가 아닌 경우
 * (5xx 등 인프라 오류)와 네트워크 실패·타임아웃·JSON 파싱 실패는 모두 "통신 오류"로 뭉뚱그려
 * 반환한다 — 상세 사유는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달해
 * 내부 정보 노출을 막는다.
 *
 * 캐시 전략은 이 모듈이 강제하지 않는다 — 호출부(Repository)가 §2.6에 따라 매 호출마다
 * `cache`/`next` 옵션을 명시해 전달해야 한다.
 */

const REQUEST_TIMEOUT_MS = 10_000;
export const COMMUNICATION_ERROR_CODE = -1;
const COMMUNICATION_ERROR_MESSAGE =
  '서버와 통신할 수 없습니다. 잠시 후 다시 시도해 주세요.';

type BackendEnvelope<T> = {
  success: boolean;
  code: number;
  message: string;
  data: T | null;
};

export type BackendResult<T> =
  | { ok: true; data: T }
  | { ok: false; code: number; message: string };

/**
 * 실패를 예외로 다루는 호출부(조회 Repository 등)가 `BackendResult`를 그대로 던질 때 쓰는 타입.
 * 로그인처럼 실패가 정상 흐름인 호출부는 이걸 쓰지 않고 `ok: false`를 값으로 다룬다.
 * 화면에는 이 메시지를 그대로 노출하지 않는다 — 에러 경계가 일반화된 문구로 바꿔 보여준다.
 */
export class BackendRequestError extends Error {
  readonly code: number;

  constructor(result: { code: number; message: string }) {
    super(result.message);
    this.name = 'BackendRequestError';
    this.code = result.code;
  }
}

type BackendRequestInit = {
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  /** JSON 본문. `@RequestBody`로 받는 엔드포인트(예: 로그인)에 쓴다. */
  body?: unknown;
  /**
   * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다.
   *
   * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute
   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록**
   * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가
   * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느
   * 쪽인지 확인하고 맞는 형식을 골라야 한다.
   *
   * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고
   * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다.
   *
   * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다).
   */
  form?: Record<string, string | number | undefined>;
  /** multipart 본문. 파일 업로드 전용 — Content-Type은 브라우저/런타임이 boundary와 함께 붙인다. */
  multipart?: FormData;
  /** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */
  query?: Record<string, string | number>;
  /**
   * 백엔드 accessToken. 인증이 필요한 엔드포인트(`/api/v1/mngr/**`는 ROLE_ADMIN 전용)를 부를 때
   * 호출부가 세션에서 꺼내 전달한다 — 이 모듈이 세션을 직접 읽지 않는 이유는, 토큰을 어디에
   * 보관하고 언제 유효로 볼지가 인증 정책(lib/auth)의 책임이고 통신 규약의 책임이 아니기 때문이다.
   */
  accessToken?: string;
  /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */
  cache?: RequestCache;
  next?: { revalidate?: number | false; tags?: string[] };
  /**
   * 이 호출은 성공 응답의 `data`가 정상적으로 `null`일 수 있다(예: 로그아웃처럼 돌려줄 데이터가
   * 없는 변경 API). 기본값 false는 기존 계약을 그대로 유지한다 — 지금까지의 모든 호출(로그인·
   * 목록 조회)은 성공 시 항상 실질 데이터를 반환했으므로 `data === null`은 통신 오류·예상 밖
   * 응답의 신호로 다뤄 왔다. true로 지정하지 않으면 `success:true`인데 `data:null`인 정상 성공
   * 응답까지 실패로 오판된다 — edupay-backend가 실제로 이 형태를 반환한다(확인:
   * ApiResponseVO.success(null) → `{success:true, code:200, data:null}`,
   * ApiResponseVO.java에 `@JsonInclude(NON_NULL)`이 없어 data 키가 생략되지 않고 명시적으로
   * null이 직렬화됨).
   */
  canHaveNullData?: boolean;
  /**
   * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로
   * 본다(`data`는 null).
   *
   * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경
   * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
   * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서,
   * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다
   * (edupay-backend develop 4d98756 확인).
   *
   * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서
   * 정상적으로 파싱한다.
   */
  canHaveEmptyBody?: boolean;
  /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */
  timeoutMs?: number;
};

function communicationError(reason: string, detail: unknown): BackendResult<never> {
  // 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다.
  console.error(`[backend-fetch] ${reason}`, detail);
  return {
    ok: false,
    code: COMMUNICATION_ERROR_CODE,
    message: COMMUNICATION_ERROR_MESSAGE,
  };
}

function resolveUrl(
  path: string,
  query: Record<string, string | number> | undefined
): string {
  const base = getApiBaseUrl().replace(/\/+$/, '');
  const suffix = path.startsWith('/') ? path : `/${path}`;
  const url = new URL(`${base}${suffix}`);

  for (const [key, value] of Object.entries(query ?? {})) {
    url.searchParams.set(key, String(value));
  }

  return url.toString();
}

/** 봉투 파싱 실패를 예외로 만들지 않는다 — 실패 응답의 형태가 깨져 있어도 호출부는 계속 진행한다. */
async function readEnvelopeSafely(
  response: Response
): Promise<Partial<BackendEnvelope<unknown>> | null> {
  try {
    const parsed: unknown = await response.json();
    return parsed !== null && typeof parsed === 'object'
      ? (parsed as Partial<BackendEnvelope<unknown>>)
      : null;
  } catch {
    return null;
  }
}

type BackendStreamRequestInit = {
  query?: Record<string, string | number>;
  accessToken?: string;
  /** 기본 타임아웃보다 오래 걸리는 호출(대용량 파일 생성 등)이 값을 올려 잡는다. */
  timeoutMs?: number;
};

/**
 * 파일처럼 **JSON 봉투가 아닌 응답**을 받는 GET 호출. 본문을 파싱하지 않고 `Response`를 그대로
 * 돌려주므로, 호출부(라우트 핸들러)가 스트림을 브라우저로 그대로 흘려보낼 수 있다 — 파일 전체를
 * 서버 메모리에 펼치지 않기 위함이다.
 *
 * 성공 판정만 `backendFetch`와 다르다: 성공 응답에 봉투가 없어 `success` 필드를 볼 수 없으므로
 * HTTP status로 판정한다. 실패는 여전히 JSON 봉투로 오므로(401 등) code/message를 살려서 내려준다.
 */
export async function backendFetchStream(
  path: string,
  init: BackendStreamRequestInit
): Promise<BackendResult<Response>> {
  let response: Response;
  try {
    response = await fetch(resolveUrl(path, init.query), {
      method: 'GET',
      headers: {
        ...(init.accessToken
          ? { Authorization: `Bearer ${init.accessToken}` }
          : {}),
      },
      signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS),
      cache: 'no-store',
    });
  } catch (error) {
    return communicationError('파일 요청 실패(네트워크·타임아웃)', error);
  }

  if (!response.ok) {
    const envelope = await readEnvelopeSafely(response);
    if (envelope && typeof envelope.code === 'number') {
      return {
        ok: false,
        code: envelope.code,
        message:
          typeof envelope.message === 'string' && envelope.message
            ? envelope.message
            : COMMUNICATION_ERROR_MESSAGE,
      };
    }

    return communicationError(
      `파일 응답의 예상치 못한 HTTP 상태: ${response.status}`,
      undefined
    );
  }

  return { ok: true, data: response };
}

/**
 * 본문 형식에 맞는 `body`와 Content-Type을 고른다. 셋 중 하나만 지정된다는 전제이며, 우선순위는
 * multipart > form > JSON이다.
 *
 * multipart일 때 Content-Type을 **직접 넣지 않는 것이 중요하다** — 직접 넣으면 boundary 파라미터가
 * 빠져 서버가 본문을 파싱하지 못한다. FormData를 body로 주면 런타임이 boundary까지 붙여 준다.
 */
function buildRequestBody(init: BackendRequestInit): {
  body: BodyInit | undefined;
  contentType: string | undefined;
} {
  if (init.multipart) {
    return { body: init.multipart, contentType: undefined };
  }

  if (init.form) {
    const params = new URLSearchParams();
    for (const [key, value] of Object.entries(init.form)) {
      if (value !== undefined) {
        params.set(key, String(value));
      }
    }
    return {
      body: params.toString(),
      contentType: 'application/x-www-form-urlencoded',
    };
  }

  return {
    body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
    contentType: 'application/json',
  };
}

/** 백엔드 REST 호출 단일 진입점. */
export async function backendFetch<T>(
  path: string,
  init: BackendRequestInit
): Promise<BackendResult<T>> {
  const { body, contentType } = buildRequestBody(init);

  let response: Response;
  try {
    response = await fetch(resolveUrl(path, init.query), {
      method: init.method,
      headers: {
        ...(contentType ? { 'Content-Type': contentType } : {}),
        ...(init.accessToken
          ? { Authorization: `Bearer ${init.accessToken}` }
          : {}),
      },
      body,
      signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS),
      cache: init.cache,
      next: init.next,
    });
  } catch (error) {
    return communicationError('요청 실패(네트워크·타임아웃)', error);
  }

  if (!response.ok) {
    // 인증 실패만은 HTTP status로도 온다 — 미인증·권한부족 모두 401 + `{success:true, auth:false,
    // code:401}`이다(실측). status만 보고 통신 오류로 뭉뚱그리면 "세션이 끊겼다"와 "서버가 죽었다"를
    // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다.
    const envelope = await readEnvelopeSafely(response);
    if (envelope && typeof envelope.code === 'number') {
      return {
        ok: false,
        code: envelope.code,
        message:
          typeof envelope.message === 'string' && envelope.message
            ? envelope.message
            : COMMUNICATION_ERROR_MESSAGE,
      };
    }

    return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
  }

  // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이
  // 파싱 오류를 던지므로 그 전에 갈라야 한다.
  if (init.canHaveEmptyBody) {
    let raw: string;
    try {
      raw = await response.text();
    } catch (error) {
      return communicationError('응답 읽기 실패', error);
    }

    if (raw.trim() === '') {
      return { ok: true, data: null as T };
    }

    try {
      const parsed = JSON.parse(raw) as BackendEnvelope<T>;
      return parsed.success
        ? { ok: true, data: parsed.data as T }
        : { ok: false, code: parsed.code, message: parsed.message };
    } catch (error) {
      return communicationError('응답 파싱 실패', error);
    }
  }

  let envelope: BackendEnvelope<T>;
  try {
    envelope = (await response.json()) as BackendEnvelope<T>;
  } catch (error) {
    return communicationError('응답 파싱 실패', error);
  }

  if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) {
    return { ok: false, code: envelope.code, message: envelope.message };
  }

  // `canHaveNullData`가 true인 호출은 data가 null이어도 위에서 걸러지지 않으므로, 여기서
  // TS는 `envelope.data`를 `T | null` 그대로 본다(narrowing 불가 — null 여부와 무관한
  // canHaveNullData 플래그가 조건에 섞여 있어서다). 그 null은 호출부가 명시적으로 허용한
  // 값이므로(그리고 그런 호출부는 보통 `backendFetch<null>(...)`로 T=null을 지정한다) 단언한다.
  return { ok: true, data: envelope.data as T };
}
