// Mocks des dependances : on les declare AVANT l'import d'apiClient pour
// que jest.mock soit hoisted correctement.
jest.mock("../../src/core/environmentService", () => ({
  requireEnvironmentBaseUrl: jest.fn(),
  getRequestTimeoutMs: jest.fn().mockReturnValue(15000),
}));

jest.mock("../../src/core/mobileConfigService", () => ({
  getMobileConfigForBaseUrl: jest.fn(),
}));

jest.mock("../../src/services/tokenStorage", () => ({
  readSession: jest.fn(),
  saveSession: jest.fn(),
  clearSession: jest.fn(),
}));

import * as environmentService from "../../src/core/environmentService";
import * as mobileConfigService from "../../src/core/mobileConfigService";
import * as tokenStorage from "../../src/services/tokenStorage";
import apiClient, {
  ApiClientError,
  buildApiUrl,
  clearApiAuthTokens,
  getApiAccessToken,
  initializeApiClientAuth,
  isApiClientError,
  registerSessionRevokedHandler,
  setApiAuthTokens,
} from "../../src/services/apiClient";

const requireBaseUrlMock = environmentService.requireEnvironmentBaseUrl as jest.Mock;
const getMobileConfigMock = mobileConfigService.getMobileConfigForBaseUrl as jest.Mock;
const readSessionMock = tokenStorage.readSession as jest.Mock;
const saveSessionMock = tokenStorage.saveSession as jest.Mock;
const clearSessionMock = tokenStorage.clearSession as jest.Mock;

function jsonResponse(body: unknown, init: Partial<Response> = {}): Response {
  const status = (init as { status?: number }).status ?? 200;
  const headers = new Headers();
  return {
    ok: status >= 200 && status < 300,
    status,
    headers,
    json: async () => body,
  } as unknown as Response;
}

function badJsonResponse(status = 500): Response {
  return {
    ok: status >= 200 && status < 300,
    status,
    headers: new Headers(),
    json: async () => {
      throw new Error("parse");
    },
  } as unknown as Response;
}

