import { HTTPError } from '../errors/HTTPError.js';
import { NetworkError } from '../errors/NetworkError.js';
import { NonError } from '../errors/NonError.js';
import { ForceRetryError } from '../errors/ForceRetryError.js';
import { SchemaValidationError } from '../errors/SchemaValidationError.js';
import { TimeoutError } from '../errors/TimeoutError.js';
import { streamRequest, streamResponse } from '../utils/body.js';
import { cloneShallow, mergeHeaders, mergeHooks, deletedParametersSymbol, } from '../utils/merge.js';
import { normalizeRequestMethod, normalizeRetryOptions } from '../utils/normalize.js';
import timeout from '../utils/timeout.js';
import delay from '../utils/delay.js';
import { findUnknownOptions, hasSearchParameters } from '../utils/options.js';
import isRawNetworkError from '../utils/is-network-error.js';
import { isHTTPError, isNetworkError, isTimeoutError } from '../utils/type-guards.js';
import { maxSafeTimeout, responseTypes, stop, RetryMarker, supportsAbortController, supportsAbortSignal, supportsFormData, supportsResponseStreams, supportsRequestStreams, } from './constants.js';
const maxErrorResponseBodySize = 10 * 1024 * 1024;
const prefixUrlRenamedErrorMessage = 'The `prefixUrl` option has been renamed `prefix` in v2 and enhanced to allow slashes in input. See also the new `baseUrl` option for improved flexibility with standard URL resolution: https://github.com/sindresorhus/ky#baseurl';
const timedOutResponseData = Symbol('timedOutResponseData');
const createTextDecoder = (contentType) => {
    const match = /;\s*charset\s*=\s*(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType);
    const charset = match?.[1] ?? match?.[2];
    if (charset) {
        try {
            return new TextDecoder(charset);
        }
        catch { }
    }
    return new TextDecoder();
};
const invalidSchemaMessage = 'The `schema` argument must follow the Standard Schema specification';
const cloneRetryOptions = (retry) => {
    if (typeof retry !== 'object') {
        return retry;
    }
    // Clone nested arrays too so init hooks can mutate retry config without leaking state across requests.
    return {
        ...retry,
        ...(retry.methods && { methods: [...retry.methods] }),
        ...(retry.statusCodes && { statusCodes: [...retry.statusCodes] }),
        ...(retry.afterStatusCodes && { afterStatusCodes: [...retry.afterStatusCodes] }),
    };
};
const objectToString = Object.prototype.toString;
const isRequestInstance = (value) => value instanceof globalThis.Request || objectToString.call(value) === '[object Request]';
// Accepted custom responses are treated as full Responses throughout Ky.
// If a custom fetch returns one, it must behave like a Response for cloning,
// body consumption, `json()` decoration, and any enabled stream features.
const isResponseInstance = (value) => value instanceof globalThis.Response || objectToString.call(value) === '[object Response]';
// Shallow-clone mutable option properties so init hook mutations don't leak across requests.
function cloneInitHookOptions(options) {
    const clonedOptions = {
        ...options,
        json: cloneShallow(options.json),
        context: cloneShallow(options.context),
        headers: cloneShallow(options.headers),
        searchParams: cloneShallow(options.searchParams),
    };
    if (options.retry !== undefined) {
        clonedOptions.retry = cloneRetryOptions(options.retry);
    }
    return clonedOptions;
}
const validateJsonWithSchema = async (jsonValue, schema) => {
    if ((typeof schema !== 'object'
        && typeof schema !== 'function')
        || schema === null) {
        throw new TypeError(invalidSchemaMessage);
    }
    const standardSchema = schema['~standard'];
    if (typeof standardSchema !== 'object'
        || standardSchema === null
        || typeof standardSchema.validate !== 'function') {
        throw new TypeError(invalidSchemaMessage);
    }
    const validationResult = await standardSchema.validate(jsonValue);
    if (validationResult.issues) {
        throw new SchemaValidationError(validationResult.issues);
    }
    return validationResult.value;
};
export class Ky {
    static create(input, options) {
        const initHooks = options.hooks?.init ?? [];
        const initHookOptions = initHooks.length > 0 ? cloneInitHookOptions(options) : options;
        for (const hook of initHooks) {
            hook(initHookOptions);
        }
        const ky = new Ky(input, initHookOptions);
        const function_ = async () => {
            if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) {
                throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
            }
            if (typeof ky.#options.totalTimeout === 'number' && ky.#options.totalTimeout > maxSafeTimeout) {
                throw new RangeError(`The \`totalTimeout\` option cannot be greater than ${maxSafeTimeout}`);
            }
            // Delay the fetch so that body method shortcuts can set the Accept header
            await Promise.resolve();
            const beforeRequestResponse = await ky.#runBeforeRequestHooks();
            let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch());
            let responseFromHook = beforeRequestResponse !== undefined
                || ky.#consumeReturnedResponseFromBeforeRetryHook();
            for (;;) {
                // `undefined` means a hook stopped the flow without providing a response.
                // Non-native Responses still continue through Ky if they pass `isResponseInstance()`.
                if (response === undefined) {
                    return response;
                }
                if (isResponseInstance(response)) {
                    try {
                        // eslint-disable-next-line no-await-in-loop
                        response = await ky.#runAfterResponseHooks(response);
                    }
                    catch (error) {
                        if (!(error instanceof ForceRetryError)) {
                            throw error;
                        }
                        // eslint-disable-next-line no-await-in-loop
                        const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch());
                        if (retriedResponse === undefined) {
                            return retriedResponse;
                        }
                        response = retriedResponse;
                        responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook();
                        continue;
                    }
                }
                const currentResponse = response;
                // Opaque responses (`response.type === 'opaque'`) from `no-cors` requests always have `status: 0` and `ok: false`, but this is not a failure - the actual status is hidden by the browser.
                if (!currentResponse.ok && currentResponse.type !== 'opaque' && (typeof ky.#options.throwHttpErrors === 'function'
                    ? ky.#options.throwHttpErrors(currentResponse.status)
                    : ky.#options.throwHttpErrors)) {
                    // `request` must reflect the request that actually failed, but `options` stays as Ky's
                    // normalized options snapshot. Replacement `Request` instances do not preserve the
                    // original `BodyInit`, so trying to make `options` mirror arbitrary requests would be lossy.
                    const httpError = new HTTPError(currentResponse, ky.#getResponseRequest(currentResponse), ky.#getNormalizedOptions());
                    const errorToThrow = httpError;
                    // eslint-disable-next-line no-await-in-loop
                    httpError.data = await ky.#getResponseData(currentResponse);
                    if (responseFromHook) {
                        throw errorToThrow;
                    }
                    // eslint-disable-next-line no-await-in-loop
                    const retriedResponse = await ky.#retryFromError(httpError, async () => ky.#fetch());
                    if (retriedResponse === undefined) {
                        return retriedResponse;
                    }
                    response = retriedResponse;
                    responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook();
                    continue;
                }
                break;
            }
            if (!isResponseInstance(response)) {
                return response;
            }
            ky.#decorateResponse(response);
            // If `onDownloadProgress` is passed, it uses the stream API internally
            if (ky.#options.onDownloadProgress) {
                if (typeof ky.#options.onDownloadProgress !== 'function') {
                    throw new TypeError('The `onDownloadProgress` option must be a function');
                }
                if (!supportsResponseStreams) {
                    throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
                }
                const progressResponse = response.clone();
                ky.#cancelResponseBody(response);
                return streamResponse(progressResponse, ky.#options.onDownloadProgress);
            }
            return response;
        };
        const result = (async () => {
            try {
                return await function_();
            }
            catch (error) {
                // Non-Error throws (e.g., thrown strings) pass through unchanged
                if (!(error instanceof Error)) {
                    throw error;
                }
                // Errors thrown by beforeRetry hooks must propagate unchanged.
                if (ky.#beforeRetryHookErrors.has(error)) {
                    throw error;
                }
                let processedError = error;
                for (const hook of ky.#options.hooks.beforeError) {
                    // `request` is the current failing request. `options` intentionally remains the
                    // stable normalized Ky options snapshot for the same reason as `HTTPError` above.
                    // eslint-disable-next-line no-await-in-loop
                    const hookResult = await hook({
                        request: ky.request,
                        options: ky.#getNormalizedOptions(),
                        error: processedError,
                        retryCount: ky.#retryCount,
                    });
                    // Only overwrite if the hook returns a valid Error instance.
                    if (hookResult instanceof Error) {
                        processedError = hookResult;
                    }
                }
                throw processedError;
            }
            finally {
                const originalRequest = ky.#originalRequest;
                // Ignore cancellation errors from already-locked or already-consumed streams.
                ky.#cancelBody(originalRequest?.body ?? undefined);
                // Only cancel the current request body if it's distinct from the original (i.e. it was cloned for retries).
                if (ky.request !== originalRequest) {
                    ky.#cancelBody(ky.request.body ?? undefined);
                }
            }
        })();
        for (const [type, mimeType] of Object.entries(responseTypes)) {
            // Only expose `.bytes()` when the environment implements it.
            if (type === 'bytes'
                && typeof globalThis.Response?.prototype?.bytes !== 'function') {
                continue;
            }
            result[type] = async (schema) => {
                // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
                ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
                const response = await result;
                if (type !== 'json') {
                    return response[type]();
                }
                const text = await response.text();
                if (text === '') {
                    if (schema !== undefined) {
                        return validateJsonWithSchema(undefined, schema);
                    }
                    return JSON.parse(text);
                }
                const jsonValue = initHookOptions.parseJson
                    ? await initHookOptions.parseJson(text, { request: ky.#getResponseRequest(response), response })
                    : JSON.parse(text);
                return schema === undefined ? jsonValue : validateJsonWithSchema(jsonValue, schema);
            };
        }
        return result;
    }
    // eslint-disable-next-line unicorn/prevent-abbreviations
    static #normalizeSearchParams(searchParams) {
        // Filter out undefined values from plain objects
        if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) {
            return Object.fromEntries(Object.entries(searchParams).filter(([, value]) => value !== undefined));
        }
        return searchParams;
    }
    request;
    #abortController;
    #retryCount = 0;
    // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202
    #input;
    #options;
    #originalRequest;
    #userProvidedAbortSignal;
    #beforeRetryHookErrors = new WeakSet();
    #cachedNormalizedOptions;
    #startTime;
    #returnedResponseFromBeforeRetryHook = false;
    #responseRequests = new WeakMap();
    // eslint-disable-next-line complexity
    constructor(input, options = {}) {
        this.#input = input;
        if (Object.hasOwn(options, 'prefixUrl')) {
            throw new Error(prefixUrlRenamedErrorMessage);
        }
        this.#options = {
            ...options,
            headers: mergeHeaders(this.#input.headers, options.headers),
            hooks: mergeHooks({}, options.hooks),
            method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'),
            // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
            prefix: String(options.prefix || ''),
            retry: normalizeRetryOptions(options.retry),
            throwHttpErrors: options.throwHttpErrors ?? true,
            timeout: options.timeout ?? 10_000,
            totalTimeout: options.totalTimeout ?? false,
            fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
            context: options.context ?? {},
        };
        if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) {
            throw new TypeError('`input` must be a string, URL, or Request');
        }
        if (typeof this.#input === 'string') {
            if (this.#options.prefix) {
                const normalizedPrefix = this.#options.prefix.replace(/\/+$/, '');
                const normalizedInput = this.#input.replace(/^\/+/, '');
                this.#input = `${normalizedPrefix}/${normalizedInput}`;
            }
            if (this.#options.baseUrl) {
                let absoluteInput;
                try {
                    absoluteInput = new URL(this.#input);
                }
                catch { }
                if (!absoluteInput) {
                    this.#input = new URL(this.#input, (new Request(this.#options.baseUrl)).url);
                }
            }
        }
        if (supportsAbortController && supportsAbortSignal) {
            this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal;
            this.#abortController = new globalThis.AbortController();
            this.#options.signal = this.#createManagedSignal();
        }
        if (supportsRequestStreams) {
            // @ts-expect-error - Types are outdated.
            this.#options.duplex = 'half';
        }
        if (this.#options.json !== undefined) {
            this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json);
            this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json');
        }
        // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body
        // Only delete if user didn't explicitly provide a custom content-type
        const userProvidedContentType = options.headers && new globalThis.Headers(options.headers).has('content-type');
        if (this.#input instanceof globalThis.Request
            && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams)
            && !userProvidedContentType) {
            this.#options.headers.delete('content-type');
        }
        this.request = new globalThis.Request(this.#input, this.#options);
        if (hasSearchParameters(this.#options.searchParams)) {
            const url = new URL(this.request.url);
            if (typeof this.#options.searchParams === 'string') {
                const stringSearchParameters = this.#options.searchParams.replace(/^\?/, '');
                if (stringSearchParameters !== '') {
                    url.search = url.search ? `${url.search}&${stringSearchParameters}` : `?${stringSearchParameters}`;
                }
            }
            else {
                const optionsSearchParameters = new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams));
                for (const [key, value] of optionsSearchParameters.entries()) {
                    url.searchParams.append(key, value);
                }
            }
            if (this.#options.searchParams
                && typeof this.#options.searchParams === 'object'
                && !Array.isArray(this.#options.searchParams)
                && !(this.#options.searchParams instanceof URLSearchParams)) {
                for (const [key, value] of Object.entries(this.#options.searchParams)) {
                    if (value === undefined) {
                        url.searchParams.delete(key);
                    }
                }
            }
            const deleted = this.#options.searchParams?.[deletedParametersSymbol];
            if (deleted) {
                for (const key of deleted) {
                    url.searchParams.delete(key);
                }
            }
            // Recreate request with the updated URL. We already have all options in this.#options, including duplex.
            this.request = new globalThis.Request(url, this.#options);
        }
        if (this.#options.onUploadProgress && typeof this.#options.onUploadProgress !== 'function') {
            throw new TypeError('The `onUploadProgress` option must be a function');
        }
        // `totalTimeout` starts when the request pipeline is created, so it also includes
        // Ky's internal scheduling and user hook time before the first fetch attempt.
        this.#startTime = typeof this.#options.totalTimeout === 'number' ? this.#getCurrentTime() : undefined;
    }
    #calculateDelay() {
        const retryDelay = this.#options.retry.delay(this.#retryCount + 1);
        let jitteredDelay = retryDelay;
        if (this.#options.retry.jitter === true) {
            jitteredDelay = Math.random() * retryDelay;
        }
        else if (typeof this.#options.retry.jitter === 'function') {
            jitteredDelay = this.#options.retry.jitter(retryDelay);
            if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) {
                jitteredDelay = retryDelay;
            }
        }
        return Math.min(this.#options.retry.backoffLimit, jitteredDelay);
    }
    async #calculateRetryDelay(error) {
        if (this.#retryCount >= this.#options.retry.limit) {
            throw error;
        }
        // Wrap non-Error throws to ensure consistent error handling
        const errorObject = error instanceof Error ? error : new NonError(error);
        // Handle forced retry from afterResponse hook - skip method check and shouldRetry
        if (errorObject instanceof ForceRetryError) {
            return errorObject.customDelay ?? this.#calculateDelay();
        }
        // Check if method is retriable for non-forced retries
        if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
            throw error;
        }
        // User-provided shouldRetry function takes precedence over default checks (retryOnTimeout, status codes, etc.)
        if (this.#options.retry.shouldRetry !== undefined) {
            const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount + 1 });
            // Strict boolean checking - only exact true/false are handled specially
            if (result === false) {
                throw error;
            }
            if (result === true) {
                // Force retry - skip all other validation and return delay
                return this.#calculateDelay();
            }
            // If undefined or any other value, fall through to default behavior
        }
        // Default timeout behavior
        if (isTimeoutError(error)) {
            if (!this.#options.retry.retryOnTimeout) {
                throw error;
            }
            return this.#calculateDelay();
        }
        if (isHTTPError(error)) {
            if (!this.#options.retry.statusCodes.includes(error.response.status)) {
                throw error;
            }
            const retryAfter = error.response.headers.get('Retry-After')
                ?? error.response.headers.get('RateLimit-Reset')
                ?? error.response.headers.get('X-RateLimit-Retry-After') // Symfony-based services
                ?? error.response.headers.get('X-RateLimit-Reset') // GitHub
                ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter
            if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) {
                let after = Number(retryAfter) * 1000;
                if (Number.isNaN(after)) {
                    after = Date.parse(retryAfter) - Date.now();
                }
                else if (after >= Date.parse('2024-01-01')) {
                    // A large number is treated as a timestamp (fixed threshold protects against clock skew)
                    after -= Date.now();
                }
                if (!Number.isFinite(after)) {
                    return Math.min(this.#options.retry.maxRetryAfter, this.#calculateDelay());
                }
                after = Math.max(0, after);
                // Don't apply jitter when server provides explicit retry timing
                return Math.min(this.#options.retry.maxRetryAfter, after);
            }
            if (error.response.status === 413) {
                throw error;
            }
            return this.#calculateDelay();
        }
        // Only retry known retriable error types. Unknown errors (e.g., programming bugs) are not retried.
        if (!isNetworkError(error)) {
            throw error;
        }
        return this.#calculateDelay();
    }
    #decorateResponse(response) {
        const request = this.#getResponseRequest(response);
        if (this.#options.parseJson) {
            response.json = async () => {
                const text = await response.text();
                if (text === '') {
                    return JSON.parse(text);
                }
                return this.#options.parseJson(text, { request, response });
            };
        }
        return response;
    }
    async #getResponseData(response) {
        // Even with request timeouts disabled, bound error-body reads so retries and error propagation
        // cannot be stalled indefinitely by never-ending response streams.
        const text = await this.#readResponseText(response, this.#getErrorDataTimeout());
        if (text === timedOutResponseData) {
            this.#throwIfTotalTimeoutExhausted();
            return undefined;
        }
        if (!text) {
            return undefined;
        }
        if (!this.#isJsonContentType(response.headers.get('content-type') ?? '')) {
            return text;
        }
        const data = await this.#parseJson(text, response, this.#getErrorDataTimeout(), this.#getResponseRequest(response));
        if (data === timedOutResponseData) {
            this.#throwIfTotalTimeoutExhausted();
            return undefined;
        }
        return data;
    }
    #getErrorDataTimeout() {
        const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
        const remainingTotal = this.#getRemainingTotalTimeout();
        if (remainingTotal === undefined) {
            return errorDataTimeout;
        }
        if (remainingTotal <= 0) {
            throw new TimeoutError(this.request);
        }
        return Math.min(errorDataTimeout, remainingTotal);
    }
    #isJsonContentType(contentType) {
        // Match JSON subtypes like `json`, `problem+json`, and `vnd.api+json`.
        const mimeType = (contentType.split(';', 1)[0] ?? '').trim().toLowerCase();
        return /\/(?:.*[.+-])?json$/.test(mimeType);
    }
    async #readResponseText(response, timeoutMs) {
        const { body } = response;
        if (!body) {
            try {
                return await response.text();
            }
            catch {
                return undefined;
            }
        }
        let reader;
        try {
            reader = body.getReader();
        }
        catch {
            // Another consumer already locked the stream.
            return undefined;
        }
        const decoder = createTextDecoder(response.headers.get('content-type') ?? '');
        const chunks = [];
        let totalBytes = 0;
        const readAll = (async () => {
            try {
                for (;;) {
                    // eslint-disable-next-line no-await-in-loop
                    const { done, value } = await reader.read();
                    if (done) {
                        break;
                    }
                    totalBytes += value.byteLength;
                    if (totalBytes > maxErrorResponseBodySize) {
                        void reader.cancel().catch(() => undefined);
                        return undefined;
                    }
                    chunks.push(decoder.decode(value, { stream: true }));
                }
            }
            catch {
                return undefined;
            }
            chunks.push(decoder.decode());
            return chunks.join('');
        })();
        const timeoutPromise = new Promise(resolve => {
            const timeoutId = setTimeout(() => {
                resolve(timedOutResponseData);
            }, timeoutMs);
            void readAll.finally(() => {
                clearTimeout(timeoutId);
            });
        });
        const result = await Promise.race([readAll, timeoutPromise]);
        if (result === timedOutResponseData) {
            void reader.cancel().catch(() => undefined);
        }
        return result;
    }
    async #parseJson(text, response, timeoutMs, request) {
        let timeoutId;
        try {
            return await Promise.race([
                Promise.resolve().then(() => this.#options.parseJson
                    ? this.#options.parseJson(text, { request, response })
                    : JSON.parse(text)),
                new Promise(resolve => {
                    timeoutId = setTimeout(() => {
                        resolve(timedOutResponseData);
                    }, timeoutMs);
                }),
            ]);
        }
        catch {
            return undefined;
        }
        finally {
            clearTimeout(timeoutId);
        }
    }
    #cancelBody(body) {
        if (!body) {
            return;
        }
        // Ignore cancellation failures from already-locked or already-consumed streams.
        void body.cancel().catch(() => undefined);
    }
    #cancelResponseBody(response) {
        // Ignore cancellation failures from already-locked or already-consumed streams.
        this.#cancelBody(response.body ?? undefined);
    }
    #createManagedSignal() {
        return this.#userProvidedAbortSignal
            ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal])
            : this.#abortController.signal;
    }
    #throwIfTotalTimeoutExhausted() {
        const remaining = this.#getRemainingTotalTimeout();
        if (remaining !== undefined && remaining <= 0) {
            throw new TimeoutError(this.request);
        }
    }
    async #runBeforeRequestHooks() {
        for (const hook of this.#options.hooks.beforeRequest) {
            // eslint-disable-next-line no-await-in-loop
            const result = await hook({
                request: this.request,
                options: this.#getNormalizedOptions(),
                retryCount: 0,
            });
            if (isRequestInstance(result)) {
                this.#assignRequest(result);
            }
            else if (isResponseInstance(result)) {
                return result;
            }
        }
        return undefined;
    }
    async #runAfterResponseHooks(response) {
        const responseRequest = this.#getResponseRequest(response);
        for (const hook of this.#options.hooks.afterResponse) {
            const hookResponse = this.#setResponseRequest(response.clone(), responseRequest);
            this.#decorateResponse(hookResponse);
            let modifiedResponse;
            try {
                // eslint-disable-next-line no-await-in-loop
                modifiedResponse = await hook({
                    request: this.request,
                    options: this.#getNormalizedOptions(),
                    response: hookResponse,
                    retryCount: this.#retryCount,
                });
            }
            catch (error) {
                // Cancel both responses to prevent memory leaks when hook throws
                if (hookResponse !== response) {
                    this.#cancelResponseBody(hookResponse);
                }
                this.#cancelResponseBody(response);
                throw error;
            }
            if (modifiedResponse instanceof RetryMarker) {
                // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun).
                // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec.
                if (hookResponse !== response) {
                    this.#cancelResponseBody(hookResponse);
                }
                this.#cancelResponseBody(response);
                throw new ForceRetryError(modifiedResponse.options);
            }
            const nextResponse = isResponseInstance(modifiedResponse)
                ? this.#setResponseRequest(modifiedResponse, responseRequest)
                : response;
            // Cancel any response bodies we won't use to prevent memory leaks.
            // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation.
            // If the hook wrapped an existing body into a new Response, both Response objects can still point at the same stream.
            if (hookResponse !== response && hookResponse !== nextResponse && hookResponse.body !== nextResponse.body) {
                this.#cancelResponseBody(hookResponse);
            }
            if (response !== nextResponse && response.body !== nextResponse.body) {
                this.#cancelResponseBody(response);
            }
            response = nextResponse;
        }
        return response;
    }
    async #retry(function_) {
        try {
            return await function_();
        }
        catch (error) {
            return this.#retryFromError(error, function_);
        }
    }
    async #retryFromError(error, function_) {
        this.#returnedResponseFromBeforeRetryHook = false;
        const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
        const delayOptions = { signal: this.#userProvidedAbortSignal };
        const remainingTimeout = this.#getRemainingTotalTimeout();
        if (remainingTimeout !== undefined) {
            if (remainingTimeout <= 0) {
                throw new TimeoutError(this.request);
            }
            // If waiting would consume all remaining budget, time out without starting another request.
            if (retryDelay >= remainingTimeout) {
                await delay(remainingTimeout, delayOptions);
                throw new TimeoutError(this.request);
            }
        }
        // Only use user-provided signal for delay, not our internal abortController
        await delay(retryDelay, delayOptions);
        this.#throwIfTotalTimeoutExhausted();
        // Apply custom request from forced retry before beforeRetry hooks
        // Ensure the custom request has the correct managed signal for timeouts and user aborts
        if (error instanceof ForceRetryError && error.customRequest) {
            this.#assignRequest(new globalThis.Request(error.customRequest, this.#options.signal ? { signal: this.#options.signal } : undefined));
        }
        for (const hook of this.#options.hooks.beforeRetry) {
            let hookResult;
            try {
                // eslint-disable-next-line no-await-in-loop
                hookResult = await hook({
                    request: this.request,
                    options: this.#getNormalizedOptions(),
                    error: error,
                    retryCount: this.#retryCount + 1,
                });
            }
            catch (hookError) {
                // Preserve the original request error path (`throw error`) so beforeError hooks can still run.
                if (hookError instanceof Error && hookError !== error) {
                    this.#beforeRetryHookErrors.add(hookError);
                }
                throw hookError;
            }
            if (isRequestInstance(hookResult)) {
                this.#assignRequest(hookResult);
                break;
            }
            if (isResponseInstance(hookResult)) {
                this.#returnedResponseFromBeforeRetryHook = true;
                this.#retryCount++;
                return hookResult;
            }
            // If `stop` is returned from the hook, the retry process is stopped
            if (hookResult === stop) {
                return;
            }
        }
        this.#throwIfTotalTimeoutExhausted();
        this.#retryCount++;
        return this.#retry(function_);
    }
    #consumeReturnedResponseFromBeforeRetryHook() {
        const value = this.#returnedResponseFromBeforeRetryHook;
        this.#returnedResponseFromBeforeRetryHook = false;
        return value;
    }
    async #fetch() {
        // Reset abortController if it was aborted (happens on timeout retry)
        if (this.#abortController?.signal.aborted) {
            this.#abortController = new globalThis.AbortController();
            this.#options.signal = this.#createManagedSignal();
            // Recreate request with new signal
            this.request = new globalThis.Request(this.request, { signal: this.#options.signal });
        }
        const nonRequestOptions = findUnknownOptions(this.#options);
        const retryRequest = this.#options.retry.limit > 0 ? this.request.clone() : undefined;
        const request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
        // Cloning is done here to prepare in advance for retries.
        // Skip cloning when retries are disabled - cloning a streaming body calls ReadableStream#tee()
        // which buffers the entire stream in memory, causing excessive memory usage for large uploads.
        this.#originalRequest = request;
        if (retryRequest) {
            this.request = retryRequest;
        }
        try {
            const remainingTotal = this.#getRemainingTotalTimeout();
            if (remainingTotal !== undefined && remainingTotal <= 0) {
                throw new TimeoutError(this.request);
            }
            const effectiveTimeout = this.#options.timeout === false
                ? remainingTotal
                : (remainingTotal === undefined
                    ? this.#options.timeout
                    : Math.min(this.#options.timeout, remainingTotal));
            const response = effectiveTimeout === undefined
                ? await this.#options.fetch(request, nonRequestOptions)
                : await timeout(request, nonRequestOptions, this.#abortController, {
                    timeout: effectiveTimeout,
                    fetch: this.#options.fetch,
                });
            return this.#setResponseRequest(response, request);
        }
        catch (error) {
            if (isRawNetworkError(error)) {
                throw new NetworkError(this.request, { cause: error });
            }
            throw error;
        }
    }
    #getRemainingTotalTimeout() {
        if (this.#startTime === undefined) {
            return undefined;
        }
        const elapsed = this.#getCurrentTime() - this.#startTime;
        return Math.max(0, this.#options.totalTimeout - elapsed);
    }
    #getCurrentTime() {
        return globalThis.performance?.now() ?? Date.now();
    }
    #getNormalizedOptions() {
        if (!this.#cachedNormalizedOptions) {
            // Exclude Ky-specific options that are not part of `RequestInit`.
            const { hooks, json, parseJson, stringifyJson, searchParams, timeout, totalTimeout, throwHttpErrors, fetch, ...normalizedOptions } = this.#options;
            this.#cachedNormalizedOptions = Object.freeze(normalizedOptions);
        }
        return this.#cachedNormalizedOptions;
    }
    #assignRequest(request) {
        this.#cachedNormalizedOptions = undefined;
        this.request = request;
    }
    #getResponseRequest(response) {
        return this.#responseRequests.get(response) ?? this.request;
    }
    #setResponseRequest(response, request) {
        this.#responseRequests.set(response, request);
        return response;
    }
    #wrapRequestWithUploadProgress(request, originalBody) {
        if (!this.#options.onUploadProgress || !request.body || !supportsRequestStreams) {
            return request;
        }
        return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
    }
}
//# sourceMappingURL=Ky.js.map