/* TODO change * for leftovers to +, change subscriptions to reactions */
import moize from 'moize';
import u from '@yanick/updeep';
import { createStore as reduxCreateStore, applyMiddleware } from 'redux';
import { get, map, mapValues, merge, difference } from 'lodash';

import { buildInitial } from './buildInitial';
import { buildActions } from './buildActions';
import { buildSelectors } from './buildSelectors';
import { action } from './actions';
import { buildUpreducer } from './buildUpreducer';
import { buildMiddleware, augmentMiddlewareApi } from './buildMiddleware';

import { AggregateDuxActions, AggregateDuxState, Dict } from './types';

/**
 * Configuration object typically passed to the constructor of the class Updux.
 */
export interface UpduxConfig<
    TState = any,
    TActions = {},
    TSelectors = {},
    TSubduxes = {}
> {
    /**
     * Local initial state.
     * @default {}
     */
    initial?: TState;

    /**
     * Subduxes to be merged to this dux.
     */
    subduxes?: TSubduxes;

    /**
     * Local actions.
     */
    actions?: TActions;

    /**
     * Local selectors.
     */
    selectors?: Record<string, Function>;

    /**
     * Local mutations
     */
    mutations?: Record<string, Function>;

    /**
     *  Selectors to apply to the mapped subduxes. Only
     *  applicable if the dux is a mapping dux.
     */
    mappedSelectors?: Record<string, Function>;

    /**
     * Local effects.
     */
    effects?: Record<string, Function>;

    /**
     * Local reactions.
     */
    reactions?: Function[];

    /**
     * If true, enables mapped reactions. Additionally, it can be
     * a reaction function, which will treated as a regular
     * reaction for the mapped dux.
     */
    mappedReaction?: Function | boolean;
}

export class Updux<
    TState extends any = {},
    TActions extends object = {},
    TSelectors = {},
    TSubduxes extends object = {}
