import * as React from "react";
import { PureComponent, ReactNode, memo, useContext, useEffect, useLayoutEffect, useEffect as useEffectFallback, useRef } from "react";
import ReactDOM from "react-dom";

import { EventEmitter } from "../EventEmitter";

const canUseDOM = typeof window !== "undefined" && typeof document !== "undefined";
const useIsomorphicLayoutEffect = canUseDOM ? useLayoutEffect : useEffectFallback;

type BlockId = string | symbol;
type BlockNamespace = string | symbol;

interface NamespaceRecord {
    key: BlockNamespace,
    blocks: Record<BlockId, Block_[]>,
    blockSubscriptions: Record<BlockId, (block: Block_) => void>,
}

type NamespaceArg = BlockNamespace | NamespaceRecord;

const DefaultNamespace = Symbol("default_block_namespace");

class BlockStore {
    private namespaces: Record<BlockNamespace, NamespaceRecord> = {};

    protected useNamespace<T>(key: NamespaceArg, f: (ns: NamespaceRecord) => T): T {
        key ??= DefaultNamespace;

        if (typeof key == 'object') {
            return f(key);
        } else {
            let ns = this.namespaces[key];
            if (!ns) {
                ns = this.namespaces[key] = {
                    key: key,
                    blocks: {},
                    blockSubscriptions: {},
                }
            }

            const x = f(ns);

            if (!Object.keys(ns.blocks).length && !Object.keys(ns.blockSubscriptions).length) {
                delete this.namespaces[key];
            }

            return x;
        }
    }

    private emitter = new EventEmitter<{
        block_registered: [block: BlockId, namespace: BlockNamespace],
        block_unregistered: [block: BlockId, namespace: BlockNamespace],
        block_switched: [block: BlockId, namespace: BlockNamespace],
    }>();

    listen = this.emitter.on;

    protected transitionBlock(namespace: NamespaceRecord, id: BlockId, transaction: () => void) {
        const current_block = this.activeBlock(namespace, id);
        transaction();
        const new_block = this.activeBlock(namespace, id);
        if (current_block != new_block) {
            namespace.blockSubscriptions[id]?.(new_block);
            this.emitter.emit('block_switched', [id, namespace.key]);
        }
    }

    registerBlock(blk: Block_) {
        this.useNamespace(blk.namespace, (ns) => {
            const key = blk.name;
            const registered = ns.blocks[key] ||= [];

            this.transitionBlock(ns, key, () => {
                registered.push(blk);
            })
            this.emitter.emit('block_registered', [key, ns.key]);
        });
    }

    unregisterBlock(blk: Block_) {
        this.useNamespace(blk.namespace, (ns) => {
            const key = blk.name;
            const registered = ns.blocks[key] ||= [];

            this.transitionBlock(ns, key, () => {
                const index = registered.indexOf(blk);
                if (index > -1) {
                    registered.splice(index, 1);
                }
            });
            this.emitter.emit('block_unregistered', [key, ns.key]);

            if (!registered.length) {
                delete ns.blocks[key];
            }
        })
    }

    subscribeBlock(namespace: NamespaceArg, blk: BlockId, callback: (block: Block_) => void) {
        this.useNamespace(namespace, (ns) => {
            if (ns.blockSubscriptions[blk]) {
                throw new Error(`Block ${blk.toString()} is already in use`);
            }

            ns.blockSubscriptions[blk] = callback;

            callback(this.activeBlock(ns, blk));
        });

        return () => {
            this.useNamespace(namespace, (ns) => {
                delete ns.blockSubscriptions[blk];
            });
        }
    }

    isBlockProvided(namespace: NamespaceArg, blk: BlockId) {
        return this.useNamespace(namespace, (ns) => {
            return !!ns.blocks[blk]?.length;
        })
    }

    activeBlock(namespace: NamespaceArg, blk: BlockId) {
        return this.useNamespace(namespace, (ns) => {
            if (!ns.blocks[blk]?.length) return null;
            return ns.blocks[blk].reduce((last, current) => current.priority >= last.priority ? current : last);
        })
    }
}

const BlockContext = React.createContext<BlockStore>(null)
const BlockNamespaceContext = React.createContext<string>(null)

