/**
 * ISR cache unit tests.
 *
 * Tests cache key generation, normalization, hash truncation,
 * revalidate duration tracking with LRU eviction, background
 * regeneration deduplication, and cache value builders.
 *
 * These complement the integration-level ISR tests in features.test.ts
 * by testing the ISR cache layer in isolation.
 */
import { describe, it, expect, vi, beforeEach, afterEach } from "vite-plus/test";
import {
  isrCacheKey,
  appIsrHtmlKey,
  appIsrRscKey,
  appIsrRouteKey,
  isrGet,
  isrSet,
  buildPagesCacheValue,
  buildAppPageCacheValue,
  normalizeMountedSlotsHeader,
  setRevalidateDuration,
  getRevalidateDuration,
  triggerBackgroundRegeneration,
} from "../packages/vinext/src/server/isr-cache.js";
import {
  APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
  APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
import { fnv1a64 } from "../packages/vinext/src/utils/hash.js";
import { buildPageCacheTags } from "../packages/vinext/src/server/implicit-tags.js";
import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js";
import {
  createRequestContext,
  getRequestContext,
  isInsideUnifiedScope,
  runWithRequestContext,
} from "../packages/vinext/src/shims/unified-request-context.js";
import {
  MemoryCacheHandler,
  setCacheHandler,
  revalidatePath,
  type CachedFetchValue,
} from "../packages/vinext/src/shims/cache.js";

// ─── isrCacheKey ────────────────────────────────────────────────────────

describe("isrCacheKey", () => {
  it("fnv1a64 uses fixed-width unambiguous output", () => {
    const hash = fnv1a64("/" + "a".repeat(250));
    expect(hash).toMatch(/^[0-9a-f]{16}$/);
  });

  it("generates pages: prefix for Pages Router", () => {
    expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
  });

  it("generates app: prefix for App Router", () => {
    expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
  });

  it("preserves root / without stripping", () => {
    expect(isrCacheKey("pages", "/")).toBe("pages:/");
  });

  it("strips trailing slash from non-root paths", () => {
    expect(isrCacheKey("pages", "/about/")).toBe("pages:/about");
  });

  it("does not strip trailing slash from root", () => {
    expect(isrCacheKey("pages", "/")).toBe("pages:/");
  });

  it("handles deeply nested paths", () => {
    expect(isrCacheKey("app", "/blog/2024/01/my-post")).toBe("app:/blog/2024/01/my-post");
  });

  it("hashes very long paths (> 200 chars)", () => {
    const longPath = "/" + "a".repeat(250);
    const key = isrCacheKey("pages", longPath);
    expect(key).toMatch(/^pages:__hash:/);
    // Hash should be deterministic
    const key2 = isrCacheKey("pages", longPath);
    expect(key).toBe(key2);
  });

  it("does not hash paths that produce keys <= 200 chars", () => {
    const shortPath = "/about";
    const key = isrCacheKey("pages", shortPath);
    expect(key).toBe("pages:/about");
    expect(key).not.toContain("__hash:");
  });

  it("different long paths produce different hashes", () => {
    const path1 = "/" + "a".repeat(250);
    const path2 = "/" + "b".repeat(250);
    expect(isrCacheKey("pages", path1)).not.toBe(isrCacheKey("pages", path2));
  });

  it("includes buildId in key when provided", () => {
    expect(isrCacheKey("pages", "/about", "abc123")).toBe("pages:abc123:/about");
  });

  it("includes buildId in app router key", () => {
    expect(isrCacheKey("app", "/dashboard", "build-42")).toBe("app:build-42:/dashboard");
  });

  it("preserves root with buildId", () => {
    expect(isrCacheKey("pages", "/", "v1")).toBe("pages:v1:/");
  });

  it("strips trailing slash with buildId", () => {
    expect(isrCacheKey("pages", "/about/", "v1")).toBe("pages:v1:/about");
  });

  it("hashes long paths with buildId", () => {
    const longPath = "/" + "a".repeat(250);
    const key = isrCacheKey("pages", longPath, "build-99");
    expect(key).toMatch(/^pages:build-99:__hash:/);
  });

  it("without buildId format is unchanged (backward compat)", () => {
    expect(isrCacheKey("pages", "/about")).toBe("pages:/about");
    expect(isrCacheKey("app", "/dashboard")).toBe("app:/dashboard");
  });
});

describe("App Router ISR cache key primitives", () => {
  const originalBuildId = process.env.__VINEXT_BUILD_ID;

  afterEach(() => {
    if (originalBuildId === undefined) {
      delete process.env.__VINEXT_BUILD_ID;
      return;
    }

    process.env.__VINEXT_BUILD_ID = originalBuildId;
  });

  it("builds separate html, rsc, and route keys from the normalized pathname", () => {
    delete process.env.__VINEXT_BUILD_ID;

    expect(appIsrHtmlKey("/about/")).toBe("app:/about:html");
    expect(appIsrRscKey("/about/")).toBe("app:/about:rsc");
    expect(appIsrRouteKey("/api/feed/")).toBe("app:/api/feed:route");
  });

  it("includes the build id when present", () => {
    process.env.__VINEXT_BUILD_ID = "build-42";

    expect(appIsrHtmlKey("/dashboard")).toBe("app:build-42:/dashboard:html");
  });

  it("hashes long pathname keys while preserving the cache entry suffix", () => {
    delete process.env.__VINEXT_BUILD_ID;

    const key = appIsrRscKey("/" + "a".repeat(250));

    expect(key).toMatch(/^app:__hash:[a-z0-9]+:rsc$/);
  });

  it("keys mounted-slot RSC variants by normalized mounted-slot header", () => {
    delete process.env.__VINEXT_BUILD_ID;

    const first = appIsrRscKey("/feed", "slot:modal:/ slot:sidebar:/");
    const second = appIsrRscKey("/feed", "slot:sidebar:/ slot:modal:/");

    expect(first).toBe(second);
    expect(first).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+$/);
  });

  it("bounds RSC cache-key cardinality against attacker-supplied mounted-slot values", () => {
    // SECURITY-AUDIT-2026-05 F-PROD-1: an attacker who forges
    // X-Vinext-Mounted-Slots: <unique-value> must not be able to fan out
    // unbounded distinct cache keys (per-write KV billing / wallet attack).
    delete process.env.__VINEXT_BUILD_ID;

    const keys = new Set<string>();
    for (let i = 0; i < 1000; i++) {
      // Each of these violates the legitimate slot:<name>:<treePath> wire
      // shape and must be dropped by normalization.
      keys.add(appIsrRscKey("/feed", `attacker-${i}`));
    }
    // All 1000 distinct attacker values collapse to the same "no slots" key.
    expect(keys.size).toBe(1);
    expect(keys.values().next().value).toBe("app:/feed:rsc");
  });

  it("caps cache-key cardinality when an attacker pads the mounted-slots header", () => {
    delete process.env.__VINEXT_BUILD_ID;

    // Even when every token has the legitimate wire shape, the raw header
    // length is capped (4096 bytes). When attacker payloads exceed the cap
    // they are dropped to null, collapsing into the no-slots cache key.
    const keys = new Set<string>();
    for (let batch = 0; batch < 100; batch++) {
      // 1000 legitimate tokens per batch easily exceeds the 4 KiB length cap.
      const tokens = Array.from({ length: 1000 }, (_, i) => `slot:b${batch}_s${i}:/`).join(" ");
      keys.add(appIsrRscKey("/feed", tokens));
    }
    expect(keys.size).toBe(1);
    expect(keys.values().next().value).toBe("app:/feed:rsc");
  });

  it("keys intercepted RSC variants by source context", () => {
    delete process.env.__VINEXT_BUILD_ID;

    const fromFeed = appIsrRscKey("/photos/42", "slot:modal:/", undefined, "/feed");
    const fromGallery = appIsrRscKey("/photos/42", "slot:modal:/", undefined, "/gallery");
    const direct = appIsrRscKey("/photos/42", "slot:modal:/");

    expect(fromFeed).not.toBe(fromGallery);
    expect(fromFeed).not.toBe(direct);
    expect(fromFeed).toMatch(/^app:\/photos\/42:rsc:source:[a-z0-9]+:slots:[a-z0-9]+$/);
  });

  it("normalizes source context before keying intercepted RSC variants", () => {
    delete process.env.__VINEXT_BUILD_ID;

    const encoded = appIsrRscKey("/photos/café", "modal", undefined, "/caf%C3%A9");
    const decoded = appIsrRscKey("/photos/café", "modal", undefined, "/café");
    const duplicateSlash = appIsrRscKey("/photos/café", "modal", undefined, "/café//");

    expect(encoded).toBe(decoded);
    expect(duplicateSlash).toBe(decoded);
  });

  it("ignores invalid source context before keying intercepted RSC variants", () => {
    delete process.env.__VINEXT_BUILD_ID;

    const invalidSource = appIsrRscKey("/photos/42", "modal", undefined, "/feed?tab=popular");
    const direct = appIsrRscKey("/photos/42", "modal");

    expect(invalidSource).toBe(direct);
  });

  it("keys RSC refresh variants separately from normal navigation variants", () => {
    delete process.env.__VINEXT_BUILD_ID;

    expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toBe(
      "app:/feed:rsc:preserve-ui",
    );
    expect(appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI)).toMatch(
      /^app:\/feed:rsc:slots:[a-z0-9]+:preserve-ui$/,
    );
  });

  it("keys RSC loading-shell prefetch variants separately from normal navigation variants", () => {
    delete process.env.__VINEXT_BUILD_ID;

    expect(appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL)).toBe(
      "app:/feed:rsc:prefetch-loading-shell",
    );
    expect(
      appIsrRscKey("/feed", "slot:modal:/", APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL),
    ).toMatch(/^app:\/feed:rsc:slots:[a-z0-9]+:prefetch-loading-shell$/);
  });

  it("round-trips normal and preserve-current-UI RSC payloads under separate keys", async () => {
    delete process.env.__VINEXT_BUILD_ID;
    setCacheHandler(new MemoryCacheHandler());

    const normalKey = appIsrRscKey("/feed");
    const preserveUiKey = appIsrRscKey("/feed", null, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI);
    const normalPayload = new TextEncoder().encode("normal-rsc").buffer;
    const preserveUiPayload = new TextEncoder().encode("preserve-ui-rsc").buffer;

    await isrSet(normalKey, buildAppPageCacheValue("", normalPayload), 60, []);
    await isrSet(preserveUiKey, buildAppPageCacheValue("", preserveUiPayload), 60, []);

    const normalEntry = await isrGet(normalKey);
    const preserveUiEntry = await isrGet(preserveUiKey);

    expect(normalEntry?.value.value?.kind).toBe("APP_PAGE");
    expect(preserveUiEntry?.value.value?.kind).toBe("APP_PAGE");
    if (
      normalEntry?.value.value?.kind !== "APP_PAGE" ||
      preserveUiEntry?.value.value?.kind !== "APP_PAGE"
    ) {
      throw new Error("Expected App Router page cache entries");
    }

    expect(new TextDecoder().decode(normalEntry.value.value.rscData)).toBe("normal-rsc");
    expect(new TextDecoder().decode(preserveUiEntry.value.value.rscData)).toBe("preserve-ui-rsc");
  });
});

