import {
    componentsByElement,
    componentsById,
    getComponentByElement,
} from "../componentManagement";
import { callRemoteMethodDiscardResponse } from "../rpc";
import {
    EventHandler,
    DragHandler,
    DragHandlerArguments,
    ClickHandlerArguments,
    ClickHandler,
} from "../eventHandling";
import { ComponentId } from "../dataModels";
import { insertWrapperElement, replaceElement } from "../utils";
import { devToolsConnector } from "../app";

/// Base for all component states. Updates received from the backend are
/// partial, hence most properties may be undefined.
export type ComponentState = {
    // The component type's unique id. Crucial so the client knows what kind of
    // component to spawn.
    readonly _type_: string;
    // Debugging information. Useful both for developing rio itself, and also
    // displayed to developers in Rio's dev tools
    _python_type_: string;
    // Debugging information
    _key_: string | number | null;
    // How much space to leave on the left, top, right, bottom
    _margin_: [number, number, number, number];
    // Explicit size request, if any
    _min_size_: [number, number];
    // Maximum size, if any
    // MAX-SIZE-BRANCH _max_size_?: [number | null, number | null];
    // Alignment of the component within its parent, if any
    _align_: [number | null, number | null];
    // Scrolling behavior
    // SCROLLING-REWORK _scroll_?: [RioScrollBehavior, RioScrollBehavior];
    // Whether the component would like to receive additional space if there is
    // any left over
    _grow_: [boolean, boolean];
    // Debugging information: The dev tools may not display components to the
    // developer if they're considered internal
    _rio_internal_: boolean;
};

export type DeltaState<S extends ComponentState> = Omit<Partial<S>, "_type_">;

/// Base class for all components
export abstract class ComponentBase<S extends ComponentState = ComponentState> {
    readonly id: ComponentId;
    readonly element: HTMLElement;

    readonly state: S;

    // Reference to the parent component. If the component is about to be
    // removed from the component tree (i.e. it's in `latent-components`), this
    // still references the *last* parent component. `null` is only for newly
    // created components and the root component.
    parent: ComponentBase | null = null;
    children = new Set<ComponentBase>();

    _eventHandlers = new Set<EventHandler>();

    private marginElement: HTMLElement | null = null;

    private outerAlignElement: HTMLElement | null = null;
    private innerAlignElement: HTMLElement | null = null;

    private outerScrollElement: HTMLElement | null = null;
    private centerScrollElement: HTMLElement | null = null;
    private innerScrollElement: HTMLElement | null = null;

    constructor(id: ComponentId, state: S) {
        this.id = id;
        this.state = state;

        this.element = this.createElement();
        this.element.classList.add("rio-component");
    }

    // Layouting (alignment, scrolling, etc) may require extra HTML elements,
    // which will be created on demand. This property always points to the
    // current outermost element. So when a component is moved around in the
    // DOM, make sure to use `outerElement` instead of `element`.
    get outerElement(): HTMLElement {
        if (this.marginElement !== null) {
            return this.marginElement;
        }

        if (this.outerScrollElement !== null) {
            return this.outerScrollElement;
        }

        if (this.outerAlignElement !== null) {
            return this.outerAlignElement;
        }

        return this.element;
    }

    get alignmentElement(): HTMLElement | null {
        return this.outerAlignElement;
    }

    /// Given a partial state update, this function updates the component's HTML
    /// element to reflect the new state.
    ///
    /// The `element` parameter is identical to `this.element`. It's passed as
    /// an argument because it's more efficient than calling `this.element`.
    updateElement(
        deltaState: DeltaState<S>,
        latentComponents: Set<ComponentBase>
    ): void {
        if (deltaState._min_size_ !== undefined) {
            this.element.style.minWidth = `${deltaState._min_size_[0]}rem`;
            this.element.style.minHeight = `${deltaState._min_size_[1]}rem`;
        }

        // MAX-SIZE-BRANCH if (deltaState._max_size_ !== undefined) {
        // MAX-SIZE-BRANCH     this._updateMaxSize(deltaState._max_size_);
        // MAX-SIZE-BRANCH }

        if (deltaState._align_ !== undefined) {
            this._updateAlign(deltaState._align_);
        }

        // SCROLLING-REWORK
        // if (deltaState._scroll_ !== undefined) {
        //     this._updateScroll(deltaState._scroll_);
        // }

        if (deltaState._margin_ !== undefined) {
            this._updateMargin(deltaState._margin_);
        }
    }

    onChildGrowChanged(): void {}

