/**
 * Odoo REST client with transparent access-token rotation.
 *
 * Tokens live in ``localStorage`` under three keys:
 *   - ``encoach_token``         — short-lived access JWT (1h)
 *   - ``encoach_refresh_token`` — long-lived refresh JWT  (7d)
 *   - ``encoach_token_exp``     — epoch seconds for the access token
 *
 * When a request receives ``401`` *and* a refresh token is present, the client
 * silently rotates the pair via ``POST /api/auth/refresh`` and retries the
 * original request exactly once. Multiple concurrent 401s coalesce onto the
 * same refresh promise so we never fire more than one rotation in flight.
 *
 * Motivation: before this change, every expired access token forced a full
 * re-login, which interrupted long admin dashboards (stats, reports) multiple
 * times per hour. With the refresh loop, access tokens can be short-lived
 * (1h) without hurting UX, giving us the security benefit of short access
 * windows without an eager logout.
 */

export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL;

export function extractApiErrorMessage(data: unknown, status: number, statusText: string): string {
  if (data && typeof data === "object" && data !== null && "error" in data) {
    const msg = (data as { error: unknown }).error;
    if (msg != null && String(msg).trim()) return String(msg);
  }
  return `${status} ${statusText}`;
}

export class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public data: unknown,
  ) {
    super(extractApiErrorMessage(data, status, statusText));
    this.name = "ApiError";
  }
}

/**
 * Turn an arbitrary thrown value from an API mutation into a human-readable,
 * actionable toast description. Deployment QA kept reporting generic
 * "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
 * status and, when we recognise the code, a hint about what to do next
 * (payload too big → ask ops to raise nginx, timeout → retry, etc.).
 *
 * @param err     the error thrown by an `api.*` call (usually an ApiError)
 * @param fallback text to show if we can't classify the error
 * @param context short context word used in the 413/504 hints
 *                ("file", "request", "payload" …)
 */
export function describeApiError(
  err: unknown,
  fallback = "Something went wrong",
  context = "request",
): string {
  const e = err as { status?: number; message?: string; data?: unknown } | null;
  const status = e?.status;
  // Try to extract a server-side error body even if the generic ApiError
  // message wasn't populated (nginx 413/504 respond with an HTML page so
  // `data.error` is empty and `message` looks like "413 ").
  const serverMsg =
    e?.data && typeof e.data === "object" && "error" in (e.data as object)
      ? String((e.data as { error: unknown }).error || "").trim()
      : "";

  if (status === 413) {
    return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
  }
  if (status === 504) {
    return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
  }
  if (status === 502) {
    return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
  }
  if (status === 401) {
    return "Your session expired — please sign in again.";
  }
  if (status === 403) {
    return serverMsg || "You don't have permission to do that (HTTP 403).";
  }
  if (status === 404) {
    return serverMsg || "The requested item no longer exists (HTTP 404).";
  }
  if (status === 422 || status === 400) {
    return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
  }
  if (status && status >= 500) {
    return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
  }

  // Network failure / fetch aborted / CORS — ApiError not thrown at all.
  if (!status && e?.message) return e.message;

  return fallback;
}

const ACCESS_KEY = "encoach_token";
const REFRESH_KEY = "encoach_refresh_token";
const EXP_KEY = "encoach_token_exp";
const ENTITY_KEY = "encoach_entity_id";

function getAccessToken(): string | null {
  return localStorage.getItem(ACCESS_KEY);
}

export function getActiveEntityId(): number | null {
  try {
    const raw = localStorage.getItem(ENTITY_KEY);
    if (!raw) return null;
    const n = Number(raw);
    return Number.isFinite(n) && n > 0 ? n : null;
  } catch {
    return null;
  }
}

export function setActiveEntityId(entityId: number | null | undefined): void {
  try {
    if (entityId && Number.isFinite(entityId)) {
      localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
    } else {
      localStorage.removeItem(ENTITY_KEY);
    }
  } catch {
    // ignore storage errors
  }
}