export const ProvideBlocks: React.FC<{ children?: ReactNode, namespace?: string }> = memo(({ namespace, children }) => {
    const localStore = useRef<BlockStore>();
    const superStore = useContext(BlockContext);
    if (!superStore) {
        localStore.current ??= new BlockStore();
    }

    let content = <>{children}</>

    if (localStore.current) {
        content = <BlockContext.Provider value={localStore.current}>
            {content}
        </BlockContext.Provider>
    }

    if (namespace) {
        content = <BlockNamespaceContext.Provider value={namespace}>
            {content}
        </BlockNamespaceContext.Provider>
    }

    return content;
})


// ==================== Block ==================== //

interface BlockProps {
    block: BlockId,
    namespace?: string,
    priority?: number,
    eager?: boolean,
    cache?: number | boolean,
    suppressed?: boolean,
    store?: BlockStore,
    children?: ReactNode,
}

class Block_ extends PureComponent<BlockProps> {
    get store() { return this.props.store! }

    state = {
        shouldRender: this.props.eager,
    }

    get name() {
        return this.props.block;
    }

    get priority() { return this.props.priority || 0 }
    get namespace() { return this.props.namespace || null }

    private _element: HTMLDivElement;
    get element() {
        if (!this._element) {
            if (!canUseDOM) return null as any;
            this._element = document.createElement("div");
            this._element.classList.add(`mlcl-block-content`);
            this._element.setAttribute("data-mlcl-block-namespace", (this.namespace ?? "").toString());
            this._element.setAttribute("data-mlcl-block", this.name.toString());
        }
        return this._element;
    }

    private unusedTimer;
    protected setUsed(used: boolean) {
        if (this.unusedTimer) clearTimeout(this.unusedTimer);

        if (used) {
            this.setState({ shouldRender: true });
            return
        }

        if (this.props.eager) return;

        if (this.props.cache !== true) {
            this.unusedTimer = setTimeout(() => {
                this.setState({ shouldRender: false });
            }, this.props.cache || 5);
        }
    }

    protected updateRegistration() {
        if (!this.props.suppressed) {
            this.store.registerBlock(this);
        } else {
            this.store.unregisterBlock(this);
        }
    }

    componentDidMount(): void {
        if (!canUseDOM) return;
        this.updateRegistration();
    }

    componentDidUpdate(prevProps: Readonly<BlockProps>): void {
        if (prevProps.suppressed != this.props.suppressed) {
            this.updateRegistration();
        }
    }

    componentWillUnmount(): void {
        this.store.unregisterBlock(this);
        if (this.unusedTimer) clearTimeout(this.unusedTimer);
    }

    private mountedParent: HTMLElement;
    private mountedPlaceholder: HTMLDivElement;
    mountOutput(newPlaceholder: HTMLDivElement) {
        const newParent = newPlaceholder.parentNode! as HTMLElement;

        if (newPlaceholder === this.mountedPlaceholder) return;
        if (this.mountedPlaceholder) throw new Error("Block is already mounted")

        this.setUsed(true);

        newParent.replaceChild(
            this.element,
            newPlaceholder,
        );

        this.mountedParent = newParent;
        this.mountedPlaceholder = newPlaceholder;
    }

    unmountOutput(expectedPlaceholder?: HTMLDivElement) {
        if (expectedPlaceholder && expectedPlaceholder != this.mountedPlaceholder) return;

        if (this.mountedParent && this.mountedPlaceholder) {
            this.mountedParent.replaceChild(
                this.mountedPlaceholder,
                this.element,
            );

            this.mountedParent = undefined;
            this.mountedPlaceholder = undefined;
        }

        this.setUsed(false);
    }

    render() {
        if (!this.state.shouldRender) return null;
        if (!canUseDOM || !this.element) return null;

        return ReactDOM.createPortal(
            this.props.children,
            this.element
        );
    }
}

const InternalBlock: React.FC<BlockProps> = ({ store, namespace, ...props }) => {
    if (store === undefined) store = useContext(BlockContext);
    if (namespace === undefined) namespace = useContext(BlockNamespaceContext);
    return <Block_ {...props} store={store} namespace={namespace} />
}


// ==================== UseBlock ==================== //

