/**
 * Unit tests for fetch cache shim.
 *
 * Tests the patched fetch() with Next.js caching semantics:
 * - next.revalidate for TTL-based caching
 * - next.tags for tag-based invalidation
 * - cache: 'no-store' and cache: 'force-cache'
 * - Stale-while-revalidate behavior
 * - next property stripping
 * - Independent cache entries per URL
 */

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

// We need to mock fetch at the module level BEFORE fetch-cache.ts captures
// `originalFetch`. Use vi.stubGlobal to intercept at import time.
let requestCount = 0;
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
  requestCount++;
  const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
  return new Response(JSON.stringify({ url, count: requestCount }), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
});

// Stub globalThis.fetch BEFORE importing modules that capture it
vi.stubGlobal("fetch", fetchMock);

// Now import — these will capture fetchMock as "originalFetch"
const { withFetchCache, runWithFetchCache, getCollectedFetchTags, getOriginalFetch } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { getCacheHandler, revalidateTag, MemoryCacheHandler, setCacheHandler } = await import("../packages/vinext/src/shims/cache.js");

describe("fetch cache shim", () => {
  let cleanup: (() => void) | null = null;

  beforeEach(() => {
    // Reset state
    requestCount = 0;
    fetchMock.mockClear();
    // Reset the cache handler to a fresh instance for each test
    setCacheHandler(new MemoryCacheHandler());
    // Install the patched fetch
    cleanup = withFetchCache();
  });

  afterEach(() => {
    cleanup?.();
    cleanup = null;
  });

  // ── Basic caching with next.revalidate ──────────────────────────────

  it("caches fetch with next.revalidate and returns cached on second call", async () => {
    const res1 = await fetch("https://api.example.com/data", {
      next: { revalidate: 60 },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    // Second call should return cached data (no new network request)
    const res2 = await fetch("https://api.example.com/data", {
      next: { revalidate: 60 },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(1); // Same count = cached
    expect(fetchMock).toHaveBeenCalledTimes(1); // Only one real fetch
  });

  it("cache: 'force-cache' caches indefinitely", async () => {
    const res1 = await fetch("https://api.example.com/force", {
      cache: "force-cache",
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/force", {
      cache: "force-cache",
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(1); // Cached
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  // ── No caching (no-store, revalidate: 0, revalidate: false) ─────────

  it("cache: 'no-store' bypasses cache entirely", async () => {
    const res1 = await fetch("https://api.example.com/nostore", {
      cache: "no-store",
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/nostore", {
      cache: "no-store",
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(2); // Fresh fetch each time
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("next.revalidate: 0 skips caching", async () => {
    const res1 = await fetch("https://api.example.com/rev0", {
      next: { revalidate: 0 },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/rev0", {
      next: { revalidate: 0 },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(2); // Not cached
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("next.revalidate: false skips caching", async () => {
    const res1 = await fetch("https://api.example.com/revfalse", {
      next: { revalidate: false },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/revfalse", {
      next: { revalidate: false },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(2); // Not cached
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("no cache or next options passes through without caching", async () => {
    const res1 = await fetch("https://api.example.com/passthrough");
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/passthrough");
    const data2 = await res2.json();
    expect(data2.count).toBe(2); // Pass-through, no caching
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  // ── Tag-based invalidation ──────────────────────────────────────────

  it("next.tags caches and revalidateTag invalidates", async () => {
    const res1 = await fetch("https://api.example.com/posts", {
      next: { tags: ["posts"] },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    // Cached
    const res2 = await fetch("https://api.example.com/posts", {
      next: { tags: ["posts"] },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(1);
    expect(fetchMock).toHaveBeenCalledTimes(1);

    // Invalidate via tag
    await revalidateTag("posts");

    // Should re-fetch after tag invalidation
    const res3 = await fetch("https://api.example.com/posts", {
      next: { tags: ["posts"] },
    });
    const data3 = await res3.json();
    expect(data3.count).toBe(2); // Fresh fetch
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("revalidateTag only invalidates matching tags", async () => {
    // Cache two different tagged fetches
    await fetch("https://api.example.com/posts-tag", {
      next: { tags: ["posts"] },
    });
    await fetch("https://api.example.com/users-tag", {
      next: { tags: ["users"] },
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);

    // Invalidate only "posts"
    await revalidateTag("posts");

    // Posts should re-fetch
    const postRes = await fetch("https://api.example.com/posts-tag", {
      next: { tags: ["posts"] },
    });
    const postData = await postRes.json();
    expect(postData.count).toBe(3); // Fresh fetch (count continues from 2)

    // Users should still be cached
    const userRes = await fetch("https://api.example.com/users-tag", {
      next: { tags: ["users"] },
    });
    const userData = await userRes.json();
    expect(userData.count).toBe(2); // Still the cached version
    expect(fetchMock).toHaveBeenCalledTimes(3); // Only posts re-fetched
  });

  // ── TTL expiry (stale-while-revalidate) ─────────────────────────────

  it("returns stale data after TTL expires and triggers background refetch", async () => {
    const res1 = await fetch("https://api.example.com/stale-test", {
      next: { revalidate: 1 },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    // Manually expire the cache entry
    const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
    const cacheKey = "fetch:GET:https://api.example.com/stale-test";
    const store = (handler as any).store as Map<string, any>;
    const entry = store.get(cacheKey);
    if (entry) {
      entry.revalidateAt = Date.now() - 1000; // Expired 1 second ago
    }

    // Should return stale data immediately
    const res2 = await fetch("https://api.example.com/stale-test", {
      next: { revalidate: 1 },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(1); // Stale data (same as first fetch)

    // Wait for background refetch
    await new Promise((resolve) => setTimeout(resolve, 50));
    expect(fetchMock).toHaveBeenCalledTimes(2); // Original + background refetch
  });

  // ── Independent cache entries per URL ───────────────────────────────

  it("different URLs get independent cache entries", async () => {
    const res1 = await fetch("https://api.example.com/url-a", {
      next: { revalidate: 60 },
    });
    const data1 = await res1.json();
    expect(data1.url).toBe("https://api.example.com/url-a");
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/url-b", {
      next: { revalidate: 60 },
    });
    const data2 = await res2.json();
    expect(data2.url).toBe("https://api.example.com/url-b");
    expect(data2.count).toBe(2); // Different URL = different cache

    // Re-fetch url-a should be cached
    const res3 = await fetch("https://api.example.com/url-a", {
      next: { revalidate: 60 },
    });
    const data3 = await res3.json();
    expect(data3.count).toBe(1); // Cached
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("same URL with different methods get separate cache entries", async () => {
    const getRes = await fetch("https://api.example.com/method-test", {
      method: "GET",
      next: { revalidate: 60 },
    });
    const getData = await getRes.json();
    expect(getData.count).toBe(1);

    const postRes = await fetch("https://api.example.com/method-test", {
      method: "POST",
      body: "test",
      next: { revalidate: 60 },
    });
    const postData = await postRes.json();
    expect(postData.count).toBe(2); // Different method = different cache

    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  // ── next property stripping ─────────────────────────────────────────

  it("strips next property before passing to real fetch", async () => {
    await fetch("https://api.example.com/strip-test", {
      next: { revalidate: 60, tags: ["test"] },
      headers: { "X-Custom": "value" },
    });

    // Verify the mock was called with init that does NOT have `next`
    const call = fetchMock.mock.calls[0];
    const init = call[1] as RequestInit;
    expect(init).toBeDefined();
    expect((init as any).next).toBeUndefined();
    expect((init as any).headers).toEqual({ "X-Custom": "value" });
  });

  it("strips next property for no-store fetches too", async () => {
    await fetch("https://api.example.com/strip-nostore", {
      cache: "no-store",
      next: { tags: ["test"] },
    });

    const call = fetchMock.mock.calls[0];
    const init = call[1] as RequestInit;
    expect((init as any).next).toBeUndefined();
  });

  // ── Tag collection during rendering ─────────────────────────────────

  it("collects tags during render pass via getCollectedFetchTags", async () => {
    await fetch("https://api.example.com/tag-collect-a", {
      next: { tags: ["posts", "list"] },
    });
    await fetch("https://api.example.com/tag-collect-b", {
      next: { tags: ["users"] },
    });

    const tags = getCollectedFetchTags();
    expect(tags).toContain("posts");
    expect(tags).toContain("list");
    expect(tags).toContain("users");
    expect(tags).toHaveLength(3);
  });

  it("does not collect duplicate tags", async () => {
    await fetch("https://api.example.com/dup-tag-a", {
      next: { tags: ["data"] },
    });
    await fetch("https://api.example.com/dup-tag-b", {
      next: { tags: ["data"] },
    });

    const tags = getCollectedFetchTags();
    expect(tags.filter(t => t === "data")).toHaveLength(1);
  });

  // ── Only caches successful responses ────────────────────────────────

  it("does not cache non-2xx responses", async () => {
    // Override mock to return 404 once
    fetchMock.mockImplementationOnce(async () => {
      requestCount++;
      return new Response("Not found", { status: 404 });
    });

    const res1 = await fetch("https://api.example.com/missing-page", {
      next: { revalidate: 60 },
    });
    expect(res1.status).toBe(404);

    // Should re-fetch since 404 wasn't cached
    const res2 = await fetch("https://api.example.com/missing-page", {
      next: { revalidate: 60 },
    });
    expect(res2.status).toBe(200);
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  // ── URL and Request object inputs ───────────────────────────────────

  it("handles URL objects as input", async () => {
    const url = new URL("https://api.example.com/url-obj");
    const res = await fetch(url, { next: { revalidate: 60 } });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.count).toBe(1);

    // Cached on second call
    const res2 = await fetch(url, { next: { revalidate: 60 } });
    const data2 = await res2.json();
    expect(data2.count).toBe(1);
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it("handles Request objects as input", async () => {
    const req = new Request("https://api.example.com/req-obj");
    const res = await fetch(req, { next: { revalidate: 60 } });
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.count).toBe(1);

    // Cached on second call with same URL
    const req2 = new Request("https://api.example.com/req-obj");
    const res2 = await fetch(req2, { next: { revalidate: 60 } });
    const data2 = await res2.json();
    expect(data2.count).toBe(1);
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  // ── force-cache with next.revalidate ────────────────────────────────

  it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => {
    const res1 = await fetch("https://api.example.com/force-ttl", {
      cache: "force-cache",
      next: { revalidate: 1 },
    });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    // Verify it's cached
    const res2 = await fetch("https://api.example.com/force-ttl", {
      cache: "force-cache",
      next: { revalidate: 1 },
    });
    const data2 = await res2.json();
    expect(data2.count).toBe(1);

    // Expire the cache manually
    const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
    const store = (handler as any).store as Map<string, any>;
    const cacheKey = "fetch:GET:https://api.example.com/force-ttl";
    const entry = store.get(cacheKey);
    if (entry) {
      entry.revalidateAt = Date.now() - 1000;
    }

    // Should return stale
    const res3 = await fetch("https://api.example.com/force-ttl", {
      cache: "force-cache",
      next: { revalidate: 1 },
    });
    const data3 = await res3.json();
    expect(data3.count).toBe(1); // Stale data returned
    // Background refetch
    await new Promise((resolve) => setTimeout(resolve, 50));
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  // ── Cleanup clears per-request state ─────────────────────────────────

  it("cleanup function clears collected tags", async () => {
    // Collect some tags
    await fetch("https://api.example.com/cleanup-test", {
      next: { tags: ["cleanup-tag"] },
    });
    expect(getCollectedFetchTags()).toContain("cleanup-tag");

    // Cleanup should reset tag state
    cleanup!();
    cleanup = null;
    expect(getCollectedFetchTags()).toHaveLength(0);

    // Re-install for afterEach cleanup
    cleanup = withFetchCache();
  });

  // ── getOriginalFetch ────────────────────────────────────────────────

  it("getOriginalFetch returns the module-level original fetch", () => {
    const orig = getOriginalFetch();
    expect(typeof orig).toBe("function");
    // It should be fetchMock since that was the global fetch when the module loaded
    expect(orig).toBe(fetchMock);
  });

  // ── next: {} empty passes through ───────────────────────────────────

  it("next: {} with no revalidate or tags passes through", async () => {
    const res1 = await fetch("https://api.example.com/empty-next", { next: {} });
    const data1 = await res1.json();
    expect(data1.count).toBe(1);

    const res2 = await fetch("https://api.example.com/empty-next", { next: {} });
    const data2 = await res2.json();
    expect(data2.count).toBe(2); // Not cached
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  // ── Concurrent request isolation via ALS ─────────────────────────────

  it("concurrent runWithFetchCache calls have isolated tags", async () => {
    // Clean up the withFetchCache() from beforeEach — runWithFetchCache
    // manages its own ALS scope.
    cleanup?.();
    cleanup = null;

    const [tags1, tags2] = await Promise.all([
      runWithFetchCache(async () => {
        await fetch("https://api.example.com/concurrent-a", {
          next: { tags: ["request-1"] },
        });
        return getCollectedFetchTags();
      }),
      runWithFetchCache(async () => {
        await fetch("https://api.example.com/concurrent-b", {
          next: { tags: ["request-2"] },
        });
        return getCollectedFetchTags();
      }),
    ]);

    expect(tags1).toEqual(["request-1"]);
    expect(tags2).toEqual(["request-2"]);

    // Re-install for afterEach
    cleanup = withFetchCache();
  });
});
