/**
 * Unit tests for lib/csrf.ts
 * Tests CSRF protection logic, token generation, double-submit cookie pattern
 */

import crypto from "crypto";

// Mock NextResponse before importing
jest.mock("next/server", () => {
  const mockCookies = {
    set: jest.fn(),
    get: jest.fn(),
    delete: jest.fn(),
    has: jest.fn(),
    getAll: jest.fn(() => []),
  };

  return {
    NextRequest: jest.fn(),
    NextResponse: {
      json: jest.fn((data, options) => ({
        status: options?.status || 200,
        headers: {
          get: jest.fn(),
          set: jest.fn(),
          append: jest.fn(),
        },
        cookies: mockCookies,
        json: async () => data,
      })),
    },
  };
});

import { NextRequest, NextResponse } from "next/server";
import {
  generateCSRFToken,
  validateCSRFToken,
  withCSRF,
  setCSRFCookie,
  createResponseWithCSRF,
} from "@/lib/csrf";

// Mock NextRequest
const createMockRequest = (
  method: string,
  headers: Record<string, string> = {},
  cookies: Record<string, string> = {},
): NextRequest => {
  return {
    method,
    headers: {
      get: jest.fn((key: string) => headers[key] || null),
    },
    cookies: {
      get: jest.fn((name: string) =>
        cookies[name] ? { value: cookies[name] } : undefined,
      ),
    },
  } as unknown as NextRequest;
};

