/**
 * Central typed API client.
 *
 * This is the single point of contact between the frontend and the BanGUI
 * backend. Components and hooks never call `fetch` directly — they use the
 * functions exported from domain-specific API modules (e.g. `api/bans.ts`),
 * which call the helpers exported from this file.
 *
 * All request and response types are defined in `src/types/` and used here
 * to guarantee type safety at the API boundary.
 *
 * Correlation IDs are automatically added to all requests to enable
 * correlating errors across frontend and backend systems. The correlation ID
 * is extracted from response headers and stored for error telemetry.
 */

import { ErrorResponse } from "../types/response";
import { ENDPOINTS } from "./endpoints";
import { CSRF_HEADER_NAME, CSRF_HEADER_VALUE } from "../utils/constants";

/** Base URL for all API calls. Falls back to `/api/v1` in production. */
const BASE_URL: string = import.meta.env.VITE_API_URL ?? "/api/v1";

/** Standard header name for correlation IDs (matches backend convention) */
const CORRELATION_ID_HEADER: string = "X-Correlation-ID";

/** sessionStorage key for the correlation ID */
const CORRELATION_ID_STORAGE_KEY: string = "bangui_correlation_id";

/**
 * Session-scoped correlation ID.
 *
 * Stored in sessionStorage to survive Hot Module Replacement (HMR), which
 * re-evaluates this module and would otherwise generate a new ID mid-session,
 * breaking distributed trace continuity.
 *
 * Cleared on logout via `clearSessionCorrelationId()`.
 */
let sessionCorrelationId: string | null = null;

/**
 * Initialize or retrieve the session-scoped correlation ID.
 *
 * On first call, generates a UUID4 and persists it to sessionStorage so that
 * HMR does not produce a new ID. Subsequent calls return the stored ID.
 *
 * The ID is never exposed in URLs or logs to prevent leakage.
 *
 * @returns A UUID4 string unique to this browser tab session.
 */
export function getSessionCorrelationId(): string {
  if (!sessionCorrelationId) {
    // Read from sessionStorage to survive HMR (module re-evaluation).
    const stored = sessionStorage.getItem(CORRELATION_ID_STORAGE_KEY);
    if (stored) {
      sessionCorrelationId = stored;
    } else {
      sessionCorrelationId = generateUUID4();
      sessionStorage.setItem(CORRELATION_ID_STORAGE_KEY, sessionCorrelationId);
    }
  }
  return sessionCorrelationId;
}

/**
 * Clear the stored correlation ID.
 * Called on logout so a new session gets a fresh ID.
 */
export function clearSessionCorrelationId(): void {
  sessionCorrelationId = null;
  sessionStorage.removeItem(CORRELATION_ID_STORAGE_KEY);
}

/**
 * Generate a UUID4 string.
 * Uses crypto.getRandomValues for cryptographic randomness.
 * @internal
 */