    private _updateMaxSize(maxSize: [number | null, number | null]): void {
        let transform: string[] = [];

        if (maxSize[0] === null) {
            this.element.style.removeProperty("max-width");
            this.element.style.removeProperty("left");
        } else {
            this.element.style.maxWidth = `${maxSize[0]}rem`;
            this.element.style.left = `50%`;
            transform.push("translateX(-50%)");
        }

        if (maxSize[1] === null) {
            this.element.style.removeProperty("max-height");
            this.element.style.removeProperty("top");
        } else {
            this.element.style.maxHeight = `${maxSize[1]}rem`;
            this.element.style.top = `50%`;
            transform.push("translateY(-50%)");
        }

        this.element.style.transform = transform.join(" ");
    }

    private _updateAlign(align: [number | null, number | null]): void {
        if (align[0] === null && align[1] === null) {
            // Remove the alignElement if we have one
            if (this.outerAlignElement !== null) {
                replaceElement(
                    this.outerAlignElement,
                    this.innerAlignElement!.firstChild!
                );
                this.outerAlignElement = null;
                this.innerAlignElement = null;
            }
        } else {
            // Create the alignElement if we don't have one already
            if (this.outerAlignElement === null) {
                this.innerAlignElement = insertWrapperElement(this.element);
                this.outerAlignElement = insertWrapperElement(
                    this.innerAlignElement
                );

                this.innerAlignElement.classList.add("rio-align-inner");
                this.outerAlignElement.classList.add("rio-align-outer");

                this.outerAlignElement.dataset.ownerId = `${this.id}`;
            }

            let transform = "";

            if (align[0] === null) {
                this.innerAlignElement!.style.removeProperty("left");
                this.innerAlignElement!.style.width = "100%";
                this.innerAlignElement!.classList.add("stretch-child-x");
            } else {
                this.innerAlignElement!.style.left = `${align[0] * 100}%`;
                this.innerAlignElement!.style.width = "min-content";
                this.innerAlignElement!.classList.remove("stretch-child-x");
                transform += `translateX(-${align[0] * 100}%) `;
            }

            if (align[1] === null) {
                this.innerAlignElement!.style.removeProperty("top");
                this.innerAlignElement!.style.height = "100%";
                this.innerAlignElement!.classList.add("stretch-child-y");
            } else {
                this.innerAlignElement!.style.top = `${align[1] * 100}%`;
                this.innerAlignElement!.style.height = "min-content";
                this.innerAlignElement!.classList.remove("stretch-child-y");
                transform += `translateY(-${align[1] * 100}%) `;
            }

            this.innerAlignElement!.style.transform = transform;
        }
    }

    // SCROLLING-REWORK
    // private _updateScroll(
    //     scroll: [RioScrollBehavior, RioScrollBehavior]
    // ): void {
    //     if (scroll[0] === 'never' && scroll[1] === 'never') {
    //         // Remove the scrollElement if we have one
    //         if (this.outerScrollElement !== null) {
    //             replaceElement(
    //                 this.outerScrollElement,
    //                 this.outerScrollElement.firstChild!
    //             );
    //             this.outerScrollElement = null;
    //         }
    //     } else {
    //         // Create the scrollElement if we don't have one already
    //         if (this.outerScrollElement === null) {
    //             this.innerScrollElement = insertWrapperElement(
    //                 this.outerAlignElement ?? this.element
    //             );
    //             this.centerScrollElement = insertWrapperElement(
    //                 this.innerScrollElement
    //             );
    //             this.outerScrollElement = insertWrapperElement(
    //                 this.centerScrollElement
    //             );

    //             this.outerScrollElement.dataset.ownerId = `${this.id}`;
    //             this.outerScrollElement.className =
    //                 'rio-scroll-helper rio-scroll';
    //         }

    //         this.outerScrollElement.dataset.scrollX = scroll[0];
    //         this.outerScrollElement.dataset.scrollY = scroll[1];
    //     }
    // }

    private _updateMargin(margin: [number, number, number, number]): void {
        if (
            margin[0] === 0 &&
            margin[1] === 0 &&
            margin[2] === 0 &&
            margin[3] === 0
        ) {
            // Remove the marginElement if we have one
            if (this.marginElement !== null) {
                replaceElement(
                    this.marginElement,
                    this.marginElement.firstChild!
                );
                this.marginElement = null;
            }
        } else {
            // Create the marginElement if we don't have one already
            if (this.marginElement === null) {
                this.marginElement = insertWrapperElement(
                    this.outerScrollElement ??
                        this.outerAlignElement ??
                        this.element
                );

                this.marginElement.classList.add("rio-margin");

                this.marginElement.dataset.ownerId = `${this.id}`;
            }

            // Margins cause weird problems (for example, they can stick out of
            // the parent element), so we use padding instead
            this.marginElement.style.paddingLeft = `${margin[0]}rem`;
            this.marginElement.style.paddingTop = `${margin[1]}rem`;
            this.marginElement.style.paddingRight = `${margin[2]}rem`;
            this.marginElement.style.paddingBottom = `${margin[3]}rem`;
        }
    }

