import { createContext, useContext, useEffect, useMemo, useState } from "react";

import { useEffectSkipFirst } from "../hooks/useEffectSkipFirst.js";
import { deepClone } from "../utils/deepClone.js";

import type { Context, DependencyList, Provider } from "react";

type ImmutableContext<
    Properties extends {
        initialState: Record<string, any>;
        modifiers?: Record<string, (...args: any[]) => Promise<any> | void>;
        refActions?: Record<string, (...args: any[]) => any>;
    } = any,
    PredefinedInitialState = {
        /**
         * This allows you to reflect values from Promises to the context value.
         */
        suspense?: {
            [K in keyof Properties["initialState"]]?: Promise<Properties["initialState"][K]>;
        };
    },
    FinalInitialState = Properties["initialState"] & PredefinedInitialState,
    ContextValue = FinalInitialState & Properties["modifiers"] & Properties["refActions"],
    PredefinedModifiers = {
        set: <ReturnType extends Promise<any> | void>(
            transaction: ((contextValue: ContextValue) => ReturnType) | Partial<ContextValue>,
        ) => Promise<ReturnType extends Promise<any> ? Awaited<ReturnType> : ReturnType>;
    },
    FinalContextValue = ContextValue & PredefinedModifiers,
> = {
    initialState: FinalInitialState;
    modifiers: Properties["modifiers"];
    refActions: Properties["refActions"];
    provider: {
        modifiers: PrependParametersToFns<Properties["modifiers"], FinalContextValue>;
        refActions: PrependParametersToFns<Properties["refActions"], FinalContextValue>;
    };
    contextValue: FinalContextValue;
};

type PrependParametersToFns<Fns extends Record<string, (...args: any[]) => any>, Parameter> = {
    [K in keyof Fns]: Fns[K] extends (...args: infer Args) => infer Return
        ? (param: Parameter, ...args: Args) => Return
        : never;
};

type CreatedContext<T extends ImmutableContext> = [Context<T>, () => T["contextValue"]];

const createdContexts: Record<symbol, CreatedContext<any>> = {};

/**
 * @param symbol Unique symbol to create the `React.Context` instance only once
 */
function ensureImmutableContext<I extends ImmutableContext>(symbol: symbol) {
    let created: CreatedContext<I> = createdContexts[symbol];
    if (!created) {
        // We do not have a default value
        const context = createContext({} as I);
        const use = () => useContext(context);
        created = [context, use];
        createdContexts[symbol] = created;
    }

    return created;
}

/**
 * Use the pure of `structuredClone` to create a context with immutability in mind.
 * It allows you to pass an initial state which you could easily modify with your
 * passed actions.
 *
 * @parma symbol Unique symbol to create the `React.Context` instance only once
 */
const useImmutableContext = <T extends ImmutableContext>(symbol: symbol) => ensureImmutableContext<T>(symbol)[1]();

