import deepMerge from "deepmerge";
import jsonFormData from "json-form-data";

import { ERouteHttpVerb } from "@devowl-wp/api";

import { batchRequest } from "./batchRequest.js";
import { applyQueryString, commonUrlBuilder } from "./commonUrlBuilder.js";
import { addCorruptRestApi, addCorruptRestApiLog, removeCorruptRestApi } from "./corruptRestApi.js";
import { nonceDeprecationPool } from "./nonceDeprecationPool.js";
import { parseResult } from "./parseResult.js";
import { waitForValidLogin } from "./waitForLogin.js";

import type { BatchQueueItem } from "./batchRequest.js";
import type {
    RouteParamsInterface,
    RouteRequestInterface,
    RouteResponseInterface,
    UrlBuilderArgs,
} from "./commonUrlBuilder.js";

const CONTENT_TYPE_JSON = "application/json;charset=utf-8";

// Fix: Return type of exported function has or is using name 'FormatOptions'
// from external module "/home/mg/vscode-workspace/devowl-wp/node_modules/@types/json-form-data/index" but cannot be named.
// type MultiPartOptions = Parameters<typeof jsonFormData>[1];
type MultiPartOptions = {
    initialFormData?: FormData;
    showLeafArrayIndexes?: boolean;
    includeNullValues?: boolean;
    mapping?: (value: string | number | boolean | File | Blob | Date | null | undefined) => string | Blob;
};

/**
 * For better minification, this is held as number.
 */
type ReplayReason = 1 | 2 | 3 | 4;

/**
 * Build and execute a specific REST query.
 *
 * @see urlBuilder
 * @returns Result of REST API
 * @throws
 */
async function commonRequest<
    TRequest extends RouteRequestInterface,
    TParams extends RouteParamsInterface,
    TResponse extends RouteResponseInterface,
