<template>
    <div class="bm-pv-canvas">
        <!-- Per-instance container CSS (layout/box/responsive) from the preview API. -->
        <component :is="'style'" v-if="previewCss">{{ previewCss }}</component>
        <header class="bm-pv-canvas__bar">
            <span class="bm-pv-canvas__label">Live Preview</span>
            <span v-if="loading" class="bm-pv-canvas__status">rendering…</span>
            <span v-else-if="error" class="bm-pv-canvas__status bm-pv-canvas__status--error">{{ error }}</span>
        </header>

        <VueDraggable
            v-model="localInstances"
            :group="{ name: 'bm-elements' }"
            item-key="instance_id"
            ghost-class="bm-pv-ghost"
            chosen-class="bm-pv-chosen"
            drag-class="bm-pv-drag"
            :force-fallback="true"
            :fallback-on-body="true"
            draggable=".bm-pv-row"
            :animation="150"
            class="bm-pv-stage"
            @add="onAdd"
        >
            <BlockPreviewNode
                v-for="instance in localInstances"
                :key="instance.instance_id"
                :instance="instance"
                :selected-id="selectedId"
                :html-by-instance="htmlByInstance"
                @select="(id) => $emit('select', id)"
                @delete="(id) => $emit('delete', id)"
                @children-input="onChildrenInput"
                @child-add="onChildAdd"
            />

            <div v-if="localInstances.length === 0" class="bm-pv-empty">
                Drag an element here, or click one on the left to add it.
            </div>
        </VueDraggable>
    </div>
</template>

<script>
import { VueDraggable } from 'vue-draggable-plus';
import BlockPreviewNode from './BlockPreviewNode.vue';
import { replaceChildren, findInstance, flattenTree } from '../../helpers/instance-tree.js';

const ENDPOINT = '/api/block-manager/preview';
const DEBOUNCE_MS = 250;