describe("normalizeMountedSlotsHeader", () => {
  it("returns null for missing or blank mounted-slot headers", () => {
    expect(normalizeMountedSlotsHeader(null)).toBeNull();
    expect(normalizeMountedSlotsHeader("   \t\n  ")).toBeNull();
  });

  it("deduplicates and sorts whitespace-separated slot ids", () => {
    expect(
      normalizeMountedSlotsHeader(" slot:sidebar:/  slot:modal:/ slot:sidebar:/\tslot:cart:/ "),
    ).toBe("slot:cart:/ slot:modal:/ slot:sidebar:/");
  });
});

// ─── buildPagesCacheValue ───────────────────────────────────────────────

describe("buildPagesCacheValue", () => {
  it("builds correct structure", () => {
    const value = buildPagesCacheValue("<html>test</html>", { title: "Test" });
    expect(value.kind).toBe("PAGES");
    expect(value.html).toBe("<html>test</html>");
    expect(value.pageData).toEqual({ title: "Test" });
    expect(value.headers).toBeUndefined();
    expect(value.status).toBeUndefined();
  });

  it("includes status when provided", () => {
    const value = buildPagesCacheValue("<html>404</html>", {}, 404);
    expect(value.status).toBe(404);
  });
});

// ─── buildAppPageCacheValue ─────────────────────────────────────────────

