import { createHash } from "node:crypto";
import { posix } from "node:path";

export const DEFAULT_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'none'; media-src 'none'; frame-src 'none'; worker-src 'none'; manifest-src 'none'";

export const DEFAULT_CEILINGS = Object.freeze({
  maxFiles: 2_000,
  maxFileBytes: 64 * 1024 * 1024,
  maxTotalBytes: 256 * 1024 * 1024,
  maxZipBytes: 128 * 1024 * 1024,
  maxPathBytes: 240,
  maxCompressionRatio: 100,
  maxDomNodes: 2_000_000,
  maxHtmlBytes: 16 * 1024 * 1024,
  maxCssBytes: 32 * 1024 * 1024,
});
export type Ceilings = typeof DEFAULT_CEILINGS;

export class PolicyError extends Error {
  constructor(public readonly code: string, message: string) { super(message); this.name = "PolicyError"; }
}
const reject = (code: string, message: string): never => { throw new PolicyError(code, message); };

const CONTROL = /[ -]/u;
const ENCODED_DANGER = /%(?:2e|2f|5c|00)/iu;
export function canonicalPath(input: string, maxBytes = DEFAULT_CEILINGS.maxPathBytes): string {
  if (!input || input !== input.normalize("NFC")) reject("PATH_NOT_CANONICAL", "path must be non-empty NFC Unicode");
  if (CONTROL.test(input) || input.includes("\\") || ENCODED_DANGER.test(input)) reject("PATH_UNSAFE", "path contains a forbidden character or encoding");
  if (input.startsWith("/") || /^[A-Za-z]:/u.test(input) || input.endsWith("/")) reject("PATH_ABSOLUTE", "path must name a relative file");
  const parts = input.split("/");
  if (parts.some((part) => !part || part === "." || part === "..")) reject("PATH_TRAVERSAL", "path has an empty or traversal segment");
  if (posix.normalize(input) !== input) reject("PATH_NOT_CANONICAL", "path is not canonical");
  if (Buffer.byteLength(input, "utf8") > maxBytes) reject("PATH_TOO_LONG", "path exceeds the UTF-8 byte ceiling");
  return input;
}

const TYPES = Object.freeze({
  ".html": "text/html", ".css": "text/css", ".json": "application/json",
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
  ".webp": "image/webp", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".js": "text/javascript",
} as const);
export type AllowedMime = typeof TYPES[keyof typeof TYPES];

