/**
 * Workspace HTML preview on the separate preview origin: token signing/verification,
 * the mint endpoint's origin derivation, and the preview route.
 *
 * The load-bearing case is "same token, App origin's Host": the preview route answers on
 * the same process as the App, so if it served Agent-written HTML there, it would be a
 * same-origin XSS with the session cookie attached.
 */
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
  createPreviewTokenSigner,
  hostOnly,
  loopbackCounterpart,
  loopbackHostRoles,
  resolvePreviewTarget,
} from "../src/services/preview-token.js";
import type { ProjectCreateResponse, SessionCreateResponse } from "../src/api/types.js";
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
import type { TestApp } from "./helpers.js";

describe("preview token signer", () => {
  const signer = createPreviewTokenSigner();
  const payload = { sessionId: "s-1", host: "127.0.0.1", expiresAt: Date.now() + 60_000 };

  it("round-trips a valid token", () => {
    expect(signer.verify(signer.sign(payload))).toEqual(payload);
  });

  it("rejects a tampered payload, a foreign signature and malformed input", () => {
    const token = signer.sign(payload);
    const [body, sig] = token.split(".");
    // Re-encode a payload pointing at another Session, keeping the original signature.
    const forged = Buffer.from(JSON.stringify({ ...payload, sessionId: "s-2" }), "utf8").toString(
      "base64url",
    );
    expect(signer.verify(`${forged}.${sig}`)).toBeNull();
    // A different secret must not validate.
    expect(createPreviewTokenSigner().verify(token)).toBeNull();
    for (const bad of ["", ".", "abc", `${body}.`, `.${sig}`, `${body}.zzzz`]) {
      expect(signer.verify(bad)).toBeNull();
    }
  });

  it("rejects an expired token", () => {
    expect(signer.verify(signer.sign({ ...payload, expiresAt: Date.now() - 1 }))).toBeNull();
  });
});