    private unparent(latentComponents: Set<ComponentBase>): void {
        // Remove this component from its parent
        console.assert(
            this.parent !== null,
            `.unparent() was called on ${this}, which doesn't have a parent`
        );

        this.parent!.children.delete(this);
        latentComponents.add(this);
    }

    registerChild(
        latentComponents: Set<ComponentBase>,
        child: ComponentBase
    ): void {
        // Remove the child from its previous parent
        if (child.parent !== null) {
            child.parent.children.delete(child);
        }

        // Add it to this component
        child.parent = this;
        this.children.add(child);
        latentComponents.delete(child);
    }

    /// Appends the given child component at the end of the given HTML element.
    /// Does not remove or modify any existing children. If `childId` is
    /// `undefined`, does nothing.
    appendChild(
        latentComponents: Set<ComponentBase>,
        childId: ComponentId | undefined,
        parentElement: HTMLElement = this.element
    ): void {
        // If undefined, do nothing
        if (childId === undefined) {
            return;
        }

        // Add the child
        let child = componentsById[childId]!;
        parentElement.appendChild(child.outerElement);

        this.registerChild(latentComponents, child);
    }

    /// Replaces the child of the given HTML element with the given child. The
    /// element must have zero or one children. If `childId` is `null`, removes
    /// the current child. If `childId` is `undefined`, does nothing.
    replaceOnlyChild(
        latentComponents: Set<ComponentBase>,
        childId: null | undefined | ComponentId,
        parentElement: HTMLElement = this.element
    ): void {
        // If undefined, do nothing
        if (childId === undefined) {
            return;
        }

        // If null, remove the current child
        let currentChildElement: Element | null = null;
        for (let child of iterChildElements(parentElement)) {
            currentChildElement = child;
            break;
        }

        if (childId === null) {
            if (currentChildElement !== null) {
                let child = getComponentByElement(currentChildElement);

                currentChildElement.remove();
                child.unparent(latentComponents);
            }

            console.assert(
                parentElement.firstElementChild === null,
                `Parent element ${parentElement} still has a child after replaceOnlyChild(null)`
            );
            return;
        }

        // If a child already exists, either move it to the latent container or
        // leave it alone if it's already the correct element
        if (currentChildElement !== null) {
            let child = getComponentByElement(currentChildElement);

            // Don't reparent the child if not necessary. This way things like
            // keyboard focus are preserved
            if (child.id === childId) {
                return;
            }

            currentChildElement.remove();
            child.unparent(latentComponents);
        }

        // Add the replacement component
        let child = componentsById[childId]!;
        parentElement.appendChild(child.outerElement);

        this.registerChild(latentComponents, child);
    }

    /// Replaces all children of the given HTML element with the given children.
    /// If `childIds` is `undefined`, does nothing.
    ///
    /// If `wrapInDivs` is true, each child is wrapped in a `<div>` element.
    /// This also requires any existing children to be wrapped in `<div>`s.
    replaceChildren(
        latentComponents: Set<ComponentBase>,
        childIds: undefined | ComponentId[],
        parentElement: HTMLElement = this.element,
        wrapInDivs: boolean = false
    ): void {
        // If undefined, do nothing
        if (childIds === undefined) {
            return;
        }

        let childElementIter = iterChildElements(parentElement);
        let curElement = childElementIter.next().value;
        let children = childIds.map((id) => componentsById[id]!);
        let curIndex = 0;

        // Since children are being moved between parents, it's possible for
        // some empty wrappers to persist. In that case `unwrap` will return
        // `null`.
        let wrap: (element: HTMLElement) => Element;
        let unwrap: (element: Element) => HTMLElement | null;
        if (wrapInDivs) {
            wrap = (element: HTMLElement) => {
                let wrapper = document.createElement("div");
                wrapper.classList.add("rio-child-wrapper");
                wrapper.appendChild(element);
                return wrapper;
            };
            unwrap = (element: Element) =>
                element.firstElementChild as HTMLElement | null;
        } else {
            wrap = (element: HTMLElement) => element;
            unwrap = (element: Element) => element as HTMLElement;
        }

        while (true) {
            // If there are no more children in the DOM element, add the
            // remaining children
            if (curElement === null) {
                while (curIndex < children.length) {
                    let child = children[curIndex];

                    parentElement.appendChild(wrap(child.outerElement));
                    this.registerChild(latentComponents, child);

                    curIndex++;
                }
                break;
            }

            // If there are no more children in the message, remove the
            // remaining DOM children
            if (curIndex >= children.length) {
                while (curElement !== null) {
                    curElement.remove();

                    let childElement = unwrap(curElement);
                    if (childElement !== null) {
                        let child = getComponentByElement(childElement);
                        child.unparent(latentComponents);
                    }

                    curElement = childElementIter.next().value;
                }
                break;
            }

            // If this was just an empty wrapper element, remove it and move on
            // to the next element
            let childElement = unwrap(curElement);

            if (childElement === null) {
                curElement.remove();
                curElement = childElementIter.next().value;
                continue;
            }

            // If this element is the correct element, move on
            let curChild = getComponentByElement(childElement);
            let expectedChild = children[curIndex];
            if (curChild === expectedChild) {
                curElement = childElementIter.next().value;
                curIndex++;
                continue;
            }

            // This element is not the correct element, insert the correct one
            // instead
            parentElement.insertBefore(
                wrap(expectedChild.outerElement),
                curElement
            );
            this.registerChild(latentComponents, expectedChild);

            curIndex++;
        }
    }

