/**
 * quota.test.ts — Unit tests for storage quota and member limit enforcement.
 *
 * Pure-function tests run without any DB. DB-bound function tests mock the
 * @/db module so no real database connection is required.
 *
 * Run: pnpm test
 */

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
  isPaidFamily,
  wouldExceedQuota,
  isAtMemberLimit,
  formatBytes,
  FREE_STORAGE_LIMIT_BYTES,
  FREE_MEMBER_LIMIT,
  STORAGE_WARN_THRESHOLD,
} from "@/lib/quota";

// ─── Pure function tests (no DB, no mocks needed) ─────────────────────────────

describe("isPaidFamily", () => {
  it("returns false for 'free'", () => {
    expect(isPaidFamily("free")).toBe(false);
  });

  it("returns false for null", () => {
    expect(isPaidFamily(null)).toBe(false);
  });

  it("returns false for undefined", () => {
    expect(isPaidFamily(undefined)).toBe(false);
  });

  it("returns true for 'pro'", () => {
    expect(isPaidFamily("pro")).toBe(true);
  });

  it("returns true for any non-free string", () => {
    expect(isPaidFamily("paid")).toBe(true);
    expect(isPaidFamily("enterprise")).toBe(true);
  });
});

describe("wouldExceedQuota", () => {
  const LIMIT = FREE_STORAGE_LIMIT_BYTES; // 1 GiB

  it("allows upload when usage + declared <= limit", () => {
    expect(wouldExceedQuota(900_000_000, 100_000_000, LIMIT)).toBe(false); // exactly 1 GiB
  });

  it("blocks upload when usage + declared > limit", () => {
    expect(wouldExceedQuota(900_000_000, 200_000_000, LIMIT)).toBe(true); // over by 100 MB
  });

  it("blocks when already at limit with any positive declared size", () => {
    expect(wouldExceedQuota(LIMIT, 1, LIMIT)).toBe(true);
  });

  it("allows zero-byte declared upload when under limit", () => {
    expect(wouldExceedQuota(500_000_000, 0, LIMIT)).toBe(false);
  });

  it("blocks zero-byte declared upload when already over limit (post-downgrade)", () => {
    // Family was on paid, accumulated > 1 GiB, then downgraded.
    // Even a 0-byte declared should report over quota.
    expect(wouldExceedQuota(LIMIT + 1, 0, LIMIT)).toBe(true);
  });

  it("correctly handles large files within quota", () => {
    // 950 MB used, 50 MB upload → 1000 MB total, under 1 GiB (1024 MB)
    expect(wouldExceedQuota(950_000_000, 50_000_000, LIMIT)).toBe(false);
  });

  it("correctly handles combined memories + attachments sum", () => {
    // SUM from memories (600 MB) + attachments (200 MB) = 800 MB, + 300 MB upload → over
    const memoriesBytes = 600_000_000;
    const attachmentsBytes = 200_000_000;
    const combined = memoriesBytes + attachmentsBytes;
    expect(wouldExceedQuota(combined, 300_000_000, LIMIT)).toBe(true);
  });
});

describe("isAtMemberLimit", () => {
  it("blocks at limit on free tier", () => {
    expect(isAtMemberLimit(2, FREE_MEMBER_LIMIT, false)).toBe(true);
  });

  it("blocks above limit on free tier", () => {
    expect(isAtMemberLimit(5, FREE_MEMBER_LIMIT, false)).toBe(true);
  });

  it("allows below limit on free tier", () => {
    expect(isAtMemberLimit(1, FREE_MEMBER_LIMIT, false)).toBe(false);
  });

  it("always allows on paid tier regardless of count", () => {
    expect(isAtMemberLimit(100, FREE_MEMBER_LIMIT, true)).toBe(false);
  });

  it("freeze rule: paid → free downgrade with 4 members blocks new invites, not existing members", () => {
    // After downgrade, all 4 members still exist (roles unchanged — enforced elsewhere).
    // The limit check sees count=4, limit=2, paid=false → blocked for new invites.
    expect(isAtMemberLimit(4, 2, false)).toBe(true);
    // But if tier were still paid, it would be allowed:
    expect(isAtMemberLimit(4, 2, true)).toBe(false);
  });
});

describe("formatBytes", () => {
  it("formats bytes", () => {
    expect(formatBytes(512)).toBe("512 B");
  });

  it("formats kilobytes", () => {
    expect(formatBytes(1024)).toBe("1.0 KB");
  });

  it("formats megabytes", () => {
    expect(formatBytes(10 * 1024 * 1024)).toBe("10.0 MB");
  });

  it("formats gigabytes", () => {
    expect(formatBytes(1_073_741_824)).toBe("1.00 GB");
  });

  it("formats 0 bytes", () => {
    expect(formatBytes(0)).toBe("0 B");
  });
});

