import { EventEmitter } from 'node:events';
import is from '@sindresorhus/is';
import { HTTPError, RetryError, } from '../core/errors.js';
import Request, { normalizeError } from '../core/index.js';
import { decodeUint8Array, isUtf8Encoding, parseBody, isResponseOk, ParseError, } from '../core/response.js';
import proxyEvents from '../core/utils/proxy-events.js';
import { applyUrlOverride, assertUrlHasSameOriginAsPrefixUrlIfNeeded, getUrlPrefixBoundary, hasUrlOrPrefixUrlBoundaryChanged, isSameOrigin, snapshotCrossOriginState, } from '../core/options.js';
const compressedEncodings = new Set(['gzip', 'deflate', 'br', 'zstd']);
const proxiedRequestEvents = [
    'request',
    'response',
    'redirect',
    'uploadProgress',
    'downloadProgress',
];
export default function asPromise(firstRequest) {
    let globalRequest;
    let globalResponse;
    const emitter = new EventEmitter();
    let promiseSettled = false;
    const promise = new Promise((resolve, reject) => {
        const makeRequest = (retryCount, defaultOptions) => {
            const request = firstRequest ?? new Request(undefined, undefined, defaultOptions);
            request.retryCount = retryCount;
            request._noPipe = true;
            globalRequest = request;
            request.once('response', (response) => {
                void (async () => {
                    // Parse body
                    const contentEncoding = (response.headers['content-encoding'] ?? '').toLowerCase();
                    const isCompressed = compressedEncodings.has(contentEncoding);
                    const { options } = request;
                    if (isCompressed && !options.decompress) {
                        response.body = response.rawBody;
                    }
                    else {
                        try {
                            response.body = parseBody(response, options.responseType, options.parseJson, options.encoding);
                        }
                        catch (error) {
                            // Fall back to `utf8`
                            try {
                                response.body = decodeUint8Array(response.rawBody);
                            }
                            catch (error) {
                                request._beforeError(new ParseError(normalizeError(error), response));
                                return;
                            }
                            if (isResponseOk(response)) {
                                request._beforeError(normalizeError(error));
                                return;
                            }
                        }
                    }
                    try {
                        const hooks = options.hooks.afterResponse;
                        for (const [index, hook] of hooks.entries()) {
                            const previousUrl = options.url ? new URL(options.url) : undefined;
                            const previousBoundary = getUrlPrefixBoundary(options);
                            const previousState = previousUrl ? snapshotCrossOriginState(options) : undefined;
                            const requestOptions = response.request.options;
                            const responseSnapshot = response;
                            // @ts-expect-error TS doesn't notice that RequestPromise is a Promise
                            // eslint-disable-next-line no-await-in-loop
                            response = await requestOptions.trackStateMutations(async (changedState) => hook(responseSnapshot, async (updatedOptions) => {
                                const preserveHooks = updatedOptions.preserveHooks ?? false;
                                const reusesRequestOptions = updatedOptions === requestOptions;
                                const hasExplicitBody = reusesRequestOptions
                                    ? changedState.has('body') || changedState.has('json') || changedState.has('form')
                                    : (Object.hasOwn(updatedOptions, 'body') && updatedOptions.body !== undefined)
                                        || (Object.hasOwn(updatedOptions, 'json') && updatedOptions.json !== undefined)
                                        || (Object.hasOwn(updatedOptions, 'form') && updatedOptions.form !== undefined);
                                const clearsCookieJar = Object.hasOwn(updatedOptions, 'cookieJar') && updatedOptions.cookieJar === undefined;
                                if (hasExplicitBody && !reusesRequestOptions) {
                                    options.clearBody();
                                }
                                if (!reusesRequestOptions && clearsCookieJar) {
                                    options.cookieJar = undefined;
                                }
                                if (!reusesRequestOptions) {
                                    const { url, ...updatedOptionsWithoutUrl } = updatedOptions;
                                    options.merge(updatedOptionsWithoutUrl);
                                    options.syncCookieHeaderAfterMerge(previousState, updatedOptionsWithoutUrl.headers);
                                }
                                options.clearUnchangedCookieHeader(previousState, reusesRequestOptions ? changedState : undefined);
                                const currentUrl = options.url;
                                if (previousUrl
                                    && currentUrl instanceof URL
                                    && hasUrlOrPrefixUrlBoundaryChanged(options, currentUrl, previousBoundary)) {
                                    assertUrlHasSameOriginAsPrefixUrlIfNeeded(options, currentUrl);
                                }
                                if (updatedOptions.url !== undefined) {
                                    const nextUrl = reusesRequestOptions
                                        ? options.url
                                        : applyUrlOverride(options, updatedOptions.url, updatedOptions);
                                    if (previousUrl) {
                                        if (reusesRequestOptions && !isSameOrigin(previousUrl, nextUrl)) {
                                            options.stripUnchangedCrossOriginState(previousState, changedState, { clearBody: !hasExplicitBody });
                                        }
                                        else {
                                            options.stripSensitiveHeaders(previousUrl, nextUrl, updatedOptions);
                                            if (!isSameOrigin(previousUrl, nextUrl) && !hasExplicitBody) {
                                                options.clearBody();
                                            }
                                        }
                                    }
                                }
                                // Remove any further hooks for that request, because we'll call them anyway.
                                // The loop continues. We don't want duplicates (asPromise recursion).
                                // Unless preserveHooks is true, in which case we keep the remaining hooks.
                                if (!preserveHooks) {
                                    options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index);
                                }
                                throw new RetryError(request);
                            }));
                            if (!(is.object(response) && is.number(response.statusCode) && 'body' in response)) {
                                throw new TypeError('The `afterResponse` hook returned an invalid value');
                            }
                        }
                    }
                    catch (error) {
                        request._beforeError(normalizeError(error));
                        return;
                    }
                    globalResponse = response;
                    if (!isResponseOk(response)) {
                        request._beforeError(new HTTPError(response));
                        return;
                    }
                    request.destroy();
                    promiseSettled = true;
                    resolve(request.options.resolveBodyOnly ? response.body : response);
                })();
            });
            let handledFinalError = false;
            const onError = (error) => {
                // Route errors emitted directly on the stream (e.g., EPIPE from Node.js)
                // through retry logic first, then handle them here after retries are exhausted.
                // See https://github.com/sindresorhus/got/issues/1995
                if (!request._stopReading) {
                    request._beforeError(error);
                    return;
                }
                // Allow the manual re-emission from Request to land only once.
                if (handledFinalError) {
                    return;
                }
                handledFinalError = true;
                promiseSettled = true;
                const { options } = request;
                if (error instanceof HTTPError && !options.throwHttpErrors) {
                    const { response } = error;
                    request.destroy();
                    resolve(request.options.resolveBodyOnly ? response.body : response);
                    return;
                }
                reject(error);
            };
            // Use .on() instead of .once() to keep the listener active across retries.
            // When _stopReading is false, we return early and the error gets re-emitted
            // after retry logic completes, so we need this listener to remain active.
            // See https://github.com/sindresorhus/got/issues/1995
            request.on('error', onError);
            const previousBody = request.options?.body;
            request.once('retry', (newRetryCount, error) => {
                firstRequest = undefined;
                // If promise already settled, don't retry
                // This prevents the race condition in #1489 where a late error
                // (e.g., ECONNRESET after successful response) triggers retry
                // after the promise has already resolved/rejected
                if (promiseSettled) {
                    return;
                }
                const newBody = request.options.body;
                if (previousBody === newBody && (is.nodeStream(newBody) || newBody instanceof ReadableStream)) {
                    error.message = 'Cannot retry with consumed body stream';
                    onError(error);
                    return;
                }
                // This is needed! We need to reuse `request.options` because they can get modified!
                // For example, by calling `promise.json()`.
                makeRequest(newRetryCount, request.options);
            });
            proxyEvents(request, emitter, proxiedRequestEvents);
            if (is.undefined(firstRequest)) {
                void request.flush();
            }
        };
        makeRequest(0);
    });
    promise.on = function (event, function_) {
        emitter.on(event, function_);
        return this;
    };
    promise.once = function (event, function_) {
        emitter.once(event, function_);
        return this;
    };
    promise.off = function (event, function_) {
        emitter.off(event, function_);
        return this;
    };
    const shortcut = (promiseToAwait, responseType) => {
        const newPromise = (async () => {
            // Wait until downloading has ended
            await promiseToAwait;
            const { options } = globalResponse.request;
            if (responseType === 'text') {
                const text = decodeUint8Array(globalResponse.rawBody, options.encoding);
                return (isUtf8Encoding(options.encoding) ? text.replace(/^\u{FEFF}/v, '') : text);
            }
            return parseBody(globalResponse, responseType, options.parseJson, options.encoding);
        })();
        // eslint-disable-next-line @typescript-eslint/no-floating-promises
        Object.defineProperties(newPromise, Object.getOwnPropertyDescriptors(promiseToAwait));
        return newPromise;
    };
    // Note: These use `function` syntax (not arrows) to access `this` context.
    // When custom handlers wrap the promise to transform errors, these methods
    // are copied to the handler's promise. Using `this` ensures we await the
    // handler's wrapped promise, not the original, so errors propagate correctly.
    promise.json = function () {
        if (globalRequest.options) {
            const { headers } = globalRequest.options;
            if (!globalRequest.writableFinished && !('accept' in headers)) {
                headers.accept = 'application/json';
            }
        }
        return shortcut(this, 'json');
    };
    promise.buffer = function () {
        return shortcut(this, 'buffer');
    };
    promise.text = function () {
        return shortcut(this, 'text');
    };
    return promise;
}
