import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
import { createInterface } from "node:readline";
import { gunzipSync } from "node:zlib";
import { prepareProxyBodyForLogging, redactProxyHeadersForLogging, } from "./requestLogger.js";
const DEBUG_FILE_PATTERN = /^proxy-debug-\d{4}-\d{2}-\d{2}\.jsonl$/;
const BUNDLE_KIND = "neurolink.proxy.replay-bundle";
const COMPARISON_KIND = "neurolink.proxy.replay-comparison";
const MAX_COMPRESSED_ARTIFACT_BYTES = 2 * 1024 * 1024;
const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
const MAX_DIRECT_RESPONSE_BYTES = 1024 * 1024;
const REDACTED_VALUE = "[REDACTED]";
const TRUSTED_CAPTURED_ENDPOINTS = new Set([
    "https://api.anthropic.com/v1/messages?beta=true",
]);
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SENSITIVE_QUERY_NAME_PATTERN = /token|secret|key|password|credential|authorization/i;
const HOP_BY_HOP_HEADERS = new Set([
    "connection",
    "content-length",
    "host",
    "keep-alive",
    "proxy-authenticate",
    "proxy-authorization",
    "te",
    "trailer",
    "transfer-encoding",
    "upgrade",
]);
export function isValidProxyReplayHeaderName(name) {
    return HEADER_NAME_PATTERN.test(name);
}
function sha256(value) {
    return createHash("sha256").update(value).digest("hex");
}
function stringValue(value) {
    return typeof value === "string" && value.length > 0 ? value : null;
}
function finiteNumber(value) {
    return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function recordValue(value) {
    return value && typeof value === "object" && !Array.isArray(value)
        ? value
        : null;
}
function headersValue(value) {
    const record = recordValue(value);
    if (!record) {
        return {};
    }
    return Object.fromEntries(Object.entries(record)
        .filter((entry) => typeof entry[1] === "string")
        .sort(([left], [right]) => left.localeCompare(right)));
}
function isStringArray(value) {
    return (Array.isArray(value) && value.every((entry) => typeof entry === "string"));
}
function isNullableString(value) {
    return value === null || typeof value === "string";
}
function isNullableNumber(value) {
    return (value === null || (typeof value === "number" && Number.isFinite(value)));
}
function isNullableSha256(value) {
    return (value === null || (typeof value === "string" && SHA256_PATTERN.test(value)));
}
function isHeaderRecord(value) {
    const record = recordValue(value);
    return (record !== null &&
        Object.entries(record).every(([name, headerValue]) => isValidProxyReplayHeaderName(name) &&
            typeof headerValue === "string" &&
            !/[\r\n]/.test(headerValue)));
}
function isReplayCapture(value) {
    const capture = recordValue(value);
    const source = recordValue(capture?.source);
    return Boolean(capture &&
        typeof capture.timestamp === "string" &&
        typeof capture.phase === "string" &&
        isNullableNumber(capture.attempt) &&
        isNullableString(capture.model) &&
        (capture.stream === null || typeof capture.stream === "boolean") &&
        isNullableString(capture.account) &&
        isNullableString(capture.accountType) &&
        isNullableNumber(capture.responseStatus) &&
        isNullableNumber(capture.durationMs) &&
        isNullableString(capture.contentType) &&
        isHeaderRecord(capture.headers) &&
        isNullableString(capture.body) &&
        isNullableSha256(capture.bodySha256) &&
        typeof capture.bodyTruncated === "boolean" &&
        isNullableNumber(capture.observedBodyBytes) &&
        (capture.metadata === null || recordValue(capture.metadata)) &&
        source &&
        typeof source.indexFile === "string" &&
        Number.isInteger(source.indexLine) &&
        Number(source.indexLine) >= 1 &&
        isNullableString(source.artifactPath) &&
        isStringArray(capture.issues));
}
function assertProxyReplayBundle(value) {
    const bundle = recordValue(value);
    const source = recordValue(bundle?.source);
    const completeness = recordValue(bundle?.completeness);
    const request = recordValue(bundle?.request);
    const valid = Boolean(bundle?.kind === BUNDLE_KIND &&
        bundle.schemaVersion === 1 &&
        typeof bundle.requestId === "string" &&
        bundle.requestId.length > 0 &&
        Number.isInteger(bundle.selectedAttempt) &&
        Number(bundle.selectedAttempt) >= 1 &&
        source &&
        typeof source.logsDirectory === "string" &&
        isStringArray(source.indexFiles) &&
        completeness &&
        Number.isInteger(completeness.captures) &&
        isStringArray(completeness.phasesPresent) &&
        isStringArray(completeness.missingRequiredPhases) &&
        Number.isInteger(completeness.truncatedCaptures) &&
        Number.isInteger(completeness.artifactsWithIssues) &&
        typeof completeness.replayable === "boolean" &&
        isStringArray(completeness.blockers) &&
        request &&
        ["POST", "PUT", "PATCH"].includes(String(request.method)) &&
        isNullableString(request.url) &&
        isHeaderRecord(request.headers) &&
        isStringArray(request.requiredHeaderInputs) &&
        request.requiredHeaderInputs.every((name) => isValidProxyReplayHeaderName(name)) &&
        isNullableString(request.body) &&
        isNullableSha256(request.bodySha256) &&
        typeof request.bodyTruncated === "boolean" &&
        isNullableString(request.contentType) &&
        isNullableString(request.account) &&
        isNullableString(request.accountType) &&
        isNullableString(request.model) &&
        (request.stream === null || typeof request.stream === "boolean") &&
        Array.isArray(bundle.captures) &&
        bundle.captures.every(isReplayCapture) &&
        (bundle.capturedResponse === null ||
            isReplayCapture(bundle.capturedResponse)));
    if (!valid) {
        throw new Error("Invalid or unsupported NeuroLink proxy replay bundle");
    }
    const typedBundle = value;
    const captures = [
        ...typedBundle.captures,
        ...(typedBundle.capturedResponse ? [typedBundle.capturedResponse] : []),
    ];
    const hashedBodies = [typedBundle.request, ...captures];
    for (const capture of hashedBodies) {
        if (capture.body !== null &&
            capture.bodySha256 !== null &&
            sha256(capture.body) !== capture.bodySha256) {
            throw new Error("Proxy replay bundle contains a body hash mismatch");
        }
    }
    if (stableJson(redactProxyHeadersForLogging(typedBundle.request.headers)) !==
        stableJson(typedBundle.request.headers)) {
        throw new Error("Proxy replay bundle contains an unredacted request header");
    }
    for (const capture of captures) {
        if (stableJson(redactProxyHeadersForLogging(capture.headers)) !==
            stableJson(capture.headers)) {
            throw new Error("Proxy replay bundle contains an unredacted capture header");
        }
        if (capture.body !== null &&
            prepareProxyBodyForLogging(capture.body).value !== capture.body) {
            throw new Error("Proxy replay bundle contains an unredacted capture body");
        }
    }
    if (typedBundle.request.body !== null &&
        prepareProxyBodyForLogging(typedBundle.request.body).value !==
            typedBundle.request.body) {
        throw new Error("Proxy replay bundle contains an unredacted request body");
    }
}
function isWithin(parent, candidate) {
    const child = relative(parent, candidate);
    return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
}
function stableValue(value) {
    if (Array.isArray(value)) {
        return value.map(stableValue);
    }
    if (value && typeof value === "object") {
        return Object.fromEntries(Object.entries(value)
            .sort(([left], [right]) => left.localeCompare(right))
            .map(([key, child]) => [key, stableValue(child)]));
    }
    return value;
}
function stableJson(value) {
    return JSON.stringify(stableValue(value));
}
export function serializeProxyReplayDocument(value) {
    return `${JSON.stringify(stableValue(value), null, 2)}\n`;
}
async function discoverDebugFiles(logsDir) {
    const entries = await readdir(logsDir, { withFileTypes: true }).catch((error) => {
        throw new Error(`Unable to read proxy logs at ${logsDir}: ${error.message}`);
    });
    return entries
        .filter((entry) => entry.isFile() && DEBUG_FILE_PATTERN.test(entry.name))
        .map((entry) => join(logsDir, entry.name))
        .sort();
}
async function findRequestIndexEntries(files, requestId) {
    const matches = [];
    const encodedRequestId = JSON.stringify(requestId);
    for (const filePath of files) {
        const lines = createInterface({
            input: createReadStream(filePath, { encoding: "utf8" }),
            crlfDelay: Infinity,
        });
        let lineNumber = 0;
        for await (const line of lines) {
            lineNumber += 1;
            if (!line.includes(encodedRequestId)) {
                continue;
            }
            let parsed;
            try {
                parsed = JSON.parse(line);
            }
            catch {
                continue;
            }
            const index = recordValue(parsed);
            if (index?.type === "body_capture" && index.requestId === requestId) {
                matches.push({ index, filePath, line: lineNumber });
            }
        }
    }
    return matches;
}
async function loadArtifact(args) {
    const bodyRoot = resolve(args.logsDir, "bodies");
    const candidate = resolve(isAbsolute(args.bodyPath)
        ? args.bodyPath
        : join(args.logsDir, args.bodyPath));
    if (!isWithin(bodyRoot, candidate)) {
        throw new Error(`Body artifact escapes the logs body directory: ${args.bodyPath}`);
    }
    const [bodyRootReal, candidateReal, candidateLstat] = await Promise.all([
        realpath(bodyRoot),
        realpath(candidate),
        lstat(candidate),
    ]);
    if (!isWithin(bodyRootReal, candidateReal) ||
        candidateLstat.isSymbolicLink()) {
        throw new Error(`Body artifact failed path-containment checks: ${args.bodyPath}`);
    }
    const handle = await open(candidateReal, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
    let compressed;
    try {
        const info = await handle.stat();
        if (!info.isFile()) {
            throw new Error(`Body artifact is not a regular file: ${args.bodyPath}`);
        }
        if (info.size > MAX_COMPRESSED_ARTIFACT_BYTES) {
            throw new Error(`Compressed body artifact exceeds the ${MAX_COMPRESSED_ARTIFACT_BYTES}-byte safety limit`);
        }
        compressed = await handle.readFile();
    }
    finally {
        await handle.close();
    }
    const payload = gunzipSync(compressed, {
        maxOutputLength: MAX_ARTIFACT_BYTES,
    });
    const artifact = recordValue(JSON.parse(payload.toString("utf8")));
    if (!artifact) {
        throw new Error(`Body artifact is not a JSON object: ${args.bodyPath}`);
    }
    if (artifact.requestId !== args.expectedRequestId ||
        artifact.phase !== args.expectedPhase) {
        throw new Error(`Body artifact identity does not match its debug index: ${args.bodyPath}`);
    }
    const body = typeof artifact.body === "string" ? artifact.body : null;
    if (body !== null &&
        args.expectedSha256 &&
        sha256(body) !== args.expectedSha256) {
        throw new Error(`Body artifact SHA-256 does not match its debug index: ${args.bodyPath}`);
    }
    return {
        artifact,
        relativePath: relative(args.logsDir, candidateReal).split(sep).join("/"),
    };
}
async function materializeCapture(args) {
    const phase = stringValue(args.index.phase) ?? "unknown";
    const expectedSha256 = stringValue(args.index.bodySha256);
    const issues = [];
    let artifact = null;
    let artifactPath = null;
    const bodyPath = stringValue(args.index.bodyPath);
    if (!bodyPath) {
        issues.push("body_artifact_missing");
    }
    else {
        try {
            const loaded = await loadArtifact({
                logsDir: args.logsDir,
                bodyPath,
                expectedRequestId: args.requestId,
                expectedPhase: phase,
                expectedSha256,
            });
            artifact = loaded.artifact;
            artifactPath = loaded.relativePath;
            for (const field of [
                "timestamp",
                "requestId",
                "phase",
                "model",
                "stream",
                "account",
                "accountType",
                "attempt",
                "responseStatus",
                "durationMs",
                "contentType",
                "headers",
                "metadata",
            ]) {
                if (stableJson(artifact[field]) !== stableJson(args.index[field])) {
                    throw new Error(`Body artifact ${field} does not match its debug index: ${bodyPath}`);
                }
            }
        }
        catch (error) {
            if (error.code === "ENOENT") {
                issues.push("body_artifact_missing");
            }
            else {
                throw error;
            }
        }
    }
    const source = artifact ?? args.index;
    const body = typeof artifact?.body === "string" ? artifact.body : null;
    if (body !== null && !expectedSha256) {
        issues.push("body_hash_missing");
    }
    return {
        timestamp: stringValue(source.timestamp) ?? stringValue(args.index.timestamp) ?? "",
        phase,
        attempt: finiteNumber(source.attempt),
        model: stringValue(source.model),
        stream: typeof source.stream === "boolean" ? source.stream : null,
        account: stringValue(source.account),
        accountType: stringValue(source.accountType),
        responseStatus: finiteNumber(source.responseStatus),
        durationMs: finiteNumber(source.durationMs),
        contentType: stringValue(source.contentType),
        headers: headersValue(source.headers),
        body,
        bodySha256: body === null ? expectedSha256 : sha256(body),
        bodyTruncated: args.index.bodyTruncated === true,
        observedBodyBytes: finiteNumber(args.index.observedBodyBytes),
        metadata: recordValue(source.metadata),
        source: {
            indexFile: basename(args.filePath),
            indexLine: args.line,
            artifactPath,
        },
        issues,
    };
}
function captureSort(left, right) {
    return (left.timestamp.localeCompare(right.timestamp) ||
        (left.attempt ?? -1) - (right.attempt ?? -1) ||
        left.phase.localeCompare(right.phase) ||
        left.source.indexFile.localeCompare(right.source.indexFile) ||
        left.source.indexLine - right.source.indexLine);
}
function findCapture(captures, phase, attempt) {
    const matches = captures.filter((capture) => capture.phase === phase &&
        (attempt === undefined || capture.attempt === attempt));
    return matches.at(-1) ?? null;
}
export async function exportProxyReplayBundle(options) {
    if (!options.requestId || options.requestId.length > 256) {
        throw new Error("requestId must contain between 1 and 256 characters");
    }
    if (options.attempt !== undefined &&
        (!Number.isInteger(options.attempt) || options.attempt < 1)) {
        throw new Error("attempt must be a positive integer");
    }
    const logsDir = resolve(options.logsDir ?? join(homedir(), ".neurolink", "logs"));
    const debugFiles = await discoverDebugFiles(logsDir);
    const indexEntries = await findRequestIndexEntries(debugFiles, options.requestId);
    if (indexEntries.length === 0) {
        throw new Error(`No body captures found for request ${options.requestId}`);
    }
    const captures = (await Promise.all(indexEntries.map((entry) => materializeCapture({ logsDir, requestId: options.requestId, ...entry })))).sort(captureSort);
    const upstreamAttempts = captures
        .filter((capture) => capture.phase === "upstream_request" && capture.attempt !== null)
        .map((capture) => capture.attempt);
    const selectedAttempt = options.attempt ??
        upstreamAttempts.reduce((highest, attempt) => Math.max(highest, attempt), Number.NEGATIVE_INFINITY);
    if (!Number.isFinite(selectedAttempt)) {
        throw new Error(`Request ${options.requestId} has no captured upstream attempt`);
    }
    const upstreamRequest = findCapture(captures, "upstream_request", selectedAttempt);
    if (!upstreamRequest) {
        throw new Error(`Request ${options.requestId} has no upstream_request capture for attempt ${selectedAttempt}`);
    }
    const capturedResponse = findCapture(captures, "upstream_response", selectedAttempt);
    const phasesPresent = [
        ...new Set(captures.map((capture) => capture.phase)),
    ].sort();
    const missingRequiredPhases = [
        ...(findCapture(captures, "client_request") ? [] : ["client_request"]),
        ...(upstreamRequest ? [] : ["upstream_request"]),
        ...(capturedResponse ? [] : ["upstream_response"]),
        ...(findCapture(captures, "client_response") ? [] : ["client_response"]),
    ];
    const requiredHeaderInputs = Object.entries(upstreamRequest.headers)
        .filter(([, value]) => value === REDACTED_VALUE)
        .map(([name]) => name.toLowerCase())
        .sort();
    const metadata = upstreamRequest.metadata ?? {};
    const url = stringValue(metadata.upstreamUrl);
    const method = (stringValue(metadata.upstreamMethod) ?? "POST").toUpperCase();
    const blockers = [
        ...missingRequiredPhases.map((phase) => `missing_phase:${phase}`),
        ...(url ? [] : ["missing_upstream_url"]),
        ...(upstreamRequest.body === null ? ["missing_upstream_request_body"] : []),
        ...(upstreamRequest.bodyTruncated
            ? ["upstream_request_body_truncated"]
            : []),
        ...(upstreamRequest.body?.includes(REDACTED_VALUE)
            ? ["upstream_request_body_contains_redactions"]
            : []),
        ...captures.flatMap((capture) => capture.issues.map((issue) => `${capture.phase}:${capture.attempt ?? "none"}:${issue}`)),
    ].sort();
    return {
        schemaVersion: 1,
        kind: BUNDLE_KIND,
        requestId: options.requestId,
        selectedAttempt,
        source: {
            logsDirectory: basename(logsDir),
            indexFiles: [
                ...new Set(captures.map((capture) => capture.source.indexFile)),
            ].sort(),
        },
        completeness: {
            captures: captures.length,
            phasesPresent,
            missingRequiredPhases,
            truncatedCaptures: captures.filter((capture) => capture.bodyTruncated)
                .length,
            artifactsWithIssues: captures.filter((capture) => capture.issues.length > 0).length,
            replayable: blockers.length === 0,
            blockers,
        },
        request: {
            method,
            url,
            headers: upstreamRequest.headers,
            requiredHeaderInputs,
            body: upstreamRequest.body,
            bodySha256: upstreamRequest.bodySha256,
            bodyTruncated: upstreamRequest.bodyTruncated,
            contentType: upstreamRequest.contentType,
            account: upstreamRequest.account,
            accountType: upstreamRequest.accountType,
            model: upstreamRequest.model,
            stream: upstreamRequest.stream,
        },
        capturedResponse,
        captures,
    };
}
export async function writeProxyReplayDocument(outputPath, document) {
    const resolvedPath = resolve(outputPath);
    const outputDirectory = dirname(resolvedPath);
    await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
    const existing = await lstat(resolvedPath).catch((error) => {
        if (error.code === "ENOENT") {
            return null;
        }
        throw error;
    });
    if (existing?.isSymbolicLink()) {
        throw new Error(`Refusing to replace symlink output path: ${resolvedPath}`);
    }
    const serialized = serializeProxyReplayDocument(document);
    const documentBytes = Buffer.byteLength(serialized, "utf8");
    if (documentBytes > MAX_BUNDLE_BYTES) {
        throw new Error(`Replay document exceeds the ${MAX_BUNDLE_BYTES}-byte safety limit`);
    }
    const temporaryPath = join(outputDirectory, `.${basename(resolvedPath)}.${process.pid}.${randomUUID()}.tmp`);
    const handle = await open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
    try {
        try {
            await handle.writeFile(serialized);
            await handle.chmod(0o600);
            await handle.sync();
        }
        finally {
            await handle.close();
        }
        await rename(temporaryPath, resolvedPath);
    }
    catch (error) {
        await unlink(temporaryPath).catch(() => undefined);
        throw error;
    }
    return { path: resolvedPath, sha256: sha256(serialized) };
}
export async function readProxyReplayBundle(bundlePath) {
    const resolvedPath = resolve(bundlePath);
    const info = await stat(resolvedPath);
    if (!info.isFile() || info.size > MAX_BUNDLE_BYTES) {
        throw new Error(`Replay bundle must be a regular file no larger than ${MAX_BUNDLE_BYTES} bytes`);
    }
    const parsed = JSON.parse(await readFile(resolvedPath, "utf8"));
    assertProxyReplayBundle(parsed);
    return parsed;
}
function normalizedContentType(value) {
    return value?.split(";", 1)[0]?.trim().toLowerCase() || null;
}
function jsonShape(body) {
    let parsed;
    try {
        parsed = JSON.parse(body);
    }
    catch {
        return null;
    }
    const paths = [];
    const walk = (value, path, depth) => {
        if (paths.length >= 2_000 || depth > 20) {
            return;
        }
        if (Array.isArray(value)) {
            paths.push(`${path}:array`);
            value
                .slice(0, 20)
                .forEach((child, index) => walk(child, `${path}/${index}`, depth + 1));
            return;
        }
        if (value && typeof value === "object") {
            paths.push(`${path}:object`);
            for (const [key, child] of Object.entries(value).sort(([left], [right]) => left.localeCompare(right))) {
                walk(child, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, depth + 1);
            }
            return;
        }
        paths.push(`${path}:${value === null ? "null" : typeof value}`);
    };
    walk(parsed, "", 0);
    return paths;
}
function validateDirectEndpoint(value, explicitOverride) {
    if (!value) {
        throw new Error("Replay bundle has no upstream URL; provide an explicit URL override");
    }
    const endpoint = new URL(value);
    if (endpoint.username || endpoint.password || endpoint.hash) {
        throw new Error("Replay endpoint must not contain credentials or a fragment");
    }
    for (const name of endpoint.searchParams.keys()) {
        if (SENSITIVE_QUERY_NAME_PATTERN.test(name)) {
            throw new Error(`Replay endpoint must not contain a sensitive query parameter: ${name}`);
        }
    }
    const loopback = ["127.0.0.1", "::1", "[::1]", "localhost"].includes(endpoint.hostname);
    if (endpoint.protocol !== "https:" &&
        !(endpoint.protocol === "http:" && loopback)) {
        throw new Error("Replay endpoint must use HTTPS; HTTP is allowed only for loopback testing");
    }
    if (!explicitOverride &&
        !TRUSTED_CAPTURED_ENDPOINTS.has(endpoint.toString())) {
        throw new Error("Replay bundle contains an untrusted endpoint; pass an explicit URL override to authorize it");
    }
    return endpoint;
}
async function readBoundedResponse(response) {
    if (!response.body) {
        return { body: "", observedBytes: 0, truncated: false };
    }
    const reader = response.body.getReader();
    const chunks = [];
    let storedBytes = 0;
    let observedBytes = 0;
    let truncated = false;
    try {
        while (true) {
            const next = await reader.read();
            if (next.done) {
                break;
            }
            observedBytes += next.value.byteLength;
            if (storedBytes < MAX_DIRECT_RESPONSE_BYTES) {
                const remaining = MAX_DIRECT_RESPONSE_BYTES - storedBytes;
                chunks.push(next.value.slice(0, remaining));
                storedBytes += Math.min(remaining, next.value.byteLength);
            }
            if (observedBytes > MAX_DIRECT_RESPONSE_BYTES) {
                truncated = true;
                await reader
                    .cancel("bounded replay comparison capture")
                    .catch(() => undefined);
                break;
            }
        }
    }
    finally {
        reader.releaseLock();
    }
    return {
        body: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8"),
        observedBytes,
        truncated,
    };
}
export async function compareProxyReplayBundle(bundle, options) {
    assertProxyReplayBundle(bundle);
    if (!options.execute) {
        throw new Error("Direct replay is disabled unless execute=true is explicitly provided");
    }
    if (!["POST", "PUT", "PATCH"].includes(bundle.request.method)) {
        throw new Error(`Unsupported replay request method: ${bundle.request.method}`);
    }
    const requestBody = options.bodyOverride ?? bundle.request.body;
    if (requestBody === null) {
        throw new Error("Replay request body is missing; provide a body override");
    }
    if (options.bodyOverride === undefined &&
        (bundle.request.bodyTruncated || requestBody.includes(REDACTED_VALUE))) {
        throw new Error("Captured replay body is truncated or redacted; provide a complete body override");
    }
    if (options.bodyOverride === undefined &&
        bundle.request.bodySha256 === null) {
        throw new Error("Captured replay body has no verified hash; provide a complete body override");
    }
    const endpoint = validateDirectEndpoint(options.urlOverride ?? bundle.request.url ?? "", options.urlOverride !== undefined);
    const suppliedHeaders = Object.fromEntries(Object.entries(options.headerValues ?? {}).map(([name, value]) => [
        name.toLowerCase(),
        value,
    ]));
    const invalidHeaderNames = Object.keys(suppliedHeaders).filter((name) => !isValidProxyReplayHeaderName(name));
    if (invalidHeaderNames.length > 0) {
        throw new Error(`Invalid replay header names: ${invalidHeaderNames.join(", ")}`);
    }
    const requiredHeaderInputs = [
        ...new Set([
            ...bundle.request.requiredHeaderInputs.map((name) => name.toLowerCase()),
            ...Object.entries(bundle.request.headers)
                .filter(([, value]) => value === REDACTED_VALUE)
                .map(([name]) => name.toLowerCase()),
        ]),
    ].sort();
    const missingHeaderValues = requiredHeaderInputs.filter((name) => !suppliedHeaders[name]);
    if (missingHeaderValues.length > 0) {
        throw new Error(`Missing environment-backed values for redacted headers: ${missingHeaderValues.join(", ")}`);
    }
    const requestHeaders = {};
    for (const [name, capturedValue] of Object.entries(bundle.request.headers)) {
        const lower = name.toLowerCase();
        if (HOP_BY_HOP_HEADERS.has(lower)) {
            continue;
        }
        requestHeaders[lower] =
            capturedValue === REDACTED_VALUE
                ? suppliedHeaders[lower]
                : capturedValue;
    }
    const timeoutMs = options.timeoutMs ?? 120_000;
    if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 600_000) {
        throw new Error("Replay timeout must be between 1000 and 600000 milliseconds");
    }
    const now = options.now ?? Date.now;
    const startedAt = now();
    const response = await (options.fetchImpl ?? fetch)(endpoint, {
        method: bundle.request.method,
        headers: requestHeaders,
        body: requestBody,
        redirect: "manual",
        signal: AbortSignal.timeout(timeoutMs),
    });
    const headersAt = now();
    const rawResponseHeaders = Object.fromEntries(response.headers.entries());
    const directCapture = await readBoundedResponse(response);
    const endedAt = now();
    const preparedBody = prepareProxyBodyForLogging(directCapture.body);
    const redactedBody = preparedBody.value ?? "";
    const captured = bundle.capturedResponse;
    const capturedShape = captured?.body ? jsonShape(captured.body) : null;
    const directShape = jsonShape(redactedBody);
    const capturedContentType = normalizedContentType(captured?.contentType ?? null);
    const directContentType = normalizedContentType(response.headers.get("content-type"));
    return {
        schemaVersion: 1,
        kind: COMPARISON_KIND,
        requestId: bundle.requestId,
        selectedAttempt: bundle.selectedAttempt,
        endpoint: endpoint.toString(),
        request: {
            method: bundle.request.method,
            headers: redactProxyHeadersForLogging(requestHeaders) ?? {},
            bodySha256: sha256(requestBody),
            bodyBytes: Buffer.byteLength(requestBody, "utf8"),
            usedBodyOverride: options.bodyOverride !== undefined,
        },
        captured: captured
            ? {
                status: captured.responseStatus,
                contentType: captured.contentType,
                bodySha256: captured.bodySha256,
                bodyBytes: captured.observedBodyBytes,
                bodyTruncated: captured.bodyTruncated,
                jsonShape: capturedShape,
            }
            : null,
        direct: {
            status: response.status,
            headers: redactProxyHeadersForLogging(rawResponseHeaders) ?? {},
            contentType: response.headers.get("content-type"),
            body: redactedBody,
            bodySha256: sha256(redactedBody),
            observedBodyBytes: directCapture.observedBytes,
            storedBodyBytes: Buffer.byteLength(redactedBody, "utf8"),
            bodyTruncated: directCapture.truncated || preparedBody.truncated,
            timeToHeadersMs: headersAt - startedAt,
            totalMs: endedAt - startedAt,
            jsonShape: directShape,
        },
        comparison: {
            statusMatches: captured && captured.responseStatus !== null
                ? captured.responseStatus === response.status
                : null,
            contentTypeMatches: captured
                ? capturedContentType !== null && directContentType !== null
                    ? capturedContentType === directContentType
                    : null
                : null,
            bodyHashMatches: captured?.bodySha256 &&
                !captured.bodyTruncated &&
                !directCapture.truncated &&
                !preparedBody.truncated
                ? captured.bodySha256 === sha256(redactedBody)
                : null,
            jsonShapeMatches: capturedShape &&
                directShape &&
                !captured?.bodyTruncated &&
                !directCapture.truncated &&
                !preparedBody.truncated
                ? JSON.stringify(capturedShape) === JSON.stringify(directShape)
                : null,
        },
    };
}
