import { createHash } from "node:crypto";
export const HOSTED_EVIDENCE_API_BASE_URL_ENV = "PATCHTRAIL_HOSTED_API_BASE_URL";
const MAX_ATTEMPTS = 3;
const RETRY_DELAY_MS = 100;
export class HostedEvidenceSubmitter {
    #baseUrl;
    #baseUrlInvalid;
    #fetch;
    #audit;
    #maxAttempts;
    #retryDelayMs;
    #sleep;
    constructor(options = {}) {
        const normalizedBaseUrl = normalizeBaseUrl(options.baseUrl);
        this.#baseUrl = normalizedBaseUrl.baseUrl;
        this.#baseUrlInvalid = normalizedBaseUrl.invalid;
        this.#fetch = options.fetchImpl ?? fetch;
        this.#audit = options.audit;
        this.#maxAttempts = Math.max(1, Math.min(MAX_ATTEMPTS, Math.trunc(options.maxAttempts ?? MAX_ATTEMPTS)));
        this.#retryDelayMs = Math.max(0, Math.trunc(options.retryDelayMs ?? RETRY_DELAY_MS));
        this.#sleep = options.sleep ?? defaultSleep;
    }
    static fromEnv(env, options = {}) {
        const submitterOptions = {
            ...options,
        };
        const baseUrl = env[HOSTED_EVIDENCE_API_BASE_URL_ENV];
        if (baseUrl !== undefined)
            submitterOptions.baseUrl = baseUrl;
        return new HostedEvidenceSubmitter(submitterOptions);
    }
    async submit(input) {
        if (!this.#baseUrl) {
            return {
                status: "environment-gated",
                reasonCode: this.#baseUrlInvalid
                    ? "hosted-api-base-url-invalid"
                    : "hosted-api-base-url-missing",
                missingSettings: [HOSTED_EVIDENCE_API_BASE_URL_ENV],
                attempts: 0,
            };
        }
        const localFailure = validateReceiptBinding(input);
        if (localFailure) {
            this.#appendAudit("evidence_submission_failed", "warning", input, {
                reasonCode: localFailure,
                attempts: 0,
            });
            return {
                status: "failed",
                reasonCode: localFailure,
                attempts: 0,
            };
        }
        const url = `${this.#baseUrl}/api/v1/run-evidence`;
        const idempotencyKey = input.idempotencyKey ?? createEvidenceIdempotencyKey(input.submission);
        let lastStatusCode;
        for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
            this.#appendAudit("evidence_submission_attempted", "info", input, {
                attempt,
            });
            let response;
            try {
                response = await this.#fetch(url, {
                    method: "POST",
                    headers: {
                        authorization: `Bearer ${encodeRunIntentReceiptBearer(input.runIntentBridgeReceipt)}`,
                        "content-type": "application/json",
                        "idempotency-key": idempotencyKey,
                        "x-patchtrail-run-intent-id": input.submission.runIntentId,
                        "x-patchtrail-workspace-id": input.submission.workspaceId,
                    },
                    body: JSON.stringify(input.submission),
                });
            }
            catch {
                lastStatusCode = undefined;
                if (attempt < this.#maxAttempts) {
                    await this.#sleep(this.#retryDelayMs * attempt);
                    continue;
                }
                return this.#failed(input, "hosted-unavailable", attempt, lastStatusCode);
            }
            lastStatusCode = response.status;
            if (response.status >= 500) {
                if (attempt < this.#maxAttempts) {
                    await this.#sleep(this.#retryDelayMs * attempt);
                    continue;
                }
                return this.#failed(input, "hosted-unavailable", attempt, lastStatusCode);
            }
            const terminalFailure = terminalFailureForStatus(response.status);
            if (terminalFailure) {
                return this.#failed(input, terminalFailure, attempt, lastStatusCode);
            }
            if (!response.ok) {
                return this.#failed(input, "payload-rejected", attempt, lastStatusCode);
            }
            const payload = await readJson(response);
            const parsed = readHostedEvidenceSubmissionResponse(payload);
            if (!parsed) {
                return this.#failed(input, "hosted-response-invalid", attempt, lastStatusCode);
            }
            if (parsed.status === "blocked") {
                return this.#failed(input, failureForBlockedReason(parsed.reasonCode), attempt, lastStatusCode);
            }
            this.#appendAudit("evidence_submission_succeeded", "info", input, {
                attempt,
                evidenceId: parsed.evidenceId,
                fileCount: parsed.patchArtifact.fileCount,
            });
            return { status: "succeeded", attempts: attempt, response: parsed };
        }
        return this.#failed(input, "hosted-unavailable", this.#maxAttempts, lastStatusCode);
    }
    #failed(input, reasonCode, attempts, statusCode) {
        this.#appendAudit("evidence_submission_failed", "warning", input, {
            reasonCode,
            attempts,
            ...(statusCode !== undefined ? { statusCode } : {}),
        });
        return {
            status: "failed",
            reasonCode,
            attempts,
            ...(statusCode !== undefined ? { statusCode } : {}),
        };
    }
    #appendAudit(type, severity, input, metadata) {
        this.#audit?.append({
            type,
            severity,
            message: `Hosted run evidence submission ${type.replace("evidence_submission_", "")}.`,
            metadata: {
                workspaceId: input.submission.workspaceId,
                preparationId: input.submission.preparationId,
                runIntentId: input.submission.runIntentId,
                bridgeRunId: input.submission.bridgeRunId,
                patchFileCount: input.submission.patchFiles.length,
                ...metadata,
            },
        });
    }
}
export function encodeRunIntentReceiptBearer(receipt) {
    return Buffer.from(JSON.stringify(receipt), "utf8").toString("base64url");
}
function normalizeBaseUrl(value) {
    const trimmed = value?.trim();
    if (!trimmed)
        return { baseUrl: null, invalid: false };
    try {
        const url = new URL(trimmed);
        if (url.protocol !== "http:" && url.protocol !== "https:") {
            return { baseUrl: null, invalid: true };
        }
        return { baseUrl: url.toString().replace(/\/$/u, ""), invalid: false };
    }
    catch {
        return { baseUrl: null, invalid: true };
    }
}
function validateReceiptBinding(input) {
    if (input.runIntentBridgeReceipt.workspaceId !== input.submission.workspaceId) {
        return "workspace-unauthorized";
    }
    if (input.runIntentBridgeReceipt.intentId !== input.submission.runIntentId) {
        return "receipt-rejected";
    }
    return null;
}
function createEvidenceIdempotencyKey(submission) {
    const digest = createHash("sha256")
        .update(`${submission.workspaceId}:${submission.preparationId}:${submission.bridgeRunId}`)
        .digest("base64url")
        .slice(0, 32);
    return `run-evidence:${digest}`;
}
function terminalFailureForStatus(statusCode) {
    if (statusCode === 401 || statusCode === 410)
        return "receipt-rejected";
    if (statusCode === 403)
        return "workspace-unauthorized";
    if (statusCode === 409)
        return "idempotency-conflict";
    if (statusCode === 400 || statusCode === 413 || statusCode === 422) {
        return "payload-rejected";
    }
    return null;
}
function failureForBlockedReason(reasonCode) {
    if (reasonCode === "run-evidence-idempotency-conflict") {
        return "idempotency-conflict";
    }
    if (reasonCode === "run-evidence-receipt-rejected") {
        return "receipt-rejected";
    }
    if (reasonCode === "run-evidence-workspace-unauthorized") {
        return "workspace-unauthorized";
    }
    return "payload-rejected";
}
async function readJson(response) {
    try {
        return await response.json();
    }
    catch {
        return null;
    }
}
function readHostedEvidenceSubmissionResponse(value) {
    if (!isRecord(value))
        return null;
    const patchArtifact = isRecord(value.patchArtifact)
        ? {
            fileCount: readNonNegativeInteger(value.patchArtifact.fileCount),
            contentBytes: readNonNegativeInteger(value.patchArtifact.contentBytes),
        }
        : null;
    if (!patchArtifact)
        return null;
    const response = {
        contractVersion: value.contractVersion,
        evidenceId: value.evidenceId,
        workspaceId: value.workspaceId,
        preparationId: value.preparationId,
        runIntentId: value.runIntentId,
        bridgeRunId: value.bridgeRunId,
        status: value.status,
        reasonCode: value.reasonCode,
        createdAt: value.createdAt,
        changedFiles: value.changedFiles,
        validations: value.validations,
        warnings: value.warnings,
        patchArtifact,
    };
    if (response.contractVersion !== "run-evidence.v1" ||
        typeof response.evidenceId !== "string" ||
        typeof response.workspaceId !== "string" ||
        typeof response.preparationId !== "string" ||
        typeof response.runIntentId !== "string" ||
        typeof response.bridgeRunId !== "string" ||
        (response.status !== "captured" && response.status !== "blocked") ||
        typeof response.reasonCode !== "string" ||
        typeof response.createdAt !== "string" ||
        !Array.isArray(response.changedFiles) ||
        !Array.isArray(response.validations) ||
        !Array.isArray(response.warnings)) {
        return null;
    }
    return response;
}
function readNonNegativeInteger(value) {
    return typeof value === "number" &&
        Number.isInteger(value) &&
        value >= 0 &&
        value <= Number.MAX_SAFE_INTEGER
        ? value
        : null;
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
function defaultSleep(milliseconds) {
    return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