describe("buildAppPageCacheValue", () => {
  it("builds correct structure", () => {
    const value = buildAppPageCacheValue("<html>app</html>");
    expect(value.kind).toBe("APP_PAGE");
    expect(value.html).toBe("<html>app</html>");
    expect(value.rscData).toBeUndefined();
    expect(value.headers).toBeUndefined();
    expect(value.postponed).toBeUndefined();
    expect(value.status).toBeUndefined();
  });

  it("includes rscData when provided", () => {
    const rscData = new ArrayBuffer(8);
    const value = buildAppPageCacheValue("<html>app</html>", rscData);
    expect(value.rscData).toBe(rscData);
  });

  it("includes status when provided", () => {
    const value = buildAppPageCacheValue("<html>app</html>", undefined, 200);
    expect(value.status).toBe(200);
  });
});

// ─── Revalidate duration tracking ───────────────────────────────────────

describe("setRevalidateDuration / getRevalidateDuration", () => {
  it("stores and retrieves a duration", () => {
    setRevalidateDuration("test-key-1", 60);
    expect(getRevalidateDuration("test-key-1")).toBe(60);
  });

  it("returns undefined for unknown keys", () => {
    expect(getRevalidateDuration("nonexistent-key-xyz")).toBeUndefined();
  });

  it("overwrites previous values", () => {
    setRevalidateDuration("test-key-2", 60);
    setRevalidateDuration("test-key-2", 120);
    expect(getRevalidateDuration("test-key-2")).toBe(120);
  });

  it("handles zero duration", () => {
    setRevalidateDuration("test-key-3", 0);
    expect(getRevalidateDuration("test-key-3")).toBe(0);
  });
});