function generateUUID4(): string {
  const arr = new Uint8Array(16);
  crypto.getRandomValues(arr);

  // Set version (4) and variant bits per RFC 4122
  const v6 = arr[6];
  if (v6 !== undefined) {
    arr[6] = (v6 & 0x0f) | 0x40;
  }
  const v8 = arr[8];
  if (v8 !== undefined) {
    arr[8] = (v8 & 0x3f) | 0x80;
  }

  const hexPairs: string[] = [];
  for (let i = 0; i < 16; i++) {
    const byte = arr[i];
    if (byte !== undefined) {
      const hex = byte.toString(16).padStart(2, "0");
      hexPairs.push(hex);
    }
  }

  return [
    hexPairs.slice(0, 4).join(""),
    hexPairs.slice(4, 6).join(""),
    hexPairs.slice(6, 8).join(""),
    hexPairs.slice(8, 10).join(""),
    hexPairs.slice(10, 16).join(""),
  ].join("-");
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/** Thrown by the API client when the server returns a non-2xx response. */
export class ApiError extends Error {
  /** HTTP status code returned by the server. */
  public readonly status: number;

  /** Raw response body text as returned by the server. */
  public readonly body: string;

  /** Parsed error response (if response was a valid ErrorResponse), undefined otherwise. */
  public readonly errorResponse: ErrorResponse | undefined;

  /** Correlation ID for this error, extracted from response headers if present. */
  public readonly correlationId: string | undefined;

  /**
   * @param status - The HTTP status code.
   * @param body   - The raw response body text.
   * @param errorResponse - Parsed ErrorResponse if available.
   * @param correlationId - Correlation ID extracted from response headers or error response.
   */
  constructor(
    status: number,
    body: string,
    errorResponse?: ErrorResponse,
    correlationId?: string,
  ) {
    super(`API error ${String(status)}: ${errorResponse?.detail || body}`);
    this.name = "ApiError";
    this.status = status;
    this.body = body;
    this.errorResponse = errorResponse;
    this.correlationId = correlationId;
  }
}

/**
 * Returns `true` when the error represents an expired or unauthorized session.
 *
 * @param err - The error returned from the API client.
 * @deprecated Use `isAuthError` from `types/api` with discriminated FetchError instead.
 */
export function isAuthError(err: unknown): err is ApiError {
  return err instanceof ApiError && (err.status === 401 || err.status === 403);
}

// ---------------------------------------------------------------------------
// Unauthorized handler callback
// ---------------------------------------------------------------------------

/**
 * Module-level callback invoked when the API client receives a 401/403 response.
 * Set via `setUnauthorizedHandler()` and called directly (no DOM events).
 */
let unauthorizedHandler: (() => void) | null = null;

/**
 * Deduplication guard: prevents the auth error handler from firing more than
 * once per 401/403 response. Without this guard, both `setUnauthorizedHandler`
 * (registered by AuthProvider) and `setAuthErrorHandler` (also registered by
 * AuthProvider) would fire independently on the same 401, causing a double
 * logout that triggers a React state mutation race and potential double
 * navigation to the login page.
 */
let isLoggingOut: boolean = false;

/**
 * Register a callback to be invoked when the API receives a 401/403 response.
 * Typically called by the AuthProvider on mount.
 *
 * @param handler - Callback to invoke on unauthorized response. Pass `null` to clear.
 */
export function setUnauthorizedHandler(handler: (() => void) | null): void {
  unauthorizedHandler = handler;
}

/**
 * Reset the isLoggingOut deduplication guard.
 * Called when the user reaches the login page to allow future 401s to trigger
 * the handler again.
 */
export function resetLogoutState(): void {
  isLoggingOut = false;
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
 * Execute a `fetch` call and return the parsed JSON body as `T`.
 *
 * @param url     - Fully-qualified URL.
 * @param options - Standard `RequestInit` options.
 * @returns Parsed JSON response cast to `T`.
 * @throws {ApiError} When the request fails or server returns non-2xx status.
 */
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
  try {
    const method: string = options.method ?? "GET";
    const hasBody: boolean = options.body !== undefined && options.body !== null;
    const isMutatingMethod: boolean = ["POST", "PUT", "DELETE", "PATCH"].includes(method);

    const headers: Record<string, string> = {
      ...(options.headers as Record<string, string> | undefined),
    };

    // Only set Content-Type for requests with a body to avoid unnecessary CORS preflights.
    if (hasBody) {
      headers["Content-Type"] = "application/json";
    }

    // Only set CSRF header for state-mutating requests (not for GET/HEAD/OPTIONS).
    if (isMutatingMethod) {
      headers[CSRF_HEADER_NAME] = CSRF_HEADER_VALUE;
    }

    // Always add correlation ID for distributed tracing
    headers[CORRELATION_ID_HEADER] = getSessionCorrelationId();

    const response: Response = await fetch(url, {
      ...options,
      credentials: "include",
      headers,
    });

    if (!response.ok) {
      const body: string = await response.text();

      if (response.status === 401 || response.status === 403) {
        if (!isLoggingOut) {
          isLoggingOut = true;
          unauthorizedHandler?.();
        }
      }

      // Extract correlation ID from response header
      const correlationId: string | undefined =
        response.headers.get(CORRELATION_ID_HEADER) ?? undefined;

      // Try to parse as ErrorResponse
      let errorResponse: ErrorResponse | undefined;
      try {
        const parsed = JSON.parse(body);
        if (parsed && typeof parsed === "object" && "code" in parsed && "detail" in parsed) {
          errorResponse = parsed as ErrorResponse;
        }
      } catch {
        // If parsing fails, errorResponse remains undefined
      }

      throw new ApiError(response.status, body, errorResponse, correlationId);
    }

    // 204 No Content — return undefined cast to T.
    if (response.status === 204) {
      return undefined as unknown as T;
    }

    return (await response.json()) as T;
  } catch (error: unknown) {
    // Re-throw as-is if already an ApiError (will be converted by caller)
    if (error instanceof ApiError) {
      throw error;
    }
    // DOMException with name "AbortError" is an abort — re-throw for caller
    if (error instanceof DOMException && error.name === "AbortError") {
      throw error;
    }
    // Other errors (network, JSON parse, etc.)
    throw error;
  }
}

// ---------------------------------------------------------------------------
// Public HTTP verb helpers
// ---------------------------------------------------------------------------

/**
 * Perform a GET request to the given path.
 *
 * @param path - API path relative to `BASE_URL`, e.g. `"/jails"`.
 * @param signal - Optional abort signal for request cancellation.
 * @returns Parsed response body typed as `T`.
 * @throws {FetchError} When the request fails.
 */
export async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
  return request<T>(`${BASE_URL}${path}`, { signal });
}

/**
 * Perform a POST request with a JSON body.
 *
 * @param path - API path relative to `BASE_URL`.
 * @param body - Request payload to serialise as JSON.
 * @param signal - Optional abort signal for request cancellation.
 * @returns Parsed response body typed as `T`.
 * @throws {FetchError} When the request fails.
 */
export async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
  return request<T>(`${BASE_URL}${path}`, {
    method: "POST",
    body: JSON.stringify(body),
    signal,
  });
}

/**
 * Perform a PUT request with a JSON body.
 *
 * @param path - API path relative to `BASE_URL`.
 * @param body - Request payload to serialise as JSON.
 * @param signal - Optional abort signal for request cancellation.
 * @returns Parsed response body typed as `T`.
 * @throws {FetchError} When the request fails.
 */
export async function put<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
  return request<T>(`${BASE_URL}${path}`, {
    method: "PUT",
    body: JSON.stringify(body),
    signal,
  });
}

/**
 * Perform a DELETE request, optionally with a JSON body.
 *
 * @param path - API path relative to `BASE_URL`.
 * @param body - Optional request payload.
 * @param signal - Optional abort signal for request cancellation.
 * @returns Parsed response body typed as `T`.
 * @throws {FetchError} When the request fails.
 */
export async function del<T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
  return request<T>(`${BASE_URL}${path}`, {
    method: "DELETE",
    body: body !== undefined ? JSON.stringify(body) : undefined,
    signal,
  });
}

/** Convenience namespace bundling all HTTP helpers. */
export const api = { get, post, put, del } as const;

// Re-export endpoints so callers only need to import from `client.ts` if desired.
export { ENDPOINTS };
