import { describe, expect, it } from "vitest";
import {
  DEFAULT_CEILINGS, DETERMINISTIC_ZIP, PolicyError, assertCompleteLocalReferences,
  assertSafeCss, assertSafeHtml, assertSafeSvg, canonicalPath, enforceCeilings,
  validateMime, validateScript, validateUrl, validateZipEntries,
} from "../src/index.js";

const fails = (fn: () => unknown, code: string) => {
  expect(fn).toThrow(PolicyError);
  try { fn(); } catch (error) { expect((error as PolicyError).code).toBe(code); }
};

describe("canonical paths", () => {
  it.each(["../index.html", "assets/../index.html", "/index.html", "C:/index.html", "assets\\x.png", "assets//x.png", "assets/%2e%2e/x.png", "index.html/", "e\u0301.html"])("rejects dangerous or noncanonical %s", (path) => expect(() => canonicalPath(path)).toThrow(PolicyError));
  it("accepts a canonical relative path", () => expect(canonicalPath("assets/images/page-1.png")).toBe("assets/images/page-1.png"));
});

describe("type and MIME policy", () => {
  it("accepts matching PNG content", () => expect(validateMime("assets/images/x.png", "image/png", Uint8Array.from([137,80,78,71,13,10,26,10]))).toBe("image/png"));
  it("rejects extension/declared MIME mismatch", () => fails(() => validateMime("assets/images/x.png", "image/jpeg", Uint8Array.from([137,80,78,71,13,10,26,10])), "MIME_MISMATCH"));
  it("rejects declared MIME/magic mismatch", () => fails(() => validateMime("assets/images/x.jpg", "image/jpeg", new TextEncoder().encode("not jpeg")), "MIME_MISMATCH"));
  it("rejects executable and misplaced types", () => fails(() => validateMime("assets/images/x.js", "text/javascript", new Uint8Array()), "TYPE_NOT_ALLOWED"));
});

describe("URL and active-content policy", () => {
  it.each(["javascript:alert(1)", "data:text/html,x", "blob:https://x/y", "//evil.example/x", "../secret", "%2e%2e/secret"])("rejects dangerous URL %s", (url) => expect(() => validateUrl(url, "navigation")).toThrow(PolicyError));
  it("allows HTTPS navigation but never remote assets", () => { expect(validateUrl("https://example.com/a", "navigation")).toBe("https://example.com/a"); expect(() => validateUrl("https://example.com/a", "asset")).toThrow(PolicyError); });
  it.each(["<script src=x></script>", "<img src=x onerror=alert(1)>", "<meta http-equiv=refresh content='0;url=x'>", "<canvas></canvas>"])("rejects active HTML", (html) => fails(() => assertSafeHtml(html), "ACTIVE_CONTENT"));
  it.each(["@import 'x.css'", "a{background:url(https://evil/x)}", "a{x:expression(alert(1))}"])("rejects active CSS", (css) => fails(() => assertSafeCss(css), "ACTIVE_CONTENT"));
  it.each(["<svg><script/></svg>", "<svg><foreignObject/></svg>", "<svg><image href='https://evil/x'/></svg>"])("rejects active SVG", (svg) => fails(() => assertSafeSvg(svg), "ACTIVE_CONTENT"));
  it("requires an exact approved script hash", async () => { const bytes = new TextEncoder().encode("export {};"); const { createHash } = await import("node:crypto"); const digest = createHash("sha256").update(bytes).digest("hex"); expect(() => validateScript("assets/runtime/main.js", bytes, [{ path: "assets/runtime/main.js", sha256: digest }])).not.toThrow(); fails(() => validateScript("assets/runtime/main.js", bytes, []), "ACTIVE_CONTENT"); });
  it("requires every local asset", () => fails(() => assertCompleteLocalReferences(["assets/images/missing.png"], new Set()), "MISSING_ASSET"));
});

describe("ceilings and deterministic ZIP", () => {
  const measurements = { fileCount: 3, totalBytes: 300, zipBytes: 200, largestFileBytes: 100, htmlBytes: 100, cssBytes: 100, domNodes: 10 };
  it("accepts bounded measurements", () => expect(() => enforceCeilings(measurements)).not.toThrow());
  it.each(Object.keys(measurements) as Array<keyof typeof measurements>)("rejects exceeded %s", (key) => { const map = { fileCount: "maxFiles", totalBytes: "maxTotalBytes", zipBytes: "maxZipBytes", largestFileBytes: "maxFileBytes", htmlBytes: "maxHtmlBytes", cssBytes: "maxCssBytes", domNodes: "maxDomNodes" } as const; fails(() => enforceCeilings({ ...measurements, [key]: DEFAULT_CEILINGS[map[key]] + 1 }), "CEILING_EXCEEDED"); });
  it("rejects missing/invalid measurement instead of treating it as zero", () => fails(() => enforceCeilings({ ...measurements, domNodes: Number.NaN }), "MEASUREMENT_MISSING"));

  const entry = (path: string, overrides = {}) => ({ path, kind: "file" as const, uncompressedBytes: 10, compressedBytes: 10, mode: DETERMINISTIC_ZIP.mode, mtime: DETERMINISTIC_ZIP.mtime, ...overrides });
  const valid = [entry("document.css"), entry("index.html"), entry("metadata.json")];
  it("accepts the canonical deterministic ZIP manifest", () => expect(() => validateZipEntries(valid)).not.toThrow());
  it.each([
    ["traversal", [entry("../index.html"), ...valid], "PATH_TRAVERSAL"],
    ["symlink", [entry("document.css"), entry("index.html", { kind: "symlink" }), entry("metadata.json")], "ZIP_ENTRY_TYPE"],
    ["duplicate", [entry("document.css"), entry("index.html"), entry("index.html"), entry("metadata.json")], "ZIP_DUPLICATE"],
    ["unsorted", [entry("index.html"), entry("document.css"), entry("metadata.json")], "ZIP_ORDER"],
    ["timestamp", [entry("document.css", { mtime: "2026-01-01" }), entry("index.html"), entry("metadata.json")], "ZIP_NONDETERMINISTIC"],
    ["ratio", [entry("document.css", { uncompressedBytes: 101, compressedBytes: 1 }), entry("index.html"), entry("metadata.json")], "CEILING_EXCEEDED"],
  ] as const)("rejects %s ZIP", (_name, entries, code) => fails(() => validateZipEntries(entries), code));
});
