import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";

// Mock better-auth modules before importing AuthService
vi.mock("better-auth/node", () => ({
  toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
}));

vi.mock("better-auth", () => ({
  betterAuth: vi.fn().mockReturnValue({
    handler: vi.fn(),
    api: { getSession: vi.fn() },
  }),
}));

vi.mock("better-auth/adapters/prisma", () => ({
  prismaAdapter: vi.fn().mockReturnValue({}),
}));

vi.mock("better-auth/plugins", () => ({
  genericOAuth: vi.fn().mockReturnValue({ id: "generic-oauth" }),
}));

import { AuthService } from "./auth.service";
import { PrismaService } from "../prisma/prisma.service";

describe("AuthService", () => {
  let service: AuthService;
  let prisma: PrismaService;

  const mockPrismaService = {
    user: {
      findUnique: vi.fn(),
    },
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: PrismaService,
          useValue: mockPrismaService,
        },
      ],
    }).compile();

    service = module.get<AuthService>(AuthService);
    prisma = module.get<PrismaService>(PrismaService);

    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.restoreAllMocks();
    delete process.env.OIDC_ENABLED;
    delete process.env.OIDC_ISSUER;
  });

  describe("getAuth", () => {
    it("should return BetterAuth instance", () => {
      const auth = service.getAuth();
      expect(auth).toBeDefined();
      expect(auth.handler).toBeDefined();
    });
  });

  describe("getUserById", () => {
    const mockUser = {
      id: "user-123",
      email: "test@example.com",
      name: "Test User",
      authProviderId: "auth-123",
    };

    it("should get user by ID", async () => {
      mockPrismaService.user.findUnique.mockResolvedValue(mockUser);

      const result = await service.getUserById("user-123");

      expect(result).toEqual(mockUser);
      expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
        where: { id: "user-123" },
        select: {
          id: true,
          email: true,
          name: true,
          authProviderId: true,
        },
      });
    });

    it("should return null when user is not found", async () => {
      mockPrismaService.user.findUnique.mockResolvedValue(null);

      const result = await service.getUserById("nonexistent-id");

      expect(result).toBeNull();
      expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
        where: { id: "nonexistent-id" },
        select: {
          id: true,
          email: true,
          name: true,
          authProviderId: true,
        },
      });
    });
  });

  describe("getUserByEmail", () => {
    const mockUser = {
      id: "user-123",
      email: "test@example.com",
      name: "Test User",
      authProviderId: "auth-123",
    };

    it("should get user by email", async () => {
      mockPrismaService.user.findUnique.mockResolvedValue(mockUser);

      const result = await service.getUserByEmail("test@example.com");

      expect(result).toEqual(mockUser);
      expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
        where: { email: "test@example.com" },
        select: {
          id: true,
          email: true,
          name: true,
          authProviderId: true,
        },
      });
    });

    it("should return null when user is not found", async () => {
      mockPrismaService.user.findUnique.mockResolvedValue(null);

      const result = await service.getUserByEmail("unknown@example.com");

      expect(result).toBeNull();
      expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
        where: { email: "unknown@example.com" },
        select: {
          id: true,
          email: true,
          name: true,
          authProviderId: true,
        },
      });
    });
  });

  describe("isOidcProviderReachable", () => {
    const discoveryUrl = "https://auth.example.com/.well-known/openid-configuration";

    beforeEach(() => {
      process.env.OIDC_ISSUER = "https://auth.example.com/";
      // Reset the cache by accessing private fields via bracket notation
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthResult = false;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).consecutiveHealthFailures = 0;
    });

    it("should return true when discovery URL returns 200", async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        status: 200,
      });
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.isOidcProviderReachable();

      expect(result).toBe(true);
      expect(mockFetch).toHaveBeenCalledWith(discoveryUrl, {
        signal: expect.any(AbortSignal) as AbortSignal,
      });
    });

    it("should return false on network error", async () => {
      const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.isOidcProviderReachable();

      expect(result).toBe(false);
    });

    it("should return false on timeout", async () => {
      const mockFetch = vi.fn().mockRejectedValue(new DOMException("The operation was aborted"));
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.isOidcProviderReachable();

      expect(result).toBe(false);
    });

    it("should return false when discovery URL returns non-200", async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: false,
        status: 503,
      });
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.isOidcProviderReachable();

      expect(result).toBe(false);
    });

    it("should cache result for 30 seconds", async () => {
      const mockFetch = vi.fn().mockResolvedValue({
        ok: true,
        status: 200,
      });
      vi.stubGlobal("fetch", mockFetch);

      // First call - fetches
      const result1 = await service.isOidcProviderReachable();
      expect(result1).toBe(true);
      expect(mockFetch).toHaveBeenCalledTimes(1);

      // Second call within 30s - uses cache
      const result2 = await service.isOidcProviderReachable();
      expect(result2).toBe(true);
      expect(mockFetch).toHaveBeenCalledTimes(1); // Still 1, no new fetch

      // Simulate cache expiry by moving lastHealthCheck back
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = Date.now() - 31_000;

      // Third call after cache expiry - fetches again
      const result3 = await service.isOidcProviderReachable();
      expect(result3).toBe(true);
      expect(mockFetch).toHaveBeenCalledTimes(2); // Now 2
    });

    it("should cache false results too", async () => {
      const mockFetch = vi
        .fn()
        .mockRejectedValueOnce(new Error("ECONNREFUSED"))
        .mockResolvedValueOnce({ ok: true, status: 200 });
      vi.stubGlobal("fetch", mockFetch);

      // First call - fails
      const result1 = await service.isOidcProviderReachable();
      expect(result1).toBe(false);
      expect(mockFetch).toHaveBeenCalledTimes(1);

      // Second call within 30s - returns cached false
      const result2 = await service.isOidcProviderReachable();
      expect(result2).toBe(false);
      expect(mockFetch).toHaveBeenCalledTimes(1);
    });

    it("should escalate to error level after 3 consecutive failures", async () => {
      const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
      vi.stubGlobal("fetch", mockFetch);

      const loggerWarn = vi.spyOn(service["logger"], "warn");
      const loggerError = vi.spyOn(service["logger"], "error");

      // Failures 1 and 2 should log at warn level
      await service.isOidcProviderReachable();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0; // Reset cache
      await service.isOidcProviderReachable();

      expect(loggerWarn).toHaveBeenCalledTimes(2);
      expect(loggerError).not.toHaveBeenCalled();

      // Failure 3 should escalate to error level
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;
      await service.isOidcProviderReachable();

      expect(loggerError).toHaveBeenCalledTimes(1);
      expect(loggerError).toHaveBeenCalledWith(
        expect.stringContaining("OIDC provider unreachable")
      );
    });

    it("should escalate to error level after 3 consecutive non-OK responses", async () => {
      const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
      vi.stubGlobal("fetch", mockFetch);

      const loggerWarn = vi.spyOn(service["logger"], "warn");
      const loggerError = vi.spyOn(service["logger"], "error");

      // Failures 1 and 2 at warn level
      await service.isOidcProviderReachable();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;
      await service.isOidcProviderReachable();

      expect(loggerWarn).toHaveBeenCalledTimes(2);
      expect(loggerError).not.toHaveBeenCalled();

      // Failure 3 at error level
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;
      await service.isOidcProviderReachable();

      expect(loggerError).toHaveBeenCalledTimes(1);
      expect(loggerError).toHaveBeenCalledWith(
        expect.stringContaining("OIDC provider returned non-OK status")
      );
    });

    it("should reset failure counter and log recovery on success after failures", async () => {
      const mockFetch = vi
        .fn()
        .mockRejectedValueOnce(new Error("ECONNREFUSED"))
        .mockRejectedValueOnce(new Error("ECONNREFUSED"))
        .mockResolvedValueOnce({ ok: true, status: 200 });
      vi.stubGlobal("fetch", mockFetch);

      const loggerLog = vi.spyOn(service["logger"], "log");

      // Two failures
      await service.isOidcProviderReachable();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;
      await service.isOidcProviderReachable();
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;

      // Recovery
      const result = await service.isOidcProviderReachable();

      expect(result).toBe(true);
      expect(loggerLog).toHaveBeenCalledWith(
        expect.stringContaining("OIDC provider recovered after 2 consecutive failure(s)")
      );
      // Verify counter reset
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      expect((service as any).consecutiveHealthFailures).toBe(0);
    });
  });

  describe("getAuthConfig", () => {
    it("should return only email provider when OIDC is disabled", async () => {
      delete process.env.OIDC_ENABLED;

      const result = await service.getAuthConfig();

      expect(result).toEqual({
        providers: [{ id: "email", name: "Email", type: "credentials" }],
      });
    });

    it("should return both email and authentik providers when OIDC is enabled and reachable", async () => {
      process.env.OIDC_ENABLED = "true";
      process.env.OIDC_ISSUER = "https://auth.example.com/";

      const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.getAuthConfig();

      expect(result).toEqual({
        providers: [
          { id: "email", name: "Email", type: "credentials" },
          { id: "authentik", name: "Authentik", type: "oauth" },
        ],
      });
    });

    it("should return only email provider when OIDC_ENABLED is false", async () => {
      process.env.OIDC_ENABLED = "false";

      const result = await service.getAuthConfig();

      expect(result).toEqual({
        providers: [{ id: "email", name: "Email", type: "credentials" }],
      });
    });

    it("should omit authentik when OIDC is enabled but provider is unreachable", async () => {
      process.env.OIDC_ENABLED = "true";
      process.env.OIDC_ISSUER = "https://auth.example.com/";

      // Reset cache
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (service as any).lastHealthCheck = 0;

      const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
      vi.stubGlobal("fetch", mockFetch);

      const result = await service.getAuthConfig();

      expect(result).toEqual({
        providers: [{ id: "email", name: "Email", type: "credentials" }],
      });
    });
  });

  describe("verifySession", () => {
    const mockSessionData = {
      user: {
        id: "user-123",
        email: "test@example.com",
        name: "Test User",
      },
      session: {
        id: "session-123",
        token: "test-token",
      },
    };

    it("should validate session token using secure BetterAuth cookie header", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("valid-token");

      expect(result).toEqual(mockSessionData);
      expect(mockGetSession).toHaveBeenCalledTimes(1);
      expect(mockGetSession).toHaveBeenCalledWith({
        headers: {
          cookie: "__Secure-better-auth.session_token=valid-token",
        },
      });
    });

    it("should preserve raw cookie token value without URL re-encoding", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockResolvedValue(mockSessionData);
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("tok/with+=chars=");

      expect(result).toEqual(mockSessionData);
      expect(mockGetSession).toHaveBeenCalledWith({
        headers: {
          cookie: "__Secure-better-auth.session_token=tok/with+=chars=",
        },
      });
    });

    it("should fall back to Authorization header when cookie-based lookups miss", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi
        .fn()
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(mockSessionData);
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("valid-token");

      expect(result).toEqual(mockSessionData);
      expect(mockGetSession).toHaveBeenNthCalledWith(1, {
        headers: {
          cookie: "__Secure-better-auth.session_token=valid-token",
        },
      });
      expect(mockGetSession).toHaveBeenNthCalledWith(2, {
        headers: {
          cookie: "better-auth.session_token=valid-token",
        },
      });
      expect(mockGetSession).toHaveBeenNthCalledWith(3, {
        headers: {
          cookie: "__Host-better-auth.session_token=valid-token",
        },
      });
      expect(mockGetSession).toHaveBeenNthCalledWith(4, {
        headers: {
          authorization: "Bearer valid-token",
        },
      });
    });

    it("should return null for invalid session", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockResolvedValue(null);
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("invalid-token");

      expect(result).toBeNull();
    });

    it("should return null for 'invalid token' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("bad-token");

      expect(result).toBeNull();
    });

    it("should return null for 'expired' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Token expired"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("expired-token");

      expect(result).toBeNull();
    });

    it("should return null for 'session not found' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Session not found"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("missing-session");

      expect(result).toBeNull();
    });

    it("should return null for 'unauthorized' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Unauthorized"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("unauth-token");

      expect(result).toBeNull();
    });

    it("should return null for 'invalid session' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid session"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("invalid-session");

      expect(result).toBeNull();
    });

    it("should return null for 'session expired' auth error", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Session expired"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("expired-session");

      expect(result).toBeNull();
    });

    it("should return null for bare 'unauthorized' (exact match)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("unauthorized"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("unauth-token");

      expect(result).toBeNull();
    });

    it("should return null for bare 'expired' (exact match)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("expired"));
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("expired-token");

      expect(result).toBeNull();
    });

    it("should re-throw 'certificate has expired' as infrastructure error (not auth)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("certificate has expired"));
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("any-token")).rejects.toThrow("certificate has expired");
    });

    it("should re-throw 'Unauthorized: Access denied for user' as infrastructure error (not auth)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi
        .fn()
        .mockRejectedValue(new Error("Unauthorized: Access denied for user"));
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("any-token")).rejects.toThrow(
        "Unauthorized: Access denied for user"
      );
    });

    it("should return null when a non-Error value is thrown", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue("string-error");
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("any-token");

      expect(result).toBeNull();
    });

    it("should return null when getSession throws a non-Error value (string)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue("some error");
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("any-token");

      expect(result).toBeNull();
    });

    it("should return null when getSession throws a non-Error value (object)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" });
      auth.api = { getSession: mockGetSession } as any;

      const result = await service.verifySession("any-token");

      expect(result).toBeNull();
    });

    it("should re-throw unexpected errors that are not known auth errors", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Verification failed"));
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("error-token")).rejects.toThrow("Verification failed");
    });

    it("should re-throw Prisma infrastructure errors", async () => {
      const auth = service.getAuth();
      const prismaError = new Error("connect ECONNREFUSED 127.0.0.1:5432");
      const mockGetSession = vi.fn().mockRejectedValue(prismaError);
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("any-token")).rejects.toThrow("ECONNREFUSED");
    });

    it("should re-throw timeout errors as infrastructure errors", async () => {
      const auth = service.getAuth();
      const timeoutError = new Error("Connection timeout after 5000ms");
      const mockGetSession = vi.fn().mockRejectedValue(timeoutError);
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("any-token")).rejects.toThrow("timeout");
    });

    it("should re-throw errors with Prisma-prefixed constructor name", async () => {
      const auth = service.getAuth();
      class PrismaClientKnownRequestError extends Error {
        constructor(message: string) {
          super(message);
          this.name = "PrismaClientKnownRequestError";
        }
      }
      const prismaError = new PrismaClientKnownRequestError("Database connection lost");
      const mockGetSession = vi.fn().mockRejectedValue(prismaError);
      auth.api = { getSession: mockGetSession } as any;

      await expect(service.verifySession("any-token")).rejects.toThrow("Database connection lost");
    });

    it("should redact Bearer tokens from logged error messages", async () => {
      const auth = service.getAuth();
      const errorWithToken = new Error(
        "Request failed: Bearer eyJhbGciOiJIUzI1NiJ9.secret-payload in header"
      );
      const mockGetSession = vi.fn().mockRejectedValue(errorWithToken);
      auth.api = { getSession: mockGetSession } as any;

      const loggerError = vi.spyOn(service["logger"], "error");

      await expect(service.verifySession("any-token")).rejects.toThrow();

      expect(loggerError).toHaveBeenCalledWith(
        "Session verification failed due to unexpected error",
        expect.stringContaining("Bearer [REDACTED]")
      );
      expect(loggerError).toHaveBeenCalledWith(
        "Session verification failed due to unexpected error",
        expect.not.stringContaining("eyJhbGciOiJIUzI1NiJ9")
      );
    });

    it("should redact Bearer tokens from error stack traces", async () => {
      const auth = service.getAuth();
      const errorWithToken = new Error("Something went wrong");
      errorWithToken.stack =
        "Error: Something went wrong\n    at fetch (Bearer abc123-secret-token)\n    at verifySession";
      const mockGetSession = vi.fn().mockRejectedValue(errorWithToken);
      auth.api = { getSession: mockGetSession } as any;

      const loggerError = vi.spyOn(service["logger"], "error");

      await expect(service.verifySession("any-token")).rejects.toThrow();

      expect(loggerError).toHaveBeenCalledWith(
        "Session verification failed due to unexpected error",
        expect.stringContaining("Bearer [REDACTED]")
      );
      expect(loggerError).toHaveBeenCalledWith(
        "Session verification failed due to unexpected error",
        expect.not.stringContaining("abc123-secret-token")
      );
    });

    it("should warn when a non-Error string value is thrown", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue("string-error");
      auth.api = { getSession: mockGetSession } as any;

      const loggerWarn = vi.spyOn(service["logger"], "warn");

      const result = await service.verifySession("any-token");

      expect(result).toBeNull();
      expect(loggerWarn).toHaveBeenCalledWith(
        "Session verification received non-Error thrown value",
        "string-error"
      );
    });

    it("should warn with JSON when a non-Error object is thrown", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue({ code: "ERR_UNKNOWN" });
      auth.api = { getSession: mockGetSession } as any;

      const loggerWarn = vi.spyOn(service["logger"], "warn");

      const result = await service.verifySession("any-token");

      expect(result).toBeNull();
      expect(loggerWarn).toHaveBeenCalledWith(
        "Session verification received non-Error thrown value",
        JSON.stringify({ code: "ERR_UNKNOWN" })
      );
    });

    it("should not warn for expected auth errors (Error instances)", async () => {
      const auth = service.getAuth();
      const mockGetSession = vi.fn().mockRejectedValue(new Error("Invalid token provided"));
      auth.api = { getSession: mockGetSession } as any;

      const loggerWarn = vi.spyOn(service["logger"], "warn");

      const result = await service.verifySession("bad-token");

      expect(result).toBeNull();
      expect(loggerWarn).not.toHaveBeenCalled();
    });
  });
});
