import { sql } from "drizzle-orm";
import { vi } from "vitest";
import db from "@/database";
import { afterEach, beforeEach, describe, expect, test } from "@/test";

// Use vi.hoisted() to create mock functions that can be accessed in vi.mock
const { mockGet, mockSet, mockDelete, mockDisconnect, mockOn } = vi.hoisted(
  () => ({
    mockGet: vi.fn(),
    mockSet: vi.fn(),
    mockDelete: vi.fn(),
    mockDisconnect: vi.fn(),
    mockOn: vi.fn(),
  }),
);

// Mock Keyv using the hoisted mock functions
vi.mock("keyv", () => ({
  default: class MockKeyv {
    get = mockGet;
    set = mockSet;
    delete = mockDelete;
    disconnect = mockDisconnect;
    on = mockOn;
  },
}));

vi.mock("@keyv/postgres", () => ({
  default: vi.fn(),
}));

// Import after mocks are set up
import {
  type AllowedCacheKey,
  cacheManager,
  LRUCacheManager,
} from "./cache-manager";

// Alias for convenience in tests
const mockKeyv = {
  get: mockGet,
  set: mockSet,
  delete: mockDelete,
  disconnect: mockDisconnect,
  on: mockOn,
};

/**
 * Helper to ensure keyv_cache table exists for SQL-based tests.
 * This table is normally created by @keyv/postgres but we mock that.
 * Note: Keyv stores expiration INSIDE the JSON value, not as a separate column.
 */
async function ensureKeyvCacheTable() {
  await db.execute(sql`
    CREATE TABLE IF NOT EXISTS keyv_cache (
      key TEXT PRIMARY KEY,
      value TEXT
    )
  `);
}

/**
 * Helper to insert a value directly into keyv_cache for testing.
 * Mimics how Keyv stores data with the "keyv:" prefix.
 * Keyv wraps values as: {"value": <actual-value>, "expires": <timestamp>}
 */
async function insertKeyvEntry(
  key: string,
  value: unknown,
  expiresAt?: number,
) {
  const keyvKey = `keyv:${key}`;
  // Keyv wraps the value with expiration inside the JSON
  const keyvPayload = {
    value,
    ...(expiresAt !== undefined && { expires: expiresAt }),
  };
  const jsonValue = JSON.stringify(keyvPayload);
  await db.execute(
    sql`INSERT INTO keyv_cache (key, value) VALUES (${keyvKey}, ${jsonValue})
        ON CONFLICT (key) DO UPDATE SET value = ${jsonValue}`,
  );
}

/**
 * Helper to check if a key exists in keyv_cache.
 */
async function keyvEntryExists(key: string): Promise<boolean> {
  const keyvKey = `keyv:${key}`;
  const result = await db.execute<{ count: string }>(
    sql`SELECT COUNT(*) as count FROM keyv_cache WHERE key = ${keyvKey}`,
  );
  return Number.parseInt(result.rows[0]?.count ?? "0", 10) > 0;
}

/**
 * Helper to clear all entries from keyv_cache.
 */
async function clearKeyvCache() {
  await db.execute(sql`DELETE FROM keyv_cache`);
}