/**
 * Build a URL to a media-streaming endpoint with the JWT attached as a
 * query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
 * (which can't attach custom Authorization headers) and by ``<a download>``
 * tags so the browser can fetch the binary directly without going through
 * a fetch + blob URL dance.
 *
 * Returns the original path unchanged when no token is stored, which lets
 * callers render a placeholder rather than crashing on ``null``.
 */
export function withAuthQuery(path: string): string {
  if (!path) return path;
  const token = getAccessToken();
  if (!token) return path;
  const sep = path.includes("?") ? "&" : "?";
  return `${path}${sep}token=${encodeURIComponent(token)}`;
}

function getRefreshToken(): string | null {
  return localStorage.getItem(REFRESH_KEY);
}

export function setToken(token: string): void {
  localStorage.setItem(ACCESS_KEY, token);
}

export function setRefreshToken(token: string | null | undefined): void {
  if (token) {
    localStorage.setItem(REFRESH_KEY, token);
  } else {
    localStorage.removeItem(REFRESH_KEY);
  }
}

export function setTokenExpiry(epochSeconds: number | null | undefined): void {
  if (epochSeconds && Number.isFinite(epochSeconds)) {
    localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
  } else {
    localStorage.removeItem(EXP_KEY);
  }
}

/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
export function persistTokenBundle(bundle: {
  access_token?: string;
  token?: string;
  refresh_token?: string;
  expires_in?: number;
}): void {
  const access = bundle.access_token || bundle.token;
  if (access) setToken(access);
  setRefreshToken(bundle.refresh_token || null);
  if (bundle.expires_in) {
    setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
  }
}

export function clearToken(): void {
  localStorage.removeItem(ACCESS_KEY);
  localStorage.removeItem(REFRESH_KEY);
  localStorage.removeItem(EXP_KEY);
}

// Shared refresh promise — any 401 that arrives while a refresh is in flight
// waits on the existing request instead of firing a duplicate.
let refreshPromise: Promise<boolean> | null = null;

