package org.vaadin.firitin.util;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.ComponentUtil;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.dom.DomListenerRegistration;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.shared.Registration;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;

/**
 * A helper to detect and observe size changes of components. Provides a Java API around the
 * <a href="https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver">ResizeObserver</a> JS API.
 * Allows you for example to easily configure Grid columns for different
 * devices or swap component implementations based on the screen size/orientation.
 * <p>
 *     When you start to observe a component size, the initial size is reported immediately.
 *     So unlike with the Page#addBrowserWindowResizeListener, you don't need to wait for the first resize event
 *     or to combine it with the Page#retrieveExtendedClientDetails to get the initial size.
 * </p>
 * <p>
 *     There is one ResizeObserver instance per UI, but your listeners are attached to a specific component.
 *     As the current version of Vaadin does not support extending UI, but this API is designed to be
 *     UI specific, fetch a ResizeObserver instance with {@link #of(UI)} or {@link #get()} (this uses
 *     {@link UI#getCurrent()} ).
 * </p>
 * <p>
 *     There are two ways to listen to size changes:
 * </p>
 * <ul>
 *     <li>Using the {@link #addResizeListener(Component, ComponentEventListener)} method, which is a Vaadin core
 *     style API, the listener gets {@link SizeChangeEvent}. and the return value is a {@link Registration} you can
 *     use to stop listening.</li>
 *     <li>Using the {@link #observe(Component, SizeChangeListener)} method, your listener simply receives the
 *     {@link Dimensions} of the listened component.</li>
 * </ul>
 */
public class ResizeObserver {

    /**
     * Event fired when the size of a component changes.
     */
    public static class SizeChangeEvent extends ComponentEvent<UI> {
        private final Component component;
        private final Dimensions dimensions;

        public SizeChangeEvent(UI ui, Component component, Dimensions dimensions) {
            super(ui, true);
            this.component = component;
            this.dimensions = dimensions;
        }

        /**
         * @return the component that was resized
         */
        public Component getComponent() {
            return component;
        }

        /**
         * @return the new width of the component in pixels
         * @deprecated Use {@link #getWidth()} instead. This method has a typo in the name.
         */
        @Deprecated(forRemoval = true)
        public int getWidht() {
            return getWidth();
        }

        /**
         * @return the new width of the component in pixels
         */
        public int getWidth() {
            return dimensions.width();
        }

        /**
         * @return the new height of the component in pixels
         */
        public int getHeight() {
            return dimensions.height();
        }

        /**
         * @return the new dimensions of the component
         */
        public Dimensions getDimensions() {
            return dimensions;
        }
    }

    /**
     * A simple listener notified when the size of a component changes.
     */
    public interface SizeChangeListener {
        void onChange(Dimensions observation);
    }

    /**
     * A listener for observing multiple components at once.
     * Called when any of the observed components changes size,
     * with fresh dimensions for all observed components.
     */
    @FunctionalInterface
    public interface MultiSizeChangeListener {
        /**
         * Called when any of the observed components changes size.
         *
         * @param dimensions a map of component to its current dimensions
         */
        void onChange(Map<Component, Dimensions> dimensions);
    }

    private record ObservationGroup(int groupId, Component[] components, MultiSizeChangeListener listener) {}

    /**
     * A record that describes the size and position of a component. Serialized from the browsers
     * <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMRectReadOnly">DOMRectReadOnly</a>
     *
     * @param x the x coordinate of the DOMRectReadOnly's origin.
     * @param y the y coordinate of the DOMRectReadOnly's origin.
     * @param width the width of the DOMRectReadOnly.
     * @param height the height of the DOMRectReadOnly.
     * @param top the top coordinate value of the DOMRectReadOnly (usually the same as y).
     * @param right the right coordinate value of the DOMRectReadOnly (usually the same as x + width).
     * @param bottom the bottom coordinate value of the DOMRectReadOnly (usually the same as y + height).
     * @param left the left coordinate value of the DOMRectReadOnly (usually the same as x).
     * @param offsetLeft the element's offsetLeft value.
     * @param offsetTop the element's offsetTop value.
     * @param offsetWidth the element's offsetWidth value.
     * @param offsetHeight the element's offsetHeight value.
     */
    public record Dimensions(
            int x,
            int y,
            int width,
            int height,
            int top,
            int right,
            int bottom,
            int left,
            int offsetLeft,
            int offsetTop,
            int offsetWidth,
            int offsetHeight
    ) {}