describe("CacheManager", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset the cacheManager state for each test by calling shutdown
    cacheManager.shutdown();
  });

  afterEach(() => {
    cacheManager.shutdown();
  });

  describe("start", () => {
    test("initializes Keyv connection", () => {
      cacheManager.start();

      // Should register error handler
      expect(mockKeyv.on).toHaveBeenCalledWith("error", expect.any(Function));
    });

    test("does not reinitialize if already started", () => {
      cacheManager.start();
      const firstCallCount = mockKeyv.on.mock.calls.length;

      cacheManager.start();
      // Should not add another error handler
      expect(mockKeyv.on.mock.calls.length).toBe(firstCallCount);
    });
  });

  describe("get", () => {
    test("returns value from cache", async () => {
      cacheManager.start();
      mockKeyv.get.mockResolvedValue({ foo: "bar" });

      const result = await cacheManager.get<{ foo: string }>(
        "test-key" as AllowedCacheKey,
      );

      expect(result).toEqual({ foo: "bar" });
      expect(mockKeyv.get).toHaveBeenCalledWith("test-key");
    });

    test("returns undefined when key does not exist", async () => {
      cacheManager.start();
      mockKeyv.get.mockResolvedValue(undefined);

      const result = await cacheManager.get("missing-key" as AllowedCacheKey);

      expect(result).toBeUndefined();
    });

    test("returns undefined when not started", async () => {
      const result = await cacheManager.get("test-key" as AllowedCacheKey);

      expect(result).toBeUndefined();
      expect(mockKeyv.get).not.toHaveBeenCalled();
    });

    test("returns undefined on error", async () => {
      cacheManager.start();
      mockKeyv.get.mockRejectedValue(new Error("Connection failed"));

      const result = await cacheManager.get("test-key" as AllowedCacheKey);

      expect(result).toBeUndefined();
    });
  });

  describe("set", () => {
    test("sets value with default TTL", async () => {
      cacheManager.start();
      mockKeyv.set.mockResolvedValue(true);

      const value = { foo: "bar" };
      const result = await cacheManager.set(
        "test-key" as AllowedCacheKey,
        value,
      );

      expect(result).toEqual(value);
      expect(mockKeyv.set).toHaveBeenCalledWith(
        "test-key",
        value,
        3600000, // 1 hour default TTL
      );
    });

    test("throws when not started", async () => {
      await expect(
        cacheManager.set("test-key" as AllowedCacheKey, { foo: "bar" }),
      ).rejects.toThrow("CacheManager: Not started");
    });

    test("throws on error", async () => {
      cacheManager.start();
      mockKeyv.set.mockRejectedValue(new Error("Write failed"));

      await expect(
        cacheManager.set("test-key" as AllowedCacheKey, { foo: "bar" }),
      ).rejects.toThrow("Write failed");
    });
  });

  describe("delete", () => {
    test("deletes key from cache", async () => {
      cacheManager.start();
      mockKeyv.delete.mockResolvedValue(true);

      const result = await cacheManager.delete("test-key" as AllowedCacheKey);

      expect(result).toBe(true);
      expect(mockKeyv.delete).toHaveBeenCalledWith("test-key");
    });

    test("returns false when not started", async () => {
      const result = await cacheManager.delete("test-key" as AllowedCacheKey);

      expect(result).toBe(false);
      expect(mockKeyv.delete).not.toHaveBeenCalled();
    });

    test("returns false on error", async () => {
      cacheManager.start();
      mockKeyv.delete.mockRejectedValue(new Error("Delete failed"));

      const result = await cacheManager.delete("test-key" as AllowedCacheKey);

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

  describe("getAndDelete", () => {
    // These tests use real database calls since getAndDelete uses raw SQL
    // for atomic delete-and-return semantics

    beforeEach(async () => {
      await ensureKeyvCacheTable();
      await clearKeyvCache();
    });

    test("gets and deletes value atomically", async () => {
      cacheManager.start();

      // Insert test data directly into keyv_cache
      await insertKeyvEntry("test-key", { foo: "bar" });

      const result = await cacheManager.getAndDelete<{ foo: string }>(
        "test-key" as AllowedCacheKey,
      );

      expect(result).toEqual({ foo: "bar" });

      // Verify the entry was deleted
      const exists = await keyvEntryExists("test-key");
      expect(exists).toBe(false);
    });

    test("returns undefined if key does not exist", async () => {
      cacheManager.start();

      const result = await cacheManager.getAndDelete(
        "missing-key" as AllowedCacheKey,
      );

      expect(result).toBeUndefined();
    });

    test("returns undefined for expired entries", async () => {
      cacheManager.start();

      // Insert an expired entry (expires in the past)
      await insertKeyvEntry("expired-key", { foo: "bar" }, Date.now() - 1000);

      const result = await cacheManager.getAndDelete(
        "expired-key" as AllowedCacheKey,
      );

      expect(result).toBeUndefined();
    });

    test("returns value for non-expired entries", async () => {
      cacheManager.start();

      // Insert an entry that expires in the future
      await insertKeyvEntry("valid-key", { foo: "bar" }, Date.now() + 60000);

      const result = await cacheManager.getAndDelete<{ foo: string }>(
        "valid-key" as AllowedCacheKey,
      );

      expect(result).toEqual({ foo: "bar" });
    });

    test("returns undefined when not started", async () => {
      const result = await cacheManager.getAndDelete(
        "test-key" as AllowedCacheKey,
      );

      expect(result).toBeUndefined();
    });
  });

  describe("deleteByPrefix", () => {
    // These tests use real database calls since deleteByPrefix uses raw SQL

    beforeEach(async () => {
      await ensureKeyvCacheTable();
      await clearKeyvCache();
    });

    test("deletes all entries matching prefix", async () => {
      cacheManager.start();

      // Insert multiple entries with same prefix
      await insertKeyvEntry("chat-mcp-tools-agent1", { tools: ["a"] });
      await insertKeyvEntry("chat-mcp-tools-agent2", { tools: ["b"] });
      await insertKeyvEntry("chat-mcp-tools-agent3", { tools: ["c"] });
      // Insert entry with different prefix
      await insertKeyvEntry("other-key", { data: "keep" });

      const deletedCount = await cacheManager.deleteByPrefix(
        "chat-mcp-tools" as AllowedCacheKey,
      );

      expect(deletedCount).toBe(3);

      // Verify the matching entries were deleted
      expect(await keyvEntryExists("chat-mcp-tools-agent1")).toBe(false);
      expect(await keyvEntryExists("chat-mcp-tools-agent2")).toBe(false);
      expect(await keyvEntryExists("chat-mcp-tools-agent3")).toBe(false);

      // Verify the non-matching entry still exists
      expect(await keyvEntryExists("other-key")).toBe(true);
    });

    test("returns 0 when no entries match prefix", async () => {
      cacheManager.start();

      await insertKeyvEntry("other-key", { data: "value" });

      const deletedCount = await cacheManager.deleteByPrefix(
        "non-existent-prefix" as AllowedCacheKey,
      );

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

    test("returns 0 when not started", async () => {
      const deletedCount = await cacheManager.deleteByPrefix(
        "test-prefix" as AllowedCacheKey,
      );

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

  describe("shutdown", () => {
    test("disconnects Keyv and clears state", () => {
      cacheManager.start();
      cacheManager.shutdown();

      expect(mockKeyv.disconnect).toHaveBeenCalled();
    });

    test("handles shutdown when not started", () => {
      // Should not throw
      expect(() => cacheManager.shutdown()).not.toThrow();
    });
  });
});

describe("LRUCacheManager", () => {
  test("runs eviction callback when an expired entry is read", () => {
    vi.useFakeTimers();

    try {
      const onEviction = vi.fn();
      const cache = new LRUCacheManager<string>({
        maxSize: 10,
        defaultTtl: 1_000,
        onEviction,
      });

      cache.set("client-1", "value");
      vi.advanceTimersByTime(1_001);

      expect(cache.get("client-1")).toBeUndefined();
      expect(onEviction).toHaveBeenCalledWith("client-1", "value");
      expect(cache.size).toBe(0);
    } finally {
      vi.useRealTimers();
    }
  });

  test("runs eviction callback when an expired entry is checked with has()", () => {
    vi.useFakeTimers();

    try {
      const onEviction = vi.fn();
      const cache = new LRUCacheManager<string>({
        maxSize: 10,
        defaultTtl: 1_000,
        onEviction,
      });

      cache.set("client-1", "value");
      vi.advanceTimersByTime(1_001);

      expect(cache.has("client-1")).toBe(false);
      expect(onEviction).toHaveBeenCalledWith("client-1", "value");
      expect(cache.size).toBe(0);
    } finally {
      vi.useRealTimers();
    }
  });

  test("has() does not promote an entry's LRU recency", () => {
    const cache = new LRUCacheManager<string>({ maxSize: 2 });

    cache.set("A", "a");
    cache.set("B", "b");
    // A pure existence check — must not count as a use of A.
    expect(cache.has("A")).toBe(true);
    cache.set("C", "c");
    cache.set("D", "d");

    // With get-based has(), A would have been promoted and survived both
    // rotations; with peek it ages out like any untouched entry.
    expect(cache.has("A")).toBe(false);
    expect(cache.has("C")).toBe(true);
    expect(cache.has("D")).toBe(true);
  });
});