/**
 * Use the power of `structuredClone` to create a context with immutability in mind.
 * It allows you to pass an initial state which you could easily modify with your
 * passed actions.
 *
 * It solves the following problems with the Context API:
 *
 * - Immutable out-of-the-box with usage of structureClone
 * - Update the context value partially
 *      - Through modifier functions
 *      - Through set
 *          - Transactional through function
 *          - Directly passing the context value
 * - Side-effect free by design - using Symbol()'s to create the context once
 * - Beside modifiers we can create "ref-actions" which allows updating the state mutable and therefore no rerendering
 *
 * Simple example:
 *
 * ```tsx
 * import { ImmutableContext, useImmutableContext, useImmutableContextProvider } from "@devowl-wp/react-utils";
 *
 * const contextSymbol = Symbol();
 * type SimpleContext = ImmutableContext<{
 *     initialState: { firstName: string; lastName: string; birthDate: Date };
 *     modifiers: { setName: (firstName: string, lastName: string) => void };
 * }>;
 * const useSimpleContext = (): SimpleContext["contextValue"] => useImmutableContext<SimpleContext>(contextSymbol);
 *
 * function MyFormHoc() {
 *     const [Provider, contextValue] = useImmutableContextProvider<SimpleContext>(
 *         contextSymbol,
 *         // Fill the initial state
 *         {
 *             firstName: "",
 *             lastName: "",
 *             birthDate: undefined
 *         },
 *         // Provide the modifiers which allow to modify the context state in an immutable way
 *         {
 *             setName: (state, firstName, lastName) => {
 *                 state.firstName = firstName;
 *                 state.lastName = lastName;
 *
 *                 // If you want to return a value which can be reused by the caller, make `setName`
 *                 // asynchronous and return the new value.
 *             }
 *         }
 *     );
 *     return (
 *         <Provider value={contextValue}>
 *             <MyForm />
 *         </Provider>
 *     );
 * }
 *
 * function MyForm() {
 *     const { firstName, lastName, setName, set } = useSimpleContext();
 *
 *     useEffect(() => {
 *         // Example: We set the name within our context through the modifier
 *         setName("John", "Smith");
 *
 *         // A special predefined modifier, which allows to set every property within
 *         // our context via a transaction function or passing a partial object
 *         set({ birthDate: new Date() });
 *         set((state) => {
 *             state.birthDate = new Date();
 *         });
 *     }, []);
 *
 *     return (
 *         <>
 *             {firstName} {lastName}
 *         </>
 *     );
 * }
 * ```
 *
 * ### Async state
 *
 * The context has a special context value `suspense` which accepts an object for each context value with the same value
 * type but represented as Promise. You can use this in conjuction with React Suspense.
 *
 * Example:
 *
 * ```ts
 * // [...]
 * const [Provider, contextValue] = useImmutableContextProvider<SimpleContext>(
 *     contextSymbol,
 *     {
 *         heavyResponse: undefined,
 *         suspense: {
 *             heavyResponse: fetchMyHeavyResponse()
 *         }
 *     }
 * );
 * ```
 *
 * @parma symbol Unique symbol to create the `React.Context` instance only once
 */
