import { describe, expect, it } from "vite-plus/test";
import {
  buildAppPageHtmlResponse,
  buildAppPageRscResponse,
  mergeMiddlewareResponseHeaders,
  resolveAppPageHtmlResponsePolicy,
  resolveAppPageRscResponsePolicy,
} from "../packages/vinext/src/server/app-page-response.js";
import {
  VINEXT_RSC_COMPATIBILITY_ID_HEADER,
  VINEXT_RSC_VARY_HEADER,
} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
import { VINEXT_DYNAMIC_STALE_TIME_HEADER } from "../packages/vinext/src/server/headers.js";
import { withEnvVar } from "./env-test-helpers.js";

function createBody(text: string): ReadableStream {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(new TextEncoder().encode(text));
      controller.close();
    },
  });
}

describe("app page response helpers", () => {
  it("resolves RSC response policy for static and ISR responses", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: true,
        isProduction: true,
        revalidateSeconds: null,
      }),
    ).toEqual({
      cacheControl: "s-maxage=31536000, stale-while-revalidate",
      cacheState: "STATIC",
    });

    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        expireSeconds: 300,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "s-maxage=60, stale-while-revalidate=240",
      cacheState: "MISS",
    });
  });

  it("resolves RSC response policy for force-dynamic, infinity, and default cases", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: true,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
    });

    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: Infinity,
      }),
    ).toEqual({
      cacheControl: "s-maxage=31536000, stale-while-revalidate",
      cacheState: "STATIC",
    });

    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: null,
      }),
    ).toEqual({});
  });

  it("resolves RSC response policy as no-store when dynamic usage is detected during build", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: true,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
    });
  });

  it("resolves draft mode response policies as uncacheable", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: true,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
    });

    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: true,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });
  });

  it("resolves HTML response policy precedence", () => {
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: true,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });

    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: false,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "s-maxage=60, stale-while-revalidate",
      cacheState: undefined,
      shouldWriteToCache: false,
    });

    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: Infinity,
      }),
    ).toEqual({
      cacheControl: "s-maxage=31536000, stale-while-revalidate",
      cacheState: "MISS",
      shouldWriteToCache: true,
    });

    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: false,
        revalidateSeconds: Infinity,
      }),
    ).toEqual({
      cacheControl: "s-maxage=31536000, stale-while-revalidate",
      cacheState: "STATIC",
      shouldWriteToCache: false,
    });
  });

  it("resolves HTML response policy when cache writes stay enabled", () => {
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "s-maxage=60, stale-while-revalidate",
      cacheState: "MISS",
      shouldWriteToCache: true,
    });
  });

  it("treats progressive action HTML responses as no-store", () => {
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        isProgressiveActionRender: true,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });
  });

  it("treats revalidate = 0 as no-store in RSC response policy", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 0,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
    });

    // revalidate = 0 takes priority over isForceStatic
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: true,
        isProduction: true,
        revalidateSeconds: 0,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
    });
  });

  it("treats revalidate = 0 as no-store in HTML response policy", () => {
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 0,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });

    // revalidate = 0 takes priority over isForceStatic
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: true,
        isProduction: true,
        revalidateSeconds: 0,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });
  });

  it("treats force-static with explicit revalidate as ISR in both policy helpers", () => {
    expect(
      resolveAppPageRscResponsePolicy({
        dynamicUsedDuringBuild: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: true,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "s-maxage=60, stale-while-revalidate",
      cacheState: "MISS",
    });

    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: false,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: true,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "s-maxage=60, stale-while-revalidate",
      cacheState: "MISS",
      shouldWriteToCache: true,
    });
  });

  it("treats HTML responses with a script nonce as no-store", () => {
    expect(
      resolveAppPageHtmlResponsePolicy({
        dynamicUsedDuringRender: false,
        hasScriptNonce: true,
        isDraftMode: false,
        isDynamicError: false,
        isForceDynamic: false,
        isForceStatic: false,
        isProduction: true,
        revalidateSeconds: 60,
      }),
    ).toEqual({
      cacheControl: "no-store, must-revalidate",
      shouldWriteToCache: false,
    });
  });

  it("builds RSC responses with params, middleware headers, and timing", async () => {
    const middlewareHeaders = new Headers();
    middlewareHeaders.set("cache-control", "private, max-age=5");
    middlewareHeaders.append("set-cookie", "session=abc; Path=/");
    middlewareHeaders.append("vary", "Next-Router-State-Tree");

    const response = buildAppPageRscResponse(createBody("flight"), {
      middlewareContext: {
        headers: middlewareHeaders,
        status: 202,
      },
      params: { slug: "test" },
      policy: {
        cacheControl: "s-maxage=60, stale-while-revalidate",
        cacheState: "MISS",
      },
      timing: {
        compileEnd: 15,
        handlerStart: 10,
        responseKind: "rsc",
      },
    });

    expect(response.status).toBe(202);
    expect(response.headers.get("content-type")).toBe("text/x-component");
    expect(response.headers.get("x-vinext-params")).toBe(encodeURIComponent('{"slug":"test"}'));
    expect(response.headers.get("cache-control")).toBe("private, max-age=5");
    expect(response.headers.get("x-vinext-cache")).toBe("MISS");
    expect(response.headers.get("x-nextjs-cache")).toBe("MISS");
    expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
    expect(response.headers.get("x-vinext-timing")).toBe("10,5,-1");
    await expect(response.text()).resolves.toBe("flight");
  });

  it("emits the `x-edge-runtime: 1` marker on RSC responses when the route opts into the edge runtime (issue #1531)", () => {
    // Next.js sets `x-edge-runtime: 1` on edge-runtime app responses (see
    // edge-ssr-app.ts in the Next.js source). Mirror that only for routes
    // whose resolved segment config is `runtime = "edge"`.
    const response = buildAppPageRscResponse(createBody("flight"), {
      isEdgeRuntime: true,
      middlewareContext: { headers: null, status: null },
      policy: {},
    });

    expect(response.headers.get("x-edge-runtime")).toBe("1");
  });

  it("omits the `x-edge-runtime` marker on RSC responses for nodejs-runtime routes", () => {
    const response = buildAppPageRscResponse(createBody("flight"), {
      middlewareContext: { headers: null, status: null },
      policy: {},
    });

    expect(response.headers.get("x-edge-runtime")).toBeNull();
  });

  it("builds RSC responses with the current compatibility ID header", () => {
    const response = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "compat-a", () =>
      buildAppPageRscResponse(createBody("flight"), {
        middlewareContext: { headers: null, status: null },
        policy: {},
      }),
    );

    expect(response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("compat-a");
  });

  it("emits per-page dynamic stale time metadata on RSC responses", () => {
    // Next.js sends the per-page dynamic stale time in the Flight response `d`
    // field; vinext carries the same response-level contract through an RSC
    // response header that the client cache can snapshot.
    const response = buildAppPageRscResponse(createBody("flight"), {
      dynamicStaleTimeSeconds: 60,
      middlewareContext: { headers: null, status: null },
      policy: {},
    });

    expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("60");
  });

  it("keeps the framework compatibility ID when middleware sets the internal header", () => {
    const middlewareHeaders = new Headers({
      [VINEXT_RSC_COMPATIBILITY_ID_HEADER]: "middleware-compat",
    });

    const response = withEnvVar("__VINEXT_RSC_COMPATIBILITY_ID", "framework-compat", () =>
      buildAppPageRscResponse(createBody("flight"), {
        middlewareContext: { headers: middlewareHeaders, status: null },
        policy: {},
      }),
    );

    expect(response.headers.get(VINEXT_RSC_COMPATIBILITY_ID_HEADER)).toBe("framework-compat");
  });

  it("percent-encodes X-Vinext-Params so non-ASCII characters survive the ByteString header constraint (issue #676)", () => {
    // HTTP headers are ByteStrings: each character value must be <= 255.
    // JSON.stringify preserves non-ASCII characters verbatim (e.g. Korean 완 = U+C644 = 50756),
    // which causes Headers.set() to throw a TypeError in compliant runtimes.
    // The fix: encodeURIComponent the JSON before setting the header.
    const koreanSlug = "useState-완전정복";
    const response = buildAppPageRscResponse(createBody("flight"), {
      middlewareContext: { headers: new Headers(), status: 200 },
      params: { slug: [koreanSlug] },
      policy: {},
      timing: { handlerStart: 0, responseKind: "rsc" },
    });

    const rawHeader = response.headers.get("x-vinext-params")!;
    // Header value must be ASCII-safe (all byte values <= 127 after encoding)
    expect(Array.from(rawHeader).every((c) => c.charCodeAt(0) <= 127)).toBe(true);
    // Decoding must round-trip back to the original params
    expect(JSON.parse(decodeURIComponent(rawHeader))).toEqual({ slug: [koreanSlug] });
  });

  it("builds HTML responses with middleware override/append header semantics", async () => {
    const middlewareHeaders = new Headers();
    middlewareHeaders.set("cache-control", "private, max-age=5");
    middlewareHeaders.append("set-cookie", "mw=1; Path=/");
    middlewareHeaders.set("vary", "Next-Router-State-Tree");
    middlewareHeaders.append("x-extra", "present");
    middlewareHeaders.set("cache-control", "private, max-age=5");

    const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
      draftCookie: "__prerender_bypass=token; Path=/",
      linkHeader: "</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
      middlewareContext: {
        headers: middlewareHeaders,
        status: 203,
      },
      policy: {
        cacheControl: "s-maxage=31536000, stale-while-revalidate",
        cacheState: "STATIC",
      },
      timing: {
        compileEnd: 12,
        handlerStart: 10,
        renderEnd: 20,
        responseKind: "html",
      },
    });

    expect(response.status).toBe(203);
    expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
    expect(response.headers.get("cache-control")).toBe("private, max-age=5");
    expect(response.headers.get("x-vinext-cache")).toBe("STATIC");
    expect(response.headers.get("x-nextjs-cache")).toBe("HIT");
    expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
    expect(response.headers.get("link")).toBe(
      "</font.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
    );
    expect(response.headers.get("x-extra")).toBe("present");
    expect(response.headers.get("x-vinext-timing")).toBe("10,2,8");

    const setCookies = response.headers.getSetCookie();
    expect(setCookies).toContain("__prerender_bypass=token; Path=/");
    expect(setCookies).toContain("mw=1; Path=/");
    await expect(response.text()).resolves.toBe("<h1>page</h1>");
  });

  it("emits the `x-edge-runtime: 1` marker on HTML responses when the route opts into the edge runtime (issue #1531)", () => {
    // Next.js sets `x-edge-runtime: 1` on edge-runtime app responses (see
    // edge-ssr-app.ts in the Next.js source). Mirror that only for routes
    // whose resolved segment config is `runtime = "edge"`.
    const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
      isEdgeRuntime: true,
      middlewareContext: { headers: null, status: null },
      policy: {},
    });

    expect(response.headers.get("x-edge-runtime")).toBe("1");
  });

  it("omits the `x-edge-runtime` marker on HTML responses for nodejs-runtime routes", () => {
    const response = buildAppPageHtmlResponse(createBody("<h1>page</h1>"), {
      middlewareContext: { headers: null, status: null },
      policy: {},
    });

    expect(response.headers.get("x-edge-runtime")).toBeNull();
  });
});