// ─── Expire ceiling handling ────────────────────────────────────────────

describe("ISR expire ceiling", () => {
  beforeEach(() => {
    vi.useRealTimers();
    setCacheHandler(new MemoryCacheHandler());
  });

  it("serves stale within expire and treats entries beyond expire as hard misses", async () => {
    vi.useFakeTimers({ toFake: ["Date"] });
    vi.setSystemTime(1_000);

    await isrSet("expire-test", buildPagesCacheValue("<html>cached</html>", {}), 1, [], 3);

    vi.setSystemTime(2_500);
    const stale = await isrGet("expire-test");
    expect(stale?.isStale).toBe(true);
    expect(stale?.value.value?.kind).toBe("PAGES");

    vi.setSystemTime(4_500);
    await expect(isrGet("expire-test")).resolves.toBeNull();
  });

  it("preserves legacy revalidate context while writing cache-control metadata", async () => {
    let setContext: Record<string, unknown> | undefined;
    setCacheHandler({
      async get() {
        return null;
      },
      async set(_key, _data, ctx) {
        setContext = ctx;
      },
      async revalidateTag() {},
    });

    await isrSet("compat-test", buildPagesCacheValue("<html>cached</html>", {}), 60, ["tag"], 300);

    expect(setContext).toEqual({
      cacheControl: { revalidate: 60, expire: 300 },
      revalidate: 60,
      tags: ["tag"],
    });
  });

  it("treats cache handlers that report expired entries as hard misses", async () => {
    setCacheHandler({
      async get() {
        return {
          lastModified: Date.now() - 10_000,
          cacheState: "expired",
          value: buildPagesCacheValue("<html>expired</html>", {}),
        };
      },
      async set() {},
      async revalidateTag() {},
    });

    await expect(isrGet("expired-handler-entry")).resolves.toBeNull();
  });
});

