import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import {
  consumePostLoginRedirectPath,
  isSafePostLoginRedirect,
  POST_LOGIN_REDIRECT_STORAGE_KEY,
  setPostLoginRedirectPath,
  springAuth,
} from "@app/auth/spring/springAuthClient";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
import apiClient from "@app/services/apiClient";
// Side-effect: configures the shared Spring engine with the (mocked) apiClient
// + oauthNavigation seam, so springAuth routes through the mocks below.
import "@app/auth/configureSpringAuth";
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
import {
  AxiosError,
  type AxiosResponse,
  type InternalAxiosRequestConfig,
} from "axios";

// Mock apiClient
vi.mock("@app/services/apiClient");
vi.mock("@app/extensions/oauthNavigation", () => ({
  startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));

describe("SpringAuthClient", () => {
  beforeEach(() => {
    // Clear localStorage before each test
    localStorage.clear();
    // Clear all mocks
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe("getSession", () => {
    it("should return null session when no JWT in localStorage", async () => {
      const result = await springAuth.getSession();

      expect(result.data.session).toBeNull();
      expect(result.error).toBeNull();
      expect(apiClient.get).not.toHaveBeenCalled();
    });

    it("should validate JWT and return session when JWT exists", async () => {
      const mockToken = "mock-jwt-token";
      const mockUser = {
        id: "123",
        email: "test@example.com",
        username: "testuser",
        role: "USER",
      };

      localStorage.setItem("stirling_jwt", mockToken);

      vi.mocked(apiClient.get).mockResolvedValueOnce({
        status: 200,
        data: { user: mockUser },
      } as unknown as AxiosResponse);

      const result = await springAuth.getSession();

      expect(apiClient.get).toHaveBeenCalledWith("/api/v1/auth/me", {
        headers: { Authorization: `Bearer ${mockToken}` },
        suppressErrorToast: true,
        skipAuthRedirect: true,
      });
      expect(result.data.session).toBeTruthy();
      expect(result.data.session?.user).toEqual(mockUser);
      expect(result.data.session?.access_token).toBe(mockToken);
      expect(result.error).toBeNull();
    });

    it("should clear invalid JWT on 401 error", async () => {
      const mockToken = "invalid-jwt-token";
      localStorage.setItem("stirling_jwt", mockToken);

      const mockError = new AxiosError(
        "Unauthorized",
        "ERR_BAD_REQUEST",
        undefined,
        undefined,
        {
          status: 401,
          statusText: "Unauthorized",
          data: {},
          headers: {},
          config: {} as InternalAxiosRequestConfig,
        },
      );

      vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
      vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);

      const result = await springAuth.getSession();

      // A 401 from /me triggers an explicit refresh attempt before treating
      // the session as invalid; lock that recovery contract in so future
      // refactors can't quietly skip it.
      expect(apiClient.post).toHaveBeenCalledWith(
        "/api/v1/auth/refresh",
        null,
        expect.any(Object),
      );
      expect(localStorage.getItem("stirling_jwt")).toBeNull();
      expect(result.data.session).toBeNull();
      // 401 is handled gracefully, so error should be null
      expect(result.error).toBeNull();
    });

    it("should clear invalid JWT on 403 error", async () => {
      const mockToken = "forbidden-jwt-token";
      localStorage.setItem("stirling_jwt", mockToken);

      const mockError = new AxiosError(
        "Forbidden",
        "ERR_BAD_REQUEST",
        undefined,
        undefined,
        {
          status: 403,
          statusText: "Forbidden",
          data: {},
          headers: {},
          config: {} as InternalAxiosRequestConfig,
        },
      );

      vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
      vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);

      const result = await springAuth.getSession();

      // A 403 from /me triggers an explicit refresh attempt before treating
      // the session as invalid; lock that recovery contract in so future
      // refactors can't quietly skip it.
      expect(apiClient.post).toHaveBeenCalledWith(
        "/api/v1/auth/refresh",
        null,
        expect.any(Object),
      );
      expect(localStorage.getItem("stirling_jwt")).toBeNull();
      expect(result.data.session).toBeNull();
      // 403 is handled gracefully, so error should be null
      expect(result.error).toBeNull();
    });
  });

  describe("signInWithPassword", () => {
    it("should successfully sign in with email and password", async () => {
      // The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
      // about defaults - incidental to what this test verifies.
      allowConsole.warn(/Cannot decode token for adaptive intervals/);
      const credentials = {
        email: "test@example.com",
        password: "password123",
      };

      const mockToken = "new-jwt-token";
      const mockUser = {
        id: "123",
        email: credentials.email,
        username: credentials.email,
        role: "USER",
      };

      vi.mocked(apiClient.post).mockResolvedValueOnce({
        status: 200,
        data: {
          user: mockUser,
          session: {
            access_token: mockToken,
            expires_in: 3600,
          },
        },
      } as unknown as AxiosResponse);

      // Spy on window.dispatchEvent
      const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");

      const result = await springAuth.signInWithPassword(credentials);

      expect(apiClient.post).toHaveBeenCalledWith(
        "/api/v1/auth/login",
        {
          username: credentials.email,
          password: credentials.password,
        },
        { withCredentials: true },
      );
      expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
      expect(dispatchEventSpy).toHaveBeenCalledWith(
        expect.objectContaining({ type: "jwt-available" }),
      );
      expect(result.user).toEqual(mockUser);
      expect(result.session?.access_token).toBe(mockToken);
      expect(result.error).toBeNull();
    });

    it("should return error on failed login", async () => {
      expectConsole.error(/\[SpringAuth\] signInWithPassword error/);
      const credentials = {
        email: "wrong@example.com",
        password: "wrongpassword",
      };

      const errorMessage = "Invalid credentials";
      const mockError = Object.assign(new Error(errorMessage), {
        isAxiosError: true,
        response: {
          status: 401,
          data: { message: errorMessage },
        },
      });

      vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);

      const result = await springAuth.signInWithPassword(credentials);

      expect(result.user).toBeNull();
      expect(result.session).toBeNull();
      expect(result.error).toBeTruthy();
      expect(result.error?.message).toBe(errorMessage);
    });
  });

  describe("signUp", () => {
    it("should successfully register new user", async () => {
      const credentials = {
        email: "newuser@example.com",
        password: "newpassword123",
      };

      const mockUser = {
        id: "456",
        email: credentials.email,
        username: credentials.email,
        role: "USER",
      };

      vi.mocked(apiClient.post).mockResolvedValueOnce({
        status: 200,
        data: { user: mockUser },
      } as unknown as AxiosResponse);

      const result = await springAuth.signUp(credentials);

      expect(apiClient.post).toHaveBeenCalledWith(
        "/api/v1/user/register",
        {
          username: credentials.email,
          password: credentials.password,
        },
        { withCredentials: true },
      );
      expect(result.user).toEqual(mockUser);
      expect(result.session).toBeNull(); // No auto-login on signup
      expect(result.error).toBeNull();
    });

    it("should return error on failed registration", async () => {
      expectConsole.error(/\[SpringAuth\] signUp error/);
      const credentials = {
        email: "existing@example.com",
        password: "password123",
      };

      const errorMessage = "User already exists";
      const mockError = Object.assign(new Error(errorMessage), {
        isAxiosError: true,
        response: {
          status: 409,
          data: { message: errorMessage },
        },
      });

      vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);

      const result = await springAuth.signUp(credentials);

      expect(result.user).toBeNull();
      expect(result.session).toBeNull();
      expect(result.error).toBeTruthy();
      expect(result.error?.message).toBe(errorMessage);
    });
  });

  describe("signOut", () => {
    it("should successfully sign out and clear JWT", async () => {
      const mockToken = "jwt-to-clear";
      localStorage.setItem("stirling_jwt", mockToken);

      vi.mocked(apiClient.post).mockResolvedValueOnce({
        status: 200,
        data: {},
      } as unknown as AxiosResponse);

      const result = await springAuth.signOut();

      expect(apiClient.post).toHaveBeenCalledWith(
        "/api/v1/auth/logout",
        null,
        expect.objectContaining({ withCredentials: true }),
      );
      expect(localStorage.getItem("stirling_jwt")).toBeNull();
      expect(result.error).toBeNull();
    });

    it("should clear JWT even if logout request fails", async () => {
      expectConsole.error(/\[SpringAuth\] signOut error/);
      const mockToken = "jwt-to-clear";
      localStorage.setItem("stirling_jwt", mockToken);

      vi.mocked(apiClient.post).mockRejectedValueOnce({
        isAxiosError: true,
        response: { status: 500 },
        message: "Server error",
      });

      const result = await springAuth.signOut();

      expect(localStorage.getItem("stirling_jwt")).toBeNull();
      expect(result.error).toBeTruthy();
    });
  });

  describe("refreshSession", () => {
    it("should refresh JWT token successfully", async () => {
      // The fake token isn't a real JWT, so calculateAdaptiveIntervals warns
      // about defaults - incidental to what this test verifies.
      allowConsole.warn(/Cannot decode token for adaptive intervals/);
      const newToken = "refreshed-jwt-token";
      const mockUser = {
        id: "123",
        email: "test@example.com",
        username: "testuser",
        role: "USER",
      };

      vi.mocked(apiClient.post).mockResolvedValueOnce({
        status: 200,
        data: {
          user: mockUser,
          session: {
            access_token: newToken,
            expires_in: 3600,
          },
        },
      } as unknown as AxiosResponse);

      const result = await springAuth.refreshSession();

      expect(localStorage.getItem("stirling_jwt")).toBe(newToken);
      // Note: refreshSession does not dispatch jwt-available event, only notifies listeners
      expect(result.data.session?.access_token).toBe(newToken);
      expect(result.error).toBeNull();
    });

    it("should clear JWT and return error on 401", async () => {
      localStorage.setItem("stirling_jwt", "expired-token");

      vi.mocked(apiClient.post).mockRejectedValueOnce({
        isAxiosError: true,
        response: { status: 401 },
        message: "Token expired",
      });

      const result = await springAuth.refreshSession();

      expect(localStorage.getItem("stirling_jwt")).toBeNull();
      expect(result.data.session).toBeNull();
      expect(result.error).toBeTruthy();
    });
  });

  describe("signInWithOAuth", () => {
    it("should redirect to OAuth provider", async () => {
      const mockAssign = vi.fn();
      Object.defineProperty(window, "location", {
        value: { assign: mockAssign },
        writable: true,
      });

      vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);

      const result = await springAuth.signInWithOAuth({
        provider: "/oauth2/authorization/github",
        options: { redirectTo: "/auth/callback" },
      });

      expect(startOAuthNavigation).toHaveBeenCalledWith(
        "/oauth2/authorization/github",
      );
      expect(mockAssign).toHaveBeenCalledWith("/oauth2/authorization/github");
      expect(result.error).toBeNull();
    });

    it("should skip redirect when handled by extension", async () => {
      const mockAssign = vi.fn();
      Object.defineProperty(window, "location", {
        value: { assign: mockAssign },
        writable: true,
      });

      vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);

      const result = await springAuth.signInWithOAuth({
        provider: "/oauth2/authorization/github",
        options: { redirectTo: "/auth/callback" },
      });

      expect(startOAuthNavigation).toHaveBeenCalledWith(
        "/oauth2/authorization/github",
      );
      expect(mockAssign).not.toHaveBeenCalled();
      expect(result.error).toBeNull();
    });
  });

  describe("post-login redirect path", () => {
    beforeEach(() => {
      sessionStorage.clear();
    });

    describe("isSafePostLoginRedirect", () => {
      it("accepts same-origin paths with a single leading slash", () => {
        expect(isSafePostLoginRedirect("/share/abc123")).toBe(true);
        expect(isSafePostLoginRedirect("/workbench")).toBe(true);
        expect(isSafePostLoginRedirect("/share/abc?x=1")).toBe(true);
      });

      it("rejects empty, null, or non-string values", () => {
        expect(isSafePostLoginRedirect("")).toBe(false);
        expect(isSafePostLoginRedirect(null)).toBe(false);
        expect(isSafePostLoginRedirect(undefined)).toBe(false);
        expect(isSafePostLoginRedirect(42 as unknown)).toBe(false);
      });

      it("rejects protocol-relative and absolute URLs (open-redirect guard)", () => {
        expect(isSafePostLoginRedirect("//evil.example")).toBe(false);
        expect(isSafePostLoginRedirect("http://evil.example")).toBe(false);
        expect(isSafePostLoginRedirect("https://evil.example/x")).toBe(false);
        expect(isSafePostLoginRedirect("/\\evil")).toBe(false);
      });

      it("rejects auth-plumbing paths to avoid login loops", () => {
        expect(isSafePostLoginRedirect("/login")).toBe(false);
        expect(isSafePostLoginRedirect("/login?foo=1")).toBe(false);
        expect(isSafePostLoginRedirect("/auth/callback")).toBe(false);
        expect(isSafePostLoginRedirect("/oauth2/authorization/google")).toBe(
          false,
        );
        expect(isSafePostLoginRedirect("/saml2/authenticate/x")).toBe(false);
      });
    });

    describe("setPostLoginRedirectPath", () => {
      it("stores a safe path in sessionStorage", () => {
        setPostLoginRedirectPath("/share/abc123");
        expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBe(
          "/share/abc123",
        );
      });

      it("clears any existing entry when given an unsafe value", () => {
        sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
        setPostLoginRedirectPath("//evil.example");
        expect(
          sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
        ).toBeNull();
      });

      it("clears any existing entry when given null", () => {
        sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/old");
        setPostLoginRedirectPath(null);
        expect(
          sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
        ).toBeNull();
      });
    });

    describe("consumePostLoginRedirectPath", () => {
      it("returns the stored path and clears it (single-use)", () => {
        sessionStorage.setItem(
          POST_LOGIN_REDIRECT_STORAGE_KEY,
          "/share/abc123",
        );
        expect(consumePostLoginRedirectPath()).toBe("/share/abc123");
        expect(
          sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
        ).toBeNull();
      });

      it("returns null (and still clears) when the stored value is unsafe", () => {
        sessionStorage.setItem(
          POST_LOGIN_REDIRECT_STORAGE_KEY,
          "//evil.example",
        );
        expect(consumePostLoginRedirectPath()).toBeNull();
        expect(
          sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY),
        ).toBeNull();
      });

      it("returns null when nothing is stored", () => {
        expect(consumePostLoginRedirectPath()).toBeNull();
      });
    });
  });
});