    private record ComponentMapping(int id, Component component, ArrayList<SizeChangeListener> listeners) {
        private ComponentMapping(int id, Component component) {
            this(id, component, new ArrayList<>());
        }
    }

    private Map<Component,Integer> componentToId = new HashMap<>();
    private Map<Integer,ComponentMapping> idToComponentMapping = new HashMap<>();
    private Map<Integer, ObservationGroup> observationGroups = new HashMap<>();
    private int nextId = 0;
    private int nextGroupId = 0;

    private static ObjectMapper om = new ObjectMapper();

    private final UI ui;
    private final Element uiElement;
    private final DomListenerRegistration reg;

    /**
     * @param ui the UI whose ResizeObserver you want to use
     * @return the ResizeObserver for the given UI
     */
    public static ResizeObserver of(UI ui) {
        ResizeObserver resizeObserver = ComponentUtil.getData(ui, ResizeObserver.class);
        if(resizeObserver == null) {
            resizeObserver = new ResizeObserver(ui);
            ComponentUtil.setData(ui, ResizeObserver.class, resizeObserver);
        }
        return resizeObserver;
    }

    /**
     * @return the ResizeObserver for the current UI
     */
    public static ResizeObserver get() {
        return ResizeObserver.of(UI.getCurrent());
    }

    private ResizeObserver(UI ui) {
        this.ui = ui;
        this.uiElement = ui.getElement();
        uiElement.executeJs("""
                var el = this;
                el._resizeObserver = new ResizeObserver((entries) => {
                  const sizes = {};
                  const affectedGroups = new Set();
                  for (const entry of entries) {
                    if (entry.target.isConnected && entry.contentBoxSize) {
                      const id = entry.target._resizeObserverId;
                      const dimensions = JSON.parse(JSON.stringify(entry.contentRect));
                      dimensions.offsetLeft = entry.target.offsetLeft;
                      dimensions.offsetTop = entry.target.offsetTop;
                      dimensions.offsetWidth = entry.target.offsetWidth;
                      dimensions.offsetHeight = entry.target.offsetHeight;
                      sizes[id] = JSON.stringify(dimensions);
                      // Check if this element belongs to any observation groups
                      if (entry.target._resizeObserverGroups) {
                        entry.target._resizeObserverGroups.forEach(gid => affectedGroups.add(gid));
                      }
                    } else {
                      console.log("Ignoring resize event for detached element " + entry.target._resizeObserverId +  ", TODO: cleanup??");
                    }
                  }
                  const event = new Event("element-resize");
                  event.dimensions = sizes;
                  el.dispatchEvent(event);

                  // Fire group resize events with fresh dimensions for all group members
                  affectedGroups.forEach(groupId => {
                    const group = el._resizeObserverGroups[groupId];
                    if (group) {
                      const groupDimensions = {};
                      group.elementIds.forEach(elemId => {
                        const elem = el._resizeObserverElements[elemId];
                        if (elem && elem.isConnected) {
                          const rect = elem.getBoundingClientRect();
                          const dims = {
                            x: rect.x, y: rect.y, width: rect.width, height: rect.height,
                            top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left,
                            offsetLeft: elem.offsetLeft, offsetTop: elem.offsetTop,
                            offsetWidth: elem.offsetWidth, offsetHeight: elem.offsetHeight
                          };
                          groupDimensions[elemId] = JSON.stringify(dims);
                        }
                      });
                      const groupEvent = new Event("group-resize");
                      groupEvent.groupId = groupId;
                      groupEvent.dimensions = groupDimensions;
                      el.dispatchEvent(groupEvent);
                    }
                  });
                });
                el._resizeObserverElements = {};
                el._resizeObserverGroups = {};
                """);
        reg = uiElement.addEventListener("element-resize", event -> {
                    // TODO fix this stupidity, quickly converted form elemental.json to jackson...
                    ObjectNode object = (ObjectNode) event.getEventData().get("event.dimensions");
                    for(String idx : object.propertyNames()) {
                        String json = object.get(idx).asString();
                        Dimensions dimensions = om.readValue(json, Dimensions.class);
                        ComponentMapping componentMapping = idToComponentMapping.get(Integer.valueOf(idx));
                        if(componentMapping != null) {
                            // Old deprecated API
                            new ArrayList<>(componentMapping.listeners()).forEach(l -> l.onChange(dimensions));
                            // Vaadin core style API
                            SizeChangeEvent sizeChangeEvent = new SizeChangeEvent(ui, componentMapping.component, dimensions);
                            // Ugly but I guess there is no other way to fire an event from UI
                            ComponentUtil.fireEvent(ui, sizeChangeEvent);
                        } else {
                            // Timing issue in Flow navigation can make this happen, simply ignore
                            Logger.getLogger(ResizeObserver.class.getName()).fine("Resize listener called for component that is already de-registered, id:" + idx);
                        }
                    }
                })
                .addEventData("event.dimensions")
                .debounce(100); // Wait a tiny bit for a pause while resizing, otherwise it will choke the connection for no reason...

        // Listener for group resize events
        uiElement.addEventListener("group-resize", event -> {
                    int groupId = (int) event.getEventData().get("event.groupId").asDouble();
                    ObservationGroup group = observationGroups.get(groupId);
                    if (group != null) {
                        ObjectNode dimensionsObj = (ObjectNode) event.getEventData().get("event.dimensions");
                        Map<Component, Dimensions> dimensionsMap = new HashMap<>();
                        for (String idx : dimensionsObj.propertyNames()) {
                            String json = dimensionsObj.get(idx).asString();
                            Dimensions dimensions = om.readValue(json, Dimensions.class);
                            ComponentMapping componentMapping = idToComponentMapping.get(Integer.valueOf(idx));
                            if (componentMapping != null) {
                                dimensionsMap.put(componentMapping.component(), dimensions);
                            }
                        }
                        group.listener().onChange(dimensionsMap);
                    }
                })
                .addEventData("event.groupId")
                .addEventData("event.dimensions")
                .debounce(100);
    }

