import { describe, it, expect, beforeEach, vi } from "vitest";

// Mock better-auth modules before importing AuthService (pulled in by AuthController)
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 { Test, TestingModule } from "@nestjs/testing";
import { HttpException, HttpStatus, UnauthorizedException } from "@nestjs/common";
import type { AuthUser, AuthSession } from "@mosaic/shared";
import type { Request as ExpressRequest, Response as ExpressResponse } from "express";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";

describe("AuthController", () => {
  let controller: AuthController;

  const mockNodeHandler = vi.fn().mockResolvedValue(undefined);

  const mockAuthService = {
    getAuth: vi.fn(),
    getNodeHandler: vi.fn().mockReturnValue(mockNodeHandler),
    getAuthConfig: vi.fn(),
  };

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

    controller = module.get<AuthController>(AuthController);

    vi.clearAllMocks();

    // Restore mock implementations after clearAllMocks
    mockAuthService.getNodeHandler.mockReturnValue(mockNodeHandler);
    mockNodeHandler.mockResolvedValue(undefined);
  });

  describe("handleAuth", () => {
    it("should delegate to BetterAuth node handler with Express req/res", async () => {
      const mockRequest = {
        method: "GET",
        url: "/auth/session",
        headers: {},
        ip: "127.0.0.1",
        socket: { remoteAddress: "127.0.0.1" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      await controller.handleAuth(mockRequest, mockResponse);

      expect(mockAuthService.getNodeHandler).toHaveBeenCalled();
      expect(mockNodeHandler).toHaveBeenCalledWith(mockRequest, mockResponse);
    });

    it("should throw HttpException with 500 when handler throws before headers sent", async () => {
      const handlerError = new Error("BetterAuth internal failure");
      mockNodeHandler.mockRejectedValueOnce(handlerError);

      const mockRequest = {
        method: "POST",
        url: "/auth/sign-in",
        headers: {},
        ip: "192.168.1.10",
        socket: { remoteAddress: "192.168.1.10" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      try {
        await controller.handleAuth(mockRequest, mockResponse);
        // Should not reach here
        expect.unreachable("Expected HttpException to be thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HttpException);
        expect((err as HttpException).getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
        expect((err as HttpException).getResponse()).toBe(
          "Unable to complete authentication. Please try again in a moment."
        );
      }
    });

    it("should preserve better-call status and body for handler APIError", async () => {
      const apiError = {
        statusCode: HttpStatus.BAD_REQUEST,
        message: "Invalid OAuth configuration",
        body: {
          message: "Invalid OAuth configuration",
          code: "INVALID_OAUTH_CONFIGURATION",
        },
      };
      mockNodeHandler.mockRejectedValueOnce(apiError);

      const mockRequest = {
        method: "POST",
        url: "/auth/sign-in/oauth2",
        headers: {},
        ip: "192.168.1.10",
        socket: { remoteAddress: "192.168.1.10" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      try {
        await controller.handleAuth(mockRequest, mockResponse);
        expect.unreachable("Expected HttpException to be thrown");
      } catch (err) {
        expect(err).toBeInstanceOf(HttpException);
        expect((err as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST);
        expect((err as HttpException).getResponse()).toMatchObject({
          message: "Invalid OAuth configuration",
        });
      }
    });

    it("should log warning and not throw when handler throws after headers sent", async () => {
      const handlerError = new Error("Stream interrupted");
      mockNodeHandler.mockRejectedValueOnce(handlerError);

      const mockRequest = {
        method: "POST",
        url: "/auth/sign-up",
        headers: {},
        ip: "10.0.0.5",
        socket: { remoteAddress: "10.0.0.5" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: true,
      } as unknown as ExpressResponse;

      // Should not throw when headers already sent
      await expect(controller.handleAuth(mockRequest, mockResponse)).resolves.toBeUndefined();
    });

    it("should handle non-Error thrown values", async () => {
      mockNodeHandler.mockRejectedValueOnce("string error");

      const mockRequest = {
        method: "GET",
        url: "/auth/callback",
        headers: {},
        ip: "127.0.0.1",
        socket: { remoteAddress: "127.0.0.1" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      await expect(controller.handleAuth(mockRequest, mockResponse)).rejects.toThrow(HttpException);
    });
  });

  describe("getConfig", () => {
    it("should return auth config from service", async () => {
      const mockConfig = {
        providers: [
          { id: "email", name: "Email", type: "credentials" as const },
          { id: "authentik", name: "Authentik", type: "oauth" as const },
        ],
      };
      mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);

      const result = await controller.getConfig();

      expect(result).toEqual(mockConfig);
      expect(mockAuthService.getAuthConfig).toHaveBeenCalled();
    });

    it("should return correct response shape with only email provider", async () => {
      const mockConfig = {
        providers: [{ id: "email", name: "Email", type: "credentials" as const }],
      };
      mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);

      const result = await controller.getConfig();

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

    it("should never leak secrets in auth config response", async () => {
      // Set ALL sensitive environment variables with known values
      const sensitiveEnv: Record<string, string> = {
        OIDC_CLIENT_SECRET: "test-client-secret",
        OIDC_CLIENT_ID: "test-client-id",
        OIDC_ISSUER: "https://auth.test.com/",
        OIDC_REDIRECT_URI: "https://app.test.com/auth/oauth2/callback/authentik",
        BETTER_AUTH_SECRET: "test-better-auth-secret",
        JWT_SECRET: "test-jwt-secret",
        CSRF_SECRET: "test-csrf-secret",
        DATABASE_URL: "postgresql://user:password@localhost/db",
        OIDC_ENABLED: "true",
      };

      const originalEnv: Record<string, string | undefined> = {};
      for (const [key, value] of Object.entries(sensitiveEnv)) {
        originalEnv[key] = process.env[key];
        process.env[key] = value;
      }

      try {
        // Mock the service to return a realistic config with both providers
        const mockConfig = {
          providers: [
            { id: "email", name: "Email", type: "credentials" as const },
            { id: "authentik", name: "Authentik", type: "oauth" as const },
          ],
        };
        mockAuthService.getAuthConfig.mockResolvedValue(mockConfig);

        const result = await controller.getConfig();
        const serialized = JSON.stringify(result);

        // Assert no secret values leak into the serialized response
        const forbiddenPatterns = [
          "test-client-secret",
          "test-client-id",
          "test-better-auth-secret",
          "test-jwt-secret",
          "test-csrf-secret",
          "auth.test.com",
          "callback",
          "password",
        ];

        for (const pattern of forbiddenPatterns) {
          expect(serialized).not.toContain(pattern);
        }

        // Assert response contains ONLY expected fields
        expect(result).toHaveProperty("providers");
        expect(Object.keys(result)).toEqual(["providers"]);
        expect(Array.isArray(result.providers)).toBe(true);

        for (const provider of result.providers) {
          const keys = Object.keys(provider);
          expect(keys).toEqual(expect.arrayContaining(["id", "name", "type"]));
          expect(keys).toHaveLength(3);
        }
      } finally {
        // Restore original environment
        for (const [key] of Object.entries(sensitiveEnv)) {
          if (originalEnv[key] === undefined) {
            delete process.env[key];
          } else {
            process.env[key] = originalEnv[key];
          }
        }
      }
    });
  });

  describe("getSession", () => {
    it("should return user and session data", () => {
      const mockUser: AuthUser = {
        id: "user-123",
        email: "test@example.com",
        name: "Test User",
        workspaceId: "workspace-123",
      };

      const mockSession = {
        id: "session-123",
        token: "session-token",
        expiresAt: new Date(Date.now() + 86400000),
      };

      const mockRequest = {
        user: mockUser,
        session: mockSession,
      };

      const result = controller.getSession(mockRequest);

      const expected: AuthSession = {
        user: mockUser,
        session: {
          id: mockSession.id,
          token: mockSession.token,
          expiresAt: mockSession.expiresAt,
        },
      };

      expect(result).toEqual(expected);
    });

    it("should throw UnauthorizedException when req.user is undefined", () => {
      const mockRequest = {
        session: {
          id: "session-123",
          token: "session-token",
          expiresAt: new Date(Date.now() + 86400000),
        },
      };

      expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
      expect(() => controller.getSession(mockRequest as never)).toThrow(
        "Missing authentication context"
      );
    });

    it("should throw UnauthorizedException when req.session is undefined", () => {
      const mockRequest = {
        user: {
          id: "user-123",
          email: "test@example.com",
          name: "Test User",
        },
      };

      expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
      expect(() => controller.getSession(mockRequest as never)).toThrow(
        "Missing authentication context"
      );
    });

    it("should throw UnauthorizedException when both req.user and req.session are undefined", () => {
      const mockRequest = {};

      expect(() => controller.getSession(mockRequest as never)).toThrow(UnauthorizedException);
      expect(() => controller.getSession(mockRequest as never)).toThrow(
        "Missing authentication context"
      );
    });
  });

  describe("getProfile", () => {
    it("should return complete user profile with identity fields", () => {
      const mockUser: AuthUser = {
        id: "user-123",
        email: "test@example.com",
        name: "Test User",
        image: "https://example.com/avatar.jpg",
        emailVerified: true,
      };

      const result = controller.getProfile(mockUser);

      expect(result).toEqual({
        id: mockUser.id,
        email: mockUser.email,
        name: mockUser.name,
        image: mockUser.image,
        emailVerified: mockUser.emailVerified,
      });
    });

    it("should return user profile with only required fields", () => {
      const mockUser: AuthUser = {
        id: "user-123",
        email: "test@example.com",
        name: "Test User",
      };

      const result = controller.getProfile(mockUser);

      expect(result).toEqual({
        id: mockUser.id,
        email: mockUser.email,
        name: mockUser.name,
      });
      // Workspace fields are not included — served by GET /api/workspaces
      expect(result).not.toHaveProperty("workspaceId");
      expect(result).not.toHaveProperty("currentWorkspaceId");
      expect(result).not.toHaveProperty("workspaceRole");
    });
  });

  describe("getClientIp (via handleAuth)", () => {
    it("should extract IP from X-Forwarded-For with single IP", async () => {
      const mockRequest = {
        method: "GET",
        url: "/auth/callback",
        headers: { "x-forwarded-for": "203.0.113.50" },
        ip: "127.0.0.1",
        socket: { remoteAddress: "127.0.0.1" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      // Spy on the logger to verify the extracted IP
      const debugSpy = vi.spyOn(controller["logger"], "debug");

      await controller.handleAuth(mockRequest, mockResponse);

      expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
    });

    it("should extract first IP from X-Forwarded-For with comma-separated IPs", async () => {
      const mockRequest = {
        method: "GET",
        url: "/auth/callback",
        headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18" },
        ip: "127.0.0.1",
        socket: { remoteAddress: "127.0.0.1" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      const debugSpy = vi.spyOn(controller["logger"], "debug");

      await controller.handleAuth(mockRequest, mockResponse);

      expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
      // Ensure it does NOT contain the second IP in the extracted position
      expect(debugSpy).toHaveBeenCalledWith(expect.not.stringContaining("70.41.3.18"));
    });

    it("should extract first IP from X-Forwarded-For as array", async () => {
      const mockRequest = {
        method: "GET",
        url: "/auth/callback",
        headers: { "x-forwarded-for": ["203.0.113.50", "70.41.3.18"] },
        ip: "127.0.0.1",
        socket: { remoteAddress: "127.0.0.1" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      const debugSpy = vi.spyOn(controller["logger"], "debug");

      await controller.handleAuth(mockRequest, mockResponse);

      expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("203.0.113.50"));
    });

    it("should fallback to req.ip when no X-Forwarded-For header", async () => {
      const mockRequest = {
        method: "GET",
        url: "/auth/callback",
        headers: {},
        ip: "192.168.1.100",
        socket: { remoteAddress: "192.168.1.100" },
      } as unknown as ExpressRequest;

      const mockResponse = {
        headersSent: false,
      } as unknown as ExpressResponse;

      const debugSpy = vi.spyOn(controller["logger"], "debug");

      await controller.handleAuth(mockRequest, mockResponse);

      expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining("192.168.1.100"));
    });
  });
});
