import { describe, it, expect, vi, beforeEach } from "vitest";
import { TierLimitError } from "../tiers";

const mockDb = vi.hoisted(() => ({
  select: vi.fn(),
  insert: vi.fn(),
  execute: vi.fn(),
}));

vi.mock("@epicure/db", () => ({
  db: mockDb,
  tierDefinitions: { tier: "tier" },
  users: { id: "id", tier: "tier" },
  userUsage: {
    userId: "user_id",
    month: "month",
    aiCallsUsed: "ai_calls_used",
    recipeCount: "recipe_count",
    storageUsedMb: "storage_used_mb",
  },
  eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
  and: vi.fn((...args) => ({ args, op: "and" })),
  sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
}));

// Import after mock
const { checkAndIncrementTierLimit, incrementUsage, refundAiCall } = await import("../tiers");

function makeChain(finalValue: unknown) {
  const chain = {
    from: vi.fn().mockReturnThis(),
    where: vi.fn().mockResolvedValue(finalValue),
  };
  return chain;
}

function makeInsertChain() {
  const chain = {
    values: vi.fn().mockReturnThis(),
    onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
  };
  return chain;
}

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

describe("TierLimitError", () => {
  it("has correct name and message", () => {
    const err = new TierLimitError("aiCall", "free");
    expect(err.name).toBe("TierLimitError");
    expect(err.message).toContain("aiCall");
    expect(err.message).toContain("free");
    expect(err).toBeInstanceOf(Error);
  });
});

describe("checkAndIncrementTierLimit", () => {
  const tierDef = {
    tier: "free",
    maxRecipes: 10,
    aiCallsPerMonth: 5,
    storageMb: 100,
    maxPublicRecipes: 3,
  };

  it("does not throw when the atomic upsert returns a row (under limit)", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
    mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
    mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);

    await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
  });

  it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
    mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
    mockDb.execute.mockResolvedValueOnce([]);

    await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
  });

  it("throws TierLimitError for recipe key when limit reached", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
    mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
    mockDb.execute.mockResolvedValueOnce([]);

    await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
  });

  it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
    mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
    mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);

    // caller still thinks it's "free" (stale session), but DB says "pro"
    await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
  });

  it("does not throw when tier definition does not exist", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
    mockDb.select.mockReturnValueOnce(makeChain([]));

    await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
    expect(mockDb.execute).not.toHaveBeenCalled();
  });
});

describe("refundAiCall", () => {
  it("issues a decrement-with-floor UPDATE for the current month", async () => {
    mockDb.execute.mockResolvedValueOnce(undefined);
    await refundAiCall("user1");
    expect(mockDb.execute).toHaveBeenCalledTimes(1);
  });
});

describe("incrementUsage", () => {
  it("calls insert with correct aiCall initial values", async () => {
    const chain = makeInsertChain();
    mockDb.insert.mockReturnValue(chain);

    await incrementUsage("user1", "aiCall");

    expect(chain.values).toHaveBeenCalledWith(
      expect.objectContaining({
        userId: "user1",
        aiCallsUsed: 1,
        recipeCount: 0,
        storageUsedMb: 0,
      })
    );
  });

  it("calls insert with correct recipe initial values", async () => {
    const chain = makeInsertChain();
    mockDb.insert.mockReturnValue(chain);

    await incrementUsage("user1", "recipe");

    expect(chain.values).toHaveBeenCalledWith(
      expect.objectContaining({
        recipeCount: 1,
        aiCallsUsed: 0,
        storageUsedMb: 0,
      })
    );
  });

  it("respects custom amount", async () => {
    const chain = makeInsertChain();
    mockDb.insert.mockReturnValue(chain);

    await incrementUsage("user1", "storage", 50);

    expect(chain.values).toHaveBeenCalledWith(
      expect.objectContaining({ storageUsedMb: 50 })
    );
  });

  it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
    const chain = makeInsertChain();
    mockDb.insert.mockReturnValue(chain);

    await incrementUsage("user1", "aiCall");

    expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
      expect.objectContaining({
        set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
      })
    );
  });
});
