/**
 * Federation OIDC Service Tests
 *
 * Tests for federated authentication using OIDC.
 */

import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { OIDCService } from "./oidc.service";
import { PrismaService } from "../prisma/prisma.service";
import { ConfigService } from "@nestjs/config";
import type {
  FederatedIdentity,
  FederatedTokenValidation,
  OIDCTokenClaims,
} from "./types/oidc.types";
import * as jose from "jose";

/**
 * Helper function to create test JWTs for testing
 */
async function createTestJWT(
  claims: OIDCTokenClaims,
  secret: string = "test-secret-key-for-jwt-signing"
): Promise<string> {
  const secretKey = new TextEncoder().encode(secret);

  const jwt = await new jose.SignJWT(claims as Record<string, unknown>)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt(claims.iat)
    .setExpirationTime(claims.exp)
    .setSubject(claims.sub)
    .setIssuer(claims.iss)
    .setAudience(claims.aud)
    .sign(secretKey);

  return jwt;
}

describe("OIDCService", () => {
  let service: OIDCService;
  let prisma: PrismaService;
  let configService: ConfigService;

  const mockPrismaService = {
    federatedIdentity: {
      create: vi.fn(),
      findUnique: vi.fn(),
      findMany: vi.fn(),
      delete: vi.fn(),
      update: vi.fn(),
    },
  };

  const mockConfigService = {
    get: vi.fn(),
  };

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

    service = module.get<OIDCService>(OIDCService);
    prisma = module.get<PrismaService>(PrismaService);
    configService = module.get<ConfigService>(ConfigService);

    // Reset mocks
    vi.clearAllMocks();
  });

  describe("linkFederatedIdentity", () => {
    it("should create a new federated identity mapping", async () => {
      const userId = "local-user-123";
      const remoteUserId = "remote-user-456";
      const remoteInstanceId = "remote-instance-789";
      const oidcSubject = "oidc-sub-abc";
      const email = "user@example.com";

      const mockIdentity: FederatedIdentity = {
        id: "identity-uuid",
        localUserId: userId,
        remoteUserId,
        remoteInstanceId,
        oidcSubject,
        email,
        metadata: {},
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      mockPrismaService.federatedIdentity.create.mockResolvedValue(mockIdentity);

      const result = await service.linkFederatedIdentity(
        userId,
        remoteUserId,
        remoteInstanceId,
        oidcSubject,
        email
      );

      expect(result).toEqual(mockIdentity);
      expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({
        data: {
          localUserId: userId,
          remoteUserId,
          remoteInstanceId,
          oidcSubject,
          email,
          metadata: {},
        },
      });
    });

    it("should include optional metadata when provided", async () => {
      const userId = "local-user-123";
      const remoteUserId = "remote-user-456";
      const remoteInstanceId = "remote-instance-789";
      const oidcSubject = "oidc-sub-abc";
      const email = "user@example.com";
      const metadata = { displayName: "John Doe", roles: ["user"] };

      mockPrismaService.federatedIdentity.create.mockResolvedValue({
        id: "identity-uuid",
        localUserId: userId,
        remoteUserId,
        remoteInstanceId,
        oidcSubject,
        email,
        metadata,
        createdAt: new Date(),
        updatedAt: new Date(),
      });

      await service.linkFederatedIdentity(
        userId,
        remoteUserId,
        remoteInstanceId,
        oidcSubject,
        email,
        metadata
      );

      expect(mockPrismaService.federatedIdentity.create).toHaveBeenCalledWith({
        data: {
          localUserId: userId,
          remoteUserId,
          remoteInstanceId,
          oidcSubject,
          email,
          metadata,
        },
      });
    });

    it("should throw error if identity already exists", async () => {
      const userId = "local-user-123";
      const remoteUserId = "remote-user-456";
      const remoteInstanceId = "remote-instance-789";

      mockPrismaService.federatedIdentity.create.mockRejectedValue({
        code: "P2002",
        message: "Unique constraint failed",
      });

      await expect(
        service.linkFederatedIdentity(
          userId,
          remoteUserId,
          remoteInstanceId,
          "oidc-sub",
          "user@example.com"
        )
      ).rejects.toThrow();
    });
  });

  describe("getFederatedIdentity", () => {
    it("should retrieve federated identity by user and instance", async () => {
      const userId = "local-user-123";
      const remoteInstanceId = "remote-instance-789";

      const mockIdentity: FederatedIdentity = {
        id: "identity-uuid",
        localUserId: userId,
        remoteUserId: "remote-user-456",
        remoteInstanceId,
        oidcSubject: "oidc-sub-abc",
        email: "user@example.com",
        metadata: {},
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(mockIdentity);

      const result = await service.getFederatedIdentity(userId, remoteInstanceId);

      expect(result).toEqual(mockIdentity);
      expect(mockPrismaService.federatedIdentity.findUnique).toHaveBeenCalledWith({
        where: {
          localUserId_remoteInstanceId: {
            localUserId: userId,
            remoteInstanceId,
          },
        },
      });
    });

    it("should return null if identity does not exist", async () => {
      mockPrismaService.federatedIdentity.findUnique.mockResolvedValue(null);

      const result = await service.getFederatedIdentity("user-123", "instance-456");

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

  describe("getUserFederatedIdentities", () => {
    it("should retrieve all federated identities for a user", async () => {
      const userId = "local-user-123";

      const mockIdentities: FederatedIdentity[] = [
        {
          id: "identity-1",
          localUserId: userId,
          remoteUserId: "remote-1",
          remoteInstanceId: "instance-1",
          oidcSubject: "sub-1",
          email: "user@example.com",
          metadata: {},
          createdAt: new Date(),
          updatedAt: new Date(),
        },
        {
          id: "identity-2",
          localUserId: userId,
          remoteUserId: "remote-2",
          remoteInstanceId: "instance-2",
          oidcSubject: "sub-2",
          email: "user@example.com",
          metadata: {},
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ];

      mockPrismaService.federatedIdentity.findMany.mockResolvedValue(mockIdentities);

      const result = await service.getUserFederatedIdentities(userId);

      expect(result).toEqual(mockIdentities);
      expect(mockPrismaService.federatedIdentity.findMany).toHaveBeenCalledWith({
        where: { localUserId: userId },
        orderBy: { createdAt: "desc" },
      });
    });

    it("should return empty array if user has no federated identities", async () => {
      mockPrismaService.federatedIdentity.findMany.mockResolvedValue([]);

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

      expect(result).toEqual([]);
    });
  });

  describe("revokeFederatedIdentity", () => {
    it("should delete federated identity", async () => {
      const userId = "local-user-123";
      const remoteInstanceId = "remote-instance-789";

      const mockIdentity: FederatedIdentity = {
        id: "identity-uuid",
        localUserId: userId,
        remoteUserId: "remote-user-456",
        remoteInstanceId,
        oidcSubject: "oidc-sub-abc",
        email: "user@example.com",
        metadata: {},
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      mockPrismaService.federatedIdentity.delete.mockResolvedValue(mockIdentity);

      await service.revokeFederatedIdentity(userId, remoteInstanceId);

      expect(mockPrismaService.federatedIdentity.delete).toHaveBeenCalledWith({
        where: {
          localUserId_remoteInstanceId: {
            localUserId: userId,
            remoteInstanceId,
          },
        },
      });
    });

    it("should throw error if identity does not exist", async () => {
      mockPrismaService.federatedIdentity.delete.mockRejectedValue({
        code: "P2025",
        message: "Record not found",
      });

      await expect(service.revokeFederatedIdentity("user-123", "instance-456")).rejects.toThrow();
    });
  });

  describe("validateToken - Real JWT Validation", () => {
    // Configure mock to return OIDC env vars by default for validation tests
    beforeEach(() => {
      mockConfigService.get.mockImplementation((key: string) => {
        switch (key) {
          case "OIDC_ISSUER":
            return "https://auth.example.com/";
          case "OIDC_CLIENT_ID":
            return "mosaic-client-id";
          case "OIDC_VALIDATION_SECRET":
            return "test-secret-key-for-jwt-signing";
          default:
            return undefined;
        }
      });
    });

    it("should reject malformed token (not a JWT)", async () => {
      const token = "not-a-jwt-token";
      const instanceId = "remote-instance-123";

      const result = await service.validateToken(token, instanceId);

      expect(result.valid).toBe(false);
      expect(result.error).toContain("Malformed token");
    });

    it("should reject token with invalid format (missing parts)", async () => {
      const token = "header.payload"; // Missing signature
      const instanceId = "remote-instance-123";

      const result = await service.validateToken(token, instanceId);

      expect(result.valid).toBe(false);
      expect(result.error).toContain("Malformed token");
    });

    it("should return error when OIDC_ISSUER is not configured", async () => {
      mockConfigService.get.mockImplementation((key: string) => {
        switch (key) {
          case "OIDC_ISSUER":
            return undefined; // Not configured
          case "OIDC_CLIENT_ID":
            return "mosaic-client-id";
          default:
            return undefined;
        }
      });

      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(token, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("OIDC_ISSUER is required");
    });

    it("should return error when OIDC_CLIENT_ID is not configured", async () => {
      mockConfigService.get.mockImplementation((key: string) => {
        switch (key) {
          case "OIDC_ISSUER":
            return "https://auth.example.com/";
          case "OIDC_CLIENT_ID":
            return undefined; // Not configured
          default:
            return undefined;
        }
      });

      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(token, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("OIDC_CLIENT_ID is required");
    });

    it("should return error when OIDC_ISSUER is empty string", async () => {
      mockConfigService.get.mockImplementation((key: string) => {
        switch (key) {
          case "OIDC_ISSUER":
            return "   "; // Empty/whitespace
          case "OIDC_CLIENT_ID":
            return "mosaic-client-id";
          default:
            return undefined;
        }
      });

      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(token, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("OIDC_ISSUER is required");
    });

    it("should use OIDC_ISSUER and OIDC_CLIENT_ID from environment", async () => {
      // Verify that the config service is called with correct keys
      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      await service.validateToken(token, "remote-instance-123");

      expect(mockConfigService.get).toHaveBeenCalledWith("OIDC_ISSUER");
      expect(mockConfigService.get).toHaveBeenCalledWith("OIDC_CLIENT_ID");
    });

    it("should reject expired token", async () => {
      // Create an expired JWT (exp in the past)
      const expiredToken = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
        iat: Math.floor(Date.now() / 1000) - 7200,
        email: "user@example.com",
      });

      const result = await service.validateToken(expiredToken, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("expired");
    });

    it("should reject token with invalid signature", async () => {
      // Create a JWT with a different key than what the service will validate
      const invalidToken = await createTestJWT(
        {
          sub: "user-123",
          iss: "https://auth.example.com",
          aud: "mosaic-client-id",
          exp: Math.floor(Date.now() / 1000) + 3600,
          iat: Math.floor(Date.now() / 1000),
          email: "user@example.com",
        },
        "wrong-secret-key"
      );

      const result = await service.validateToken(invalidToken, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("signature");
    });

    it("should reject token with wrong issuer", async () => {
      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://wrong-issuer.com", // Wrong issuer
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(token, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("issuer");
    });

    it("should reject token with wrong audience", async () => {
      const token = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "wrong-audience", // Wrong audience
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(token, "remote-instance-123");

      expect(result.valid).toBe(false);
      expect(result.error).toContain("audience");
    });

    it("should validate a valid JWT token with correct signature and claims", async () => {
      const validToken = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
        email_verified: true,
        name: "Test User",
      });

      const result = await service.validateToken(validToken, "remote-instance-123");

      expect(result.valid).toBe(true);
      expect(result.userId).toBe("user-123");
      expect(result.subject).toBe("user-123");
      expect(result.email).toBe("user@example.com");
      expect(result.instanceId).toBe("remote-instance-123");
      expect(result.error).toBeUndefined();
    });

    it("should extract all user info from valid token", async () => {
      const validToken = await createTestJWT({
        sub: "user-456",
        iss: "https://auth.example.com",
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "test@example.com",
        email_verified: true,
        name: "Test User",
        preferred_username: "testuser",
      });

      const result = await service.validateToken(validToken, "remote-instance-123");

      expect(result.valid).toBe(true);
      expect(result.userId).toBe("user-456");
      expect(result.email).toBe("test@example.com");
      expect(result.subject).toBe("user-456");
    });

    it("should normalize issuer with trailing slash for JWT validation", async () => {
      // Config returns issuer WITH trailing slash (as per auth.config.ts validation)
      mockConfigService.get.mockImplementation((key: string) => {
        switch (key) {
          case "OIDC_ISSUER":
            return "https://auth.example.com/"; // With trailing slash
          case "OIDC_CLIENT_ID":
            return "mosaic-client-id";
          case "OIDC_VALIDATION_SECRET":
            return "test-secret-key-for-jwt-signing";
          default:
            return undefined;
        }
      });

      // JWT issuer is without trailing slash (standard JWT format)
      const validToken = await createTestJWT({
        sub: "user-123",
        iss: "https://auth.example.com", // Without trailing slash (matches normalized)
        aud: "mosaic-client-id",
        exp: Math.floor(Date.now() / 1000) + 3600,
        iat: Math.floor(Date.now() / 1000),
        email: "user@example.com",
      });

      const result = await service.validateToken(validToken, "remote-instance-123");

      expect(result.valid).toBe(true);
      expect(result.userId).toBe("user-123");
    });
  });

  describe("generateAuthUrl", () => {
    it("should generate authorization URL for federated authentication", () => {
      const remoteInstanceId = "remote-instance-123";
      const redirectUrl = "http://localhost:3000/callback";

      mockConfigService.get.mockReturnValue("http://localhost:3001");

      const result = service.generateAuthUrl(remoteInstanceId, redirectUrl);

      // Current implementation is a placeholder
      // Real implementation would fetch remote instance OIDC config
      expect(result).toContain("client_id=placeholder");
      expect(result).toContain("response_type=code");
      expect(result).toContain("scope=openid");
      expect(result).toContain(`state=${remoteInstanceId}`);
      expect(result).toContain(encodeURIComponent(redirectUrl));
    });
  });
});