// ─── triggerBackgroundRegeneration ───────────────────────────────────────

describe("triggerBackgroundRegeneration", () => {
  it("calls the render function", async () => {
    const renderFn = vi.fn().mockResolvedValue(undefined);
    triggerBackgroundRegeneration("regen-test-1", renderFn);
    // Wait for the async operation
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFn).toHaveBeenCalledOnce();
  });

  it("deduplicates concurrent regeneration for same key", async () => {
    let resolveFirst: () => void;
    const firstPromise = new Promise<void>((r) => {
      resolveFirst = r;
    });
    const renderFn1 = vi.fn().mockReturnValue(firstPromise);
    const renderFn2 = vi.fn().mockResolvedValue(undefined);

    triggerBackgroundRegeneration("regen-test-2", renderFn1);
    triggerBackgroundRegeneration("regen-test-2", renderFn2);

    // Only the first should have been called
    expect(renderFn1).toHaveBeenCalledOnce();
    expect(renderFn2).not.toHaveBeenCalled();

    // Complete the first
    resolveFirst!();
    await new Promise((resolve) => setTimeout(resolve, 10));
  });

  it("allows regeneration after previous completes", async () => {
    const renderFn1 = vi.fn().mockResolvedValue(undefined);
    triggerBackgroundRegeneration("regen-test-3", renderFn1);
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFn1).toHaveBeenCalledOnce();

    // After completion, a new regeneration should be allowed
    const renderFn2 = vi.fn().mockResolvedValue(undefined);
    triggerBackgroundRegeneration("regen-test-3", renderFn2);
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFn2).toHaveBeenCalledOnce();
  });

  it("handles render function errors gracefully", async () => {
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
    const renderFn = vi.fn().mockRejectedValue(new Error("render failed"));

    triggerBackgroundRegeneration("regen-test-4", renderFn);
    await new Promise((resolve) => setTimeout(resolve, 10));

    expect(renderFn).toHaveBeenCalledOnce();
    expect(consoleError).toHaveBeenCalled();

    // After error, key should be cleared so new regeneration is possible
    const renderFn2 = vi.fn().mockResolvedValue(undefined);
    triggerBackgroundRegeneration("regen-test-4", renderFn2);
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFn2).toHaveBeenCalledOnce();

    consoleError.mockRestore();
  });

  it("reports error via onRequestError handler when errorContext is provided", async () => {
    const handler = vi.fn();
    globalThis.__VINEXT_onRequestErrorHandler__ = handler;
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

    try {
      const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
      triggerBackgroundRegeneration("regen-report-error", renderFn, {
        routerKind: "App Router",
        routePath: "/blog/[slug]",
        routeType: "render",
      });
      await new Promise((resolve) => setTimeout(resolve, 10));

      expect(handler).toHaveBeenCalledOnce();
      const [error, request, context] = handler.mock.calls[0];
      expect(error).toBeInstanceOf(Error);
      expect(error.message).toBe("regen failed");
      expect(request).toEqual({ path: "regen-report-error", method: "GET", headers: {} });
      expect(context).toEqual({
        routerKind: "App Router",
        routePath: "/blog/[slug]",
        routeType: "render",
        revalidateReason: "stale",
      });
    } finally {
      delete globalThis.__VINEXT_onRequestErrorHandler__;
      consoleError.mockRestore();
    }
  });

  it("does NOT call onRequestError handler when errorContext is omitted", async () => {
    const handler = vi.fn();
    globalThis.__VINEXT_onRequestErrorHandler__ = handler;
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

    try {
      const renderFn = vi.fn().mockRejectedValue(new Error("regen failed"));
      triggerBackgroundRegeneration("regen-no-ctx", renderFn);
      await new Promise((resolve) => setTimeout(resolve, 10));

      expect(consoleError).toHaveBeenCalled();
      expect(handler).not.toHaveBeenCalled();
    } finally {
      delete globalThis.__VINEXT_onRequestErrorHandler__;
      consoleError.mockRestore();
    }
  });

  it("wraps non-Error throw values in Error before reporting", async () => {
    const handler = vi.fn();
    globalThis.__VINEXT_onRequestErrorHandler__ = handler;
    const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

    try {
      const renderFn = vi.fn().mockRejectedValue("string error");
      triggerBackgroundRegeneration("regen-string-error", renderFn, {
        routerKind: "Pages Router",
        routePath: "/about",
        routeType: "render",
      });
      await new Promise((resolve) => setTimeout(resolve, 10));

      expect(handler).toHaveBeenCalledOnce();
      const [error] = handler.mock.calls[0];
      expect(error).toBeInstanceOf(Error);
      expect(error.message).toBe("string error");
    } finally {
      delete globalThis.__VINEXT_onRequestErrorHandler__;
      consoleError.mockRestore();
    }
  });

  it("different keys run independently", async () => {
    const renderFnA = vi.fn().mockResolvedValue(undefined);
    const renderFnB = vi.fn().mockResolvedValue(undefined);

    triggerBackgroundRegeneration("regen-test-5a", renderFnA);
    triggerBackgroundRegeneration("regen-test-5b", renderFnB);

    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFnA).toHaveBeenCalledOnce();
    expect(renderFnB).toHaveBeenCalledOnce();
  });

  it("calls ctx.waitUntil with the regen promise when ctx is in ALS", async () => {
    const waitUntil = vi.fn();
    const ctx = { waitUntil };

    let resolveRender: () => void;
    const renderPromise = new Promise<void>((r) => {
      resolveRender = r;
    });
    const renderFn = vi.fn().mockReturnValue(renderPromise);

    await runWithExecutionContext(ctx, async () => {
      triggerBackgroundRegeneration("regen-ctx-1", renderFn);
    });

    expect(waitUntil).toHaveBeenCalledOnce();
    expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));

    resolveRender!();
    await renderPromise;
  });

  it("preserves unified request context for async work started by regeneration", async () => {
    let releaseRender!: () => void;
    const resumeRender = new Promise<void>((resolve) => {
      releaseRender = resolve;
    });

    let regenPromise: Promise<unknown> | null = null;
    const executionContext = {
      waitUntil(promise: Promise<unknown>) {
        regenPromise = promise;
      },
    };

    let sawUnifiedScope = false;
    let collectedTags: string[] = [];

    await runWithExecutionContext(executionContext, async () => {
      await runWithRequestContext(
        createRequestContext({ currentRequestTags: ["outer-tag"] }),
        async () => {
          triggerBackgroundRegeneration("regen-unified-scope", async () => {
            await resumeRender;
            sawUnifiedScope = isInsideUnifiedScope();
            collectedTags = [...getRequestContext().currentRequestTags];
          });
        },
      );
    });

    expect(isInsideUnifiedScope()).toBe(false);
    if (!regenPromise) {
      throw new Error("expected triggerBackgroundRegeneration to register waitUntil");
    }
    const pendingRegen = regenPromise;

    releaseRender();
    await Promise.resolve(pendingRegen);

    expect(sawUnifiedScope).toBe(true);
    expect(collectedTags).toEqual(["outer-tag"]);
  });

  it("does not require ctx — works without it", async () => {
    const renderFn = vi.fn().mockResolvedValue(undefined);
    // No ctx passed — should not throw
    triggerBackgroundRegeneration("regen-no-ctx", renderFn);
    await new Promise((resolve) => setTimeout(resolve, 10));
    expect(renderFn).toHaveBeenCalledOnce();
  });
});

