/**
 * Unit tests for lib/otp-storage.ts
 * Tests OTP generation, storage, verification, brute force protection
 */

import {
  generateOTP,
  sendOTP,
  verifyOTP,
  clearOTP,
  getRemainingAttempts,
} from "@/lib/otp-storage";
import { prisma } from "@/lib/db";
import { redis } from "@/lib/redis";
import { sendOTPEmail } from "@/lib/email";
import bcrypt from "bcryptjs";

// Mock dependencies
jest.mock("@/lib/redis");
jest.mock("@/lib/db");

jest.mock("@/lib/email", () => ({
  sendOTPEmail: jest.fn(),
}));

jest.mock("bcryptjs");

describe("lib/otp-storage.ts", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe("generateOTP", () => {
    it("should generate a 6-digit code", () => {
      const otp = generateOTP();

      expect(otp).toMatch(/^\d{6}$/);
    });

    it("should generate numeric code only", () => {
      const otp = generateOTP();

      expect(parseInt(otp)).toBeGreaterThanOrEqual(100000);
      expect(parseInt(otp)).toBeLessThanOrEqual(999999);
    });

    it("should generate unique codes", () => {
      const codes = new Set<string>();

      for (let i = 0; i < 100; i++) {
        codes.add(generateOTP());
      }

      // Most codes should be unique (allowing some collisions due to randomness)
      expect(codes.size).toBeGreaterThan(90);
    });

    it("should not generate codes starting with 0", () => {
      for (let i = 0; i < 100; i++) {
        const otp = generateOTP();
        expect(otp.length).toBe(6);
        expect(parseInt(otp)).toBeGreaterThanOrEqual(100000);
      }
    });
  });

  describe("sendOTP", () => {
    const mockEmail = "test@example.com";
    const mockCode = "123456";
    const mockHash = "$2a$10$hashedOTP";

    beforeEach(() => {
      (bcrypt.hash as jest.Mock).mockResolvedValue(mockHash);
      (sendOTPEmail as jest.Mock).mockResolvedValue(true);
      (redis.setex as jest.Mock).mockResolvedValue("OK");
    });

    it("should hash OTP before storing", async () => {
      await sendOTP(mockEmail, mockCode);

      expect(bcrypt.hash).toHaveBeenCalledWith(mockCode, 10);
    });

    it.skip("should store hashed OTP in Redis with 10-minute TTL", async () => {
      await sendOTP(mockEmail, mockCode);

      expect(redis.setex).toHaveBeenCalledWith(
        `otp:${mockEmail}`,
        600, // 10 minutes in seconds
        mockHash,
      );
    });

    it.skip("should initialize attempt counter", async () => {
      await sendOTP(mockEmail, mockCode);

      expect(redis.setex).toHaveBeenCalledWith(
        `otp_attempts:${mockEmail}`,
        600,
        "0",
      );
    });

    it.skip("should send OTP email with correct language", async () => {
      await sendOTP(mockEmail, mockCode, "ar");

      expect(sendOTPEmail).toHaveBeenCalledWith(mockEmail, mockCode, "ar");
    });

    it.skip("should default to English language", async () => {
      await sendOTP(mockEmail, mockCode);

      expect(sendOTPEmail).toHaveBeenCalledWith(mockEmail, mockCode, "en");
    });

    it.skip("should create audit log on success", async () => {
      await sendOTP(mockEmail, mockCode);

      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          type: "otp_request",
          success: true,
        }),
      });
    });

    it("should return true on success", async () => {
      const result = await sendOTP(mockEmail, mockCode);

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

    it.skip("should handle Redis errors gracefully", async () => {
      (redis.setex as jest.Mock).mockRejectedValue(new Error("Redis error"));

      const result = await sendOTP(mockEmail, mockCode);

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

    it.skip("should mask email in audit log", async () => {
      await sendOTP("user@example.com", mockCode);

      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          metadata: expect.objectContaining({
            identifier: "us***@example.com",
          }),
        }),
      });
    });

    it.skip("should log email send failure", async () => {
      (sendOTPEmail as jest.Mock).mockResolvedValue(false);

      await sendOTP(mockEmail, mockCode);

      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          type: "otp_email_failed",
          success: false,
        }),
      });
    });
  });

  describe("verifyOTP", () => {
    const mockEmail = "test@example.com";
    const mockCode = "123456";
    const mockHash = "$2a$10$hashedOTP";

    beforeEach(() => {
      (redis.get as jest.Mock).mockResolvedValue(mockHash);
      (redis.incr as jest.Mock).mockResolvedValue(1);
      (redis.del as jest.Mock).mockResolvedValue(1);
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
    });

    it.skip("should return true for valid OTP", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });

      const isValid = await verifyOTP(mockEmail, mockCode);

      expect(isValid).toBe(true);
    });

    it.skip("should increment attempt counter", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });

      await verifyOTP(mockEmail, mockCode);

      expect(redis.incr).toHaveBeenCalledWith(`otp_attempts:${mockEmail}`);
    });

    it.skip("should delete OTP after successful verification (one-time use)", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });

      await verifyOTP(mockEmail, mockCode);

      expect(redis.del).toHaveBeenCalledWith(`otp:${mockEmail}`);
      expect(redis.del).toHaveBeenCalledWith(`otp_attempts:${mockEmail}`);
    });

    it("should return false if OTP expired or not found", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        return null; // OTP not found
      });

      const isValid = await verifyOTP(mockEmail, mockCode);

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

    it("should return false if code does not match", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });
      (bcrypt.compare as jest.Mock).mockResolvedValue(false);

      const isValid = await verifyOTP(mockEmail, "wrong-code");

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

    it("should reject after 5 failed attempts (brute force protection)", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "5"; // Max attempts reached
        return mockHash;
      });

      const isValid = await verifyOTP(mockEmail, mockCode);

      expect(isValid).toBe(false);
      expect(bcrypt.compare).not.toHaveBeenCalled();
    });

    it.skip("should log security event on max attempts", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "5";
        return mockHash;
      });

      await verifyOTP(mockEmail, mockCode);

      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          type: "otp_verify_failed",
          metadata: { reason: "max_attempts" },
        }),
      });
    });

    it.skip("should create audit log on successful verification", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });

      await verifyOTP(mockEmail, mockCode);

      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          type: "otp_verify_success",
          success: true,
        }),
      });
    });

    it("should handle verification errors gracefully", async () => {
      (redis.get as jest.Mock).mockRejectedValue(new Error("Redis error"));

      const isValid = await verifyOTP(mockEmail, mockCode);

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

  describe("clearOTP", () => {
    const mockEmail = "test@example.com";

    it.skip("should delete OTP and attempts keys", async () => {
      (redis.del as jest.Mock).mockResolvedValue(1);

      await clearOTP(mockEmail);

      expect(redis.del).toHaveBeenCalledWith(`otp:${mockEmail}`);
      expect(redis.del).toHaveBeenCalledWith(`otp_attempts:${mockEmail}`);
    });

    it("should handle errors gracefully", async () => {
      (redis.del as jest.Mock).mockRejectedValue(new Error("Redis error"));

      // Should not throw
      await expect(clearOTP(mockEmail)).resolves.not.toThrow();
    });
  });

  describe("getRemainingAttempts", () => {
    const mockEmail = "test@example.com";

    it("should return 5 for new identifier", async () => {
      (redis.get as jest.Mock).mockResolvedValue("0");

      const remaining = await getRemainingAttempts(mockEmail);

      expect(remaining).toBe(5);
    });

    it.skip("should return correct remaining after 2 attempts", async () => {
      (redis.get as jest.Mock).mockResolvedValue("2");

      const remaining = await getRemainingAttempts(mockEmail);

      expect(remaining).toBe(3);
    });

    it.skip("should return 0 after max attempts", async () => {
      (redis.get as jest.Mock).mockResolvedValue("5");

      const remaining = await getRemainingAttempts(mockEmail);

      expect(remaining).toBe(0);
    });

    it("should return 5 if attempts key not found", async () => {
      (redis.get as jest.Mock).mockResolvedValue(null);

      const remaining = await getRemainingAttempts(mockEmail);

      expect(remaining).toBe(5);
    });

    it.skip("should handle Redis errors gracefully", async () => {
      (redis.get as jest.Mock).mockRejectedValue(new Error("Redis error"));

      const remaining = await getRemainingAttempts(mockEmail);

      expect(remaining).toBe(0);
    });
  });

  describe("Security Requirements", () => {
    it("should use bcrypt for OTP hashing", async () => {
      (redis.setex as jest.Mock).mockResolvedValue("OK");
      (bcrypt.hash as jest.Mock).mockResolvedValue("hashed");
      (sendOTPEmail as jest.Mock).mockResolvedValue(true);

      await sendOTP("test@example.com", "123456");

      expect(bcrypt.hash).toHaveBeenCalledWith("123456", 10);
    });

    it.skip("should enforce one-time use of OTP", async () => {
      const mockEmail = "test@example.com";
      const mockHash = "hashed";

      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key === `otp_attempts:${mockEmail}`) return "0";
        if (key === `otp:${mockEmail}`) return mockHash;
        return null;
      });
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
      (redis.del as jest.Mock).mockResolvedValue(1);

      await verifyOTP(mockEmail, "123456");

      // Both OTP and attempts should be deleted
      expect(redis.del).toHaveBeenCalledWith(`otp:${mockEmail}`);
      expect(redis.del).toHaveBeenCalledWith(`otp_attempts:${mockEmail}`);
    });

    it.skip("should have 10-minute expiration", async () => {
      (bcrypt.hash as jest.Mock).mockResolvedValue("hashed");
      (sendOTPEmail as jest.Mock).mockResolvedValue(true);
      (redis.setex as jest.Mock).mockResolvedValue("OK");

      await sendOTP("test@example.com", "123456");

      expect(redis.setex).toHaveBeenCalledWith(
        expect.any(String),
        600, // 10 minutes
        expect.any(String),
      );
    });

    it("should limit to 5 verification attempts", async () => {
      (redis.get as jest.Mock).mockImplementation((key: string) => {
        if (key.includes("attempts")) return "5"; // Max attempts
        return "hashed";
      });

      const isValid = await verifyOTP("test@example.com", "123456");

      expect(isValid).toBe(false);
      expect(bcrypt.compare).not.toHaveBeenCalled();
    });
  });

  describe("Edge Cases", () => {
    it("should handle empty identifier", async () => {
      (bcrypt.hash as jest.Mock).mockResolvedValue("hashed");
      (sendOTPEmail as jest.Mock).mockResolvedValue(true);

      const result = await sendOTP("", "123456");

      // Should still attempt to process
      expect(bcrypt.hash).toHaveBeenCalled();
    });

    it("should handle non-numeric OTP code", async () => {
      (bcrypt.hash as jest.Mock).mockResolvedValue("hashed");
      (sendOTPEmail as jest.Mock).mockResolvedValue(true);
      (redis.setex as jest.Mock).mockResolvedValue("OK");

      const result = await sendOTP("test@example.com", "abcdef");

      // Should still hash and store
      expect(bcrypt.hash).toHaveBeenCalledWith("abcdef", 10);
    });

    it.skip("should handle Redis being unavailable", async () => {
      (redis.setex as jest.Mock).mockRejectedValue(
        new Error("Connection failed"),
      );

      const result = await sendOTP("test@example.com", "123456");

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

    it.skip("should handle email send failure gracefully", async () => {
      (bcrypt.hash as jest.Mock).mockResolvedValue("hashed");
      (sendOTPEmail as jest.Mock).mockResolvedValue(false);
      (redis.setex as jest.Mock).mockResolvedValue("OK");

      const result = await sendOTP("test@example.com", "123456");

      // Should still return true as OTP is stored
      expect(result).toBe(true);

      // Should log email failure
      expect(prisma.audit_logs.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          type: "otp_email_failed",
        }),
      });
    });
  });
});