export default {
    name: 'BlockPreviewCanvas',
    components: { VueDraggable, BlockPreviewNode },
    props: {
        value: { type: Array, default: () => [] },
        selectedId: { type: String, default: null },
    },
    data() {
        return {
            loading: false,
            error: null,
            // Maps instance_id → outerHTML returned by the preview API. Refreshed
            // whenever `value` changes, debounced. Uses a plain `{}` (not
            // Object.create(null)) so Vue 2's reactivity can observe it.
            htmlByInstance: {},
            // Generated, token-resolved CSS for all container instances (scoped to
            // their .bm-ci-… classes), injected as a <style> block above the stage.
            previewCss: '',
        };
    },
    created() {
        // Private mutable state held outside `data()` because Vue 2 silently
        // strips underscore-prefixed names from the data proxy, and these don't
        // need to be reactive anyway.
        this.timer = null;
        this.seq = 0;
        // Drag-mutation buffer. Cross-list drags (e.g. canvas → container, or
        // container → container at any depth) fire multiple @input events in the
        // same synchronous tick. We batch them and flush atomically in $nextTick
        // to avoid ghost duplicates. Child updates are keyed by container id so a
        // nested→nested move (source loses a child, target gains one) keeps both.
        this._pendingTopLevel = null;
        this._pendingChildUpdates = {};
        this._flushPending = false;
    },
    computed: {
        localInstances: {
            // v-model passthrough so vue-draggable-plus can mutate the parent's
            // array directly. Routes through the drag-mutation buffer so cross-list
            // drops (e.g. canvas element → container) are applied atomically.
            get() { return this.value; },
            set(next) { this.onTopLevelInput(next); },
        },
        signature() {
            // Recompute previews whenever element type/variant/style changes at
            // ANY depth. Flattening parent-before-children gives a stable string
            // that also shifts when the nesting itself changes (a node moving in
            // or out of a container reorders the flattened sequence).
            return flattenTree(this.value)
                .map((inst) => {
                    const style = inst.style_props ? JSON.stringify(inst.style_props) : '';
                    return `${inst.instance_id}:${inst.element_type}:${inst.variant}:${style}`;
                })
                .join('|');
        },
    },
    watch: {
        signature: { immediate: true, handler() { this.schedule(); } },
    },
    beforeDestroy() {
        if (this.timer) clearTimeout(this.timer);
    },
    methods: {
        schedule() {
            if (this.timer) clearTimeout(this.timer);
            this.timer = setTimeout(() => this.fetchPreview(), DEBOUNCE_MS);
        },
        async fetchPreview() {
            this.seq += 1;
            const mySeq = this.seq;
            this.loading = true;
            this.error = null;
            try {
                const res = await fetch(ENDPOINT, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Accept': 'application/json',
                        'X-Requested-With': 'XMLHttpRequest',
                    },
                    body: JSON.stringify({ structure_json: { elements: this.value } }),
                    credentials: 'same-origin',
                });
                if (!res.ok) throw new Error(`status ${res.status}`);
                const { data } = await res.json();
                if (mySeq !== this.seq) return; // a newer fetch superseded us
                this.htmlByInstance = this.splitHtml(data.html || '');
                this.previewCss = data.css || '';
            } catch (err) {
                this.error = `Preview failed: ${err.message}`;
            } finally {
                if (mySeq === this.seq) this.loading = false;
            }
        },
        /**
         * Split the full preview HTML into a per-instance map. BlockPreviewService
         * already wraps each rendered element in a `<div data-bm-instance="…">`,
         * so we just walk those top-level children and capture their innerHTML.
         */
        splitHtml(html) {
            const out = Object.create(null);
            if (!html) return out;
            const tpl = document.createElement('template');
            tpl.innerHTML = html;
            tpl.content.querySelectorAll('[data-bm-instance]').forEach((node) => {
                const id = node.getAttribute('data-bm-instance');
                if (id) out[id] = node.innerHTML;
            });
            return out;
        },
        onAdd(event) {
            // When the picker drops a fresh top-level instance, auto-select it
            // so the right rail jumps to the Element tab. The cloned object
            // already sits in `value` at `event.newIndex`.
            const dropped = this.value[event.newIndex];
            if (dropped) this.$emit('select', dropped.instance_id);
        },

        // --- Container nesting helpers ---

        /**
         * Buffered setter for top-level VueDraggable mutations. Cross-list drags
         * (canvas element → container) fire both a top-level removal AND a child
         * addition in the same synchronous tick. Batching them avoids ghost
         * duplicates where the moved element briefly appears in both places.
         */
        onTopLevelInput(newInstances) {
            this._pendingTopLevel = newInstances;
            this.scheduleDragFlush();
        },

        /**
         * Buffered setter for a container's nested VueDraggable children, keyed
         * by container id. A nested→nested move fires two of these in one tick
         * (source container loses a child, target gains one); keying keeps both
         * so neither is dropped. Paired with onTopLevelInput so cross-list drags
         * flush together.
         */
        onChildrenInput(containerId, newChildren) {
            this._pendingChildUpdates[containerId] = newChildren;
            this.scheduleDragFlush();
        },

        /**
         * Flush all pending drag mutations atomically in the next microtask.
         * Only one flush is ever queued per tick regardless of how many @input
         * events fire. Child updates are applied via replaceChildren so they
         * land on the correct container at any nesting depth; the updates touch
         * disjoint subtrees, so application order doesn't matter.
         */
        scheduleDragFlush() {
            if (this._flushPending) return;
            this._flushPending = true;
            this.$nextTick(() => {
                this._flushPending = false;
                let result = this._pendingTopLevel !== null ? this._pendingTopLevel : this.value;
                for (const [containerId, children] of Object.entries(this._pendingChildUpdates)) {
                    result = replaceChildren(result, containerId, children);
                }
                this._pendingTopLevel = null;
                this._pendingChildUpdates = {};
                this.$emit('input', result);
            });
        },

        /**
         * When the picker drops a fresh element into a container, auto-select it.
         * The cloned object sits in the container's children at event.newIndex;
         * since we batched the update, resolve it from the pending child state
         * first, falling back to the current tree (container may be nested).
         */
        onChildAdd(containerId, event) {
            const children = this._pendingChildUpdates[containerId]
                || (findInstance(this.value, containerId) || {}).children
                || [];
            const dropped = children[event.newIndex];
            if (dropped) this.$emit('select', dropped.instance_id);
        },
    },
};
</script>