async function performRefresh(): Promise<boolean> {
  const refreshToken = getRefreshToken();
  if (!refreshToken) return false;
  try {
    const res = await fetch(`${BASE_URL}/auth/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refresh_token: refreshToken }),
    });
    if (!res.ok) return false;
    const data: {
      access_token?: string;
      token?: string;
      refresh_token?: string;
      expires_in?: number;
    } = await res.json().catch(() => ({} as never));
    if (!data.access_token && !data.token) return false;
    persistTokenBundle(data);
    return true;
  } catch {
    return false;
  }
}

function refreshOnce(): Promise<boolean> {
  if (!refreshPromise) {
    refreshPromise = performRefresh().finally(() => {
      refreshPromise = null;
    });
  }
  return refreshPromise;
}

function getCurrentLanguage(): string {
  // Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
  // pick wins; everything else falls back to English. We intentionally do
  // not consult navigator.language here so AI-generated content stays in
  // English on first visit (matching the UI default) until the user flips
  // the language toggle.
  try {
    const stored = localStorage.getItem("encoach-lang");
    if (stored) return stored;
  } catch {
    // localStorage unavailable (SSR, sandboxing, etc.)
  }
  return "en";
}

function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
  const headers: Record<string, string> = { ...extra };
  if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";

  const token = getAccessToken();
  if (token) headers["Authorization"] = `Bearer ${token}`;

  // Tell the backend which UI language the user is on so AI-generated content
  // (coaching tips, insights, alerts, narratives) can be returned in the same
  // language instead of always defaulting to English.
  if (!headers["Accept-Language"]) {
    headers["Accept-Language"] = getCurrentLanguage();
  }
  return headers;
}

/**
 * Query param values we know how to serialise into a URL. Arrays are joined
 * with commas because that's what our Odoo controllers expect for multi-value
 * filters (e.g. `?state=draft,confirmed`).
 */
export type QueryParamValue =
  | string
  | number
  | boolean
  | null
  | undefined
  | Array<string | number | boolean>;

/**
 * Accept any object-shaped bag of query params. We intentionally use `object`
 * here rather than `Record<string, QueryParamValue>` because typed interfaces
 * (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
 * default, which would force every call-site to cast.
 */
export type QueryParams = object;

const ENTITY_QUERY_SCOPE_RE =
  /^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
const ENTITY_BODY_SCOPE_RE =
  /^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;

function buildUrl(path: string, params?: QueryParams): string {
  const url = new URL(`${BASE_URL}${path}`, window.location.origin);
  if (params) {
    for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
      if (rawValue === undefined || rawValue === null) continue;
      if (Array.isArray(rawValue)) {
        if (rawValue.length === 0) continue;
        url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
        continue;
      }
      url.searchParams.set(key, String(rawValue));
    }
  }
  // Multi-entity LMS scope: when the user has selected an active entity
  // in the UI, append it to entity-scoped endpoints unless the caller
  // already provided an explicit entity_id.
  const activeEntityId = getActiveEntityId();
  if (
    activeEntityId &&
    !url.searchParams.has("entity_id") &&
    ENTITY_QUERY_SCOPE_RE.test(path)
  ) {
    url.searchParams.set("entity_id", String(activeEntityId));
  }
  return url.toString();
}

function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
  const activeEntityId = getActiveEntityId();
  if (!activeEntityId) return body;
  if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
  const rec = body as Record<string, unknown>;
  if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
  return { ...rec, entity_id: activeEntityId };
}

async function parseResponse<T>(response: Response): Promise<T> {
  const data = await response.json().catch(() => null);
  if (!response.ok) throw new ApiError(response.status, response.statusText, data);
  return data as T;
}

type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };

async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
  const response = await fetch(url, init);

  // Auth/refresh endpoints opt out of the retry loop — otherwise a bad
  // refresh token would recurse forever.
  const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");

  if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
    const hadRefresh = !!getRefreshToken();
    if (hadRefresh) {
      const rotated = await refreshOnce();
      if (rotated) {
        // Rebuild headers so the new access token is attached.
        const newInit: RequestInitWithSkip = {
          ...init,
          _skipRetry: true,
          headers: {
            ...(init.headers as Record<string, string>),
            Authorization: `Bearer ${getAccessToken()}`,
          },
        };
        return performRequest<T>(url, newInit);
      }
    }
    const hadAccess = !!getAccessToken();
    clearToken();
    if (hadAccess || hadRefresh) {
      // Use SPA-style navigation when possible; fall back to a hard nav only
      // when we're inside a worker / non-browser context. A full document
      // reload here used to feel like "the browser refreshes on every click"
      // whenever an access token silently expired.
      if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
        window.history.pushState({}, "", "/login");
        window.dispatchEvent(new PopStateEvent("popstate"));
      }
    }
    throw new ApiError(401, response.statusText, await response.json().catch(() => null));
  }

  return parseResponse<T>(response);
}

export const api = {
  async get<T>(path: string, params?: QueryParams): Promise<T> {
    return performRequest<T>(buildUrl(path, params), {
      method: "GET",
      headers: buildHeaders(),
    });
  },

  async post<T>(path: string, body?: unknown): Promise<T> {
    const payload = maybeInjectEntityIntoBody(path, body);
    return performRequest<T>(buildUrl(path), {
      method: "POST",
      headers: buildHeaders(),
      body: payload ? JSON.stringify(payload) : undefined,
    });
  },

  async patch<T>(path: string, body?: unknown): Promise<T> {
    const payload = maybeInjectEntityIntoBody(path, body);
    return performRequest<T>(buildUrl(path), {
      method: "PATCH",
      headers: buildHeaders(),
      body: payload ? JSON.stringify(payload) : undefined,
    });
  },

  async put<T>(path: string, body?: unknown): Promise<T> {
    const payload = maybeInjectEntityIntoBody(path, body);
    return performRequest<T>(buildUrl(path), {
      method: "PUT",
      headers: buildHeaders(),
      body: payload ? JSON.stringify(payload) : undefined,
    });
  },

  async delete<T>(path: string): Promise<T> {
    return performRequest<T>(buildUrl(path), {
      method: "DELETE",
      headers: buildHeaders(),
    });
  },

  async upload<T>(path: string, formData: FormData): Promise<T> {
    return performRequest<T>(buildUrl(path), {
      method: "POST",
      headers: buildHeaders(undefined, false),
      body: formData,
    });
  },
};