    /// Removes all children of the given HTML element. Children that are
    /// components are removed properly, while simple HTML are simply removed
    /// from the DOM.
    ///
    /// This is **not recursive**. It only looks through the direct children of
    /// an element and removes them.
    removeHtmlOrComponentChildren(
        latentComponents: Set<ComponentBase>,
        parentElement: HTMLElement
    ) {
        while (true) {
            let childElement =
                parentElement.firstElementChild! as HTMLElement | null;

            // Done?
            if (childElement === null) {
                break;
            }

            // Is this a component?
            let childComponent = componentsByElement.get(childElement);

            if (childComponent === undefined) {
                // Nope, it's just HTML
                childElement.remove();
            } else {
                // Yes, take extra special tender loving care
                childComponent.unparent(latentComponents);
                childElement.remove();
            }
        }
    }

    /// Creates the HTML element associated with this component. This function does
    /// not attach the element to the DOM, but merely returns it.
    protected abstract createElement(): HTMLElement;

    /// This method is called when a component is about to be removed from the
    /// component tree. It can be used for cleaning up event handlers and helper
    /// HTML elements (like popups).
    onDestruction(): void {
        for (let handler of this._eventHandlers) {
            handler.disconnect();
        }
    }

    /// Send a message to the python instance corresponding to this component. The
    /// message is an arbitrary JSON object and will be passed to the instance's
    /// `_on_message` method.
    sendMessageToBackend(message: object): void {
        callRemoteMethodDiscardResponse("componentMessage", {
            component_id: this.id,
            payload: message,
        });
    }

    _setStateDontNotifyBackend(deltaState: DeltaState<S>): void {
        // Trigger an update
        this.updateElement(deltaState, null as any as Set<ComponentBase>);

        // Set the state
        Object.assign(this.state, deltaState);

        // Notify the dev tools, if any
        if (devToolsConnector !== null) {
            devToolsConnector.afterComponentStateChange({
                [this.id]: deltaState,
            });
        }
    }

    setStateAndNotifyBackend(deltaState: DeltaState<S>): void {
        // Set the state. This also updates the component
        this._setStateDontNotifyBackend(deltaState);

        // Notify the backend
        callRemoteMethodDiscardResponse("componentStateUpdate", {
            componentId: this.id,
            deltaState: deltaState,
        });
    }

    addClickHandler(args: ClickHandlerArguments): ClickHandler {
        return new ClickHandler(this, args);
    }

    addDragHandler(args: DragHandlerArguments): DragHandler {
        return new DragHandler(this, args);
    }

    toString(): string {
        let class_name = this.constructor.name;
        return `<${class_name} id:${this.id}>`;
    }
}

globalThis.RIO_COMPONENT_BASE = ComponentBase;

/// Iterates over an element's children, but ignores elements that have the
/// `rio-not-a-child-component` class. This is used by e.g. the RippleEffect.
function* iterChildElements(parentElement: Element) {
    // Since `replaceChildren` removes elements from the DOM, it messes up the
    // iteration for us. So we'll first store the elements in an array, and then
    // yield them.
    //
    // Yes, I know this function is pretty weird, but the upside is that
    // `replaceChildren` is neater in exchange.
    let children: Element[] = [];

    let element = parentElement.firstElementChild;

    while (element !== null) {
        if (!element.classList.contains("rio-not-a-child-component")) {
            children.push(element);
        }

        element = element.nextElementSibling;
    }

    for (let element of children) {
        yield element;
    }
    return null; // Return instead of yield to shut up the type checker
}