describe("apiClient", () => {
  beforeEach(() => {
    jest.clearAllMocks();
    clearApiAuthTokens();
    requireBaseUrlMock.mockResolvedValue("https://api.test");
    getMobileConfigMock.mockResolvedValue(null);
    readSessionMock.mockResolvedValue({ accessToken: null, refreshToken: null, user: null });
    (globalThis as unknown as { fetch: jest.Mock }).fetch = jest.fn();
  });

  describe("ApiClientError / isApiClientError", () => {
    it("construit avec message et status", () => {
      const err = new ApiClientError("boom", 500);
      expect(err.name).toBe("ApiClientError");
      expect(err.status).toBe(500);
      expect(err.message).toBe("boom");
    });

    it("isApiClientError discrimine correctement", () => {
      expect(isApiClientError(new ApiClientError("x"))).toBe(true);
      expect(isApiClientError(new Error("x"))).toBe(false);
      expect(isApiClientError(null)).toBe(false);
      expect(isApiClientError("str")).toBe(false);
    });
  });

  describe("setApiAuthTokens / clearApiAuthTokens / getApiAccessToken", () => {
    it("set puis get le token sans rehydrater", async () => {
      setApiAuthTokens("tok", "ref");
      expect(await getApiAccessToken()).toBe("tok");
      expect(readSessionMock).not.toHaveBeenCalled();
    });

    it("clear remet a null", async () => {
      setApiAuthTokens("a", "b");
      clearApiAuthTokens();
      expect(await getApiAccessToken()).toBeNull();
    });
  });

  describe("initializeApiClientAuth + hydration depuis SecureStore", () => {
    it("charge les tokens depuis SecureStore", async () => {
      jest.resetModules();
      jest.doMock("../../src/services/tokenStorage", () => ({
        readSession: jest.fn().mockResolvedValue({
          accessToken: "stored-acc",
          refreshToken: "stored-ref",
          user: null,
        }),
        saveSession: jest.fn(),
        clearSession: jest.fn(),
      }));
      jest.doMock("../../src/core/environmentService", () => ({
        requireEnvironmentBaseUrl: jest.fn().mockResolvedValue("https://api.test"),
        getRequestTimeoutMs: jest.fn().mockReturnValue(15000),
      }));
      jest.doMock("../../src/core/mobileConfigService", () => ({
        getMobileConfigForBaseUrl: jest.fn().mockResolvedValue(null),
      }));

      const isolated = require("../../src/services/apiClient");
      await isolated.initializeApiClientAuth();
      expect(await isolated.getApiAccessToken()).toBe("stored-acc");

      // hydrated=true, second call ne rehydrate pas
      await isolated.initializeApiClientAuth();
      const ts = require("../../src/services/tokenStorage");
      expect(ts.readSession).toHaveBeenCalledTimes(1);
    });
  });

  describe("buildApiUrl", () => {
    it("retourne baseUrl + path", async () => {
      const url = await buildApiUrl("/api/foo");
      expect(url).toBe("https://api.test/api/foo");
    });

    it("retourne path absolu tel quel si commence par http", async () => {
      const url = await buildApiUrl("https://other.test/x");
      expect(url).toBe("https://other.test/x");
    });
  });

  describe("GET — cas nominal", () => {
    it("fait un GET avec base url", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({ ok: true }));
      const res = await apiClient.get<{ ok: boolean }>("/api/foo");
      expect(res.status).toBe(200);
      expect(res.data).toEqual({ ok: true });
      expect(globalThis.fetch).toHaveBeenCalledWith(
        "https://api.test/api/foo",
        expect.objectContaining({ method: "GET" })
      );
    });

    it("serialise les params en query string et ignore null/undefined", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { params: { a: 1, b: null, c: undefined, d: "x" } });
      const call = (globalThis.fetch as jest.Mock).mock.calls[0][0];
      expect(call).toContain("a=1");
      expect(call).toContain("d=x");
      expect(call).not.toContain("b=");
      expect(call).not.toContain("c=");
    });

    it("concatene avec & quand l'URL a deja un ?", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo?existing=1", { params: { a: 1 } });
      const call = (globalThis.fetch as jest.Mock).mock.calls[0][0];
      expect(call).toBe("https://api.test/api/foo?existing=1&a=1");
    });

    it("ne rajoute pas de query si params vides", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { params: {} });
      expect((globalThis.fetch as jest.Mock).mock.calls[0][0]).toBe("https://api.test/api/foo");
    });

    it("ne rajoute pas de query si tous les params sont filtres", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { params: { a: null, b: undefined } });
      expect((globalThis.fetch as jest.Mock).mock.calls[0][0]).toBe("https://api.test/api/foo");
    });

    it("retourne data={} quand payload null (parse a echoue)", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(badJsonResponse(200));
      const res = await apiClient.get("/api/foo");
      expect(res.data).toEqual({});
    });
  });

  describe("POST — cas nominal", () => {
    it("fait un POST avec body JSON et Content-Type auto", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.post("/api/foo", { x: 1 });
      const call = (globalThis.fetch as jest.Mock).mock.calls[0][1];
      expect(call.method).toBe("POST");
      expect(call.body).toBe(JSON.stringify({ x: 1 }));
      expect(call.headers["Content-Type"]).toBe("application/json");
    });

    it("envoie body={} si body absent", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.post("/api/foo");
      expect((globalThis.fetch as jest.Mock).mock.calls[0][1].body).toBe("{}");
    });

    it("ne surcharge pas Content-Type si deja fourni", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.post("/api/foo", {}, { headers: { "Content-Type": "text/plain" } });
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers["Content-Type"]).toBe("text/plain");
    });
  });

  describe("headers auth + tenant", () => {
    it("injecte Bearer quand access token present", async () => {
      setApiAuthTokens("abc", "def");
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo");
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers.Authorization).toBe("Bearer abc");
    });

    it("respecte Authorization deja fourni", async () => {
      setApiAuthTokens("abc", "def");
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { headers: { Authorization: "Basic xxx" } });
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers.Authorization).toBe("Basic xxx");
    });

    it("injecte X-Tenant-Id depuis la config stockee", async () => {
      getMobileConfigMock.mockResolvedValue({
        base_url: "https://api.test",
        config: { app_name: "x", primary_color: "#000", logo_url: null, tenant_id: 42 },
        saved_at: "2026-01-01",
      });
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo");
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers["X-Tenant-Id"]).toBe("42");
    });

    it("respecte X-Tenant-Id deja fourni (case-insensitive)", async () => {
      getMobileConfigMock.mockResolvedValue({
        base_url: "https://api.test",
        config: { app_name: "x", primary_color: "#000", logo_url: null, tenant_id: 42 },
        saved_at: "2026-01-01",
      });
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { headers: { "x-tenant-id": "99" } });
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers["x-tenant-id"]).toBe("99");
    });

    it("X-Skip-Auth supprime l'Authorization auto et le header", async () => {
      setApiAuthTokens("abc", "def");
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { headers: { "X-Skip-Auth": "1" } });
      const headers = (globalThis.fetch as jest.Mock).mock.calls[0][1].headers;
      expect(headers.Authorization).toBeUndefined();
      expect(headers["X-Skip-Auth"]).toBeUndefined();
    });
  });

  describe("erreurs reseau / HTTP", () => {
    it("encapsule l'erreur fetch en ApiClientError", async () => {
      (globalThis.fetch as jest.Mock).mockRejectedValue(new Error("offline"));
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        name: "ApiClientError",
        message: "offline",
      });
    });

    it("encapsule une exception non-Error en ApiClientError avec fallback", async () => {
      (globalThis.fetch as jest.Mock).mockRejectedValue("raw");
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        message: "Network error",
      });
    });

    it("leve ApiClientError avec message du serveur si payload.message present", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse({ message: "nope" }, { status: 400 } as Partial<Response>)
      );
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        message: "nope",
        status: 400,
      });
    });

    it("leve ApiClientError avec HTTP status si pas de message", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(badJsonResponse(500));
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        message: "HTTP 500",
        status: 500,
      });
    });

    it("utilise 'Request failed' si payload.message vide", async () => {
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse({ message: "" }, { status: 400 } as Partial<Response>)
      );
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        message: "Request failed",
      });
    });

    it("propage le code metier du body sur ApiClientError.code (mur de facturation 402)", async () => {
      // Body REEL renvoye par missioflow-app response402() (commit serveur
      // ead5778b) : le code est sous `code` ET `error`. La sync lit `code` via
      // extractResponseCode -> doit classer "wall", pas retomber en retryable.
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse(
          {
            success: false,
            error: "BILLING_WALL",
            code: "BILLING_WALL",
            message: "Abonnement requis. Seules les interventions deja en cours du jour peuvent etre finalisees.",
          },
          { status: 402 } as Partial<Response>
        )
      );
      await expect(apiClient.post("/mobile/intervention_workflow.php", {})).rejects.toMatchObject({
        name: "ApiClientError",
        status: 402,
        code: "BILLING_WALL",
        message: "Abonnement requis. Seules les interventions deja en cours du jour peuvent etre finalisees.",
      });
    });
  });

  describe("401 + refresh flow", () => {
    it("refresh via endpoint .php puis retry et reussit", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({ message: "expired" }, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(
          jsonResponse({
            success: true,
            data: {
              access_token: "new",
              refresh_token: "new-ref",
              user: { id: 1, email: "a", name: "n", role: "r", user_type: "t" },
              token_type: "Bearer",
              expires_in: 3600,
              refresh_expires_in: 86400,
            },
          })
        )
        .mockResolvedValueOnce(jsonResponse({ ok: true }));

      const res = await apiClient.get<{ ok: boolean }>("/api/foo");
      expect(res.data).toEqual({ ok: true });
      expect(saveSessionMock).toHaveBeenCalled();
      expect(await getApiAccessToken()).toBe("new");
    });

    it("fallback endpoint sans .php si premier renvoie 404", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({ message: "x" }, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(jsonResponse({ message: "not found" }, { status: 404 } as Partial<Response>))
        .mockResolvedValueOnce(
          jsonResponse({
            success: true,
            data: {
              access_token: "new",
              refresh_token: "new-ref",
              user: { id: 1, email: "a", name: "n", role: "r", user_type: "t" },
              token_type: "Bearer",
              expires_in: 3600,
              refresh_expires_in: 86400,
            },
          })
        )
        .mockResolvedValueOnce(jsonResponse({ ok: true }));

      const res = await apiClient.get<{ ok: boolean }>("/api/foo");
      expect(res.data).toEqual({ ok: true });
    });

    it("rejette Unauthorized si pas de refresh token", async () => {
      setApiAuthTokens("old", null);
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse({}, { status: 401 } as Partial<Response>)
      );
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        status: 401,
      });
    });

    it("refresh echoue (404 sur les 2 endpoints) -> clearSession + 401", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({}, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(jsonResponse({}, { status: 404 } as Partial<Response>))
        .mockResolvedValueOnce(jsonResponse({ message: "bad" }, { status: 401 } as Partial<Response>));
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({ status: 401 });
      expect(clearSessionMock).toHaveBeenCalled();
    });

    it("X-Skip-Refresh bypass le refresh et leve directement", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse({}, { status: 401 } as Partial<Response>)
      );
      await expect(
        apiClient.get("/api/foo", { headers: { "X-Skip-Refresh": "1" } })
      ).rejects.toMatchObject({ status: 401 });
    });

    it("refresh parse JSON vide sur endpoint 2 mais 401 apres", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({}, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(badJsonResponse(200));
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({ status: 401 });
    });

    it("refresh leve si fetch rejette (exception dans refreshAccessToken)", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({}, { status: 401 } as Partial<Response>))
        .mockRejectedValueOnce(new Error("network"));
      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({ status: 401 });
      expect(clearSessionMock).toHaveBeenCalled();
    });

    it("refreshPromise mutualise les refresh concurrents", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({}, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(jsonResponse({}, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(
          jsonResponse({
            success: true,
            data: {
              access_token: "new",
              refresh_token: "new-ref",
              user: { id: 1, email: "a", name: "n", role: "r", user_type: "t" },
              token_type: "Bearer",
              expires_in: 3600,
              refresh_expires_in: 86400,
            },
          })
        )
        .mockResolvedValueOnce(jsonResponse({ ok: true }))
        .mockResolvedValueOnce(jsonResponse({ ok: true }));

      const [r1, r2] = await Promise.all([apiClient.get("/a"), apiClient.get("/b")]);
      expect(r1.status).toBe(200);
      expect(r2.status).toBe(200);
    });
  });

  describe("401 code=SESSION_REVOKED (Lot 5 — session unique per-seat)", () => {
    it("court-circuite le refresh, purge la session et leve une erreur code=SESSION_REVOKED", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse(
          { success: false, message: "Session revoquee", code: "SESSION_REVOKED" },
          { status: 401 } as Partial<Response>
        )
      );

      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({
        status: 401,
        code: "SESSION_REVOKED",
      });
      // Un seul fetch : aucune tentative de refresh (pas d'appel a refresh.php).
      expect((globalThis.fetch as jest.Mock).mock.calls).toHaveLength(1);
      expect(clearSessionMock).toHaveBeenCalled();
    });

    it("invoque le handler global enregistre", async () => {
      setApiAuthTokens("old", "ref");
      const handler = jest.fn();
      registerSessionRevokedHandler(handler);
      (globalThis.fetch as jest.Mock).mockResolvedValue(
        jsonResponse(
          { success: false, message: "Session revoquee", code: "SESSION_REVOKED" },
          { status: 401 } as Partial<Response>
        )
      );

      await expect(apiClient.get("/api/foo")).rejects.toMatchObject({ code: "SESSION_REVOKED" });
      expect(handler).toHaveBeenCalledTimes(1);
      registerSessionRevokedHandler(null);
    });

    it("un 401 SANS code suit le flux refresh classique (pas de court-circuit)", async () => {
      setApiAuthTokens("old", "ref");
      (globalThis.fetch as jest.Mock)
        .mockResolvedValueOnce(jsonResponse({ message: "expired" }, { status: 401 } as Partial<Response>))
        .mockResolvedValueOnce(
          jsonResponse({
            success: true,
            data: {
              access_token: "new",
              refresh_token: "new-ref",
              user: { id: 1, email: "a", name: "n", role: "r", user_type: "t" },
              token_type: "Bearer",
              expires_in: 3600,
              refresh_expires_in: 86400,
            },
          })
        )
        .mockResolvedValueOnce(jsonResponse({ ok: true }));

      const res = await apiClient.get("/api/foo");
      expect(res.status).toBe(200);
      // 401 initial + refresh.php + retry = au moins 3 fetch (refresh tente).
      expect((globalThis.fetch as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(3);
    });
  });

  describe("signal", () => {
    it("utilise le signal fourni plutot que le timeout interne", async () => {
      const controller = new AbortController();
      (globalThis.fetch as jest.Mock).mockResolvedValue(jsonResponse({}));
      await apiClient.get("/api/foo", { signal: controller.signal });
      const callArgs = (globalThis.fetch as jest.Mock).mock.calls[0][1];
      expect(callArgs.signal).toBe(controller.signal);
    });
  });
});
