import jsonFormData from "json-form-data";

import { ClientHandlingErrorReason, ClientHandlingErrorStatusCode } from "./contract-map.js";

import type { ContractToClosure, ContractVersionPicker, ContractsRPCClient } from "./contract-map.js";
import type { Contract } from "../webserver/contract.js";

type AdditionalClientOptions = {
    timeout?: number;
    retry?: {
        count?: number;
        delay?: number;
        statusCodes?: number[];
        clientHandlingErrorStatuses?: ClientHandlingErrorReason[];
    };
};

type ClientRequestInit = Pick<
    RequestInit,
    "headers" | "credentials" | "keepalive" | "mode" | "redirect" | "referrer" | "referrerPolicy" | "signal" | "window"
> &
    AdditionalClientOptions;

type AdditionalClientResponse = { responseInstance?: Response };

const DEFAULT_ACCEPT_HEADER = "application/json, */*;q=0.1";

/**
 * Create a full type-safe fetch client from multiple API endpoints defined by contracts:
 *
 * - [x] Isomorphic (works in Node.js and browser).
 * - [x] Type-safe error handling without try/catch at call sites through discriminated union.
 * - [x] Timeouts (per request & per attempt) with AbortController.
 *     - [x] Pass `AbortSignal` to the request in combination with the timeout.
 * - [x] Retry mechanism with attempt count and delay.
 *     - [x] Default retry on timeout, network errors and these statuses: 408, 409, 425, 429, 500, 502, 503, 504
 *     - [ ] Respect Retry-After (seconds or HTTP-date).
 *     - [ ] Configurable retries with exponential backoff + jitter.
 * - [x] Uploads / Multipart requests.
 *
 * ### Error handling
 *
 * Due to the fact that nor TypeScript nor JavaScript provide a type-safe way to handle errors,
 * all errors (non-2xx status codes) and network errors are handled by the client and returned
 * as a discriminated union:
 *
 * - No try/catch at call sites
 * - Fully typed success/error flows
 * - Easy exhaustive handling with switch/if guards
 *
 * See the `README.md` for more examples.
 */
function createFetchClient<
    Routes,
    Version extends ContractVersionPicker<Routes> = ContractVersionPicker<Routes>,
    ClientOptions extends Omit<ClientRequestInit, "headers"> = Omit<ClientRequestInit, "headers">,
    ClientResponse extends AdditionalClientResponse = AdditionalClientResponse,
    RPCClient = ContractsRPCClient<Routes, Version, ClientOptions, ClientResponse>,
    ThisContractClosure extends ContractToClosure<
        Contract<any, any, any, any>,
        ClientOptions,
        ClientResponse
    > = ContractToClosure<Contract<any, any, any, any>, ClientOptions, ClientResponse>,
