import type { AuthSession } from "@rubis/shared";
import { env } from "./env";
import { authStore } from "./auth";

/**
 * Client HTTP — façade unique pour l'API REST. Centralise :
 *  - Header Authorization (Bearer token depuis authStore)
 *  - Cookie refresh httpOnly (`credentials: 'include'`)
 *  - Silent refresh sur 401 (rotation du token + retry une fois)
 *  - Gestion d'erreur uniforme via la classe ApiError
 *
 * Le contrat de réponse Adonis est `{ data: ... }` (cf. backend.md §6).
 * Les erreurs sont `{ errors: [{ code, message, field? }] }`.
 *
 * En dev avec VITE_USE_MOCKS=true, MSW intercepte transparently — ce
 * fichier fonctionne pareil dans les deux modes.
 */

export class ApiError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly fieldErrors?: Record<string, string[]>,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

type RequestOptions = {
  method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
  body?: unknown;
  signal?: AbortSignal;
  /** Si true, n'inclut pas le header Authorization (utile pour /auth/login). */
  anonymous?: boolean;
};

/**
 * Refresh en cours partagé entre toutes les requêtes 401 simultanées :
 * si N requêtes reviennent 401 en même temps, on n'envoie qu'un seul
 * /auth/refresh et toutes attendent le même résultat.
 */
let pendingRefresh: Promise<void> | null = null;

async function performRefresh(): Promise<void> {
  if (!pendingRefresh) {
    pendingRefresh = (async () => {
      try {
        const session = await rawRequest<AuthSession>("/api/v1/auth/refresh", {
          method: "POST",
          anonymous: true,
        });
        authStore.setSession(session.accessToken, session.user);
      } catch (err) {
        authStore.clear();
        throw err;
      } finally {
        pendingRefresh = null;
      }
    })();
  }
  return pendingRefresh;
}

/**
 * Bas niveau : fait la requête sans tenter de silent refresh.
 * Utilisé par performRefresh elle-même (sinon boucle infinie sur
 * /auth/refresh qui revient 401).
 */
async function rawRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
  const { method = "GET", body, signal, anonymous = false } = options;
  const isFormData = body instanceof FormData;
  const headers: Record<string, string> = {
    Accept: "application/json",
  };
  if (body !== undefined && !isFormData) headers["Content-Type"] = "application/json";
  if (!anonymous && authStore.token) {
    headers.Authorization = `Bearer ${authStore.token}`;
  }

  const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;

  const response = await fetch(url, {
    method,
    headers,
    credentials: "include",
    body:
      body === undefined
        ? undefined
        : isFormData
          ? body
          : JSON.stringify(body),
    signal,
  });

  if (response.status === 204) {
    return undefined as T;
  }

  const json = (await response.json().catch(() => null)) as
    | { data?: T; errors?: Array<{ code: string; message: string; field?: string }> }
    | null;

  if (!response.ok) {
    const firstError = json?.errors?.[0];
    const fieldErrors: Record<string, string[]> | undefined = json?.errors?.reduce(
      (acc, err) => {
        if (err.field) {
          acc[err.field] = [...(acc[err.field] ?? []), err.message];
        }
        return acc;
      },
      {} as Record<string, string[]>,
    );
    throw new ApiError(
      response.status,
      firstError?.code ?? "unknown",
      firstError?.message ?? `Requête échouée (${response.status})`,
      fieldErrors,
    );
  }

  // Convention de réponse Adonis : { data: ..., meta?: ... }. On extrait
  // `data` quand il est présent (contrat documenté), sinon on renvoie
  // le body tel quel (cas rare : endpoint qui retourne un objet plat).
  return (json?.data ?? json) as T;
}

/**
 * Niveau public : ajoute le silent refresh sur 401.
 *
 * Si une requête authentifiée revient 401 :
 *  1. On lance (ou attend) un seul /auth/refresh
 *  2. Si le refresh réussit, on retry la requête originale avec le
 *     nouveau token
 *  3. Si le refresh échoue, l'authStore est cleared (le router guard
 *     redirige vers /login) et on propage le 401 original
 *
 * Les requêtes anonymes (signup, login, refresh lui-même) ne tentent
 * pas de refresh — elles n'ont pas de token à rafraîchir.
 */
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  try {
    return await rawRequest<T>(path, options);
  } catch (err) {
    const isAuthEndpoint = path.startsWith("/api/v1/auth/");
    const shouldRefresh =
      err instanceof ApiError &&
      err.status === 401 &&
      !options.anonymous &&
      !isAuthEndpoint &&
      authStore.token !== null;

    if (!shouldRefresh) throw err;

    try {
      await performRefresh();
    } catch {
      // refresh KO → on propage le 401 original (le store est déjà cleared)
      throw err;
    }

    // Retry une seule fois avec le nouveau token.
    return rawRequest<T>(path, options);
  }
}

export const api = {
  get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
    request<T>(path, { ...options, method: "GET" }),
  post: <T>(
    path: string,
    body?: unknown,
    options?: Omit<RequestOptions, "method" | "body">,
  ): Promise<T> => request<T>(path, { ...options, method: "POST", body }),
  patch: <T>(
    path: string,
    body?: unknown,
    options?: Omit<RequestOptions, "method" | "body">,
  ): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
  delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
    request<T>(path, { ...options, method: "DELETE" }),
};