    private ComponentMapping getComponentMapping(Component component) {
        return idToComponentMapping.computeIfAbsent(
                componentToId.computeIfAbsent(component, c -> nextId++), id -> mapComponent(component, id));
    }

    private ComponentMapping mapComponent(Component c, Integer id) {
        Runnable register = () -> {
            var componentElement = c.getElement();
            uiElement.executeJs("""
                    const el = $0;
                    const id = $1;
                    if(el instanceof HTMLElement) {
                        el._resizeObserverId = id;
                        this._resizeObserver.observe(el);
                        this._resizeObserverElements[id] = el;
                    } else {
                        throw new Error("el not Element, Flow bug?");
                    }
                """, componentElement, id).then(jsonvalue -> {
            });
        };
        if(c.isAttached()) {
            register.run();
        } else {
            c.addAttachListener(e -> {
                register.run();
                e.unregisterListener();
            });
        }
        c.addDetachListener(e -> {
            if(e.getUI().isClosing()) {
                // UI itself is being closed, no need to do clean up
                return;
            }
            idToComponentMapping.remove(id);
            componentToId.remove(c);
            // Note sure how browsers have implemented ResizeObserver, but manually cleaning
            // up elements registered to the observer to avoid memory leaks
            uiElement.executeJs("""
                    const el = this._resizeObserverElements[$0];
                    if(el) {
                        delete this._resizeObserverElements[$0];
                        this._resizeObserver.unobserve(el);
                    }
                """, id);
            e.unregisterListener();
        });

        return new ComponentMapping(id, c);
    }

    /**
     * Adds a listener to be notified when the size of the given component changes. Also the initial
     * size is reported immediately.
     *
     * @param component the component to observe
     * @param listener the listener to be notified
     * @return a registration that can be used to stop listening
     */
    public Registration addResizeListener(Component component, ComponentEventListener<SizeChangeEvent> listener) {
        SizeChangeListener sizeChangeListener = d -> listener.onComponentEvent(new SizeChangeEvent(ui, component, d));
        getComponentMapping(component).listeners().add(sizeChangeListener);
        return () -> {
            ComponentMapping componentMapping = getComponentMapping(component);
            componentMapping.listeners().remove(sizeChangeListener);
            if(componentMapping.listeners().isEmpty()) {
                // Do cleanup if no listeners left
                unobserve(component);
            }
        };
    }


    /**
     * Observe the size of a component. The listener will be notified when the size of the component changes.
     * @param component the component to observe
     * @param listener the listener to be notified
     * @return this for chaining
     */
    public ResizeObserver observe(Component component, SizeChangeListener listener) {
        getComponentMapping(component).listeners().add(listener);
        return this;
    }

