package com.example.components;

import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.ClientCallable;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.ComponentEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.HasSize;
import com.vaadin.flow.component.HasStyle;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.dependency.NpmPackage;
import com.vaadin.flow.component.Tag;
import com.vaadin.flow.shared.Registration;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Vaadin wrapper around <a href="https://gridstack.js.org">gridstack.js</a> —
 * a draggable, resizable grid container. Children must be {@link GridStackItem}
 * instances (or a plain {@link Component}, which gets auto-wrapped); gridstack
 * manipulates the real DOM nodes directly, so this component keeps its children
 * in light DOM (see {@code grid-stack.ts}) rather than routing them through a
 * Lit render root the way {@link ApexChart} does.
 * <p>
 * Layout changes made by drag/resize are, by default, only persisted in the
 * browser's {@code localStorage} (see {@link #setStorageKey(String)}); the
 * server is notified via {@link #addLayoutChangeListener}, and item positions
 * are kept in sync in {@link #onLayoutChange(String)} so {@link #getLayout()}
 * stays truthful across a reload of this component (though not across a page
 * reload, since server-side item state is not itself persisted).
 */
@Tag("grid-stack-layout")
@NpmPackage(value = "gridstack", version = "13.1.0")
@JsModule("./components/grid-stack.ts")
@CssImport("gridstack/dist/gridstack.min.css")
public class GridStackLayout extends Component implements HasSize, HasStyle {

    private static final JsonMapper MAPPER = JsonMapper.builder().build();

    private final Map<String, Object> options = new LinkedHashMap<>();
    private String storageKey;

    public GridStackLayout() {
        options.put("column", 12);
        options.put("cellHeight", "120px");
        options.put("margin", "8px");
        options.put("minRow", 1);
        options.put("float", true);
        options.put("animate", true);
        // gridstack's default handle is `.grid-stack-item-content`, i.e. the
        // whole widget, which swallows clicks meant for the components inside
        // it. Restrict dragging to the grip each GridStackItem renders.
        options.put("handle", "." + GridStackItem.DRAG_HANDLE_CLASS);
    }

    public GridStackLayout setColumn(int column) {
        options.put("column", column);
        return this;
    }

    public GridStackLayout setCellHeight(String cellHeight) {
        options.put("cellHeight", cellHeight);
        return this;
    }

    public GridStackLayout setMargin(String margin) {
        options.put("margin", margin);
        return this;
    }

    public GridStackLayout setMinRow(int minRow) {
        options.put("minRow", minRow);
        return this;
    }

    public GridStackLayout setFloat(boolean floatItems) {
        options.put("float", floatItems);
        return this;
    }

    public GridStackLayout setAnimate(boolean animate) {
        options.put("animate", animate);
        return this;
    }

    /** Overrides the selector items are dragged by (default: the grip rendered
     *  by {@link GridStackItem}). The selector must match an element inside the
     *  item. */
    public GridStackLayout setDragHandle(String selector) {
        options.put("handle", selector);
        return this;
    }

    public GridStackLayout setStaticGrid(boolean staticGrid) {
        options.put("staticGrid", staticGrid);
        return this;
    }

    /**
     * Enables browser-localStorage persistence of the layout under the given
     * key (shared across sessions/tabs on the same origin — pick something
     * unique per grid instance/view). Pass {@code null} to disable.
     */
    public GridStackLayout setStorageKey(String storageKey) {
        this.storageKey = storageKey;
        return this;
    }

    public void add(GridStackItem... items) {
        for (GridStackItem item : items) {
            getElement().appendChild(item.getElement());
        }
    }

    /** Convenience overload: wraps a plain component in a default-sized
     *  {@link GridStackItem} (an item passed in directly is added as is).
     *  <p>
     *  Appends through the element API rather than calling {@code add(item)}:
     *  a single-argument call resolves to this overload, not the varargs one,
     *  so delegating would recurse into itself. */
    public GridStackItem add(Component content) {
        GridStackItem item = content instanceof GridStackItem existing
                ? existing
                : new GridStackItem(content);
        getElement().appendChild(item.getElement());
        return item;
    }

    public void remove(GridStackItem... items) {
        for (GridStackItem item : items) {
            getElement().removeChild(item.getElement());
        }
    }

    public void removeAll() {
        getElement().removeAllChildren();
    }

    /** Restores every item to the position/size currently declared server-side
     *  (its {@code gs-*} attributes) and clears any saved localStorage layout. */
    public void resetLayout() {
        getElement().callJsFunction("clearStorage");
    }

    public List<GridStackItem.Position> getLayout() {
        List<GridStackItem.Position> positions = new ArrayList<>();
        getElement().getChildren().forEach(child -> {
            if (!(child.getComponent().orElse(null) instanceof GridStackItem item)) {
                return;
            }
            positions.add(new GridStackItem.Position(
                    item.getItemId(),
                    intAttr(item, "gs-x"),
                    intAttr(item, "gs-y"),
                    intAttr(item, "gs-w"),
                    intAttr(item, "gs-h")));
        });
        return positions;
    }

    @Override
    protected void onAttach(AttachEvent attachEvent) {
        super.onAttach(attachEvent);
        getElement().callJsFunction("initGrid", MAPPER.writeValueAsString(options), storageKey);
    }

    @ClientCallable
    private void onLayoutChange(String nodesJson) {
        JsonNode nodes = MAPPER.readTree(nodesJson);
        List<GridStackItem.Position> positions = new ArrayList<>();

        for (JsonNode node : nodes) {
            String id = node.path("id").asString(null);
            if (id == null) continue;

            // gridstack omits w/h from its saved layout when they are 1, so a
            // missing value means 1 — defaulting to 0 wrote a zero-sized widget
            // back onto the item and lost its size on the next attach.
            int x = node.path("x").asInt(0);
            int y = node.path("y").asInt(0);
            int w = node.path("w").asInt(1);
            int h = node.path("h").asInt(1);

            findItem(id).ifPresent(item -> item.setPosition(x, y, w, h));

            positions.add(new GridStackItem.Position(id, x, y, w, h));
        }

        fireEvent(new LayoutChangeEvent(this, true, positions));
    }

    private java.util.Optional<GridStackItem> findItem(String id) {
        return getElement().getChildren()
                .map(child -> child.getComponent().orElse(null))
                .filter(GridStackItem.class::isInstance)
                .map(GridStackItem.class::cast)
                .filter(item -> id.equals(item.getItemId()))
                .findFirst();
    }

    private int intAttr(Component component, String name) {
        String value = component.getElement().getAttribute(name);
        return value == null ? 0 : Integer.parseInt(value);
    }

    public Registration addLayoutChangeListener(ComponentEventListener<LayoutChangeEvent> listener) {
        return addListener(LayoutChangeEvent.class, listener);
    }

    public static class LayoutChangeEvent extends ComponentEvent<GridStackLayout> {
        private final List<GridStackItem.Position> positions;

        LayoutChangeEvent(GridStackLayout source, boolean fromClient, List<GridStackItem.Position> positions) {
            super(source, fromClient);
            this.positions = positions;
        }

        public List<GridStackItem.Position> getPositions() {
            return positions;
        }
    }
}