describe("preview origin derivation", () => {
  it("strips the port, keeping IPv6 brackets", () => {
    expect(hostOnly("127.0.0.1:7364")).toBe("127.0.0.1");
    expect(hostOnly("localhost")).toBe("localhost");
    expect(hostOnly("[::1]:7364")).toBe("[::1]");
  });

  it("maps loopback names to their counterpart and nothing else", () => {
    expect(loopbackCounterpart("127.0.0.1")).toBe("localhost");
    expect(loopbackCounterpart("localhost")).toBe("127.0.0.1");
    expect(loopbackCounterpart("[::1]")).toBe("127.0.0.1");
    // A LAN IP or a real domain has no safe counterpart — those need explicit config.
    expect(loopbackCounterpart("192.168.1.5")).toBeNull();
    expect(loopbackCounterpart("penguin.example.com")).toBeNull();
  });

  it("assigns fixed App/preview roles only for loopback binds", () => {
    // localhost is the canonical App host; 127.0.0.1 is reserved for previews. Fixed, not
    // "counterpart of whoever asked", so the preview host is never a host the App logs in on.
    expect(loopbackHostRoles("127.0.0.1")).toEqual({ app: "localhost", preview: "127.0.0.1" });
    expect(loopbackHostRoles("localhost")).toEqual({ app: "localhost", preview: "127.0.0.1" });
    // Wildcard / LAN / bare ::1 binds get no loopback roles — they must configure an origin.
    expect(loopbackHostRoles("0.0.0.0")).toBeNull();
    expect(loopbackHostRoles("::")).toBeNull();
    expect(loopbackHostRoles("192.168.1.5")).toBeNull();
  });

  const bind = { host: "127.0.0.1", port: 7364 };

  it("derives the counterpart origin on the server's own port", () => {
    expect(resolvePreviewTarget("http://127.0.0.1:7364/x", "127.0.0.1:7364", null, bind)).toEqual({
      origin: "http://localhost:7364",
      host: "localhost",
    });
    expect(
      resolvePreviewTarget("http://localhost:80/x", "localhost", null, { ...bind, port: 80 }),
    ).toEqual({ origin: "http://127.0.0.1", host: "127.0.0.1" });
  });

  it("uses the server port, not the browser's — dev serves the SPA on a different port", () => {
    // `pnpm dev`: the SPA is on Vite:7365 and only /api is proxied, so a preview URL on
    // :7365 would hit a port that does not serve /preview at all.
    expect(resolvePreviewTarget("http://localhost:7365/x", "localhost:7365", null, bind)).toEqual({
      origin: "http://127.0.0.1:7364",
      host: "127.0.0.1",
    });
  });

  it("gives up while the bind port is still 0, rather than emitting an unloadable :0 URL", () => {
    // PORT=0 (the desktop shell) before index.ts writes the actual port back: a `:0` URL
    // is rejected by Chromium as ERR_UNSAFE_PORT, so previewIsolated reports false and the
    // UI falls back to the sandboxed same-origin preview.
    expect(
      resolvePreviewTarget("http://localhost:0/x", "localhost:0", null, { ...bind, port: 0 }),
    ).toBeNull();
  });

  it("gives up when the loopback counterpart is not reachable from the bind address", () => {
    expect(
      resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, {
        host: "192.168.1.5",
        port: 7364,
      }),
    ).toBeNull();
    // A wildcard bind (0.0.0.0 / ::) no longer qualifies: localhost may resolve to ::1,
    // which 0.0.0.0 never serves, so the preview URL would refuse the connection while
    // /api/me claimed isolation. Only a loopback-name bind offers a loopback preview; the
    // rest fall back and must set PENGUIN_PREVIEW_ORIGIN.
    expect(
      resolvePreviewTarget("http://localhost:7364/x", "localhost:7364", null, {
        host: "0.0.0.0",
        port: 7364,
      }),
    ).toBeNull();
  });

  it("prefers a configured origin, and gives up on hosts with no counterpart", () => {
    expect(
      resolvePreviewTarget(
        "https://app.example.com/x",
        "app.example.com",
        "https://p.example.com",
        bind,
      ),
    ).toEqual({ origin: "https://p.example.com", host: "p.example.com" });
    expect(
      resolvePreviewTarget("http://192.168.1.5:7364/x", "192.168.1.5:7364", null, bind),
    ).toBeNull();
  });

  it("refuses a configured origin that matches the App request's host", () => {
    // PENGUIN_PREVIEW_ORIGIN pointing back at the host the App is being used on is no
    // boundary: "isolated" previews would be same-origin with the App (and the UI would
    // mount an allow-same-origin iframe onto them). Must degrade to null so
    // previewIsolated reports false and the safe same-origin sandbox engages.
    expect(
      resolvePreviewTarget(
        "https://app.example.com/x",
        "app.example.com",
        "https://app.example.com",
        bind,
      ),
    ).toBeNull();
    // Port and case differences don't rescue it: cookies ignore ports, hosts ignore case.
    expect(
      resolvePreviewTarget(
        "https://app.example.com/x",
        "app.example.com:8443",
        "https://APP.example.com:9443",
        bind,
      ),
    ).toBeNull();
    // A genuinely different host on the same registrable domain is still accepted.
    expect(
      resolvePreviewTarget(
        "https://app.example.com/x",
        "app.example.com",
        "https://preview.app.example.com",
        bind,
      ),
    ).toEqual({ origin: "https://preview.app.example.com", host: "preview.app.example.com" });
  });
});