describe("mergeMiddlewareResponseHeaders", () => {
  it("is a no-op when middleware headers are null", () => {
    const target = new Headers({ "Content-Type": "text/plain" });
    mergeMiddlewareResponseHeaders(target, null);
    expect(target.get("Content-Type")).toBe("text/plain");
    expect([...target].length).toBe(1);
  });

  it("sets singular headers via set(), overriding existing values", () => {
    const target = new Headers({ "Cache-Control": "no-store", "X-Custom": "original" });
    const mwHeaders = new Headers();
    mwHeaders.set("Cache-Control", "private, max-age=5");
    mwHeaders.set("X-Custom", "from-middleware");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Cache-Control")).toBe("private, max-age=5");
    expect(target.get("X-Custom")).toBe("from-middleware");
  });

  it("appends Set-Cookie headers instead of overriding", () => {
    const target = new Headers();
    target.append("Set-Cookie", "existing=1; Path=/");
    const mwHeaders = new Headers();
    mwHeaders.append("Set-Cookie", "mw-session=abc; Path=/");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    const cookies = target.getSetCookie();
    expect(cookies).toContain("existing=1; Path=/");
    expect(cookies).toContain("mw-session=abc; Path=/");
  });

  it("appends Vary headers instead of overriding", () => {
    const target = new Headers({ Vary: "RSC, Accept" });
    const mwHeaders = new Headers();
    mwHeaders.set("Vary", "Next-Router-State-Tree");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Vary")).toBe("RSC, Accept, Next-Router-State-Tree");
  });

  it("deduplicates Vary values when appending middleware headers", () => {
    const target = new Headers({ Vary: VINEXT_RSC_VARY_HEADER });
    const mwHeaders = new Headers();
    mwHeaders.set("Vary", "next-router-state-tree, X-Auth-State");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, X-Auth-State`);
  });

  it("preserves wildcard Vary semantics when appending middleware headers", () => {
    const target = new Headers({ Vary: "RSC, Accept" });
    const mwHeaders = new Headers();
    mwHeaders.set("Vary", "*, X-Auth-State");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Vary")).toBe("*");
  });

  it("preserves wildcard Vary semantics when target already has Vary wildcard", () => {
    const target = new Headers({ Vary: "*" });
    const mwHeaders = new Headers();
    mwHeaders.set("Vary", "X-Auth-State");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Vary")).toBe("*");
  });

  it("preserves wildcard Vary semantics when middleware is the first Vary source", () => {
    const target = new Headers();
    const mwHeaders = new Headers();
    mwHeaders.set("Vary", "*, X-Auth-State");

    mergeMiddlewareResponseHeaders(target, mwHeaders);

    expect(target.get("Vary")).toBe("*");
  });
});