function useImmutableContextProvider<T extends ImmutableContext>(
    symbol: symbol,
    initialState: T["initialState"],
    /**
     * A modifier can only return values when it is async as setting new state in
     * React is also async.
     */
    modifiers: T["provider"]["modifiers"] = {} as any,
    opts: {
        /**
         * A ref-action allows to modify the context value **without** rerendering the HOC.
         * Behind the scenes it does not call `setContextValue`.
         */
        refActions?: T["provider"]["refActions"];
        /**
         * A list of initial state which is observed and automatically replaced in the context
         * value when the value changes. Internally, it uses `useEffect`. This is useful for controlled
         * state (e.g. active tab).
         *
         * > Warning: Maximum update depth exceeded. This can happen when a component calls setState inside
         * > useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
         *
         * If you are running into issues with update-depth in React, make sure to memoize the value with `useMemo` before passing down
         * to the `initialState` or use `contextValue.set()`.
         */
        observe?: Array<keyof T["initialState"]>;
        /**
         * This is different to `observe` and allows to consume variables coming from `initialState` **without** reflecting to the context
         * value. That means, that value can never be modified through the context implementation as it is not part of the context value state.
         */
        inherit?: Array<keyof T["initialState"]>;
        /**
         * When the dependency list changes, the context value is updated from the `initialState` value.
         */
        deps?: DependencyList;
    } = {},
) {
    const { refActions, observe, inherit, deps } = opts;
    const context = ensureImmutableContext<T>(symbol);

    const [contextValue, setContextValue] = useState<T["contextValue"]>(() => {
        const modifierKeys = Object.keys(modifiers);
        const refActionKeys = Object.keys(refActions || {});

        const wrapFn = (fn: (...args: any[]) => any, ...args: any[]) =>
            new Promise<any>((resolve) =>
                setContextValue((state) => {
                    // First, we create a non-deep copy of the new state
                    const newState = { ...state };

                    // With `Proxy` we can "copy on purpose" and avoid copying untouched properties
                    const deepCloned: (string | symbol)[] = [];

                    // Create a proxy so we can interact with changes to the new state
                    let proxyActive = true;
                    const proxyState = new Proxy(newState, {
                        get: (...args) => {
                            const [target, property] = args;
                            let value = Reflect.get(...args);

                            if (!proxyActive) {
                                return value;
                            }

                            // Create lazy clone of the property
                            if (deepCloned.indexOf(property) === -1) {
                                value = deepClone(value);
                                Reflect.set(target, property, value);
                                deepCloned.push(property);
                            }

                            // Allow to run modifiers in modifiers by reusing our already cloned object
                            if (typeof property === "string") {
                                let transactionFn: (...args: any[]) => any;
                                if (modifierKeys.indexOf(property) > -1) {
                                    transactionFn = modifiers[property];
                                } else if (refActionKeys.indexOf(property) > -1) {
                                    transactionFn = refActions[property];
                                }

                                if (transactionFn) {
                                    return (...args: any[]) => transactionFn(proxyState, ...args);
                                }
                            }

                            return value;
                        },
                    });

                    const returnType = fn(proxyState, ...args);

                    // Keep the proxy as long active as the transaction is running
                    const resolver = (value: any) => {
                        proxyActive = false;
                        resolve(value);
                    };

                    if (returnType instanceof Promise) {
                        returnType.then(resolver);
                    } else {
                        resolver(undefined);
                    }
                    return newState;
                }),
            );

        const returnValue = {
            set: (transaction: any) => {
                if (typeof transaction === "function") {
                    return wrapFn(transaction);
                } else {
                    return wrapFn((newState) => Object.assign(newState, transaction));
                }
            },
            ...initialState,
            ...modifierKeys.reduce(
                (p, c) => {
                    // @ts-expect-error c does not reflect the name for the modifier at this time
                    p[c] = (...args: any[]) => wrapFn(modifiers[c], ...args);
                    return p;
                },
                {} as typeof modifiers,
            ),
            ...refActionKeys.reduce(
                (p, c) => {
                    // @ts-expect-error c does not reflect the name for the ref-action at this time
                    p[c] = (...args: any[]) => refActions[c](contextValue, ...args);
                    return p;
                },
                {} as typeof refActions,
            ),
        };

        if (!returnValue.suspense) {
            returnValue.suspense = {};
        }

        return returnValue;
    });

    if (observe?.length) {
        useEffectSkipFirst(() => {
            const changed = observe.filter((k) => initialState[k] !== contextValue[k]);
            if (changed.length) {
                contextValue.set(
                    observe.reduce(
                        (p, c) => {
                            p[c] = initialState[c];
                            return p;
                        },
                        {} as Record<keyof T["initialState"], any>,
                    ),
                );
            }
        }, [observe.map((k) => initialState[k])]);
    }

    if (Array.isArray(deps)) {
        useEffectSkipFirst(() => {
            contextValue.set(initialState);
        }, deps);
    }

    const [{ Provider }] = context;
    let useContextValue = contextValue;
    if (inherit?.length) {
        useContextValue = {
            ...contextValue,
            ...inherit.reduce(
                (p, c) => {
                    p[c] = initialState[c];
                    return p;
                },
                {} as Record<keyof T["initialState"], any>,
            ),
        };
    }

    // Automatically reflect suspended Promises to the context value
    const addedListenersToPromises = useMemo(() => ({}) as Record<string, Promise<any>>, []);
    useEffect(() => {
        const { suspense } = contextValue;
        if (suspense) {
            for (const suspenseKey in suspense) {
                const promise = suspense[suspenseKey];
                const previousPromise = addedListenersToPromises[suspenseKey];
                if (promise instanceof Promise && previousPromise !== promise) {
                    addedListenersToPromises[suspenseKey] = promise;
                    promise.then((value) => contextValue.set({ [suspenseKey]: value }));
                }
            }
        }
    }, [contextValue]);

    return [Provider as unknown as Provider<T["contextValue"]>, useContextValue] as const;
}

type RemoveFirst<T> = T extends [any, ...infer Rest] ? Rest : never;
type FnCreateImmutableContextForSymbol<T extends ImmutableContext> = (
    ...args: RemoveFirst<Parameters<typeof useImmutableContext<T>>>
) => T["contextValue"];
type FnCreateImmutableContextProviderForSymbol<T extends ImmutableContext> = (
    ...args: RemoveFirst<Parameters<typeof useImmutableContextProvider<T>>>
) => readonly [Provider<T["contextValue"]>, T["contextValue"]];

export {
    type ImmutableContext,
    ensureImmutableContext,
    useImmutableContext,
    type FnCreateImmutableContextForSymbol,
    type FnCreateImmutableContextProviderForSymbol,
    useImmutableContextProvider,
};
