import { createUpdatePlan, parseHatchProductPolicy, verifySignedReleaseManifest, } from "@owl-kestrel/hatch-contracts";
import path from "node:path";
import { HatchRuntimeError } from "./errors.js";
import { ensurePrivateDirectory, readPrivateText, writePrivateText } from "./private-storage.js";
import { createHatchUpdater } from "./updater.js";
const DEFAULT_MAX_MANIFEST_BYTES = 1024 * 1024;
const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
const PLAN_DIGEST = /^[a-f0-9]{64}$/u;
/**
 * Creates the two-stage updater surface embedded by a downloadable product.
 *
 * `check()` performs the unprivileged network operation, verifies its signed
 * result against compile-time product configuration, and stores the exact
 * verified manifest in an owner-private cache. `apply()` accepts only the
 * cached digest plus a new native consent receipt. It re-verifies the cache
 * against the same pinned configuration before delegating to the existing
 * transactional updater.
 */
export function createHatchOrchestrator(input) {
    const config = pinProductConfig(input);
    const cache = createCheckedPlanStore(config.stateDirectory);
    const updater = createHatchUpdater({
        adapter: config.adapter,
        stateDirectory: config.stateDirectory,
        now: config.now,
        fetchImpl: config.fetchImpl,
        consentMaxAgeMilliseconds: config.consentMaxAgeMilliseconds,
    });
    return {
        journal: updater.journal,
        reconcile: updater.reconcile,
        async check() {
            const manifest = await fetchSignedManifest({
                url: config.manifestUrl,
                policy: config.policy,
                fetchImpl: config.fetchImpl,
                maxBytes: config.maxManifestBytes,
            });
            const installed = await resolveInstalled(config.installed);
            const observedAt = config.now();
            const verified = verifySignedReleaseManifest(manifest, {
                trustedPublishers: config.trustedPublishers,
                policy: config.policy,
                now: observedAt,
            });
            if (normalizeFixedManifestUrl(verified.payload.updateFeed.url, config.policy) !== config.manifestUrl) {
                throw new HatchRuntimeError("manifest_location_mismatch", "The signed manifest does not identify the product's fixed configured update feed.");
            }
            const plan = createUpdatePlan({
                manifest,
                trustedPublishers: config.trustedPublishers,
                installed,
                policy: config.policy,
                now: observedAt,
            });
            const checkedAt = observedAt.toISOString();
            await cache.write({
                schemaVersion: 1,
                checkedAt,
                manifestUrl: config.manifestUrl,
                planDigest: plan.planDigest,
                manifest,
                plan,
            });
            return {
                type: "ok.hatch.checked-update.v1",
                schemaVersion: 1,
                checkedAt,
                planDigest: plan.planDigest,
                plan,
                display: toNativeDisplayPlan(plan, verified.payload.releaseNotes ?? null),
            };
        },
        async apply(request) {
            if (!PLAN_DIGEST.test(request.planDigest)) {
                throw new HatchRuntimeError("checked_plan_missing", "A canonical checked Hatch plan digest is required.");
            }
            const cached = await cache.read(request.planDigest);
            if (!cached) {
                throw new HatchRuntimeError("checked_plan_missing", "The checked Hatch plan is not available in private local state.");
            }
            if (cached.manifestUrl !== config.manifestUrl || cached.planDigest !== request.planDigest) {
                throw new HatchRuntimeError("checked_plan_drift", "The cached check is not bound to this product configuration.");
            }
            const installed = await resolveInstalled(config.installed);
            const appliedAt = config.now();
            const verified = verifySignedReleaseManifest(cached.manifest, {
                trustedPublishers: config.trustedPublishers,
                policy: config.policy,
                now: appliedAt,
            });
            if (normalizeFixedManifestUrl(verified.payload.updateFeed.url, config.policy) !== config.manifestUrl) {
                throw new HatchRuntimeError("manifest_location_mismatch", "The cached manifest does not identify the product's fixed configured update feed.");
            }
            const refreshedPlan = createUpdatePlan({
                manifest: cached.manifest,
                trustedPublishers: config.trustedPublishers,
                installed,
                policy: config.policy,
                now: appliedAt,
            });
            if (refreshedPlan.planDigest !== cached.planDigest || cached.plan.planDigest !== cached.planDigest) {
                throw new HatchRuntimeError("checked_plan_drift", "The checked plan changed before native consent could be applied.");
            }
            const updateRequest = {
                manifest: cached.manifest,
                trustedPublishers: config.trustedPublishers,
                installed,
                policy: config.policy,
                consent: request.consent,
            };
            return updater.apply(updateRequest);
        },
    };
}
function pinProductConfig(input) {
    const policy = parseHatchProductPolicy(structuredClone(input.policy));
    const manifestUrl = normalizeFixedManifestUrl(input.manifestUrl, policy);
    const adapter = input.adapter;
    if (!adapter
        || adapter.productId !== policy.productId
        || adapter.adapterId !== policy.installer.adapterId
        || adapter.executableVerifier !== policy.executableTrust.verifier
        || adapter.executableKeyId !== policy.executableTrust.keyId) {
        throw new HatchRuntimeError("adapter_mismatch", "The embedding product must compile in the adapter named by its pinned policy.");
    }
    if (typeof input.stateDirectory !== "string" || !input.stateDirectory.trim()) {
        throw new Error("Hatch requires an explicit private product state directory.");
    }
    const maxManifestBytes = input.maxManifestBytes ?? DEFAULT_MAX_MANIFEST_BYTES;
    if (!Number.isSafeInteger(maxManifestBytes) || maxManifestBytes <= 0 || maxManifestBytes > MAX_MANIFEST_BYTES) {
        throw new Error(`Hatch maxManifestBytes must be a positive integer no larger than ${MAX_MANIFEST_BYTES}.`);
    }
    const now = () => {
        const value = input.now?.() ?? new Date();
        if (!(value instanceof Date) || Number.isNaN(value.getTime()))
            throw new Error("Hatch clock returned an invalid date.");
        return new Date(value.getTime());
    };
    return {
        manifestUrl,
        trustedPublishers: structuredClone([...input.trustedPublishers]),
        policy,
        installed: input.installed,
        adapter,
        stateDirectory: input.stateDirectory,
        ...(input.fetchImpl ? { fetchImpl: input.fetchImpl } : {}),
        now,
        ...(input.consentMaxAgeMilliseconds === undefined ? {} : { consentMaxAgeMilliseconds: input.consentMaxAgeMilliseconds }),
        maxManifestBytes,
    };
}
async function resolveInstalled(input) {
    const value = typeof input === "function" ? await input() : input;
    return structuredClone(value);
}
function normalizeFixedManifestUrl(value, policy) {
    let url;
    try {
        url = new URL(value);
    }
    catch (error) {
        throw new HatchRuntimeError("manifest_fetch_rejected", "The fixed update manifest URL is invalid.", error);
    }
    const loopback = ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
    if (url.username || url.password || url.hash
        || (url.protocol !== "https:" && !(policy.allowLoopbackHttpForDevelopment && loopback && url.protocol === "http:"))
        || !policy.allowedFeedOrigins.includes(url.origin)) {
        throw new HatchRuntimeError("manifest_fetch_rejected", "The fixed update manifest URL is outside the pinned product feed policy.");
    }
    return url.href;
}
async function fetchSignedManifest(input) {
    const url = normalizeFixedManifestUrl(input.url, input.policy);
    let response;
    try {
        response = await (input.fetchImpl ?? fetch)(url, {
            method: "GET",
            redirect: "manual",
            credentials: "omit",
            headers: { accept: "application/json", "cache-control": "no-cache" },
        });
    }
    catch (error) {
        throw new HatchRuntimeError("manifest_fetch_rejected", "The fixed update manifest could not be fetched.", error);
    }
    if (!response.ok || response.status >= 300 || !response.body) {
        throw new HatchRuntimeError("manifest_fetch_rejected", `Manifest fetch failed with HTTP ${response.status}.`);
    }
    const declaredLength = response.headers.get("content-length");
    if (declaredLength !== null) {
        const length = Number(declaredLength);
        if (!Number.isSafeInteger(length) || length < 0 || length > input.maxBytes) {
            throw new HatchRuntimeError("manifest_fetch_rejected", "Manifest Content-Length exceeds the configured bound.");
        }
    }
    const chunks = [];
    let bytes = 0;
    try {
        const reader = response.body.getReader();
        while (true) {
            const { done, value } = await reader.read();
            if (done)
                break;
            bytes += value.byteLength;
            if (bytes > input.maxBytes) {
                throw new HatchRuntimeError("manifest_fetch_rejected", "Manifest response exceeds the configured bound.");
            }
            chunks.push(value);
        }
        const source = new TextDecoder("utf-8", { fatal: true }).decode(concatenate(chunks, bytes));
        return JSON.parse(source);
    }
    catch (error) {
        if (error instanceof HatchRuntimeError)
            throw error;
        throw new HatchRuntimeError("manifest_fetch_rejected", "The fixed update manifest is not valid UTF-8 JSON.", error);
    }
}
function concatenate(chunks, bytes) {
    const combined = new Uint8Array(bytes);
    let offset = 0;
    for (const chunk of chunks) {
        combined.set(chunk, offset);
        offset += chunk.byteLength;
    }
    return combined;
}
function toNativeDisplayPlan(plan, releaseNotes) {
    const candidate = plan.candidate;
    return {
        type: "ok.hatch.native-display-plan.v1",
        schemaVersion: 1,
        productId: plan.productId,
        planDigest: plan.planDigest,
        status: plan.status,
        requiresNativeConsent: plan.requiresNativeConsent,
        installed: {
            version: plan.installed.version,
            build: plan.installed.build,
            platform: plan.installed.platform,
            architecture: plan.installed.architecture,
        },
        candidate: candidate ? {
            version: candidate.version,
            build: candidate.build,
            artifactKind: candidate.artifactKind,
            artifactSizeBytes: candidate.artifactSizeBytes,
            publishedAt: candidate.publishedAt,
            expiresAt: candidate.expiresAt,
            minimumSystemVersion: candidate.minimumSystemVersion,
            minimumUpdaterVersion: candidate.minimumUpdaterVersion,
            // Contracts cap signed notes at 20,000 characters; this lower display
            // cap avoids making a native confirmation surface an unbounded renderer.
            releaseNotes: releaseNotes?.slice(0, 12_000) ?? null,
        } : null,
    };
}
function createCheckedPlanStore(stateDirectory) {
    const directory = path.join(stateDirectory, "checked-plans");
    const fileFor = (planDigest) => path.join(directory, `${planDigest}.json`);
    return {
        async write(value) {
            await ensurePrivateDirectory(stateDirectory, "Hatch state directory");
            await ensurePrivateDirectory(directory, "Hatch checked-plan directory");
            await writePrivateText(fileFor(value.planDigest), `${JSON.stringify(value)}\n`, "Hatch checked plan");
        },
        async read(planDigest) {
            await ensurePrivateDirectory(stateDirectory, "Hatch state directory");
            await ensurePrivateDirectory(directory, "Hatch checked-plan directory");
            const source = await readPrivateText(fileFor(planDigest), "Hatch checked plan");
            if (source === null)
                return null;
            let value;
            try {
                value = JSON.parse(source);
            }
            catch (error) {
                throw new HatchRuntimeError("checked_plan_drift", "The checked plan cache is invalid.", error);
            }
            if (!isCachedCheckedPlan(value)) {
                throw new HatchRuntimeError("checked_plan_drift", "The checked plan cache has an unsupported shape.");
            }
            return value;
        },
    };
}
function isCachedCheckedPlan(value) {
    if (!value || typeof value !== "object" || Array.isArray(value))
        return false;
    const record = value;
    return record.schemaVersion === 1
        && typeof record.checkedAt === "string"
        && typeof record.manifestUrl === "string"
        && typeof record.planDigest === "string"
        && PLAN_DIGEST.test(record.planDigest)
        && record.manifest !== undefined
        && !!record.plan
        && typeof record.plan === "object"
        && record.plan.planDigest === record.planDigest;
}
