import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import path from "node:path";
import { createUpdatePlan } from "@owl-kestrel/hatch-contracts";
import { downloadArtifact } from "./download.js";
import { HatchRuntimeError } from "./errors.js";
import { createExecutionJournal } from "./journal.js";
const TERMINAL = new Set(["active", "rolled-back"]);
export function createHatchUpdater(options) {
    const now = () => (options.now ?? (() => new Date()))().toISOString();
    const journal = createExecutionJournal(path.join(options.stateDirectory, "execution-journal.json"));
    async function transition(transactionId, state, errorCode, fields = {}) {
        return journal.update(transactionId, (receipt) => {
            const at = now();
            return {
                ...receipt,
                state,
                updatedAt: at,
                ...(errorCode ? { errorCode } : {}),
                ...fields,
                history: [...receipt.history, { state, at, ...(errorCode ? { errorCode } : {}) }],
            };
        });
    }
    return {
        journal,
        async reconcile(transactionId) {
            const state = await journal.read();
            const receipt = state.transactions.find((item) => item.transactionId === transactionId);
            if (!receipt)
                throw new Error(`Unknown Hatch transaction ${transactionId}.`);
            if (TERMINAL.has(receipt.state))
                return structuredClone(receipt);
            if (!receipt.candidateOpaqueId && ["downloading", "downloaded", "verified"].includes(receipt.state)) {
                await cleanupStaging(options.stateDirectory, receipt.transactionId);
                return transition(receipt.transactionId, "rolled-back", "interrupted_before_staging");
            }
            const outcome = await options.adapter.reconcile({
                transactionId: receipt.transactionId,
                candidate: receipt.candidateOpaqueId ? { opaqueId: receipt.candidateOpaqueId } : null,
                plan: receipt.plan,
                receipt,
            });
            if (!["active", "rolled-back", "indeterminate"].includes(outcome)) {
                throw new Error("The product adapter returned an invalid reconciliation outcome.");
            }
            if (outcome !== "indeterminate")
                await cleanupStaging(options.stateDirectory, receipt.transactionId);
            return transition(receipt.transactionId, outcome);
        },
        async apply(request) {
            const plan = createUpdatePlan({
                manifest: request.manifest,
                trustedPublishers: request.trustedPublishers,
                installed: request.installed,
                policy: request.policy,
                now: new Date(now()),
            });
            if (plan.status !== "update_available" || !plan.candidate) {
                throw new HatchRuntimeError("update_not_available", `Hatch plan status is ${plan.status}.`);
            }
            assertAdapter(plan, options.adapter);
            assertConsent(request.consent, plan.productId, plan.planDigest, Date.parse(now()), options.consentMaxAgeMilliseconds ?? 15 * 60 * 1_000);
            const state = await journal.read();
            const active = state.transactions.find((item) => item.productId === plan.productId && !TERMINAL.has(item.state));
            if (active)
                throw new HatchRuntimeError("transaction_in_progress", `Hatch transaction ${active.transactionId} is not settled.`);
            const startedAt = now();
            let receipt = {
                type: "ok.hatch.execution-receipt.v1",
                transactionId: `hatch_${randomUUID()}`,
                productId: plan.productId,
                planDigest: plan.planDigest,
                installedBuild: plan.installed.build,
                candidateBuild: plan.candidate.build,
                state: "downloading",
                artifactSha256: plan.candidate.artifactSha256,
                startedAt,
                updatedAt: startedAt,
                history: [{ state: "downloading", at: startedAt }],
                plan,
            };
            receipt = await journal.append(receipt);
            let downloaded;
            try {
                downloaded = await downloadArtifact({
                    url: plan.candidate.artifactUrl,
                    expectedSha256: plan.candidate.artifactSha256,
                    expectedSizeBytes: plan.candidate.artifactSizeBytes,
                    stagingRoot: path.join(options.stateDirectory, "staging", receipt.transactionId),
                    fetchImpl: options.fetchImpl,
                });
                receipt = await transition(receipt.transactionId, "downloaded");
                await options.adapter.verifyArtifact({
                    artifactPath: downloaded.artifactPath,
                    expectedSha256: plan.candidate.artifactSha256,
                    expectedSizeBytes: plan.candidate.artifactSizeBytes,
                    plan,
                });
                receipt = await transition(receipt.transactionId, "verified");
            }
            catch (error) {
                await cleanupStaging(options.stateDirectory, receipt.transactionId);
                await transition(receipt.transactionId, "rolled-back", runtimeErrorCode(error));
                throw error;
            }
            let candidate;
            try {
                candidate = await options.adapter.stageCandidate({
                    transactionId: receipt.transactionId,
                    artifactPath: downloaded.artifactPath,
                    plan,
                });
                receipt = await transition(receipt.transactionId, "staged", undefined, { candidateOpaqueId: candidate.opaqueId });
            }
            catch (error) {
                await transition(receipt.transactionId, "indeterminate", runtimeErrorCode(error));
                throw error;
            }
            let activationAttempted = false;
            try {
                receipt = await transition(receipt.transactionId, "activating");
                activationAttempted = true;
                await options.adapter.activate({ candidate, plan });
                receipt = await transition(receipt.transactionId, "candidate-health");
                if (!await options.adapter.healthCheck({ candidate, plan })) {
                    throw new HatchRuntimeError("health_check_failed", "The candidate failed its product-native health check.");
                }
                const settled = await transition(receipt.transactionId, "active");
                await cleanupStaging(options.stateDirectory, receipt.transactionId);
                return settled;
            }
            catch (error) {
                if (!activationAttempted)
                    throw error;
                await transition(receipt.transactionId, "rolling-back", error instanceof HatchRuntimeError ? error.code : "activation_failed");
                try {
                    await options.adapter.rollback({ candidate, plan });
                    const settled = await transition(receipt.transactionId, "rolled-back", error instanceof HatchRuntimeError ? error.code : "activation_failed");
                    await cleanupStaging(options.stateDirectory, receipt.transactionId);
                    return Promise.reject(Object.assign(error instanceof Error ? error : new Error(String(error)), { hatchReceipt: settled }));
                }
                catch (rollbackError) {
                    await transition(receipt.transactionId, "indeterminate", "rollback_failed");
                    throw new HatchRuntimeError("rollback_failed", "Candidate activation failed and rollback did not settle.", rollbackError);
                }
            }
        },
    };
}
function runtimeErrorCode(error) {
    return error instanceof HatchRuntimeError ? error.code : "adapter_failed";
}
async function cleanupStaging(stateDirectory, transactionId) {
    if (!/^hatch_[0-9a-f-]{36}$/u.test(transactionId)) {
        throw new Error("Refusing to clean an invalid Hatch transaction path.");
    }
    await rm(path.join(stateDirectory, "staging", transactionId), { recursive: true, force: true });
}
function assertAdapter(plan, adapter) {
    const candidate = plan.candidate;
    if (!candidate
        || adapter.productId !== plan.productId
        || adapter.adapterId !== candidate.installerAdapterId
        || adapter.executableVerifier !== candidate.executableVerifier
        || adapter.executableKeyId !== candidate.executableKeyId) {
        throw new HatchRuntimeError("adapter_mismatch", "The compiled product adapter does not match the signed, policy-bound update plan.");
    }
}
function assertConsent(consent, productId, planDigest, nowMilliseconds, maxAgeMilliseconds) {
    const grantedAt = Date.parse(consent?.grantedAt ?? "");
    if (consent?.type !== "ok.hatch.native-consent.v1"
        || consent.productId !== productId
        || consent.planDigest !== planDigest
        || !Number.isFinite(grantedAt)
        || !Number.isSafeInteger(maxAgeMilliseconds)
        || maxAgeMilliseconds <= 0
        || grantedAt > nowMilliseconds + 60_000
        || nowMilliseconds - grantedAt > maxAgeMilliseconds) {
        throw new HatchRuntimeError("consent_invalid", "A product-native consent receipt bound to this exact plan is required.");
    }
}
