import { GridStack, type GridStackNode } from 'gridstack';

const STORAGE_PREFIX = 'gridstack:';

/** Vanilla custom element (not Lit) — its children are Flow-rendered Vaadin
 *  components, not something this element renders itself. A Lit render root
 *  would fight gridstack for ownership of the light DOM, so this class never
 *  touches its own childList except by reading/reacting to it. */
class GridStackLayout extends HTMLElement {
    private grid?: GridStack;
    private observer?: MutationObserver;
    private storageKey: string | null = null;
    private persistTimer?: ReturnType<typeof setTimeout>;

    connectedCallback() {
        this.observer = new MutationObserver((mutations) => this.onMutation(mutations));
        this.observer.observe(this, { childList: true });
    }

    disconnectedCallback() {
        this.observer?.disconnect();
        this.observer = undefined;
        // Write out a still-pending debounced save instead of dropping it:
        // a drag/resize followed straight away by navigating to another route
        // detaches this element inside the debounce window, which used to lose
        // the very last layout change. The server is not notified — its view is
        // going away with us.
        this.flushPersist();
        this.grid?.destroy(false); // keep DOM — Flow owns the children
        this.grid = undefined;
    }

    /** Called from the server once on attach (and again if the element is
     *  re-attached). Idempotent: tears down any previous grid instance
     *  first so it's safe to call more than once. */
    async initGrid(optionsJson: string, storageKey: string | null) {
        this.grid?.destroy(false);
        this.storageKey = storageKey;

        this.classList.add('grid-stack');
        const options = JSON.parse(optionsJson);
        const grid = GridStack.init(options, this);
        if (!grid) return;
        this.grid = grid;

        this.registerChildren();
        this.restore();

        grid.on('change added removed', () => this.schedulePersist());
        grid.on('resizestop', () => window.dispatchEvent(new Event('resize')));
    }

    /** Drops any saved layout for this grid and re-applies the positions
     *  currently declared server-side (the gs-* attributes on each child). */
    clearStorage() {
        if (this.storageKey) {
            localStorage.removeItem(STORAGE_PREFIX + this.storageKey);
        }
        if (!this.grid) return;
        for (const el of Array.from(this.children) as HTMLElement[]) {
            if (!el.classList.contains('grid-stack-item')) continue;
            this.grid.update(el, {
                x: this.intAttr(el, 'gs-x'),
                y: this.intAttr(el, 'gs-y'),
                w: this.intAttr(el, 'gs-w'),
                h: this.intAttr(el, 'gs-h'),
            });
        }
    }

    private onMutation(mutations: MutationRecord[]) {
        if (!this.grid) return;
        for (const mutation of mutations) {
            mutation.addedNodes.forEach((node) => {
                if (!(node instanceof HTMLElement)) return;
                if (!node.classList.contains('grid-stack-item')) return;
                if ((node as any).gridstackNode) return;
                this.grid!.makeWidget(node);
            });
            mutation.removedNodes.forEach((node) => {
                if (!(node instanceof HTMLElement)) return;
                if (!(node as any).gridstackNode) return;
                // gridstack's own _sortDom() re-appends children (within this
                // same element) to match layout order after every
                // move/resize, which fires childList mutations too. A
                // reordered node is back in the (still connected) DOM by the
                // time this callback runs; only a genuinely removed node is
                // disconnected. Without this check, removeWidget() here
                // would fire on gridstack's own reorder, re-triggering
                // _sortDom() and looping forever.
                if (node.isConnected) return;
                this.grid!.removeWidget(node, false);
            });
        }
    }

    private registerChildren() {
        for (const el of Array.from(this.children) as HTMLElement[]) {
            if (!el.classList.contains('grid-stack-item')) continue;
            if ((el as any).gridstackNode) continue;
            this.grid!.makeWidget(el);
        }
    }

    private schedulePersist() {
        clearTimeout(this.persistTimer);
        this.persistTimer = setTimeout(() => {
            this.persistTimer = undefined;
            this.persist();
        }, 150);
    }

    private flushPersist() {
        if (this.persistTimer === undefined) return;
        clearTimeout(this.persistTimer);
        this.persistTimer = undefined;
        this.persist(false);
    }

    private persist(notifyServer = true) {
        if (!this.grid) return;
        const nodes = this.grid.save(false) as GridStackNode[];
        if (this.storageKey) {
            localStorage.setItem(STORAGE_PREFIX + this.storageKey, JSON.stringify(nodes));
        }
        if (notifyServer) {
            (this as any).$server?.onLayoutChange(JSON.stringify(nodes));
        }
    }

    private restore() {
        if (!this.storageKey || !this.grid) return;
        const raw = localStorage.getItem(STORAGE_PREFIX + this.storageKey);
        if (!raw) return;

        let nodes: GridStackNode[];
        try {
            nodes = JSON.parse(raw);
        } catch {
            return;
        }
        if (!Array.isArray(nodes)) return;

        // Items dropped server-side since the layout was saved are skipped, so a
        // stale entry degrades gracefully instead of blocking the restore.
        const known = nodes.filter((node) => node.id
                && this.querySelector(`:scope > [gs-id="${CSS.escape(String(node.id))}"]`));
        if (!known.length) return;

        // load() rather than a per-item update() loop: it sorts the saved nodes,
        // pulls them out of the engine before re-placing them, and does the whole
        // thing in one batch. Updating item by item let a later item collide with
        // one already put back and push it off its saved spot for good — the
        // restored layout was then not the one the user left. load() also re-applies
        // gridstack's w/h defaults, which save() omits when they are 1.
        // `false` keeps add/remove off: Flow owns which children exist.
        this.grid.load(known, false);
    }

    private intAttr(el: HTMLElement, name: string): number | undefined {
        const value = el.getAttribute(name);
        return value === null ? undefined : parseInt(value, 10);
    }
}

customElements.define('grid-stack-layout', GridStackLayout);