>({
    baseUrl,
    fetchFn = fetch,
    headers: baseHeaders,
    timeout: baseTimeout,
    retry: baseRetry,
    version,
    ...baseRequestInit
}: {
    baseUrl: string;
    fetchFn?: typeof fetch;
    version?: Version;
} & Omit<ClientRequestInit, "signal">): RPCClient {
    type RunResult = {
        /**
         * The logical status of the fetch request, used for type-safe/exhaustive response handling.
         *
         * - When a strictly defined response for a given status code is returned,
         *   `status` is the HTTP status code as a number (e.g., 200, 404, etc.).
         * - If a client-side error occurs (such as network or timeout), `status` is -1,
         *   and the `reason` property will describe the failure.
         * - If a contract's response shape uses a `"default"` clause for unspecified statuses,
         *   this is indicated with `status: "default"` (string). In this case, see also the `code`
         *   property for the actual HTTP status code, and handle in a `switch (status)` with `"default"`.
         *
         * Example usage:
         *   switch (result.status) {
         *     case 200: // handle 200 OK
         *     case -1:  // handle client error, see result.reason
         *     case "default": // handle fallback/default contract case, see result.code
         *   }
         *
         * See: test `should create a contract from an openapi path`
         */
        status: number | "default";
        /**
         * The actual numeric HTTP status code, present only when `status` is `"default"`.
         * For other cases (number or -1), this is undefined.
         */
        code?: number;
        response?: any;
        error?: Error;
        reason?: ClientHandlingErrorReason;
    } & AdditionalClientResponse;
    const run = async (
        path: string[],
        [
            {
                headers: requestHeaders = {},
                params: originalRequestParams = {},
                request: requestBody = undefined,
                signal: requestSignal,
                timeout: requestTimeout,
                retry: requestRetry,
                ...requestInit
            },
        ]: Parameters<ThisContractClosure>,
    ): Promise<RunResult> => {
        const method = path.pop()?.toUpperCase();

        // The `pathToUrlString` removes all used params which are interpolated to the path, therefore we do not want to touch the original params object.
        const requestParams = { ...originalRequestParams };

        const url = new URL(
            `${version === null || version === undefined ? "" : `${version}/`}${pathToUrlString(path, requestParams)}`,
            baseUrl,
        );
        const { searchParams } = url;
        const timeout = requestTimeout ?? baseTimeout;
        const retryOptions: AdditionalClientOptions["retry"] = {
            count: requestRetry?.count ?? baseRetry?.count ?? 0,
            delay: requestRetry?.delay ?? baseRetry?.delay ?? 500,
            statusCodes: requestRetry?.statusCodes ??
                baseRetry?.statusCodes ?? [408, 409, 425, 429, 500, 502, 503, 504],
            clientHandlingErrorStatuses: requestRetry?.clientHandlingErrorStatuses ??
                baseRetry?.clientHandlingErrorStatuses ?? [
                    ClientHandlingErrorReason.Network,
                    ClientHandlingErrorReason.Timeout,
                ],
        };
        const { count: retryCount, delay: retryDelay } = retryOptions;
        let attempt = 0;

        for (const [key, value] of Object.entries(requestParams)) {
            if (value !== undefined) {
                searchParams.set(key, String(value));
            }
        }

        let foundFile = false;
        if (requestBody) {
            JSON.stringify(requestBody, (key, value) => {
                if (typeof value === "object" && ["File", "Blob"].includes(value?.constructor?.name)) {
                    foundFile = true;
                    return value.name;
                }
                return value;
            });
        }

        const headers = new Headers({
            Accept: DEFAULT_ACCEPT_HEADER,
            ...baseHeaders,
            ...requestHeaders,
        });

        // Only set Content-Type for JSON requests. For FormData, let the browser set it with the boundary.
        // Only set Content-Type when there's a body to send.
        // "Accept"-header defines the response body, and "Content-Type" defines the request body.
        // Don't set it for GET requests - some servers (like Browserless) reject it.
        if (requestBody !== undefined && !foundFile && !headers.has("content-type")) {
            headers.set("Content-Type", "application/json");
        }

        const singleRun = async (): Promise<RunResult> => {
            const timeoutSignal = timeout ? AbortSignal.timeout(timeout) : undefined;
            const signal = anyAbortSignal([requestSignal, timeoutSignal].filter(Boolean));
            let responseInstance: Response;

            try {
                responseInstance = await fetchFn(url.toString(), {
                    ...baseRequestInit,
                    ...requestInit,
                    method,
                    headers,
                    body: requestBody
                        ? foundFile
                            ? jsonFormData(requestBody, {})
                            : JSON.stringify(requestBody)
                        : undefined,
                    signal,
                });

                const { headers: responseHeaders } = responseInstance;
                const responseContentType = responseHeaders.get("Content-Type");

                let responseJson: Awaited<ReturnType<ThisContractClosure>>["response"];

                // const responseContentLength = +(responseHeaders.get("Content-Length") ?? 0);
                // if (responseContentLength > 0) { -> not always given, e.g. when response is chunked: https://stackoverflow.com/a/11375745/5506547
                if (responseContentType?.includes("application/json")) {
                    try {
                        responseJson = await responseInstance.clone().json();
                    } catch (e) {
                        return {
                            status: ClientHandlingErrorStatusCode,
                            reason: ClientHandlingErrorReason.ParseError,
                            error: e as Error,
                        };
                    }
                }
                // }

                return {
                    status: responseInstance.status,
                    code: responseInstance.status,
                    response: responseJson,
                    responseInstance,
                };
            } catch (e: unknown) {
                if (e instanceof Error && e.name === "AbortError") {
                    return {
                        status: ClientHandlingErrorStatusCode,
                        reason: timeoutSignal?.aborted
                            ? ClientHandlingErrorReason.Timeout
                            : ClientHandlingErrorReason.Abort,
                        error: e,
                        responseInstance,
                    };
                }
                return {
                    status: ClientHandlingErrorStatusCode,
                    reason: ClientHandlingErrorReason.Network,
                    error: e as Error,
                    responseInstance,
                };
            }
        };

        // Retry mechanism
        let doRetry = true;
        let result: RunResult;
        while (doRetry) {
            if (attempt > 0 && retryDelay > 0) {
                await new Promise<void>((resolve) => setAbortableTimeout(resolve, resolve, retryDelay, requestSignal));
            }

            // Special case: If the consumer aborted the request, we should not retry
            if (requestSignal?.aborted) {
                if (!result) {
                    result = {
                        status: ClientHandlingErrorStatusCode,
                        reason: ClientHandlingErrorReason.Abort,
                        error: new Error("Request aborted"),
                    };
                }
                break;
            }

            result = await singleRun();
            if (
                retryCount > 0 &&
                (retryOptions.statusCodes.includes(result.status as number) ||
                    (result.status === ClientHandlingErrorStatusCode &&
                        retryOptions.clientHandlingErrorStatuses.includes(result.reason)))
            ) {
                if (attempt >= retryCount) {
                    doRetry = false;
                    result = {
                        status: ClientHandlingErrorStatusCode,
                        reason: ClientHandlingErrorReason.RetryLimitReached,
                        error: new Error("Retry limit reached"),
                    };
                    break;
                }
                doRetry = true;
                attempt++;
            } else {
                doRetry = false;
            }
        }
        return result;
    };

    return createProxy(run) as RPCClient;
}