>({
    location,
    options,
    request: routeRequest,
    params,
    settings = {},
    cookieValueAsParam,
    multipart = false,
    sendRestNonce = true,
    sendReferer,
    replayReason: happenedReplayReason,
    allowBatchRequest,
}: {
    request?: TRequest;
    params?: TParams;
    /**
     * Settings for the `window.fetch` call.
     */
    settings?: Partial<{ -readonly [P in keyof Request]: Request[P] }>;
    multipart?: boolean | MultiPartOptions;
    sendRestNonce?: boolean;
    /**
     * Send the current URL as `_wp_http_referer` as URL parameter (for GET and DELETE) or body payload (for non-GET requests).
     *
     * @see https://app.clickup.com/t/86954236z
     */
    sendReferer?: boolean;
    replayReason?: ReplayReason;
    /**
     * See `batchRequest` for more information.
     *
     * @see https://make.wordpress.org/core/2020/11/20/rest-api-batch-framework-in-wordpress-5-6/
     */
    allowBatchRequest?: boolean | Pick<BatchQueueItem["options"], "onQueueItemFinished" | "waitForPromise">;
} & UrlBuilderArgs): Promise<TResponse> {
    const { href } = window.location;
    const namespace = location.namespace || options.restNamespace;
    const url = commonUrlBuilder({ location, params, nonce: false, options, cookieValueAsParam });

    // Use global parameter (see https://developer.wordpress.org/rest-api/using-the-rest-api/global-parameters/)
    if (
        ["wp-json/", "rest_route="].filter((s) => url.indexOf(s) > -1).length > 0 &&
        location.method &&
        location.method !== ERouteHttpVerb.GET
    ) {
        settings.method = ERouteHttpVerb.POST;
    } else {
        settings.method = location.method || ERouteHttpVerb.GET;
    }

    // Request with GET/HEAD method cannot have body
    const apiUrl = new URL(url, href);
    const allowBody = ["HEAD", "GET"].indexOf(settings.method) === -1;

    if (sendReferer) {
        if (allowBody) {
            Object.assign(routeRequest, {
                _wp_http_referer: href,
            });
        } else {
            apiUrl.searchParams.set("_wp_http_referer", href);
        }
    }

    if (!allowBody && routeRequest) {
        applyQueryString(apiUrl, [routeRequest], true);
    }
    const apiUrlBuilt = apiUrl.toString();

    // Determine body
    let body: string | FormData;
    if (allowBody) {
        if (multipart) {
            // Let's create a multipart request...
            body = jsonFormData(routeRequest as any, typeof multipart === "boolean" ? {} : multipart);

            // Check if one of the form data is a blob and if not, revert back to JSON
            const hasBlob = Array.from(body.values()).filter((v) => v instanceof File).length > 0;
            if (!hasBlob) {
                body = JSON.stringify(routeRequest);
            }
        } else {
            // It is a usual JSON request, we do not need to send a multipart request
            body = JSON.stringify(routeRequest);
        }
    }

    // Do the request
    const restNonce = await nonceDeprecationPool(options.restNonce);
    const hasRestNonce = typeof restNonce !== "undefined";
    const init = deepMerge.all(
        [
            settings,
            {
                headers: {
                    ...(typeof body === "string" ? { "Content-Type": CONTENT_TYPE_JSON } : {}),
                    ...(hasRestNonce && sendRestNonce ? { "X-WP-Nonce": restNonce } : {}),
                    Accept: "application/json, */*;q=0.1",
                },
            },
        ],
        {
            // Do not override e.g. `AbortSignal` instances
            isMergeableObject: (value) => Object.prototype.toString.call(value) === "[object Object]",
        },
    ) as any;
    init.body = body; // Do not make body merge-able

    if (allowBatchRequest && location.method !== ERouteHttpVerb.GET && !(body instanceof FormData)) {
        return batchRequest<TResponse>(
            {
                method: location.method,
                path: commonUrlBuilder({
                    location,
                    params,
                    nonce: false,
                    options: { ...options, restRoot: "https://a.de/wp-json" },
                    cookieValueAsParam,
                }).substring(20),
                body: routeRequest,
            },
            {
                ...options,
                signal: settings.signal,
                ...(typeof allowBatchRequest === "boolean" ? {} : allowBatchRequest),
            },
        );
    }

    let result: Response;

    // Detect page hide of browser which can lead to cancelled requests which throw an error.
    // In this case, we should not show the notice (see CU-33tce0y).
    let pageUnload = false;
    const pageUnloadListener = () => {
        pageUnload = true;
    };
    window.addEventListener("pagehide", pageUnloadListener);
    window.addEventListener("beforeunload", pageUnloadListener);

    const start = new Date().getTime();
    let ms: number;
    try {
        result = await window.fetch(apiUrlBuilt, init);
        ms = new Date().getTime() - start;
        removeCorruptRestApi(namespace);
    } catch (e) {
        // window.fetch does not throw by default, so there must be an error with the network or Ad-blocker
        ms = new Date().getTime() - start;
        if (!pageUnload) {
            addCorruptRestApiLog({
                method: location.method,
                route: apiUrl.pathname,
                ms,
                response: `${e}`,
            });
            addCorruptRestApi(settings, namespace);
        }
        console.error(e);
        throw e;
    } finally {
        window.removeEventListener("pagehide", pageUnloadListener);
        window.removeEventListener("beforeunload", pageUnloadListener);
    }

    // `window.fetch` does not throw an error if the server response an error code.
    if (!result.ok) {
        let responseJSON = undefined;
        let replay: boolean | number = false;
        let replayReason: ReplayReason;
        try {
            responseJSON = await parseResult<TResponse>(apiUrlBuilt, result, location.method);

            // wordpress.com private site compatibility
            // Currently not covered by tests
            /* v8 ignore start */
            if (responseJSON.code === "private_site" && result.status === 403 && hasRestNonce && !sendRestNonce) {
                replay = true;
                replayReason = 1;
            }
            /* v8 ignore end */

            // Refresh nonce automatically
            // Currently not covered by tests
            /* v8 ignore start */
            if (responseJSON.code === "rest_cookie_invalid_nonce" && hasRestNonce) {
                const { restRecreateNonceEndpoint } = options;
                try {
                    replay = true;

                    if (happenedReplayReason === 2) {
                        // We recreated the nonce previously, but it failed again. The login might be outdated.
                        // See also https://github.com/WordPress/gutenberg/issues/13509#issuecomment-1563964440
                        replayReason = 4;
                        await waitForValidLogin();
                    } else {
                        replayReason = 2;
                    }

                    await nonceDeprecationPool(restNonce, restRecreateNonceEndpoint);
                } catch (e) {
                    // Silence is golden.
                }
            }
            /* v8 ignore end */

            // Support retry-after header (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
            // Currently, only `<delay-seconds>` value is respected, not a Date yet
            const retryAfter = result.headers.get("retry-after");
            const isAllowedForRetryAfter = [503, 429, 301].includes(result.status);
            if (isAllowedForRetryAfter && retryAfter?.match(/^\d+$/)) {
                replay = +retryAfter * 1000;
                replayReason = 3;
            }
        } catch (e) {
            // Silence is golden.
        }

        if (replay) {
            const replayData = {
                location,
                options,
                multipart,
                params,
                request: routeRequest,
                sendRestNonce: true,
                settings,
                replayReason,
            };

            if (typeof replay === "number") {
                return new Promise<any>((resolve) =>
                    setTimeout(() => commonRequest(replayData).then(resolve), replay as number),
                );
            } else {
                return await commonRequest(replayData);
            }
        }

        addCorruptRestApiLog({
            method: location.method,
            route: apiUrl.pathname,
            ms,
            response: JSON.stringify(responseJSON),
        });
        addCorruptRestApi(settings);
        const resultAny = result as any;
        resultAny.responseJSON = responseJSON;
        throw resultAny;
    }

    return parseResult<TResponse>(apiUrlBuilt, result, location.method);
}

export { commonRequest, type MultiPartOptions };