export function expectedMime(path: string): AllowedMime {
  canonicalPath(path);
  const ext = posix.extname(path).toLowerCase() as keyof typeof TYPES;
  const mime = TYPES[ext];
  if (!mime) reject("TYPE_NOT_ALLOWED", `file extension is not allowlisted: ${ext || "(none)"}`);
  const top = path === "index.html" || path === "document.css" || path === "metadata.json";
  const asset = /^assets\/(?:fonts|images|vectors|runtime)\/[^/]+(?:\/[^/]+)*$/u.test(path);
  if (!top && !asset) reject("PATH_NOT_ALLOWED", "file is outside the generated-package layout");
  if (path.startsWith("assets/fonts/") && ext !== ".woff2") reject("TYPE_NOT_ALLOWED", "fonts must be WOFF2");
  if (path.startsWith("assets/images/") && ![".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(ext)) reject("TYPE_NOT_ALLOWED", "image type is not allowlisted");
  if (path.startsWith("assets/vectors/") && ext !== ".svg") reject("TYPE_NOT_ALLOWED", "vectors must be SVG");
  if (path.startsWith("assets/runtime/") && ext !== ".js") reject("TYPE_NOT_ALLOWED", "runtime assets must be JavaScript");
  return mime;
}

function isUtf8(bytes: Uint8Array): boolean { try { new TextDecoder("utf-8", { fatal: true }).decode(bytes); return true; } catch { return false; } }
function has(bytes: Uint8Array, signature: number[]): boolean { return signature.every((v, i) => bytes[i] === v); }
export function validateMime(path: string, declared: string, bytes: Uint8Array): AllowedMime {
  const mime = expectedMime(path);
  if (declared.toLowerCase().split(";", 1)[0] !== mime) reject("MIME_MISMATCH", `expected ${mime}, received ${declared}`);
  const textual = mime.startsWith("text/") || mime === "application/json" || mime === "image/svg+xml";
  if (textual && !isUtf8(bytes)) reject("MIME_MISMATCH", "text file is not valid UTF-8");
  const valid = mime === "image/png" ? has(bytes, [137,80,78,71,13,10,26,10])
    : mime === "image/jpeg" ? has(bytes, [255,216,255])
    : mime === "image/gif" ? new TextDecoder().decode(bytes.slice(0,6)) === "GIF87a" || new TextDecoder().decode(bytes.slice(0,6)) === "GIF89a"
    : mime === "image/webp" ? new TextDecoder().decode(bytes.slice(0,4)) === "RIFF" && new TextDecoder().decode(bytes.slice(8,12)) === "WEBP"
    : mime === "font/woff2" ? new TextDecoder().decode(bytes.slice(0,4)) === "wOF2" : true;
  if (!valid) reject("MIME_MISMATCH", `bytes do not match ${mime}`);
  return mime;
}

export type UrlUse = "navigation" | "asset";
export function validateUrl(raw: string, use: UrlUse): string {
  const value = raw.trim();
  if (!value || value !== raw || CONTROL.test(value) || ENCODED_DANGER.test(value)) reject("URL_UNSAFE", "URL is empty, disguised, or contains controls");
  if (value.startsWith("//") || /^[^/?#]+:/u.test(value)) {
    let url: URL; try { url = new URL(value); } catch { return reject("URL_UNSAFE", "URL cannot be parsed"); }
    if (use !== "navigation" || !["https:", "mailto:", "tel:"].includes(url.protocol) || url.username || url.password) reject("URL_SCHEME", `scheme is forbidden for ${use}`);
    return value;
  }
  if (value.startsWith("#")) { if (use !== "navigation") reject("URL_UNSAFE", "asset cannot be a fragment"); return value; }
  const suffix = value.search(/[?#]/u);
  const withoutSuffix = suffix < 0 ? value : value.slice(0, suffix);
  if (!withoutSuffix) reject("URL_UNSAFE", "URL has no local path");
  canonicalPath(withoutSuffix);
  return value;
}

export interface ApprovedScript { path: string; sha256: string; }
export function validateScript(path: string, bytes: Uint8Array, approved: readonly ApprovedScript[]): void {
  if (!path.startsWith("assets/runtime/") || expectedMime(path) !== "text/javascript") reject("ACTIVE_CONTENT", "script is outside approved runtime assets");
  const digest = createHash("sha256").update(bytes).digest("hex");
  if (!approved.some((item) => canonicalPath(item.path) === path && item.sha256 === digest)) reject("ACTIVE_CONTENT", "script is not pinned by path and SHA-256");
}

export function assertSafeHtml(html: string): void {
  const forbidden = /<\s*(?:script|iframe|object|embed|applet|base|form|input|button|textarea|select|video|audio|canvas)\b|\son[a-z]+\s*=|<meta\b[^>]*http-equiv\s*=\s*["']?refresh|(?:javascript|vbscript|data|blob)\s*:/iu;
  if (forbidden.test(html)) reject("ACTIVE_CONTENT", "HTML contains forbidden active content");
}
export function assertSafeCss(css: string): void {
  if (/@import\b|expression\s*\(|-moz-binding|behavior\s*:|url\s*\(\s*["']?\s*(?:https?:|\/\/|data:|blob:|javascript:)/iu.test(css)) reject("ACTIVE_CONTENT", "CSS contains an import, remote URL, or executable behavior");
}
export function assertSafeSvg(svg: string): void {
  if (/<\s*(?:script|foreignObject|iframe|object|embed|animate|set)\b|\son[a-z]+\s*=|(?:href|src)\s*=\s*["']\s*(?:https?:|\/\/|data:|blob:|javascript:)/iu.test(svg)) reject("ACTIVE_CONTENT", "SVG contains active or external content");
}

export interface PackageMeasurements { fileCount: number; totalBytes: number; zipBytes: number; largestFileBytes: number; htmlBytes: number; cssBytes: number; domNodes: number; }
export function enforceCeilings(m: PackageMeasurements, c: Ceilings = DEFAULT_CEILINGS): void {
  for (const [key, value] of Object.entries(m)) if (!Number.isSafeInteger(value) || value < 0) reject("MEASUREMENT_MISSING", `${key} must be a non-negative safe integer`);
  const checks: Array<[number, number, string]> = [[m.fileCount,c.maxFiles,"file count"],[m.totalBytes,c.maxTotalBytes,"total bytes"],[m.zipBytes,c.maxZipBytes,"ZIP bytes"],[m.largestFileBytes,c.maxFileBytes,"individual file bytes"],[m.htmlBytes,c.maxHtmlBytes,"HTML bytes"],[m.cssBytes,c.maxCssBytes,"CSS bytes"],[m.domNodes,c.maxDomNodes,"DOM nodes"]];
  for (const [actual, limit, name] of checks) if (actual > limit) reject("CEILING_EXCEEDED", `${name} ${actual} exceeds ${limit}`);
  if (m.zipBytes === 0 ? m.totalBytes > 0 : m.totalBytes / m.zipBytes > c.maxCompressionRatio) reject("CEILING_EXCEEDED", "ZIP compression ratio exceeds ceiling");
}

export type ZipEntryKind = "file" | "directory" | "symlink" | "hardlink" | "device" | "fifo" | "socket";
export interface ZipEntry { path: string; kind: ZipEntryKind; uncompressedBytes: number; compressedBytes: number; mode: number; mtime: string; encrypted?: boolean; zip64?: boolean; extraFields?: boolean; dataDescriptor?: boolean; comment?: string; }
export const DETERMINISTIC_ZIP = Object.freeze({ creator: "unix", mode: 0o100644, mtime: "1980-01-01T00:00:00.000Z", compression: "deflate-9" });
export function validateZipEntries(entries: readonly ZipEntry[], ceilings: Ceilings = DEFAULT_CEILINGS): void {
  const seen = new Set<string>(); let previous: string | undefined; let total = 0; let packed = 0;
  for (const entry of entries) {
    const path = canonicalPath(entry.path, ceilings.maxPathBytes); expectedMime(path);
    if (seen.has(path)) reject("ZIP_DUPLICATE", `duplicate ZIP entry: ${path}`);
    if (previous !== undefined && Buffer.compare(Buffer.from(previous), Buffer.from(path)) >= 0) reject("ZIP_ORDER", "ZIP entries must be sorted by UTF-8 path bytes");
    seen.add(path); previous = path;
    if (entry.kind !== "file") reject("ZIP_ENTRY_TYPE", `non-regular ZIP entry: ${entry.kind}`);
    if (entry.mode !== DETERMINISTIC_ZIP.mode || entry.mtime !== DETERMINISTIC_ZIP.mtime) reject("ZIP_NONDETERMINISTIC", "ZIP metadata is not canonical");
    if (entry.encrypted || entry.zip64 || entry.extraFields || entry.dataDescriptor || entry.comment) reject("ZIP_STRUCTURE", "ZIP optional structures are forbidden");
    if (!Number.isSafeInteger(entry.uncompressedBytes) || !Number.isSafeInteger(entry.compressedBytes) || entry.uncompressedBytes < 0 || entry.compressedBytes < 0) reject("ZIP_STRUCTURE", "ZIP sizes are invalid");
    if (entry.uncompressedBytes > ceilings.maxFileBytes || (entry.compressedBytes === 0 ? entry.uncompressedBytes > 0 : entry.uncompressedBytes / entry.compressedBytes > ceilings.maxCompressionRatio)) reject("CEILING_EXCEEDED", "ZIP entry exceeds size or ratio ceiling");
    total += entry.uncompressedBytes; packed += entry.compressedBytes;
  }
  if (entries.length > ceilings.maxFiles || total > ceilings.maxTotalBytes || packed > ceilings.maxZipBytes) reject("CEILING_EXCEEDED", "ZIP package exceeds a package ceiling");
  for (const required of ["index.html", "document.css", "metadata.json"]) if (!seen.has(required)) reject("PACKAGE_REQUIRED_FILE", `missing ${required}`);
}

export function assertCompleteLocalReferences(references: readonly string[], files: ReadonlySet<string>): void {
  for (const reference of references) {
    validateUrl(reference, "asset");
    const suffix = reference.search(/[?#]/u);
    const path = suffix < 0 ? reference : reference.slice(0, suffix);
    if (!path) reject("URL_UNSAFE", "asset reference has no local path");
    if (!files.has(path)) reject("MISSING_ASSET", `missing local asset: ${path}`);
  }
}