describe("constants", () => {
  it("FREE_STORAGE_LIMIT_BYTES is exactly 1 GiB", () => {
    expect(FREE_STORAGE_LIMIT_BYTES).toBe(1_073_741_824);
  });

  it("FREE_MEMBER_LIMIT is 2", () => {
    expect(FREE_MEMBER_LIMIT).toBe(2);
  });

  it("STORAGE_WARN_THRESHOLD is 0.8", () => {
    expect(STORAGE_WARN_THRESHOLD).toBe(0.8);
  });
});

// ─── DB-bound function tests (mocked DB) ──────────────────────────────────────

// We mock the @/db module so tests don't need a real Postgres connection.
vi.mock("@/db", () => ({
  sql: vi.fn(),
}));

import { sql } from "@/db";
import {
  getFamilyStorageUsage,
  checkStorageQuota,
  checkMemberLimit,
  getStorageInfo,
} from "@/lib/quota";

const mockSql = sql as unknown as ReturnType<typeof vi.fn>;

// Helper: make sql return specific results for tagged template literals.
// The postgres.js `sql` is a tagged template function, not a regular function,
// but vi.fn() captures the call regardless.
function sqlReturns(...results: unknown[][]) {
  let callIndex = 0;
  mockSql.mockImplementation((..._args: unknown[]) => {
    return Promise.resolve(results[callIndex++ % results.length]);
  });
}

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

describe("getFamilyStorageUsage", () => {
  it("sums memories and attachments correctly", async () => {
    // memories: 600 MB, attachments: 200 MB → 800 MB total
    sqlReturns(
      [{ bytes: "629145600" }],  // memories: 600 MB
      [{ bytes: "209715200" }]   // attachments: 200 MB
    );
    const usage = await getFamilyStorageUsage("family-a");
    expect(usage).toBe(629145600 + 209715200);
  });

  it("returns 0 when both tables are empty", async () => {
    sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
    expect(await getFamilyStorageUsage("family-empty")).toBe(0);
  });

  it("tenant isolation: queried with the correct familyId", async () => {
    sqlReturns([{ bytes: "0" }], [{ bytes: "0" }]);
    await getFamilyStorageUsage("family-a");
    // Both SQL calls should have received family-a in their args
    const calls = mockSql.mock.calls;
    expect(calls).toHaveLength(2);
    // The familyId is passed as a tagged template argument; verify it appears
    const allArgs = calls.flatMap((c: unknown[]) => c);
    expect(allArgs).toContain("family-a");
  });

  it("excludes uploading-status memories (counted by WHERE clause in SQL)", async () => {
    // The WHERE processing_status != 'uploading' is in the SQL template.
    // Here we verify the SUM result reflects only confirmed uploads.
    // Simulate: one confirmed memory (100 MB), pending stays excluded via SQL
    sqlReturns([{ bytes: "104857600" }], [{ bytes: "0" }]);
    const usage = await getFamilyStorageUsage("family-b");
    expect(usage).toBe(104857600);
  });
});

describe("checkStorageQuota", () => {
  it("allows upload when family is under quota", async () => {
    // family: free tier, 500 MB used
    sqlReturns(
      [{ tier: "free" }],         // families query
      [{ bytes: "524288000" }],   // memories SUM (500 MB)
      [{ bytes: "0" }]            // attachments SUM
    );
    const result = await checkStorageQuota("family-a", 100 * 1024 * 1024); // +100 MB
    expect(result.allowed).toBe(true);
  });

  it("blocks upload when declared size would push over quota", async () => {
    // family: free tier, 950 MB used; trying to upload 200 MB → would be 1150 MB > 1 GiB
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: "996147200" }],  // 950 MB
      [{ bytes: "0" }]
    );
    const result = await checkStorageQuota("family-a", 200 * 1024 * 1024); // 200 MB
    expect(result.allowed).toBe(false);
    if (!result.allowed) {
      expect(result.reason).toBe("storage_quota_exceeded");
      expect(result.usedBytes).toBe(996147200);
      expect(result.limitBytes).toBe(FREE_STORAGE_LIMIT_BYTES);
    }
  });

  it("blocks upload when family is already over quota (post-downgrade)", async () => {
    // Paid family accumulated 2 GB, then downgraded to free.
    // Even a tiny upload (1 byte) should be blocked.
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: "2147483648" }],  // 2 GB in memories
      [{ bytes: "0" }]
    );
    const result = await checkStorageQuota("family-a", 1);
    expect(result.allowed).toBe(false);
  });

  it("allows upload for paid family regardless of usage", async () => {
    sqlReturns(
      [{ tier: "pro" }],
      [{ bytes: "10737418240" }], // 10 GB (way over free limit)
      [{ bytes: "0" }]
    );
    const result = await checkStorageQuota("family-paid", 500 * 1024 * 1024);
    expect(result.allowed).toBe(true);
  });

  it("blocks upload when declared=0 but family is over quota", async () => {
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: `${FREE_STORAGE_LIMIT_BYTES + 1}` }],
      [{ bytes: "0" }]
    );
    const result = await checkStorageQuota("family-a", 0);
    expect(result.allowed).toBe(false);
  });
});