// ─── revalidatePath with type parameter ──────────────────────────────────

describe("revalidatePath type parameter", () => {
  let handler: MemoryCacheHandler;

  function staticRouteSegments(pathname: string): string[] {
    return pathname.split("/").filter(Boolean);
  }

  /** Helper: store a FETCH cache entry with path + implicit hierarchy tags. */
  async function seedEntry(
    path: string,
    body: string,
    routeSegments = staticRouteSegments(path),
  ): Promise<void> {
    const tags = buildPageCacheTags(path, [], routeSegments, "page");
    const value: CachedFetchValue = {
      kind: "FETCH",
      data: { headers: {}, body, url: path },
      tags,
      revalidate: false,
    };
    await handler.set(`entry:${path}`, value, { tags });
  }

  beforeEach(() => {
    handler = new MemoryCacheHandler();
    setCacheHandler(handler);
  });

  it("invalidates the layout path AND all child paths when type is 'layout'", async () => {
    await seedEntry("/dashboard", "dashboard-root");
    await seedEntry("/dashboard/settings", "settings");
    await seedEntry("/dashboard/profile", "profile");
    await seedEntry("/about", "about-page");

    // All four entries should be present before revalidation
    expect(await handler.get("entry:/dashboard")).not.toBeNull();
    expect(await handler.get("entry:/dashboard/settings")).not.toBeNull();
    expect(await handler.get("entry:/dashboard/profile")).not.toBeNull();
    expect(await handler.get("entry:/about")).not.toBeNull();

    await revalidatePath("/dashboard", "layout");

    // All three dashboard entries should be invalidated
    expect(await handler.get("entry:/dashboard")).toBeNull();
    expect(await handler.get("entry:/dashboard/settings")).toBeNull();
    expect(await handler.get("entry:/dashboard/profile")).toBeNull();

    // /about should NOT be invalidated
    expect(await handler.get("entry:/about")).not.toBeNull();
  });

  it("invalidates only the exact path when type is 'page'", async () => {
    await seedEntry("/about", "about-page");
    await seedEntry("/about/team", "about-team");

    await revalidatePath("/about", "page");

    // Only /about should be invalidated
    expect(await handler.get("entry:/about")).toBeNull();
    // /about/team should remain
    expect(await handler.get("entry:/about/team")).not.toBeNull();
  });

  it("invalidates the exact path when no type is specified", async () => {
    await seedEntry("/about", "about-page");
    await seedEntry("/about/team", "about-team");

    await revalidatePath("/about");

    // Only /about should be invalidated
    expect(await handler.get("entry:/about")).toBeNull();
    // /about/team should remain
    expect(await handler.get("entry:/about/team")).not.toBeNull();
  });

  it("uses route pattern tags for typed dynamic route invalidation", async () => {
    await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);

    await revalidatePath("/blog/hello", "layout");
    expect(await handler.get("entry:/blog/hello")).not.toBeNull();

    await revalidatePath("/blog/[slug]", "layout");
    expect(await handler.get("entry:/blog/hello")).toBeNull();

    await seedEntry("/blog/hello", "hello", ["blog", "[slug]"]);

    await revalidatePath("/blog/hello");
    expect(await handler.get("entry:/blog/hello")).toBeNull();
  });

  it("handles deeply nested children under a layout prefix", async () => {
    await seedEntry("/app", "app-root");
    await seedEntry("/app/blog", "blog");
    await seedEntry("/app/blog/2024", "blog-2024");
    await seedEntry("/app/blog/2024/01/post", "blog-post");

    await revalidatePath("/app", "layout");

    // All entries under /app should be invalidated
    expect(await handler.get("entry:/app")).toBeNull();
    expect(await handler.get("entry:/app/blog")).toBeNull();
    expect(await handler.get("entry:/app/blog/2024")).toBeNull();
    expect(await handler.get("entry:/app/blog/2024/01/post")).toBeNull();
  });

  it("does not invalidate paths that merely share a string prefix", async () => {
    // /dashboard-admin starts with "/dashboard" as a string, but it's NOT
    // a child route of /dashboard — it's a sibling. The prefix match must
    // be path-segment-aware (match "/dashboard/" or exact "/dashboard").
    await seedEntry("/dashboard", "dashboard");
    await seedEntry("/dashboard-admin", "dashboard-admin");
    await seedEntry("/dashboard/settings", "settings");

    await revalidatePath("/dashboard", "layout");

    expect(await handler.get("entry:/dashboard")).toBeNull();
    expect(await handler.get("entry:/dashboard/settings")).toBeNull();
    // /dashboard-admin should NOT be invalidated — different route
    expect(await handler.get("entry:/dashboard-admin")).not.toBeNull();
  });

  it("handles root path '/' with layout type — invalidates everything", async () => {
    await seedEntry("/", "home");
    await seedEntry("/about", "about");
    await seedEntry("/dashboard", "dashboard");
    await seedEntry("/dashboard/settings", "settings");

    await revalidatePath("/", "layout");

    // Root layout covers all routes
    expect(await handler.get("entry:/")).toBeNull();
    expect(await handler.get("entry:/about")).toBeNull();
    expect(await handler.get("entry:/dashboard")).toBeNull();
    expect(await handler.get("entry:/dashboard/settings")).toBeNull();
  });

  it("handles root path '/' with page type — invalidates only the root page", async () => {
    await seedEntry("/", "home");
    await seedEntry("/about", "about");

    await revalidatePath("/", "page");

    // Root page should be invalidated
    expect(await handler.get("entry:/")).toBeNull();
    // Other pages should remain — "page" type targets only the exact route
    expect(await handler.get("entry:/about")).not.toBeNull();
  });

  it("trailing slash on layout path is normalized — same as without trailing slash", async () => {
    await seedEntry("/dashboard", "dashboard-root");
    await seedEntry("/dashboard/settings", "settings");
    await seedEntry("/about", "about-page");

    // revalidatePath("/dashboard/", "layout") must behave like ("/dashboard", "layout")
    await revalidatePath("/dashboard/", "layout");

    expect(await handler.get("entry:/dashboard")).toBeNull();
    expect(await handler.get("entry:/dashboard/settings")).toBeNull();
    // /about should NOT be invalidated
    expect(await handler.get("entry:/about")).not.toBeNull();
  });

  it("type 'page' invalidates via /page tag, not the bare path tag", async () => {
    // Seed two synthetic entries to prove the tag paths are distinct:
    // Entry A: only the /page leaf tag — only revalidatePath(path, "page") should hit it
    // Entry B: only the bare _N_T_ path tag — only revalidatePath(path) should hit it
    const pageOnlyValue: CachedFetchValue = {
      kind: "FETCH",
      data: { headers: {}, body: "page-only", url: "/about" },
      tags: ["_N_T_/about/page"],
      revalidate: false,
    };
    const barePathValue: CachedFetchValue = {
      kind: "FETCH",
      data: { headers: {}, body: "bare-path", url: "/about" },
      tags: ["/about", "_N_T_/about"],
      revalidate: false,
    };
    await handler.set("entry:page-only", pageOnlyValue, { tags: ["_N_T_/about/page"] });
    await handler.set("entry:bare-path", barePathValue, { tags: ["/about", "_N_T_/about"] });

    await revalidatePath("/about", "page");

    // "page" type targets the /page leaf tag only
    expect(await handler.get("entry:page-only")).toBeNull();
    // The bare path entry should NOT be touched
    expect(await handler.get("entry:bare-path")).not.toBeNull();
  });

  it("trailing slash on page path is normalized — same as without trailing slash", async () => {
    await seedEntry("/about", "about-page");
    await seedEntry("/about/team", "about-team");

    // revalidatePath("/about/", "page") must be equivalent to ("/about", "page")
    await revalidatePath("/about/", "page");

    expect(await handler.get("entry:/about")).toBeNull();
    // /about/team should remain — only the exact path was invalidated
    expect(await handler.get("entry:/about/team")).not.toBeNull();
  });
});
