import { createPublicKey, verify } from "node:crypto";
import { assertKeys, canonicalJson, decodeCanonicalBase64Url, isRecord, requiredString, sha256, } from "./canonical.js";
import { HatchError } from "./errors.js";
import { hatchProductPolicyDigest, parseHatchProductPolicy } from "./policy.js";
const SHA256 = /^[0-9a-f]{64}$/u;
const SOURCE_COMMIT = /^[0-9a-f]{40}$/u;
const UTC_RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
export function verifySignedReleaseManifest(input, options) {
    const policy = parseHatchProductPolicy(options.policy);
    const envelope = parseSignedReleaseEnvelope(input);
    const matchingPublishers = options.trustedPublishers.filter((candidate) => candidate.id === envelope.publisher.id
        && candidate.domain === envelope.publisher.domain
        && candidate.keyId === envelope.publisher.keyId
        && candidate.algorithm === envelope.signature.alg);
    if (matchingPublishers.length !== 1 || matchingPublishers[0].revokedAt) {
        throw new HatchError("publisher_untrusted", "The release publisher or signing key is not trusted.");
    }
    const publisher = matchingPublishers[0];
    let publicKey;
    try {
        publicKey = createPublicKey(publisher.publicKeyPem);
    }
    catch (error) {
        throw new HatchError("publisher_untrusted", "The product publisher key is invalid.", error);
    }
    const publicKeySpkiSha256 = sha256(publicKey.export({ format: "der", type: "spki" }));
    const allowedKey = policy.publisher.allowedKeys.find(({ keyId }) => keyId === envelope.publisher.keyId);
    if (envelope.publisher.id !== policy.publisher.id
        || envelope.publisher.domain !== policy.publisher.domain
        || !allowedKey
        || allowedKey.publicKeySpkiSha256 !== publicKeySpkiSha256) {
        throw new HatchError("policy_rejected", "The release publisher is outside this product policy.");
    }
    const payloadBytes = decodeCanonicalBase64Url(envelope.signedPayload, "signedPayload", "envelope_invalid");
    const signatureBytes = decodeCanonicalBase64Url(envelope.signature.value, "signature.value", "envelope_invalid");
    let valid = false;
    try {
        valid = verify("sha256", payloadBytes, publicKey, signatureBytes);
    }
    catch (error) {
        throw new HatchError("signature_invalid", "The release manifest signature could not be verified.", error);
    }
    if (!valid)
        throw new HatchError("signature_invalid", "The release manifest signature is invalid.");
    let rawPayload;
    try {
        rawPayload = JSON.parse(payloadBytes.toString("utf8"));
    }
    catch (error) {
        throw new HatchError("manifest_invalid", "The signed release payload is not valid JSON.", error);
    }
    const payload = parseProductUpdateManifest(rawPayload);
    const now = options.now ?? new Date();
    enforceProductPolicy(payload, policy, now);
    const envelopeIdentity = [
        envelope.schema,
        envelope.publisher.id,
        envelope.publisher.domain,
        envelope.publisher.keyId,
        envelope.signedPayload,
        envelope.signature.alg,
        envelope.signature.value,
    ].join("\0");
    return {
        envelope,
        payload,
        productId: policy.productId,
        policyDigest: hatchProductPolicyDigest(policy),
        signedPayloadSha256: sha256(payloadBytes),
        envelopeSha256: sha256(envelopeIdentity),
        verifiedAt: now.toISOString(),
    };
}
export function parseSignedReleaseEnvelope(input) {
    if (!isRecord(input) || !isRecord(input.publisher) || !isRecord(input.signature)) {
        throw new HatchError("envelope_invalid", "The signed release envelope is invalid.");
    }
    assertKeys(input, ["schema", "publisher", "signedPayload", "signature"], "envelope", "envelope_invalid");
    assertKeys(input.publisher, ["id", "name", "domain", "keyId"], "envelope.publisher", "envelope_invalid");
    assertKeys(input.signature, ["alg", "value"], "envelope.signature", "envelope_invalid");
    if (input.schema !== "ok.signed-manifest.v1" || input.signature.alg !== "ECDSA_P256_SHA256_DER") {
        throw new HatchError("envelope_invalid", "The signed release envelope schema or algorithm is unsupported.");
    }
    return {
        schema: "ok.signed-manifest.v1",
        publisher: {
            id: requiredString(input.publisher.id, "publisher.id", "envelope_invalid", 200),
            ...(input.publisher.name === undefined
                ? {}
                : { name: requiredString(input.publisher.name, "publisher.name", "envelope_invalid", 200) }),
            domain: requiredString(input.publisher.domain, "publisher.domain", "envelope_invalid", 253),
            keyId: requiredString(input.publisher.keyId, "publisher.keyId", "envelope_invalid", 200),
        },
        signedPayload: requiredString(input.signedPayload, "signedPayload", "envelope_invalid", 2_000_000),
        signature: {
            alg: "ECDSA_P256_SHA256_DER",
            value: requiredString(input.signature.value, "signature.value", "envelope_invalid", 1_000),
        },
    };
}
export function parseProductUpdateManifest(input) {
    if (!isRecord(input) || !isRecord(input.source) || !isRecord(input.updateFeed) || !Array.isArray(input.artifacts)) {
        throw new HatchError("manifest_invalid", "The product update manifest is invalid.");
    }
    assertKeys(input, [
        "schema", "appId", "bundleId", "version", "build", "channel",
        "minimumSystemVersion", "minimumUpdaterVersion", "publishedAt", "expiresAt",
        "releaseNotes", "downloadPageUrl", "updateFeed", "source", "artifacts",
    ], "manifest");
    assertKeys(input.source, ["repository", "commit", "dirty", "buildConfiguration"], "manifest.source");
    assertKeys(input.updateFeed, ["format", "url", "sha256", "publicKey", "automaticByDefault"], "manifest.updateFeed");
    if (input.schema !== "ok.product-update.v1") {
        throw new HatchError("manifest_invalid", "The product update manifest schema is unsupported.");
    }
    if (!Number.isSafeInteger(input.build) || Number(input.build) <= 0) {
        throw new HatchError("manifest_invalid", "manifest.build must be a positive safe integer.");
    }
    if (typeof input.source.dirty !== "boolean") {
        throw new HatchError("manifest_invalid", "manifest.source.dirty must be boolean.");
    }
    if (!SHA256.test(String(input.updateFeed.sha256 ?? ""))) {
        throw new HatchError("manifest_invalid", "manifest.updateFeed.sha256 must be lowercase SHA-256.");
    }
    if (input.updateFeed.automaticByDefault !== undefined && typeof input.updateFeed.automaticByDefault !== "boolean") {
        throw new HatchError("manifest_invalid", "manifest.updateFeed.automaticByDefault must be boolean.");
    }
    const artifacts = input.artifacts.map((artifact, index) => parseArtifact(artifact, index));
    if (artifacts.length === 0 || artifacts.length > 16) {
        throw new HatchError("manifest_invalid", "manifest.artifacts must contain between one and sixteen artifacts.");
    }
    const manifest = {
        schema: "ok.product-update.v1",
        appId: requiredString(input.appId, "manifest.appId", "manifest_invalid", 200),
        bundleId: requiredString(input.bundleId, "manifest.bundleId", "manifest_invalid", 200),
        version: requiredString(input.version, "manifest.version", "manifest_invalid", 100),
        build: Number(input.build),
        channel: requiredString(input.channel, "manifest.channel", "manifest_invalid", 100),
        ...(input.minimumSystemVersion === undefined ? {} : { minimumSystemVersion: requiredString(input.minimumSystemVersion, "manifest.minimumSystemVersion", "manifest_invalid", 100) }),
        ...(input.minimumUpdaterVersion === undefined ? {} : { minimumUpdaterVersion: requiredString(input.minimumUpdaterVersion, "manifest.minimumUpdaterVersion", "manifest_invalid", 100) }),
        publishedAt: requiredString(input.publishedAt, "manifest.publishedAt", "manifest_invalid", 40),
        ...(input.expiresAt === undefined ? {} : { expiresAt: requiredString(input.expiresAt, "manifest.expiresAt", "manifest_invalid", 40) }),
        ...(input.releaseNotes === undefined ? {} : { releaseNotes: requiredString(input.releaseNotes, "manifest.releaseNotes", "manifest_invalid", 20_000) }),
        downloadPageUrl: requiredString(input.downloadPageUrl, "manifest.downloadPageUrl", "manifest_invalid", 2_000),
        updateFeed: {
            format: requiredString(input.updateFeed.format, "manifest.updateFeed.format", "manifest_invalid", 100),
            url: requiredString(input.updateFeed.url, "manifest.updateFeed.url", "manifest_invalid", 2_000),
            sha256: String(input.updateFeed.sha256),
            ...(input.updateFeed.publicKey === undefined ? {} : { publicKey: requiredString(input.updateFeed.publicKey, "manifest.updateFeed.publicKey", "manifest_invalid", 2_000) }),
            ...(input.updateFeed.automaticByDefault === undefined ? {} : { automaticByDefault: input.updateFeed.automaticByDefault }),
        },
        source: {
            repository: requiredString(input.source.repository, "manifest.source.repository", "manifest_invalid", 2_000),
            commit: requiredString(input.source.commit, "manifest.source.commit", "manifest_invalid", 40),
            dirty: input.source.dirty,
            buildConfiguration: requiredString(input.source.buildConfiguration, "manifest.source.buildConfiguration", "manifest_invalid", 200),
        },
        artifacts,
    };
    if (!SOURCE_COMMIT.test(manifest.source.commit)) {
        throw new HatchError("manifest_invalid", "manifest.source.commit must be a full lowercase Git commit ID.");
    }
    timestamp(manifest.publishedAt, "manifest.publishedAt");
    if (manifest.expiresAt)
        timestamp(manifest.expiresAt, "manifest.expiresAt");
    return manifest;
}
export function enforceProductPolicy(payload, policy, now) {
    if (payload.appId !== policy.appId || payload.bundleId !== policy.packageId || payload.channel !== policy.channel) {
        throw new HatchError("policy_rejected", "Release identity or channel is outside this product policy.");
    }
    if (!policy.allowedSourceRepositories.includes(payload.source.repository)
        || !policy.sourceBuildConfigurations.includes(payload.source.buildConfiguration)
        || (policy.requireCleanSource && payload.source.dirty)) {
        throw new HatchError("policy_rejected", "Release source provenance is outside this product policy.");
    }
    const publishedAt = timestamp(payload.publishedAt, "manifest.publishedAt");
    const expiresAt = payload.expiresAt ? timestamp(payload.expiresAt, "manifest.expiresAt") : null;
    if (publishedAt.getTime() > now.getTime() + 5 * 60_000) {
        throw new HatchError("policy_rejected", "The update manifest publication time is in the future.");
    }
    if (policy.requireExpiry && !expiresAt) {
        throw new HatchError("policy_rejected", "This product requires an expiring update manifest.");
    }
    if (expiresAt && expiresAt.getTime() <= now.getTime()) {
        throw new HatchError("manifest_expired", "The signed update manifest has expired.");
    }
    if (expiresAt && expiresAt.getTime() <= publishedAt.getTime()) {
        throw new HatchError("policy_rejected", "The update manifest expiry must follow publication.");
    }
    if (expiresAt
        && policy.maxManifestLifetimeSeconds !== undefined
        && expiresAt.getTime() - publishedAt.getTime() > policy.maxManifestLifetimeSeconds * 1_000) {
        throw new HatchError("policy_rejected", "The update manifest lifetime exceeds product policy.");
    }
    if (!policy.allowedFeedFormats.includes(payload.updateFeed.format)) {
        throw new HatchError("policy_rejected", "The update feed format is outside this product policy.");
    }
    assertAllowedUrl(payload.updateFeed.url, policy.allowedFeedOrigins, policy.allowLoopbackHttpForDevelopment, "update feed");
    assertAllowedUrl(payload.downloadPageUrl, policy.allowedFeedOrigins, policy.allowLoopbackHttpForDevelopment, "download page");
    for (const artifact of payload.artifacts) {
        if (!policy.allowedPlatforms.includes(artifact.platform) || artifact.sizeBytes > policy.maxArtifactBytes) {
            throw new HatchError("policy_rejected", "An update artifact is outside the allowed platform or size policy.");
        }
        const url = assertAllowedUrl(artifact.url, policy.allowedArtifactOrigins, policy.allowLoopbackHttpForDevelopment, "artifact");
        if (policy.allowedArtifactPathPrefixes?.length
            && !policy.allowedArtifactPathPrefixes.some((prefix) => url.pathname.startsWith(prefix))) {
            throw new HatchError("policy_rejected", "An update artifact path is outside product policy.");
        }
    }
    return payload;
}
function parseArtifact(input, index) {
    if (!isRecord(input))
        throw new HatchError("manifest_invalid", `manifest.artifacts[${index}] is invalid.`);
    assertKeys(input, ["platform", "architectures", "kind", "url", "sha256", "sizeBytes"], `manifest.artifacts[${index}]`);
    if (!SHA256.test(String(input.sha256 ?? "")) || !Number.isSafeInteger(input.sizeBytes) || Number(input.sizeBytes) <= 0) {
        throw new HatchError("manifest_invalid", `manifest.artifacts[${index}] has invalid digest or size.`);
    }
    if (input.architectures !== undefined && (!Array.isArray(input.architectures)
        || input.architectures.length === 0
        || input.architectures.length > 16
        || input.architectures.some((value) => typeof value !== "string" || !value.trim()))) {
        throw new HatchError("manifest_invalid", `manifest.artifacts[${index}].architectures is invalid.`);
    }
    if (input.kind !== undefined && !["archive", "installer", "package", "bundle"].includes(String(input.kind))) {
        throw new HatchError("manifest_invalid", `manifest.artifacts[${index}].kind is invalid.`);
    }
    return {
        platform: requiredString(input.platform, `manifest.artifacts[${index}].platform`, "manifest_invalid", 100),
        ...(input.architectures === undefined ? {} : {
            architectures: [...new Set(input.architectures.map((value) => String(value).trim()))].sort(),
        }),
        ...(input.kind === undefined ? {} : { kind: input.kind }),
        url: requiredString(input.url, `manifest.artifacts[${index}].url`, "manifest_invalid", 2_000),
        sha256: String(input.sha256),
        sizeBytes: Number(input.sizeBytes),
    };
}
function timestamp(value, label) {
    const parsed = new Date(value);
    if (!UTC_RFC3339.test(value) || !Number.isFinite(parsed.getTime())) {
        throw new HatchError("manifest_invalid", `${label} must be a UTC RFC 3339 timestamp.`);
    }
    return parsed;
}
function assertAllowedUrl(value, origins, allowLoopback = false, label) {
    let url;
    try {
        url = new URL(value);
    }
    catch (error) {
        throw new HatchError("policy_rejected", `The ${label} URL is invalid.`, error);
    }
    const loopback = ["127.0.0.1", "::1", "localhost"].includes(url.hostname);
    if ((url.protocol !== "https:" && !(allowLoopback && loopback && url.protocol === "http:"))
        || url.username || url.password || url.hash || url.search
        || !origins.includes(url.origin)) {
        throw new HatchError("policy_rejected", `The ${label} URL is outside product policy.`);
    }
    return url;
}
export function manifestIdentity(value) {
    return sha256(canonicalJson({
        productId: value.productId,
        policyDigest: value.policyDigest,
        envelopeSha256: value.envelopeSha256,
        signedPayloadSha256: value.signedPayloadSha256,
    }));
}