    /**
     * Observe the size of multiple components at once. When any of the observed components
     * changes size, the listener receives fresh dimensions for ALL observed components.
     * This is useful when you need to coordinate based on multiple component positions,
     * like drawing a line between two buttons.
     *
     * @param listener the listener to be notified with a map of all component dimensions
     * @param components the components to observe
     * @return a Registration that can be used to stop observing
     */
    public Registration observe(MultiSizeChangeListener listener, Component... components) {
        int groupId = nextGroupId++;
        ObservationGroup group = new ObservationGroup(groupId, components, listener);
        observationGroups.put(groupId, group);

        // Collect element IDs and ensure all components are registered
        int[] elementIds = new int[components.length];
        for (int i = 0; i < components.length; i++) {
            ComponentMapping mapping = getComponentMapping(components[i]);
            elementIds[i] = mapping.id();
        }

        // Register the group in JS for all components
        StringBuilder idsArray = new StringBuilder("[");
        for (int i = 0; i < elementIds.length; i++) {
            if (i > 0) idsArray.append(",");
            idsArray.append(elementIds[i]);
        }
        idsArray.append("]");

        uiElement.executeJs("""
                const groupId = $0;
                const elementIds = JSON.parse($1);
                // Register group
                this._resizeObserverGroups[groupId] = { elementIds: elementIds };
                // Mark each element as belonging to this group
                elementIds.forEach(id => {
                    const el = this._resizeObserverElements[id];
                    if (el) {
                        if (!el._resizeObserverGroups) {
                            el._resizeObserverGroups = new Set();
                        }
                        el._resizeObserverGroups.add(groupId);
                    }
                });
                // Trigger initial measurement for this group
                const groupDimensions = {};
                elementIds.forEach(elemId => {
                    const elem = this._resizeObserverElements[elemId];
                    if (elem && elem.isConnected) {
                        const rect = elem.getBoundingClientRect();
                        const dims = {
                            x: rect.x, y: rect.y, width: rect.width, height: rect.height,
                            top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left,
                            offsetLeft: elem.offsetLeft, offsetTop: elem.offsetTop,
                            offsetWidth: elem.offsetWidth, offsetHeight: elem.offsetHeight
                        };
                        groupDimensions[elemId] = JSON.stringify(dims);
                    }
                });
                const groupEvent = new Event("group-resize");
                groupEvent.groupId = groupId;
                groupEvent.dimensions = groupDimensions;
                this.dispatchEvent(groupEvent);
                """, groupId, idsArray.toString());

        return () -> {
            observationGroups.remove(groupId);
            uiElement.executeJs("""
                    const groupId = $0;
                    const group = this._resizeObserverGroups[groupId];
                    if (group) {
                        // Remove group reference from elements
                        group.elementIds.forEach(id => {
                            const el = this._resizeObserverElements[id];
                            if (el && el._resizeObserverGroups) {
                                el._resizeObserverGroups.delete(groupId);
                            }
                        });
                        delete this._resizeObserverGroups[groupId];
                    }
                    """, groupId);
        };
    }

    /**
     * Stop observing the size of a component.
     *
     * @param component the component to stop observing
     * @param listener the listener to remove
     * @return this for chaining
     */
    public ResizeObserver unobserve(Component component, SizeChangeListener listener) {
        ComponentMapping componentMapping = getComponentMapping(component);
        componentMapping.listeners().remove(listener);
        if(componentMapping.listeners().isEmpty()) {
            unobserve(component);
        }
        return this;
    }

    /**
     * Stop observing the size of a component.
     *
     * @param component the component to stop observing
     * @return this for chaining
     */
    private ResizeObserver unobserve(Component component) {
        ComponentMapping componentMapping = getComponentMapping(component);
        idToComponentMapping.remove(componentMapping.id());
        componentToId.remove(component);
        uiElement.executeJs("""
                const el = this._resizeObserverElements[$0];
                if(el) {
                    delete this._resizeObserverElements[$0];
                    this._resizeObserver.unobserve(el);
                }
            """, componentMapping.id());
        return this;
    }

    /**
     * Set a debounce timeout for the resize events. This can be useful if you want to avoid
     * doing heavy operations on every resize event. The default is 100ms
     *
     * @param timeout the timeout in milliseconds
     * @return this for chaining
     */
    public ResizeObserver withDebounceTimeout(int timeout) {
        reg.debounce(timeout);
        return this;
    }

}