describe("lib/csrf.ts", () => {
  describe("generateCSRFToken", () => {
    it("should generate a token", () => {
      const token = generateCSRFToken();

      expect(token).toBeDefined();
      expect(typeof token).toBe("string");
    });

    it("should generate 64-character hex string (32 bytes)", () => {
      const token = generateCSRFToken();

      expect(token.length).toBe(64); // 32 bytes = 64 hex characters
      expect(/^[0-9a-f]{64}$/.test(token)).toBe(true);
    });

    it("should generate unique tokens", () => {
      const token1 = generateCSRFToken();
      const token2 = generateCSRFToken();

      expect(token1).not.toBe(token2);
    });

    it("should generate cryptographically random tokens", () => {
      const tokens = new Set();
      for (let i = 0; i < 100; i++) {
        tokens.add(generateCSRFToken());
      }

      // All 100 tokens should be unique
      expect(tokens.size).toBe(100);
    });
  });

  describe("validateCSRFToken", () => {
    const validToken = crypto.randomBytes(32).toString("hex");

    it("should return true when header and cookie tokens match", () => {
      const request = createMockRequest(
        "POST",
        { "x-csrf-token": validToken },
        { "csrf-token": validToken },
      );

      const isValid = validateCSRFToken(request);

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

    it("should return false when header token is missing", () => {
      const request = createMockRequest(
        "POST",
        {},
        { "csrf-token": validToken },
      );

      const isValid = validateCSRFToken(request);

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

    it("should return false when cookie token is missing", () => {
      const request = createMockRequest(
        "POST",
        { "x-csrf-token": validToken },
        {},
      );

      const isValid = validateCSRFToken(request);

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

    it("should return false when tokens do not match", () => {
      const token1 = crypto.randomBytes(32).toString("hex");
      const token2 = crypto.randomBytes(32).toString("hex");

      const request = createMockRequest(
        "POST",
        { "x-csrf-token": token1 },
        { "csrf-token": token2 },
      );

      const isValid = validateCSRFToken(request);

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

    it("should return false when both tokens are missing", () => {
      const request = createMockRequest("POST", {}, {});

      const isValid = validateCSRFToken(request);

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

    it("should use timing-safe comparison (security)", () => {
      // This test verifies that crypto.timingSafeEqual is used
      const token1 = "a".repeat(64);
      const token2 = "b".repeat(64);

      const request = createMockRequest(
        "POST",
        { "x-csrf-token": token1 },
        { "csrf-token": token2 },
      );

      // Should not throw even with different length tokens
      const isValid = validateCSRFToken(request);
      expect(isValid).toBe(false);
    });
  });

  describe("withCSRF", () => {
    const mockHandler = jest
      .fn()
      .mockResolvedValue(NextResponse.json({ success: true }));
    const validToken = crypto.randomBytes(32).toString("hex");

    beforeEach(() => {
      mockHandler.mockClear();
    });

    it("should call handler if CSRF token is valid for POST request", async () => {
      const request = createMockRequest(
        "POST",
        { "x-csrf-token": validToken },
        { "csrf-token": validToken },
      );

      const wrappedHandler = withCSRF(mockHandler);
      const response = await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalledWith(request);
      expect(response.status).toBe(200);
    });

    it("should return 403 if CSRF token is invalid for POST request", async () => {
      const token1 = crypto.randomBytes(32).toString("hex");
      const token2 = crypto.randomBytes(32).toString("hex");

      const request = createMockRequest(
        "POST",
        { "x-csrf-token": token1 },
        { "csrf-token": token2 },
      );

      const wrappedHandler = withCSRF(mockHandler);
      const response = await wrappedHandler(request);

      expect(mockHandler).not.toHaveBeenCalled();
      expect(response.status).toBe(403);

      const body = await response.json();
      expect(body.error).toBe("Invalid or missing CSRF token");
    });

    it("should validate CSRF for PUT requests", async () => {
      const request = createMockRequest(
        "PUT",
        { "x-csrf-token": validToken },
        { "csrf-token": validToken },
      );

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalled();
    });

    it("should validate CSRF for DELETE requests", async () => {
      const request = createMockRequest(
        "DELETE",
        { "x-csrf-token": validToken },
        { "csrf-token": validToken },
      );

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalled();
    });

    it("should validate CSRF for PATCH requests", async () => {
      const request = createMockRequest(
        "PATCH",
        { "x-csrf-token": validToken },
        { "csrf-token": validToken },
      );

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalled();
    });

    it("should skip CSRF validation for GET requests", async () => {
      const request = createMockRequest("GET", {}, {});

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      // Handler should be called even without CSRF token
      expect(mockHandler).toHaveBeenCalled();
    });

    it("should skip CSRF validation for HEAD requests", async () => {
      const request = createMockRequest("HEAD", {}, {});

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalled();
    });

    it("should skip CSRF validation for OPTIONS requests", async () => {
      const request = createMockRequest("OPTIONS", {}, {});

      const wrappedHandler = withCSRF(mockHandler);
      await wrappedHandler(request);

      expect(mockHandler).toHaveBeenCalled();
    });
  });

  describe("setCSRFCookie", () => {
    it("should set CSRF cookie on response", () => {
      const response = NextResponse.json({ data: "test" });
      const token = "test-token-123";

      setCSRFCookie(response, token);

      // Verify cookie was set with correct name and value
      expect(response.cookies.set).toHaveBeenCalledWith(
        "csrf-token",
        token,
        expect.objectContaining({
          httpOnly: false,
          sameSite: "lax",
        }),
      );
    });

    it("should set cookie with secure flag in production", () => {
      const originalEnv = process.env.NODE_ENV;
      process.env.NODE_ENV = "production";

      const response = NextResponse.json({ data: "test" });
      const token = "test-token-123";

      setCSRFCookie(response, token);

      expect(response.cookies.set).toHaveBeenCalledWith(
        "csrf-token",
        token,
        expect.objectContaining({
          secure: true,
        }),
      );

      process.env.NODE_ENV = originalEnv;
    });

    it("should set httpOnly to false (readable by JavaScript)", () => {
      const response = NextResponse.json({ data: "test" });
      setCSRFCookie(response, "token");

      expect(response.cookies.set).toHaveBeenCalledWith(
        expect.any(String),
        expect.any(String),
        expect.objectContaining({ httpOnly: false }),
      );
    });

    it("should set sameSite to strict", () => {
      const response = NextResponse.json({ data: "test" });
      setCSRFCookie(response, "token");

      expect(response.cookies.set).toHaveBeenCalledWith(
        expect.any(String),
        expect.any(String),
        expect.objectContaining({ sameSite: "lax" }),
      );
    });

    it("should set 2-hour max age", () => {
      const response = NextResponse.json({ data: "test" });
      setCSRFCookie(response, "token");

      expect(response.cookies.set).toHaveBeenCalledWith(
        expect.any(String),
        expect.any(String),
        expect.objectContaining({ maxAge: 60 * 60 * 2 }),
      );
    });
  });

  describe("createResponseWithCSRF", () => {
    it("should create response with data and CSRF token", () => {
      const data = { message: "success" };
      const response = createResponseWithCSRF(data);

      expect(response).toBeDefined();
      expect(response.cookies).toBeDefined();
      expect(response.cookies.set).toHaveBeenCalled();
    });

    it("should include custom options in response", () => {
      const data = { message: "created" };
      const response = createResponseWithCSRF(data, { status: 201 });

      expect(response.status).toBe(201);
    });

    it("should generate unique token for each response", () => {
      // Clear mocks to get fresh calls
      const mockSet = jest.fn();
      (NextResponse.json as jest.Mock).mockImplementation((data, options) => ({
        status: options?.status || 200,
        headers: { get: jest.fn(), set: jest.fn(), append: jest.fn() },
        cookies: {
          set: mockSet,
          get: jest.fn(),
          delete: jest.fn(),
          has: jest.fn(),
          getAll: jest.fn(() => []),
        },
        json: async () => data,
      }));

      createResponseWithCSRF({ data: 1 });
      createResponseWithCSRF({ data: 2 });

      // Both should have set different tokens
      expect(mockSet).toHaveBeenCalledTimes(2);

      const call1 = mockSet.mock.calls[0][1]; // token from first call
      const call2 = mockSet.mock.calls[1][1]; // token from second call

      expect(call1).not.toBe(call2);
    });

    it("should preserve response data", async () => {
      const data = { user: "test", id: 123 };
      const response = createResponseWithCSRF(data);

      const body = await response.json();
      expect(body).toEqual(data);
    });
  });

  describe("Security Properties", () => {
    it("should generate tokens with sufficient entropy", () => {
      const tokens = new Set<string>();

      for (let i = 0; i < 1000; i++) {
        tokens.add(generateCSRFToken());
      }

      // All 1000 tokens should be unique
      expect(tokens.size).toBe(1000);
    });

    it("should prevent timing attacks with constant-time comparison", () => {
      const token = "a".repeat(64);
      const request1 = createMockRequest(
        "POST",
        { "x-csrf-token": "a".repeat(64) },
        { "csrf-token": "b".repeat(64) },
      );

      const request2 = createMockRequest(
        "POST",
        { "x-csrf-token": "a".repeat(63) + "b" },
        { "csrf-token": "b".repeat(64) },
      );

      // Both should fail without revealing timing information
      expect(validateCSRFToken(request1)).toBe(false);
      expect(validateCSRFToken(request2)).toBe(false);
    });

    it("should reject empty tokens", () => {
      const request = createMockRequest(
        "POST",
        { "x-csrf-token": "" },
        { "csrf-token": "" },
      );

      const isValid = validateCSRFToken(request);

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

  describe("Integration with Middleware", () => {
    it("should protect state-changing operations", async () => {
      const handler = jest
        .fn()
        .mockResolvedValue(NextResponse.json({ success: true }));
      const wrappedHandler = withCSRF(handler);

      // Test all state-changing methods
      const methods = ["POST", "PUT", "DELETE", "PATCH"];

      for (const method of methods) {
        handler.mockClear();
        const request = createMockRequest(method, {}, {});
        const response = await wrappedHandler(request);

        expect(response.status).toBe(403);
        expect(handler).not.toHaveBeenCalled();
      }
    });

    it("should allow safe methods without CSRF token", async () => {
      const handler = jest
        .fn()
        .mockResolvedValue(NextResponse.json({ success: true }));
      const wrappedHandler = withCSRF(handler);

      const safeMethods = ["GET", "HEAD", "OPTIONS"];

      for (const method of safeMethods) {
        handler.mockClear();
        const request = createMockRequest(method, {}, {});
        await wrappedHandler(request);

        expect(handler).toHaveBeenCalled();
      }
    });
  });
});