describe("preview route", () => {
  let t: TestApp;
  let owner: ReturnType<typeof apiClient>;
  let ownerCookie: string;
  let sessionId: string;
  let workspace: string;

  beforeEach(async () => {
    t = await createTestApp();
    const a = await provisionUser(t.app, "owner");
    owner = apiClient(t.app, a.cookie);
    ownerCookie = a.cookie;
    const created = (await (
      await owner.post("/api/projects", { projectId: "owner-preview", name: "project" })
    ).json()) as ProjectCreateResponse;
    const projectId = created.project.projectId;
    await owner.put(`/api/projects/${projectId}/models`, {
      defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" },
      models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6", contextWindow: 128000 }],
    });
    const sess = (await (
      await owner.post(`/api/projects/${projectId}/agents/default_agent/sessions`, {})
    ).json()) as SessionCreateResponse;
    sessionId = sess.session.sessionId;
    workspace = sess.session.workspace;
    await fs.mkdir(path.join(workspace, "assets"));
    await fs.writeFile(path.join(workspace, "index.html"), "<!doctype html><script src=app.js>");
    await fs.writeFile(path.join(workspace, "assets", "app.js"), "console.log(1)");
  });
  afterEach(async () => {
    await t.cleanup();
  });

  /**
   * Follow the "open in a new tab" link the way a browser would; app.request() addresses
   * the App on localhost, so the counterpart origin is 127.0.0.1.
   */
  const mint = async (rel: string): Promise<{ url: string | null; status: number }> => {
    const res = await owner.get(
      `/api/sessions/${sessionId}/files/preview-redirect?path=${encodeURIComponent(rel)}`,
    );
    return { url: res.headers.get("location"), status: res.status };
  };

  it("redirects to the loopback counterpart of the App host", async () => {
    const { url, status } = await mint("index.html");
    expect(status).toBe(302);
    expect(url).toMatch(/^http:\/\/127\.0\.0\.1:7364\/preview\/[^/]+\/index\.html$/);
  });

  it("serves the file with a real content type, no sandbox, and no referrer", async () => {
    const { url } = await mint("index.html");
    const res = await t.app.request(url!);
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toContain("text/html");
    // The origin is the boundary now, so the sandbox that broke storage is gone.
    expect(res.headers.get("content-security-policy")).toBeNull();
    // Without this the token-bearing URL leaks to every third-party the page embeds.
    expect(res.headers.get("referrer-policy")).toBe("no-referrer");
    expect(res.headers.get("x-content-type-options")).toBe("nosniff");
  });

  it("resolves relative subresources under the same token", async () => {
    const { url } = await mint("index.html");
    const token = url!.split("/preview/")[1]!.split("/")[0]!;
    const res = await t.app.request(`http://127.0.0.1/preview/${token}/assets/app.js`);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("console.log(1)");
  });

  it("refuses to serve on the App origin's host", async () => {
    const { url } = await mint("index.html");
    const token = url!.split("/preview/")[1]!.split("/")[0]!;
    // Same token, same path, but Host is the App origin: serving here would run
    // Agent-written HTML same-origin with the session cookie.
    const res = await t.app.request(`http://localhost/preview/${token}/index.html`);
    expect(res.status).toBe(404);
  });

  it("rejects a garbage token and keeps path confinement", async () => {
    expect((await t.app.request("http://127.0.0.1/preview/nope/index.html")).status).toBe(404);
    const { url } = await mint("index.html");
    const token = url!.split("/preview/")[1]!.split("/")[0]!;
    // Traversal with encoded slashes: WHATWG URL parsing collapses `../` — and even
    // `%2e%2e/` — before the handler sees it, so encode only the slashes. The `..` segments
    // then survive as one path segment, and decodeURIComponent -> WorkspaceFilesService is
    // what rejects them. (A plain `../../etc/passwd` would instead normalize to `/etc/passwd`
    // and never match the /preview route at all.)
    const escape = await t.app.request(`http://127.0.0.1/preview/${token}/..%2f..%2fetc%2fpasswd`);
    expect(escape.status).toBe(404);
  });

  it("serves only /preview on the preview host: /api is 401 even authenticated", async () => {
    // The preview host (127.0.0.1) and the App (localhost) are one process. If /api answered
    // here, Agent HTML previewed on 127.0.0.1 could drive it same-origin — so it is refused
    // even with a valid cookie, and no cookie is ever set here either.
    const api = await t.app.request(`http://127.0.0.1/api/sessions/${sessionId}/files?path=`, {
      headers: { cookie: ownerCookie },
    });
    expect(api.status).toBe(401);
    // A top-level App navigation on the preview host is redirected to the canonical host, so
    // login (and every cookie) only ever happens on localhost.
    const doc = await t.app.request("http://127.0.0.1/some/spa/route");
    expect(doc.status).toBe(302);
    expect(doc.headers.get("location")).toBe("http://localhost/some/spa/route");
  });

  it("requires authentication to mint, and validates the path", async () => {
    const anon = await t.app.request(
      `/api/sessions/${sessionId}/files/preview-redirect?path=index.html`,
    );
    expect(anon.status).toBe(401);
    const { status } = await mint("nope.html");
    expect(status).toBe(404);
  });

  it("reports isolation on /api/me so the UI can warn before opening", async () => {
    const me = (await (await owner.get("/api/me")).json()) as { previewIsolated: boolean };
    expect(me.previewIsolated).toBe(true);
  });

  it("leaves the loopback guard off when PENGUIN_PREVIEW_ORIGIN is set", async () => {
    // With a configured preview origin, previews no longer use 127.0.0.1, so it is an
    // ordinary App host and must not be redirected/refused (deployments enforce the
    // equivalent at their reverse proxy). A guarded app would 302 this to localhost.
    const configured = await createTestApp({ config: { previewOrigin: "https://p.example.com" } });
    try {
      const doc = await configured.app.request("http://127.0.0.1/some/spa/route");
      expect(doc.status).not.toBe(302);
    } finally {
      await configured.cleanup();
    }
  });
});
