/**
 * VaultService Unit Tests
 *
 * Tests for OpenBao Transit encryption service with fallback to CryptoService.
 */

import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { Logger } from "@nestjs/common";
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { VaultService } from "./vault.service";
import { CryptoService } from "../federation/crypto.service";
import { TransitKey } from "./vault.constants";

describe("VaultService", () => {
  let configService: ConfigService;
  let cryptoService: CryptoService;
  let mockFetch: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    // Mock fetch
    mockFetch = vi.fn();
    global.fetch = mockFetch;

    // Mock ConfigService
    configService = {
      get: vi.fn((key: string) => {
        const config: Record<string, string> = {
          OPENBAO_ADDR: "http://localhost:8200",
          OPENBAO_ROLE_ID: "test-role-id",
          OPENBAO_SECRET_ID: "test-secret-id",
          ENCRYPTION_KEY: "0".repeat(64), // Valid 32-byte hex key
        };
        return config[key];
      }),
    } as unknown as ConfigService;

    // Mock CryptoService
    cryptoService = {
      encrypt: vi.fn((plaintext: string) => `iv:tag:${plaintext}`),
      decrypt: vi.fn((ciphertext: string) => {
        const parts = ciphertext.split(":");
        return parts[2] || "";
      }),
    } as unknown as CryptoService;

    // Suppress logger output during tests
    vi.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
    vi.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
    vi.spyOn(Logger.prototype, "error").mockImplementation(() => undefined);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  // Helper to create authenticated service
  async function createAuthenticatedService(): Promise<VaultService> {
    // Mock successful AppRole login
    mockFetch.mockResolvedValueOnce({
      ok: true,
      status: 200,
      json: async () => ({
        auth: {
          client_token: "test-token",
          lease_duration: 3600,
        },
      }),
    } as Response);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        VaultService,
        {
          provide: ConfigService,
          useValue: configService,
        },
        {
          provide: CryptoService,
          useValue: cryptoService,
        },
      ],
    }).compile();

    const service = module.get<VaultService>(VaultService);
    await service.waitForInitialization();
    return service;
  }

  describe("initialization", () => {
    it("should be defined", async () => {
      const service = await createAuthenticatedService();
      expect(service).toBeDefined();
    });

    it("should initialize with OpenBao unavailable when fetch fails", async () => {
      mockFetch.mockRejectedValueOnce(new Error("Connection refused"));

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          VaultService,
          {
            provide: ConfigService,
            useValue: configService,
          },
          {
            provide: CryptoService,
            useValue: cryptoService,
          },
        ],
      }).compile();

      const service = module.get<VaultService>(VaultService);
      await service.waitForInitialization();

      const status = service.getStatus();
      expect(status.available).toBe(false);
      expect(status.fallbackMode).toBe(true);
    });

    it("should authenticate with AppRole on initialization when OpenBao is available", async () => {
      await createAuthenticatedService();

      expect(mockFetch).toHaveBeenCalledWith(
        "http://localhost:8200/v1/auth/approle/login",
        expect.objectContaining({
          method: "POST",
          body: JSON.stringify({
            role_id: "test-role-id",
            secret_id: "test-secret-id",
          }),
        })
      );
    });
  });

  describe("encrypt", () => {
    it("should encrypt using OpenBao Transit when available", async () => {
      const service = await createAuthenticatedService();

      // Mock successful Transit encrypt
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:base64encodeddata",
          },
        }),
      } as Response);

      const plaintext = "secret-api-key";
      const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS);

      expect(encrypted).toBe("vault:v1:base64encodeddata");
      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/encrypt/mosaic-credentials",
        expect.objectContaining({
          method: "POST",
          headers: expect.objectContaining({
            "X-Vault-Token": "test-token",
          }),
        })
      );
    });

    it("should fallback to CryptoService when OpenBao is unavailable", async () => {
      mockFetch.mockRejectedValueOnce(new Error("Connection refused"));

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          VaultService,
          {
            provide: ConfigService,
            useValue: configService,
          },
          {
            provide: CryptoService,
            useValue: cryptoService,
          },
        ],
      }).compile();

      const service = module.get<VaultService>(VaultService);
      await service.waitForInitialization();

      const plaintext = "secret-api-key";
      const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS);

      expect(encrypted).toMatch(/^iv:tag:/);
      expect(cryptoService.encrypt).toHaveBeenCalledWith(plaintext);
    });

    it("should fallback to CryptoService when Transit encrypt fails", async () => {
      const service = await createAuthenticatedService();

      // Mock Transit encrypt failure
      mockFetch.mockRejectedValueOnce(new Error("Transit unavailable"));

      const plaintext = "secret-api-key";
      const encrypted = await service.encrypt(plaintext, TransitKey.CREDENTIALS);

      expect(encrypted).toMatch(/^iv:tag:/);
      expect(cryptoService.encrypt).toHaveBeenCalledWith(plaintext);
    });

    it("should handle base64 encoding correctly", async () => {
      const service = await createAuthenticatedService();

      // Mock successful Transit encrypt
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:base64encodeddata",
          },
        }),
      } as Response);

      const plaintext = "special chars: 🔒 @#$%";
      await service.encrypt(plaintext, TransitKey.ACCOUNT_TOKENS);

      const encodedPlaintext = Buffer.from(plaintext).toString("base64");
      expect(mockFetch).toHaveBeenLastCalledWith(
        expect.any(String),
        expect.objectContaining({
          body: JSON.stringify({ plaintext: encodedPlaintext }),
        })
      );
    });
  });

  describe("decrypt", () => {
    it("should decrypt OpenBao Transit ciphertext when available", async () => {
      const service = await createAuthenticatedService();

      // Mock successful Transit decrypt
      const plaintext = "secret-api-key";
      const encodedPlaintext = Buffer.from(plaintext).toString("base64");
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            plaintext: encodedPlaintext,
          },
        }),
      } as Response);

      const ciphertext = "vault:v1:base64encodeddata";
      const decrypted = await service.decrypt(ciphertext, TransitKey.CREDENTIALS);

      expect(decrypted).toBe(plaintext);
      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/decrypt/mosaic-credentials",
        expect.objectContaining({
          method: "POST",
          headers: expect.objectContaining({
            "X-Vault-Token": "test-token",
          }),
          body: JSON.stringify({ ciphertext }),
        })
      );
    });

    it("should use CryptoService to decrypt AES ciphertext", async () => {
      const service = await createAuthenticatedService();

      const ciphertext = "iv:tag:encrypteddata";
      const decrypted = await service.decrypt(ciphertext, TransitKey.CREDENTIALS);

      expect(decrypted).toBe("encrypteddata");
      expect(cryptoService.decrypt).toHaveBeenCalledWith(ciphertext);
    });

    it("should throw error when vault ciphertext but OpenBao unavailable", async () => {
      mockFetch.mockRejectedValueOnce(new Error("Connection refused"));

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          VaultService,
          {
            provide: ConfigService,
            useValue: configService,
          },
          {
            provide: CryptoService,
            useValue: cryptoService,
          },
        ],
      }).compile();

      const service = module.get<VaultService>(VaultService);
      await service.waitForInitialization();

      const ciphertext = "vault:v1:base64encodeddata";
      await expect(service.decrypt(ciphertext, TransitKey.CREDENTIALS)).rejects.toThrow(
        "Cannot decrypt vault:v1: ciphertext"
      );
    });

    it("should handle base64 decoding correctly", async () => {
      const service = await createAuthenticatedService();

      // Mock successful Transit decrypt
      const plaintext = "special chars: 🔒 @#$%";
      const encodedPlaintext = Buffer.from(plaintext).toString("base64");
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            plaintext: encodedPlaintext,
          },
        }),
      } as Response);

      const ciphertext = "vault:v1:base64encodeddata";
      const decrypted = await service.decrypt(ciphertext, TransitKey.ACCOUNT_TOKENS);

      expect(decrypted).toBe(plaintext);
    });
  });

  describe("ciphertext format detection", () => {
    it("should detect vault:v1: prefix as OpenBao Transit format", () => {
      const ciphertext = "vault:v1:base64encodeddata";
      expect(ciphertext.startsWith("vault:v1:")).toBe(true);
    });

    it("should detect colon-separated parts as AES format", () => {
      const ciphertext = "iv:tag:encrypted";
      const parts = ciphertext.split(":");
      expect(parts.length).toBe(3);
    });

    it("should handle legacy plaintext without encryption", () => {
      const plaintext = "legacy-plaintext-value";
      expect(plaintext.startsWith("vault:v1:")).toBe(false);
      expect(plaintext.split(":").length).not.toBe(3);
    });
  });

  describe("getStatus", () => {
    it("should return available status when OpenBao is reachable", async () => {
      const service = await createAuthenticatedService();
      const status = service.getStatus();

      expect(status.available).toBe(true);
      expect(status.fallbackMode).toBe(false);
    });

    it("should return unavailable status when OpenBao is unreachable", async () => {
      mockFetch.mockRejectedValueOnce(new Error("Connection refused"));

      const module: TestingModule = await Test.createTestingModule({
        providers: [
          VaultService,
          {
            provide: ConfigService,
            useValue: configService,
          },
          {
            provide: CryptoService,
            useValue: cryptoService,
          },
        ],
      }).compile();

      const service = module.get<VaultService>(VaultService);
      await service.waitForInitialization();

      const status = service.getStatus();
      expect(status.available).toBe(false);
      expect(status.fallbackMode).toBe(true);
    });

    it("should include endpoint in status", async () => {
      const service = await createAuthenticatedService();
      const status = service.getStatus();

      expect(status.endpoint).toBe("http://localhost:8200");
    });
  });

  describe("error handling", () => {
    it("should throw error when encrypting empty string", async () => {
      const service = await createAuthenticatedService();
      await expect(service.encrypt("", TransitKey.CREDENTIALS)).rejects.toThrow();
    });

    it("should throw error when decrypting empty string", async () => {
      const service = await createAuthenticatedService();
      await expect(service.decrypt("", TransitKey.CREDENTIALS)).rejects.toThrow();
    });

    it("should fallback to CryptoService on malformed Transit responses", async () => {
      const service = await createAuthenticatedService();

      // Mock malformed Transit response
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          error: "Internal error",
        }),
      } as Response);

      const encrypted = await service.encrypt("test", TransitKey.CREDENTIALS);
      expect(encrypted).toMatch(/^iv:tag:/);
      expect(cryptoService.encrypt).toHaveBeenCalledWith("test");
    });

    it("should fallback to CryptoService on HTTP error responses", async () => {
      const service = await createAuthenticatedService();

      // Mock HTTP error
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 500,
        statusText: "Internal Server Error",
      } as Response);

      const encrypted = await service.encrypt("test", TransitKey.CREDENTIALS);
      expect(encrypted).toMatch(/^iv:tag:/);
      expect(cryptoService.encrypt).toHaveBeenCalledWith("test");
    });
  });

  describe("different transit keys", () => {
    it("should use correct key name for CREDENTIALS", async () => {
      const service = await createAuthenticatedService();

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:data",
          },
        }),
      } as Response);

      await service.encrypt("test", TransitKey.CREDENTIALS);

      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/encrypt/mosaic-credentials",
        expect.any(Object)
      );
    });

    it("should use correct key name for ACCOUNT_TOKENS", async () => {
      const service = await createAuthenticatedService();

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:data",
          },
        }),
      } as Response);

      await service.encrypt("test", TransitKey.ACCOUNT_TOKENS);

      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/encrypt/mosaic-account-tokens",
        expect.any(Object)
      );
    });

    it("should use correct key name for FEDERATION", async () => {
      const service = await createAuthenticatedService();

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:data",
          },
        }),
      } as Response);

      await service.encrypt("test", TransitKey.FEDERATION);

      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/encrypt/mosaic-federation",
        expect.any(Object)
      );
    });

    it("should use correct key name for LLM_CONFIG", async () => {
      const service = await createAuthenticatedService();

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          data: {
            ciphertext: "vault:v1:data",
          },
        }),
      } as Response);

      await service.encrypt("test", TransitKey.LLM_CONFIG);

      expect(mockFetch).toHaveBeenLastCalledWith(
        "http://localhost:8200/v1/transit/encrypt/mosaic-llm-config",
        expect.any(Object)
      );
    });
  });

  describe("token renewal lifecycle", () => {
    it("should successfully renew token and reschedule renewal", async () => {
      const service = await createAuthenticatedService();

      // Mock successful token renewal
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            client_token: "renewed-token",
            lease_duration: 3600,
          },
        }),
      } as Response);

      // Access the private method via reflection for testing
      await service["renewToken"]();

      // Verify token was updated
      expect(service["token"]).toBe("renewed-token");
      expect(service["tokenExpiry"]).toBeGreaterThan(Date.now());

      // Verify renewal timer was rescheduled
      expect(service["renewalTimer"]).toBeDefined();
    });

    it("should re-authenticate and reschedule on renewal HTTP failure", async () => {
      const service = await createAuthenticatedService();

      // Mock token renewal failure (HTTP error)
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 403,
        statusText: "Forbidden",
      } as Response);

      // Mock successful re-authentication
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            client_token: "new-auth-token",
            lease_duration: 3600,
          },
        }),
      } as Response);

      await service["renewToken"]();

      // Verify re-authentication occurred
      expect(service["token"]).toBe("new-auth-token");

      // Verify renewal timer was rescheduled after re-authentication
      expect(service["renewalTimer"]).toBeDefined();
    });

    it("should re-authenticate and reschedule on renewal exception", async () => {
      const service = await createAuthenticatedService();

      // Mock token renewal that throws an exception
      mockFetch.mockRejectedValueOnce(new Error("Network timeout"));

      // Mock successful re-authentication
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            client_token: "recovery-token",
            lease_duration: 3600,
          },
        }),
      } as Response);

      await service["renewToken"]();

      // Verify re-authentication occurred
      expect(service["token"]).toBe("recovery-token");

      // Verify renewal timer was rescheduled after recovery
      expect(service["renewalTimer"]).toBeDefined();
    });

    it("should reschedule renewal after successful token renewal", async () => {
      const service = await createAuthenticatedService();

      // Clear the initial timer
      if (service["renewalTimer"]) {
        clearTimeout(service["renewalTimer"]);
        service["renewalTimer"] = null;
      }

      // Mock successful token renewal
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            client_token: "renewed-token",
            lease_duration: 3600,
          },
        }),
      } as Response);

      await service["renewToken"]();

      // Verify renewal timer was scheduled
      expect(service["renewalTimer"]).not.toBeNull();
    });

    it("should clear renewal timer on module destroy", async () => {
      const service = await createAuthenticatedService();

      // Verify timer exists
      expect(service["renewalTimer"]).toBeDefined();

      // Call onModuleDestroy
      service.onModuleDestroy();

      // Verify timer was cleared
      // Note: We can't directly check if timeout was cleared, but we verify the method executes without error
      expect(service.onModuleDestroy).toBeDefined();
    });

    it("should handle missing token during renewal gracefully", async () => {
      const service = await createAuthenticatedService();

      // Clear mock history after initialization
      mockFetch.mockClear();

      // Clear token to simulate edge case
      service["token"] = null;

      // Should throw error without attempting fetch
      await expect(service["renewToken"]()).rejects.toThrow("No token to renew");

      // Verify no fetch was attempted after clearing token
      expect(mockFetch).not.toHaveBeenCalled();
    });

    it("should handle malformed renewal response and recover", async () => {
      const service = await createAuthenticatedService();

      // Mock malformed renewal response (missing required fields)
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            // Missing client_token or lease_duration
          },
        }),
      } as Response);

      // Mock successful re-authentication
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          auth: {
            client_token: "recovery-token",
            lease_duration: 3600,
          },
        }),
      } as Response);

      await service["renewToken"]();

      // Verify re-authentication occurred
      expect(service["token"]).toBe("recovery-token");

      // Verify renewal was rescheduled
      expect(service["renewalTimer"]).toBeDefined();
    });
  });

  describe("fetchWithTimeout", () => {
    it("should timeout after specified duration", async () => {
      const service = await createAuthenticatedService();

      // Mock a fetch that checks for abort signal and rejects when aborted
      mockFetch.mockImplementationOnce((url: string, options: RequestInit) => {
        return new Promise((resolve, reject) => {
          if (options.signal) {
            options.signal.addEventListener("abort", () => {
              const error = new Error("The operation was aborted");
              error.name = "AbortError";
              reject(error);
            });
          }
        });
      });

      // Should timeout after 100ms
      await expect(service["fetchWithTimeout"]("http://test", {}, 100)).rejects.toThrow(
        "Request timeout after 100ms"
      );
    });

    it("should successfully complete requests within timeout", async () => {
      const service = await createAuthenticatedService();

      // Mock a fast response
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({ data: "test" }),
      } as Response);

      const response = await service["fetchWithTimeout"]("http://test", {}, 5000);
      expect(response.ok).toBe(true);
    });

    it("should propagate non-timeout errors", async () => {
      const service = await createAuthenticatedService();

      // Mock a network error
      mockFetch.mockRejectedValueOnce(new Error("Network error"));

      await expect(service["fetchWithTimeout"]("http://test", {}, 5000)).rejects.toThrow(
        "Network error"
      );
    });

    it("should abort request when timeout occurs", async () => {
      const service = await createAuthenticatedService();

      let aborted = false;
      mockFetch.mockImplementationOnce((url: string, options: RequestInit) => {
        return new Promise((resolve, reject) => {
          if (options.signal) {
            options.signal.addEventListener("abort", () => {
              aborted = true;
              const error = new Error("The operation was aborted");
              error.name = "AbortError";
              reject(error);
            });
          }
        });
      });

      await expect(service["fetchWithTimeout"]("http://test", {}, 100)).rejects.toThrow(
        "Request timeout"
      );

      // Verify abort was called
      expect(aborted).toBe(true);
    });
  });
});