> {
    /** @type { unknown } */
    #initial = {};
    #subduxes = {};

    /** @type Record<string,Function> */
    #actions = {};
    #selectors = {};
    #mutations = {};
    #effects = [];
    #reactions = [];
    #mappedSelectors = undefined;
    #mappedReaction = undefined;

    constructor(config: UpduxConfig<TState, TActions, TSelectors, TSubduxes>) {
        this.#initial = config.initial ?? {};
        this.#subduxes = config.subduxes ?? {};

        if (config.subduxes) {
            this.#subduxes = mapValues(config.subduxes, (sub) =>
                sub instanceof Updux ? sub : new Updux(sub)
            );
        }

        if (config.actions) {
            for (const [type, actionArg] of Object.entries(config.actions)) {
                if (typeof actionArg === 'function' && actionArg.type) {
                    this.#actions[type] = actionArg;
                } else {
                    this.#actions[type] = action(type, actionArg);
                }
            }
        }

        this.#selectors = config.selectors ?? {};
        this.#mappedSelectors = config.mappedSelectors;

        this.#mutations = config.mutations ?? {};

        Object.keys(this.#mutations)
            .filter((action) => action !== '+')
            .filter((action) => !this.actions.hasOwnProperty(action))
            .forEach((action) => {
                throw new Error(`action '${action}' is not defined`);
            });

        if (config.effects) {
            this.#effects = Object.entries(config.effects);
        }

        this.#reactions = config.reactions ?? [];

        this.#mappedReaction = config.mappedReaction;
    }

    #memoInitial = moize(buildInitial);
    #memoActions = moize(buildActions);
    #memoSelectors = moize(buildSelectors);
    #memoUpreducer = moize(buildUpreducer);
    #memoMiddleware = moize(buildMiddleware);

    setMappedSelector(name, f) {
        this.#mappedSelectors = {
            ...this.#mappedSelectors,
            [name]: f,
        };
    }

    get middleware() {
        return this.#memoMiddleware(
            this.#effects,
            this.actions,
            this.selectors,
            this.#subduxes
        );
    }

    /** @member { unknown } */
    get initial(): AggregateDuxState<TState, TSubduxes> {
        return this.#memoInitial(this.#initial, this.#subduxes);
    }

    get actions(): AggregateDuxActions<TActions, TSubduxes> {
        return this.#memoActions(this.#actions, this.#subduxes) as any;
    }

    get selectors() {
        return this.#memoSelectors(
            this.#selectors,
            this.#mappedSelectors,
            this.#subduxes
        );
    }

    get upreducer() {
        return this.#memoUpreducer(
            this.initial,
            this.#mutations,
            this.#subduxes
        );
    }

    get reducer() {
        return (state, action) => this.upreducer(action)(state);
    }

    addSubscription(subscription) {
        this.#reactions = [...this.#reactions, subscription];
    }

    setAction(type, payloadFunc?: Function) {
        const theAction = action(type, payloadFunc);

        this.#actions = { ...this.#actions, [type]: theAction };

        return theAction;
    }

    setSelector(name, func) {
        // TODO selector already exists? Complain!
        this.#selectors = {
            ...this.#selectors,
            [name]: func,
        };
        return func;
    }

    setMutation(name, mutation) {
        if (typeof name === 'function') name = name.type;

        this.#mutations = {
            ...this.#mutations,
            [name]: mutation,
        };

        return mutation;
    }

    addEffect(action, effect) {
        this.#effects = [...this.#effects, [action, effect]];
    }

    splatSubscriber(store, inner, splatReaction) {
        const cache = {};

        return () => (state, previous, unsub) => {
            const cacheKeys = Object.keys(cache);

            const newKeys = difference(Object.keys(state), cacheKeys);

            for (const slice of newKeys) {
                let localStore = {
                    ...store,
                    getState: () => store.getState()[slice],
                };

                cache[slice] = [];

                if (typeof splatReaction === 'function') {
                    localStore = {
                        ...localStore,
                        ...splatReaction(localStore, slice),
                    };
                }

                const { unsub, subscriber, subscriberRaw } =
                    inner.subscribeAll(localStore);

                cache[slice].push({ unsub, subscriber, subscriberRaw });
                subscriber();
            }

            const deletedKeys = difference(cacheKeys, Object.keys(state));

            for (const deleted of deletedKeys) {
                for (const inner of cache[deleted]) {
                    inner.subscriber();
                    inner.unsub();
                }
                delete cache[deleted];
            }
        };
    }

    subscribeTo(store, subscription, setupArgs = []) {
        const localStore = augmentMiddlewareApi(
            {
                ...store,
                subscribe: (subscriber) =>
                    this.subscribeTo(store, () => subscriber),
            },
            this.actions,
            this.selectors
        );

        const subscriber = subscription(localStore, ...setupArgs);

        let previous;

        const memoSub = () => {
            const state = store.getState();
            if (state === previous) return;
            let p = previous;
            previous = state;
            subscriber(state, p, unsub);
        };

        let ret = store.subscribe(memoSub);
        const unsub = typeof ret === 'function' ? ret : ret.unsub;
        return {
            unsub,
            subscriber: memoSub,
            subscriberRaw: subscriber,
        };
    }

    subscribeAll(store) {
        let results = this.#reactions.map((sub) =>
            this.subscribeTo(store, sub)
        );

        for (const subdux in this.#subduxes) {
            if (subdux !== '*') {
                const localStore = {
                    ...store,
                    getState: () => get(store.getState(), subdux),
                };
                results.push(this.#subduxes[subdux].subscribeAll(localStore));
            }
        }

        if (this.#mappedReaction) {
            results.push(
                this.subscribeTo(
                    store,
                    this.splatSubscriber(
                        store,
                        this.#subduxes['*'],
                        this.#mappedReaction
                    )
                )
            );
        }

        return {
            unsub: () => results.forEach(({ unsub }) => unsub()),
            subscriber: () => results.forEach(({ subscriber }) => subscriber()),
            subscriberRaw: (...args) =>
                results.forEach(({ subscriberRaw }) => subscriberRaw(...args)),
        };
    }

    createStore(initial?: unknown, enhancerGenerator?: Function) {
        const enhancer = (enhancerGenerator ?? applyMiddleware)(
            this.middleware
        );

        const store: {
            getState: Function & Record<string, Function>;
            dispatch: Function & Record<string, Function>;
            selectors: Record<string, Function>;
            actions: AggregateDuxActions<TActions, TSubduxes>;
        } = reduxCreateStore(
            this.reducer,
            initial ?? this.initial,
            enhancer
        ) as any;

        store.actions = this.actions;

        store.selectors = this.selectors;

        merge(
            store.getState,
            mapValues(this.selectors, (selector) => {
                return (...args) => {
                    let result = selector(store.getState());

                    if (typeof result === 'function') return result(...args);

                    return result;
                };
            })
        );

        for (const action in this.actions) {
            store.dispatch[action] = (...args) => {
                return store.dispatch(this.actions[action](...args));
            };
        }

        this.subscribeAll(store);

        return store;
    }
}