// AbortSignal.any() is only available through the DOM library in TypeScript
// but it is definitely available in Node since
// @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/60868
// @see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65782
// @see https://github.com/microsoft/TypeScript/issues/58026
const anyAbortSignal = (AbortSignal as any).any as (signals: AbortSignal[]) => AbortSignal;

/**
 * Create a URL string from a path array. The path is the camelCase'd, slash-stripped path of the contract.
 */
const pathToUrlString = (path: string[], params: Record<string, unknown>) => {
    return path
        .map((p) => {
            if (p.startsWith(":")) {
                const paramKey = p.slice(1);
                const value = params[paramKey];
                delete params[paramKey];
                return value as string;
            }
            return p.replace(/([A-Z])/g, "-$1").toLowerCase();
        })
        .join("/");
};

/**
 * Set a timeout that can be aborted by an AbortSignal.
 *
 * @see https://www.bennadel.com/blog/4195-using-abortcontroller-to-debounce-settimeout-calls-in-javascript.htm
 */
const setAbortableTimeout = (onSuccess: () => void, onAbort: () => void, ms: number, signal: AbortSignal) => {
    signal?.addEventListener("abort", handleAbort, { once: true });
    const internalTimer = setTimeout(internalCallback, ms);
    function internalCallback() {
        signal?.removeEventListener("abort", handleAbort);
        onSuccess();
    }
    function handleAbort() {
        clearTimeout(internalTimer);
        onAbort();
    }
};

/**
 * Create a callable object where any nested path is valid.
 * When that path is called, we invoke `handler(pathArray, argsArray)`.
 */
const createProxy = (handler: (path: string[], args: any) => any) => {
    const build = (path = []) =>
        new Proxy(() => {}, {
            get(_t, prop) {
                // Return undefined for 'then' to prevent being treated as a Promise
                if (prop === "then") {
                    return undefined;
                }

                // Handle stringification and inspection methods
                const stringMethods = ["toString", "valueOf", Symbol.toPrimitive];
                if (stringMethods.includes(prop)) {
                    return () => path.join(".");
                }

                // Handle Node.js inspection
                const inspectSymbols = ["inspect", Symbol.for("nodejs.util.inspect.custom")];
                if (inspectSymbols.includes(prop)) {
                    return () => `ProxyPath(${path.join(".")})`;
                }

                // Set object type tag
                if (prop === Symbol.toStringTag) {
                    return "Function";
                }

                // Any other property extends the path
                return build(path.concat(String(prop)));
            },

            apply(_t, _thisArg, args) {
                // Call the user handler with the collected path + args
                return handler([...path], Array.from(args));
            },
        });

    return build();
};

export { createFetchClient, createProxy };