describe("checkMemberLimit", () => {
  it("allows invite when count < limit on free tier", async () => {
    sqlReturns([{ count: "1", max_members: 2, tier: "free" }]);
    const result = await checkMemberLimit("family-a");
    expect(result.allowed).toBe(true);
    if (result.allowed) expect(result.currentCount).toBe(1);
  });

  it("blocks invite when count = limit on free tier", async () => {
    sqlReturns([{ count: "2", max_members: 2, tier: "free" }]);
    const result = await checkMemberLimit("family-a");
    expect(result.allowed).toBe(false);
    if (!result.allowed) {
      expect(result.reason).toBe("member_limit_reached");
      expect(result.currentCount).toBe(2);
      expect(result.limit).toBe(2);
    }
  });

  it("blocks invite when count > limit (post-downgrade freeze)", async () => {
    // 4 members from paid tier, now free — new invites blocked, existing retained
    sqlReturns([{ count: "4", max_members: 2, tier: "free" }]);
    const result = await checkMemberLimit("family-downgraded");
    expect(result.allowed).toBe(false);
    if (!result.allowed) {
      expect(result.currentCount).toBe(4);
      expect(result.limit).toBe(2);
    }
  });

  it("allows invite on paid tier regardless of count", async () => {
    sqlReturns([{ count: "50", max_members: 2, tier: "pro" }]);
    const result = await checkMemberLimit("family-paid");
    expect(result.allowed).toBe(true);
  });

  it("respects custom max_members from DB (configurable per family)", async () => {
    // Admin could set max_members=5 for a special promo plan
    sqlReturns([{ count: "4", max_members: 5, tier: "free" }]);
    const result = await checkMemberLimit("family-promo");
    expect(result.allowed).toBe(true);
  });
});

describe("getStorageInfo", () => {
  it("returns approaching=true near 80% usage", async () => {
    const nearLimit = Math.floor(FREE_STORAGE_LIMIT_BYTES * 0.85);
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: String(nearLimit) }],
      [{ bytes: "0" }]
    );
    const info = await getStorageInfo("family-a");
    expect(info.approaching).toBe(true);
    expect(info.exceeded).toBe(false);
  });

  it("returns exceeded=true at 100%+ usage", async () => {
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: String(FREE_STORAGE_LIMIT_BYTES + 1000) }],
      [{ bytes: "0" }]
    );
    const info = await getStorageInfo("family-a");
    expect(info.exceeded).toBe(true);
    expect(info.approaching).toBe(false); // exceeded takes over
  });

  it("returns approaching=false and exceeded=false for paid family", async () => {
    sqlReturns(
      [{ tier: "pro" }],
      [{ bytes: String(FREE_STORAGE_LIMIT_BYTES * 10) }],
      [{ bytes: "0" }]
    );
    const info = await getStorageInfo("family-paid");
    expect(info.approaching).toBe(false);
    expect(info.exceeded).toBe(false);
    expect(info.isPaid).toBe(true);
  });

  it("over-quota family can still derive usage (read path unblocked)", async () => {
    // getStorageInfo is the read path for the UI meter — must work even over quota.
    sqlReturns(
      [{ tier: "free" }],
      [{ bytes: "2000000000" }],
      [{ bytes: "0" }]
    );
    const info = await getStorageInfo("family-over");
    expect(info.usedBytes).toBe(2_000_000_000);
    expect(info.exceeded).toBe(true);
    // The read itself succeeds — memories can still be read (enforced at route level)
  });
});

// ─── RLS / tenant isolation ────────────────────────────────────────────────────

describe("tenant isolation", () => {
  it("does not mix usage across families", async () => {
    // Family A: 900 MB; Family B: 100 MB. Each call should see only its own usage.
    const familyAUsage = 943718400; // 900 MB
    const familyBUsage = 104857600; // 100 MB

    mockSql
      .mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyAUsage) }]))
      .mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]))
      .mockImplementationOnce(() => Promise.resolve([{ bytes: String(familyBUsage) }]))
      .mockImplementationOnce(() => Promise.resolve([{ bytes: "0" }]));

    const usageA = await getFamilyStorageUsage("family-a");
    const usageB = await getFamilyStorageUsage("family-b");

    expect(usageA).toBe(familyAUsage);
    expect(usageB).toBe(familyBUsage);
    expect(usageA).not.toBe(usageB);
  });
});
