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

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

vi.mock("@epicure/db", () => ({
  db: mockDb,
  tierDefinitions: { tier: "tier" },
  users: { id: "id", tier: "tier", avatarSizeMb: "avatar_size_mb" },
  userUsage: { userId: "user_id", month: "month", aiCallsUsed: "ai_calls_used" },
  recipes: { id: "id", authorId: "author_id" },
  recipePhotos: { recipeId: "recipe_id", sizeMb: "size_mb" },
  ratings: { userId: "user_id", photoSizeMb: "photo_size_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, getRecipeCount, getStorageUsedMb, refundAiCall } = await import("../tiers");

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

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,
  };

  describe("aiCall (monthly, atomic upsert)", () => {
    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("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("recipe (lifetime total, live count)", () => {
    it("does not throw when the live count is under the limit", async () => {
      mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
      mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
      mockDb.select.mockReturnValueOnce(makeChain([{ n: 9 }]));

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

    it("throws TierLimitError when the live count has already reached the limit", async () => {
      mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
      mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
      mockDb.select.mockReturnValueOnce(makeChain([{ n: 10 }]));

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

    it("does not query the count when the tier is unlimited", async () => {
      mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
      mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, maxRecipes: -1 }]));

      await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).resolves.toBeUndefined();
      expect(mockDb.select).toHaveBeenCalledTimes(2);
    });
  });

  describe("storage (lifetime total, live sum)", () => {
    it("does not throw when the live sum is under the limit", async () => {
      mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
      mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
      mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }])); // recipePhotos
      mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }])); // ratings
      mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }])); // users

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

    it("throws TierLimitError when the live sum plus amount exceeds the limit", async () => {
      mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
      mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
      mockDb.select.mockReturnValueOnce(makeChain([{ total: 80 }]));
      mockDb.select.mockReturnValueOnce(makeChain([{ total: 15 }]));
      mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }]));

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

describe("getRecipeCount", () => {
  it("returns the live count", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ n: 7 }]));
    await expect(getRecipeCount("user1")).resolves.toBe(7);
  });

  it("returns 0 when no rows come back", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([]));
    await expect(getRecipeCount("user1")).resolves.toBe(0);
  });
});

describe("getStorageUsedMb", () => {
  it("sums recipe photos, review photos, and avatar", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([{ total: 40 }]));
    mockDb.select.mockReturnValueOnce(makeChain([{ total: 10 }]));
    mockDb.select.mockReturnValueOnce(makeChain([{ avatarSizeMb: 5 }]));

    await expect(getStorageUsedMb("user1")).resolves.toBe(55);
  });

  it("treats missing rows as 0", async () => {
    mockDb.select.mockReturnValueOnce(makeChain([]));
    mockDb.select.mockReturnValueOnce(makeChain([]));
    mockDb.select.mockReturnValueOnce(makeChain([]));

    await expect(getStorageUsedMb("user1")).resolves.toBe(0);
  });
});

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);
  });
});