interface UseBlockProps {
    block: BlockId,
    namespace?: string,
    store?: BlockStore,
}

const UseBlock_: React.FC<UseBlockProps> = memo(({ store, namespace, block }) => {
    const placeholder_ref = useRef<HTMLDivElement>();

    useIsomorphicLayoutEffect(() => {
        let activeBlock: Block_;

        const unsubscribe = store.subscribeBlock(namespace, block, (block) => {
            if (block == activeBlock) return;
            if (activeBlock) activeBlock.unmountOutput(placeholder_ref.current);
            activeBlock = block;
            if (activeBlock) activeBlock.mountOutput(placeholder_ref.current);
        })

        return () => {
            if (activeBlock) activeBlock.unmountOutput(placeholder_ref.current);
            unsubscribe();
        }
    }, [block, namespace, store]);

    return <div
        ref={placeholder_ref}
        className="mlcl-block-placeholder"
        data-mlcl-block-namespace={(namespace ?? "").toString()}
        data-mlcl-block={block.toString()}
    />;
})
UseBlock_.displayName = "UseBlock_";

export const UseBlock: React.FC<UseBlockProps & { children?: ReactNode }> = memo(({ namespace, block, store, children }) => {
    if (store === undefined) store = useContext(BlockContext);
    if (namespace === undefined) namespace = useContext(BlockNamespaceContext);

    return <>
        {children && <Block_ store={store} namespace={namespace} block={block} priority={-100} children={children} />}
        <UseBlock_ store={store} namespace={namespace} block={block} />
    </>
})
UseBlock.displayName = "UseBlock";


// ==================== Public API ==================== //

const BlockApi = {
    Use: UseBlock,
    Provide: ProvideBlocks,
    _useStore: () => useContext(BlockContext),
    useIsProvided: (block: BlockId | BlockId[], mode: "any" | "all", opts?: { store?: BlockStore, namespace?: BlockNamespace }) => {
        if (!Array.isArray(block)) block = [block];

        let store = opts?.store;
        store ??= useContext(BlockContext);

        let namespace = opts?.namespace;
        namespace ??= useContext(BlockNamespaceContext);

        const blocksRef = useRef(block);
        blocksRef.current = block;

        function checkProvided() {
            const blocks = blocksRef.current;
            const predicate = id => store.isBlockProvided(namespace, id);
            if (mode == "any") {
                return blocks.some(predicate);
            } else {
                return blocks.every(predicate);
            }
        }

        const [blockProvided, setBlockProvided] = React.useState(checkProvided);

        useEffect(() => {
            return store.listen(['block_registered', 'block_unregistered'], (id, ns) => {
                if (ns !== namespace) return;

                const block = blocksRef.current;
                if (block.includes(id)) {
                    setBlockProvided(checkProvided());
                }
            })
        }, [store]);

        return blockProvided;
    },
    IfBlockProvided: (props: { block: BlockId | BlockId[], any?: boolean, namespace?: BlockNamespace, children: React.ReactNode }) => {
        const provided = BlockApi.useIsProvided(props.block, props.any ? "any" : "all", { namespace: props.namespace });
        return provided ? <>{props.children}</> : null;
    },
}

Object.assign(InternalBlock, BlockApi);

/**
 * Block-based rendering in React, similar to Django templates. Mainly intended for use in libraries as a replacement for passing elements as props.
 * 
 * Blocks may receive an optional `priority` prop, which determines which block is rendered when multiple blocks are defined with the same name.
 * It is generally intended that Blocks are singletons, but there are some use cases where it is easier to override a Block multiple times
 * 
 * Example Usage:
 * ```tsx
 * <ProvideBlocks>
 *     <UseBlock block="bob">
 *         Optional Default Content
 *     </UseBlock>
 * 
 *     <UserProvidedComponent>
 *         <Block block="bob">
 *             User Overridden Content
 *         </Block>
 *     </UserProvidedComponent>
 * </ProvideBlocks>
 * ```
 * 
 * NB: Blocks are rendered where they are defined, not where they are used.
 * This means that `Block`s (and their descendants) will receive Context from where they are placed in the hierarchy, not from where `UseBlock` is placed.
 */
export const Block: typeof InternalBlock & typeof BlockApi = InternalBlock as any;
