(function($) {
    'use strict';

    // ============================
    // SVG Folder Icons
    // ============================

    const SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" class="kookoawpf-svg-folder">' +
        '<path d="M2 6C2 4.89543 2.89543 4 4 4H9.17157C9.70201 4 10.2107 4.21071 10.5858 4.58579L11.4142 5.41421C11.7893 5.78929 12.298 6 12.8284 6H20C21.1046 6 22 6.89543 22 8V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z" fill="currentColor"/>' +
        '</svg>';

    const SVG_FOLDER_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" class="kookoawpf-svg-folder">' +
        '<path d="M2 6C2 4.89543 2.89543 4 4 4H9.17157C9.70201 4 10.2107 4.21071 10.5858 4.58579L11.4142 5.41421C11.7893 5.78929 12.298 6 12.8284 6H20C21.1046 6 22 6.89543 22 8V9H6C4.34315 9 3 10.3431 3 12V18C3 18.3506 3.06015 18.6872 3.17071 19H4C2.89543 19 2 18.1046 2 17V6Z" fill="currentColor"/>' +
        '<path d="M4 12C4 10.8954 4.89543 10 6 10H21C22.1046 10 23 10.8954 23 12V18C23 19.1046 22.1046 20 21 20H6C4.89543 20 4 19.1046 4 18V12Z" fill="currentColor" opacity="0.85"/>' +
        '</svg>';

    // DnD visual indicator CSS classes
    const DRAG_CLASSES = 'drag-bot drag-in drag-up';

    // ============================
    // Main Controller
    // ============================

    const FO = window.KookoaWPF = {
        data: window.kookoawpfData || {},
        currentFolder: 'all',
        tree: null,
        suppressSelectEvent: false,

        /** Cached folder list from server (invalidated on refreshTree) */
        _folderListCache: null,

        /** Edge-drop tracking for DnD outside tree area */
        _edgeDrop: null,

        /** Flag to skip move_node handler when triggered programmatically */
        _skipMoveHandler: false,

        /** SVG constants (shared with media-modal.js) */
        SVG_FOLDER: SVG_FOLDER,
        SVG_FOLDER_OPEN: SVG_FOLDER_OPEN,

        // ========================
        // Init
        // ========================

        init: function() {
            // On page builder editors, skip full sidebar initialization.
            // KookoawpfMediaModal handles media modal integration independently.
            if (FO.data.isBuilderEditor) return;

            // If the inline script already collapsed the sidebar, skip re-adding
            // kookoawpf-has-sidebar to avoid a flash.
            if (!$('body').hasClass('kookoawpf-sidebar-collapsed')) {
                $('body').addClass('kookoawpf-has-sidebar');
            }

            // Sync content offset with actual sidebar width
            const sidebarW = parseInt($('#kookoawpf-sidebar-wrapper').css('width'), 10);
            if (sidebarW && sidebarW >= 200) {
                $('#wpcontent').css('padding-left', (sidebarW + 30) + 'px');
            }

            // Hide folder item counts if setting is disabled
            if (!this.data.showCount) {
                $('body').addClass('kookoawpf-hide-counts');
            }

            // Hide Keyboard Shortcuts option in ⋮ menu if disabled
            if (!this.data.shortcutsEnabled) {
                $('#kookoawpf-more-shortcuts').hide();
            }

            const urlParams = new URLSearchParams(window.location.search);
            const urlFolder = urlParams.get('kookoawpf_folder');
            this.currentFolder = urlFolder || 'all';

            // Smart Startup Folder — if the URL doesn't explicitly request a folder,
            // honor the startup preference.
            const postStatusParam = urlParams.get('post_status');
            const isTrashView = (postStatusParam === 'trash');
            if (!urlFolder && this.data.startupFolderId && parseInt(this.data.startupFolderId, 10) > 0) {
                if (isTrashView) {
                    // Trash: keep the grid showing all trashed items (no folder filter),
                    // but still reveal the startup folder in the tree so the user doesn't
                    // lose their orientation. No selection / no green vertical line.
                    this._startupRevealOnly = String(this.data.startupFolderId);
                } else {
                    this.currentFolder = String(this.data.startupFolderId);
                    this._startupApplied = true;
                }
            }

            // Pre-hide "Add Media File" if a specific folder is in the URL
            // (prevents flash while tree loads to determine if it's dynamic)
            if (this.currentFolder !== 'all' && this.currentFolder !== 'uncategorized') {
                this._addMediaBtnHidden = true;
                $('.page-title-action').hide();
            }

            // If a post_status filter is active (Published, Drafts, etc.),
            // deselect the sidebar so it doesn't look like a folder is selected.
            const postStatusFilter = urlParams.get('post_status');
            if (postStatusFilter && postStatusFilter !== 'all') {
                this._hasPostStatusFilter = true;
            }

            this.initSidebar();
            this.initTree();
            this.initDragDrop();
            this.initModals();
            this.initSearch();
            this.initResize();
            this.initBulkOrganize();
            this.initFolderBulk();
            this.initExportImport();
            this.initSort();
            this.initMoreMenu();
            this.initBreadcrumb();
            this._initMediaGridFilter();
            this._initDeleteListener();
            this._initUploadListener();
        },

        // ========================
        // Shared Helpers
        // ========================

        /**
         * Remove all DnD visual indicator classes from the tree.
         */
        clearDragClasses: function() {
            if (this.tree) {
                this.tree.find('.drag-bot, .drag-in, .drag-up').removeClass(DRAG_CLASSES);
            }
        },

        /**
         * Comparator that mirrors the server-side usort callback in
         * class-kookoawpf-folders.php::build_tree(). It MUST stay in sync with
         * that PHP comparator — otherwise the optimistic UI in auto sort modes
         * will momentarily disagree with the next refresh.
         *
         * Order of rules:
         *   1. Pinned siblings always come before non-pinned siblings.
         *   2. In auto sort modes (name/date/count), dynamic folders are
         *      pushed to the bottom of their tier.
         *   3. Within each resulting group, sort by the chosen field with the
         *      chosen direction. Custom sort uses the saved folder_order meta.
         *
         * @param {Object} a       jstree node A.
         * @param {Object} b       jstree node B.
         * @param {string} sortBy  'custom' | 'name' | 'date' | 'count'.
         * @param {string} sortDir 'ASC' | 'DESC'.
         * @return {number} Negative if a < b, positive if a > b, 0 if equal.
         */
        _compareFolders: function(a, b, sortBy, sortDir) {
            // Thin delegate to the pure, unit-tested comparator in
            // assets/js/kookoawpf-utils.js (enqueued as a dependency). Keeping
            // the ordering rules in one tested module avoids duplicating the
            // pin / dynamic-sink / natural-sort logic across the tree code.
            return KookoaWPFUtils.compareFolders(a, b, sortBy, sortDir);
        },

        /**
         * Re-sort the children of a parent jstree node in-place, applying the
         * current sort preference. No-op when sort is custom (the user owns
         * the order in custom mode) or when the parent has 0/1 children.
         *
         * Used by every operation that adds or moves a folder in auto sort
         * mode (drag & drop, move-to-folder, cut/copy paste, create folder,
         * create smart folder) so the optimistic UI always matches what the
         * server would render on the next refresh.
         *
         * @param {string} parentNodeId jstree node id, or '#' for the root.
         * @param {Object} [optInst]    Optional jstree instance — useful for
         *                              the modal sidebar tree which is a
         *                              separate instance from the main one.
         */
        _resortChildren: function(parentNodeId, optInst) {
            const self = this;
            if (self._sortBy === 'custom') return;

            const inst = optInst || (self.tree ? self.tree.jstree(true) : null);
            if (!inst) return;

            // The media modal sidebar tree always behaves as manual sort
            // (its server response is forced to custom via force_sort_by, and
            // it has no sort UI). Skip the resort here so the optimistic add
            // stays at its 'last' position regardless of FO._sortBy — which
            // may have been set to an auto sort by the main library sidebar
            // on the same page.
            if (inst.element && inst.element.hasClass('kookoawpf-modal-jstree')) return;

            const parentNode = inst.get_node(parentNodeId);
            if (!parentNode || !parentNode.children || parentNode.children.length <= 1) return;

            const sortBy  = self._sortBy  || 'name';
            const sortDir = self._sortDir || 'ASC';

            const sorted = parentNode.children.slice().sort(function(idA, idB) {
                const nodeA = inst.get_node(idA);
                const nodeB = inst.get_node(idB);
                return self._compareFolders(nodeA, nodeB, sortBy, sortDir);
            });

            // Apply only if the order actually changed (avoids needless redraws).
            let changed = false;
            for (let i = 0; i < sorted.length; i++) {
                if (sorted[i] !== parentNode.children[i]) { changed = true; break; }
            }
            if (changed) {
                parentNode.children = sorted;
                inst.redraw_node(parentNodeId, true);
            }
        },

        /**
         * Blink the underscore cursor in the sidebar brand (3 quick blinks).
         * Called on any folder structure change (create, delete, rename, move, etc.).
         */
        _blinkUnderscore: function() {
            const $cursor = $('.kookoawpf-brand-cursor');
            if (!$cursor.length) return;
            $cursor.removeClass('kookoawpf-blink-action');
            // Force reflow so the animation restarts even if called rapidly
            void $cursor[0].offsetWidth;
            $cursor.addClass('kookoawpf-blink-action');
        },

        /**
         * Get folder list (flat) with client-side caching.
         * Cache is invalidated whenever refreshTree() is called.
         *
         * @param {Function} callback - Receives array of {id, text, color, depth}
         */
        getCachedFolderList: function(callback) {
            const self = this;

            if (self._folderListCache) {
                callback(self._folderListCache);
                return;
            }

            self.ajax('kookoawpf_get_folder_list', { post_type: self.data.postType }, function(r) {
                if (r.success && r.data.folders) {
                    self._folderListCache = r.data.folders;
                    callback(self._folderListCache);
                } else {
                    callback([]);
                }
            });
        },

        /**
         * Populate a <select> element with folder options.
         *
         * @param {jQuery}   $select     - The select element
         * @param {string}   [currentVal] - Value to pre-select after populating
         */
        populateFolderSelect: function($select, currentVal, excludeFolder) {
            this.getCachedFolderList(function(folders) {
                $.each(folders, function(i, f) {
                    // Skip the currently active folder
                    if (excludeFolder && (String(f.id) === String(excludeFolder) || parseInt(f.id, 10) === parseInt(excludeFolder, 10))) return;
                    let label = f.text;
                    if (typeof f.count !== 'undefined') {
                        label += ' (' + f.count + ')';
                    }
                    if (f.pinned) label += ' ⭐';
                    if (f.locked) label += ' 🔒';
                    $select.append($('<option></option>').val(f.id).text(label));
                });
                if (currentVal) {
                    $select.val(currentVal);
                }
            });
        },

        /**
         * Extract folder_id from a jsTree node object.
         *
         * @param {Object} node - jsTree node
         * @returns {number|null}
         */
        getFolderId: function(node) {
            if (node.data && node.data.folder_id) return node.data.folder_id;
            if (node.original && node.original.data && node.original.data.folder_id) return node.original.data.folder_id;
            if (node.id && typeof node.id === 'string' && node.id.indexOf('folder_') === 0) {
                return parseInt(node.id.replace('folder_', ''), 10);
            }
            return null;
        },

        /**
         * Get post/attachment ID from a dragged list row or grid item.
         *
         * @param {jQuery} $item - The dragged element
         * @returns {number}
         */
        getItemId: function($item) {
            let id = $item.find('.check-column input[type="checkbox"]').val();
            if (id) return parseInt(id, 10);
            id = $item.data('id');
            if (id) return parseInt(id, 10);
            return 0;
        },

        /**
         * Get the current folder as a from_folder_id (0 if all/uncategorized).
         *
         * @returns {number|string}
         */
        getFromFolderId: function() {
            return (this.currentFolder !== 'all' && this.currentFolder !== 'uncategorized')
                ? this.currentFolder : 0;
        },

        /**
         * True when the current view is a smart (dynamic) folder.
         *
         * Smart folders are query-based virtual views — items inside them
         * aren't truly assigned, so dragging them out (or reorganising
         * within) has no real effect. Used by drag handlers to short-circuit.
         *
         * @returns {boolean}
         */
        isCurrentFolderSmart: function() {
            if (this.currentFolder === 'all' || this.currentFolder === 'uncategorized') {
                return false;
            }
            const inst = this.tree && this.tree.jstree && this.tree.jstree(true);
            if (!inst) return false;
            const node = inst.get_node('folder_' + this.currentFolder);
            return !!(node && node.data && node.data.is_dynamic);
        },

        /**
         * Get parent folder ID for a jsTree node.
         *
         * @param {Object} inst - jsTree instance
         * @param {Object} node - jsTree node
         * @returns {number}
         */
        getParentFolderId: function(inst, node) {
            if (node.parent === '#') return 0;
            const parentNode = inst.get_node(node.parent);
            return this.getFolderId(parentNode) || 0;
        },

        // ========================
        // Sidebar
        // ========================

        initSidebar: function() {
            const self = this;

            // Nav item clicks (All Items / Uncategorized)
            $(document).on('click', '.kookoawpf-nav-item', function(e) {
                e.preventDefault();
                self.goToFolder($(this).data('folder'));
            });

            // Set initial active state
            if (this._hasPostStatusFilter) {
                // A WP status filter (Published, Drafts, etc.) is active — deselect sidebar
                $('.kookoawpf-nav-item').removeClass('active');
            } else if (this.currentFolder === 'uncategorized') {
                $('.kookoawpf-nav-item').removeClass('active');
                $('.kookoawpf-nav-uncategorized').addClass('active');
            } else if (this.currentFolder !== 'all') {
                $('.kookoawpf-nav-item').removeClass('active');
            }

            // Toggle button
            self._injectToggleButton();

            $(document).on('click', '#kookoawpf-toggle-sidebar', function() {
                self.toggleSidebar();
            });
        },

        _injectToggleButton: function() {
            const $btn = $(
                '<button type="button" id="kookoawpf-toggle-sidebar" class="kookoawpf-toggle-sidebar" title="Toggle Folders">' +
                '<span class="kookoawpf-toggle-arrow dashicons dashicons-arrow-left-alt2"></span>' +
                '</button>'
            );
            const $sidebar = $('#kookoawpf-sidebar-wrapper');
            if ($sidebar.length) {
                $sidebar.append($btn);
            } else {
                $('body').append($btn);
            }
        },


        toggleSidebar: function() {
            const self = this;
            const $sidebar = $('#kookoawpf-sidebar-wrapper');
            const $arrow = $('#kookoawpf-toggle-sidebar .kookoawpf-toggle-arrow');
            const isCollapsed = $sidebar.hasClass('kookoawpf-collapsed');

            if (isCollapsed) {
                $sidebar.removeClass('kookoawpf-collapsed');
                $('body').removeClass('kookoawpf-sidebar-collapsed').addClass('kookoawpf-has-sidebar');
                $arrow.removeClass('dashicons-arrow-right-alt2').addClass('dashicons-arrow-left-alt2');
                try { localStorage.removeItem('kookoawpf_sidebar_collapsed_' + self.data.postType); } catch (e) {}
                // Sync content offset with sidebar width
                const sidebarW = parseInt($sidebar.css('width'), 10);
                if (sidebarW && sidebarW >= 200) {
                    $('#wpcontent').css('padding-left', (sidebarW + 30) + 'px');
                }
            } else {
                $sidebar.addClass('kookoawpf-collapsed');
                $('body').addClass('kookoawpf-sidebar-collapsed').removeClass('kookoawpf-has-sidebar');
                $arrow.removeClass('dashicons-arrow-left-alt2').addClass('dashicons-arrow-right-alt2');
                try { localStorage.setItem('kookoawpf_sidebar_collapsed_' + self.data.postType, '1'); } catch (e) {}
            }

            // Trigger resize so WP media grid recalculates layout
            setTimeout(function() { $(window).trigger('resize'); }, 100);

        },

        // ========================
        // jsTree Initialization
        // ========================

        initTree: function() {
            const self = this;

            self.loadTree(function() {
                self.tree = $('#kookoawpf-jstree');

                self.tree.jstree({
                    core: {
                        data: self.treeData || [],
                        animation: 0,
                        check_callback: function(op, node, parent, position, more) {
                            return self._checkCallback(op, node, parent, position, more);
                        },
                        themes: { name: 'default', dots: true, icons: true },
                        multiple: false,
                        dblclick_toggle: false
                    },
                    plugins: ['dnd', 'search'],
                    dnd: {
                        copy: false,
                        open_timeout: 700,
                        is_draggable: function(nodes) {
                            return true;
                        }
                    },
                    search: {
                        show_only_matches: true,
                        show_only_matches_children: true,
                        search_callback: function(str, node) {
                            // Search only in the visible folder name text, not HTML/attributes
                            let text = node.data && node.data.original_text ? node.data.original_text : '';
                            if (!text) {
                                // Fallback: strip HTML tags from node.text
                                text = (node.text || '').replace(/<[^>]*>/g, '').trim();
                            }
                            return text.toLowerCase().indexOf(str.toLowerCase()) !== -1;
                        }
                    }
                });

                self._bindTreeEdgeDnD();
                self._bindTreeEvents();
            });
        },

        /**
         * jsTree check_callback — controls DnD visual feedback and permissions.
         */
        _checkCallback: function(op, node, parent, position, more) {
            const self = this;
            self.clearDragClasses();

            if ((op === 'move_node' || op === 'copy_node') && more && more.dnd) {
                const inst = self.tree.jstree(true);
                const isNodeLocked = node && node.data && node.data.locked;

                // ── Locked folder constraints ──
                if (isNodeLocked) {
                    // Locked folders can only reorder among siblings (same parent).
                    // Dropping INTO another folder (pos 'i') = reparent → block.
                    if (more.pos === 'i') return false;

                    // For 'a'/'b' (reorder lines): only allow if ref has same parent
                    if ((more.pos === 'a' || more.pos === 'b') && more.ref) {
                        const refNode = inst.get_node(more.ref.id || more.ref);
                        if (refNode && refNode.parent !== node.parent) return false;
                    }
                }

                // ── Pinned folder boundary constraints ──
                // When reordering (pos 'a' or 'b'), enforce pin boundary
                if ((more.pos === 'a' || more.pos === 'b') && more.ref) {
                    const refId = more.ref.id || more.ref;
                    const refNode = inst.get_node(refId);
                    const dragNode = inst.get_node(node.id || node);
                    if (refNode && dragNode) {
                        const dragPinned = dragNode.data && dragNode.data.pinned;
                        const refPinned = refNode.data && refNode.data.pinned;
                        if (dragPinned && !refPinned) return false;
                        if (!dragPinned && refPinned) return false;
                    }
                }

                // Cannot drop INTO a dynamic/smart folder
                if (more.pos === 'i' && more.ref && more.ref.data && more.ref.data.is_dynamic) {
                    return false;
                }

                // Cannot drop a folder INTO a locked folder (locked = flat, items only)
                if (more.pos === 'i' && more.ref && more.ref.data && more.ref.data.locked) {
                    // Check if we're dragging a folder (tree node), not an item
                    // Tree DnD = always folder nodes, so block it
                    return false;
                }

                // Cannot drop a folder INTO its CURRENT parent (no-op reparent).
                // Matches "Move to Folder" which shows "already in this location".
                // Without this the user gets a tempting drop-in indicator on a
                // target that would do nothing.
                if (more.pos === 'i' && more.ref && node) {
                    const refIdForSelfParent = more.ref.id || more.ref;
                    if (node.parent === refIdForSelfParent) {
                        return false;
                    }
                }

                // Suppress reorder lines at positions adjacent to the dragged node
                // (dropping there would be a no-op)
                if ((more.pos === 'b' || more.pos === 'a') && more.ref && node) {
                    const refId = more.ref.id || (typeof more.ref === 'string' ? more.ref : null);
                    const dragId = node.id;

                    // Always suppress line on the dragged node itself
                    if (refId === dragId) {
                        return false;
                    }

                    const dragParent = node.parent;
                    const refParent = more.ref.parent || (refId ? (inst.get_node(refId) || {}).parent : null);

                    // Check adjacent siblings (same parent)
                    if (dragParent && refParent && dragParent === refParent) {
                        const parentNode = inst.get_node(dragParent);
                        if (parentNode && parentNode.children) {
                            const siblings = parentNode.children;
                            const dragIdx = siblings.indexOf(dragId);
                            const refIdx = siblings.indexOf(refId);

                            if (dragIdx >= 0 && refIdx >= 0) {
                                // pos 'a' = line AFTER ref (drag-bot): no-op if ref is directly above
                                // pos 'b' = line BEFORE ref (drag-up): no-op if ref is directly below
                                if (more.pos === 'a' && refIdx === dragIdx - 1) {
                                    return false;
                                }
                                if (more.pos === 'b' && refIdx === dragIdx + 1) {
                                    return false;
                                }
                            }
                        }
                    }
                }

                // Remember the last approved drop position type. move_node fires
                // later without this info, and handleMoveNode uses it to tell
                // "drop ON a folder" ('i' = reparent → last in tier) apart from
                // "drop between siblings" ('a'/'b' = insert exactly there).
                self._lastDropPos = more.pos;

                // Visual feedback classes
                // Convert drag-up (before ref) → drag-bot on previous sibling to avoid flicker
                if (more.pos === 'b' && more.ref) {
                    const refId = more.ref.id || more.ref;
                    const refNode = inst.get_node(refId);
                    if (refNode && refNode.parent) {
                        const parentNode = inst.get_node(refNode.parent);
                        if (parentNode && parentNode.children) {
                            const refIdx = parentNode.children.indexOf(refId);
                            if (refIdx > 0) {
                                const prevId = parentNode.children[refIdx - 1];
                                const $prevDom = inst.get_node(prevId, true);
                                if ($prevDom && $prevDom.length) {
                                    $prevDom.addClass('drag-bot');
                                }
                            } else {
                                // First child — no previous sibling, use drag-up on ref
                                more.origin.get_node(more.ref, true).addClass('drag-up');
                            }
                        }
                    }
                } else {
                    const classMap = { a: 'drag-bot', i: 'drag-in' };
                    if (classMap[more.pos]) {
                        more.origin.get_node(more.ref, true).addClass(classMap[more.pos]);
                    }
                }
                return true;
            }

            return true;
        },

        /**
         * Smart edge DnD: when mouse leaves the tree area, snap to first/last node.
         */
        _bindTreeEdgeDnD: function() {
            const self = this;

            $(document).on('dnd_move.vakata.kwpf', function(e, data) {
                if (!data || !data.data || !data.data.jstree) return;
                $('body').addClass('kookoawpf-tree-dragging');

                const target = $(data.event.target);
                const $closestNode = target.closest('.jstree-node');
                const $treeContainer = $('#kookoawpf-tree-container');

                // If over a valid tree node, jsTree handles it
                if ($closestNode.length && $closestNode.closest('#kookoawpf-jstree').length) {
                    self._edgeDrop = null;
                    return;
                }

                if (!$treeContainer.length) return;

                const treeOffset = $treeContainer.offset();
                const treeHeight = $treeContainer.outerHeight();
                const mouseY = data.event.pageY;
                const mouseX = data.event.pageX;

                // Only trigger if mouse is horizontally within the sidebar
                const sidebarRight = treeOffset.left + $treeContainer.outerWidth();
                if (mouseX < treeOffset.left || mouseX > sidebarRight) {
                    self.clearDragClasses();
                    self._edgeDrop = null;
                    return;
                }

                const $allNodes = self.tree.find('> ul > li.jstree-node');
                if (!$allNodes.length) return;

                const $firstNode = $allNodes.first();
                const $lastNode = $allNodes.last();

                // Get the dragged node ID
                const draggedNodeId = (data.data.nodes && data.data.nodes[0]) ? data.data.nodes[0] : null;

                self.clearDragClasses();

                // Pin boundary check helper for edge DnD
                const inst = self.tree.jstree(true);
                const dragNode = inst ? inst.get_node(draggedNodeId) : null;
                const dragPinned = dragNode && dragNode.data && dragNode.data.pinned;
                const dragLocked = dragNode && dragNode.data && dragNode.data.locked;
                const dragParent = dragNode ? dragNode.parent : null;

                // Locked subfolder: suppress edge drop entirely (would change parent to root)
                if (dragLocked && dragParent && dragParent !== '#') return;

                if (mouseY < treeOffset.top + 10) {
                    // Suppress if the dragged node is already the first node
                    if (draggedNodeId && $firstNode.attr('id') === draggedNodeId) return;
                    // Pin check: non-pinned cannot go before a pinned first node
                    const firstNode = inst ? inst.get_node($firstNode.attr('id')) : null;
                    if (!dragPinned && firstNode && firstNode.data && firstNode.data.pinned) return;
                    $firstNode.addClass('drag-up');
                    self._edgeDrop = { pos: 'before', nodeId: $firstNode.attr('id') };
                } else if (mouseY > treeOffset.top + treeHeight - 10 ||
                           mouseY > $lastNode.offset().top + $lastNode.outerHeight()) {
                    // Suppress if the dragged node is already the last node
                    if (draggedNodeId && $lastNode.attr('id') === draggedNodeId) return;
                    // Pin check: pinned cannot go after a non-pinned last node
                    const lastNode = inst ? inst.get_node($lastNode.attr('id')) : null;
                    if (dragPinned && lastNode && lastNode.data && !lastNode.data.pinned) return;
                    // Pin check: non-pinned cannot go after another non-pinned if it would cross the pin boundary
                    $lastNode.addClass('drag-bot');
                    self._edgeDrop = { pos: 'after', nodeId: $lastNode.attr('id') };
                } else {
                    self._edgeDrop = null;
                }
            });

            $(document).on('dnd_stop.vakata.kwpf', function(e, data) {
                $('body').removeClass('kookoawpf-tree-dragging');
                self.clearDragClasses();

                if (self._edgeDrop && data && data.data && data.data.jstree &&
                    data.data.nodes && data.data.nodes[0]) {

                    const edge = self._edgeDrop;
                    self._edgeDrop = null;

                    const inst = self.tree.jstree(true);
                    const sourceNodeId = data.data.nodes[0];
                    const targetNode = inst.get_node(edge.nodeId);
                    if (!targetNode) return;

                    if (!self.getFolderId(inst.get_node(sourceNodeId))) return;

                    // Pin boundary check: block edge-drop if it violates pin order
                    const srcNode = inst.get_node(sourceNodeId);
                    const srcPinned = srcNode && srcNode.data && srcNode.data.pinned;
                    const tgtPinned = targetNode.data && targetNode.data.pinned;
                    if (edge.pos === 'before' && !srcPinned && tgtPinned) return;
                    if (edge.pos === 'after' && srcPinned && !tgtPinned) return;

                    if (edge.pos === 'before') {
                        inst.move_node(sourceNodeId, '#', 0);
                    } else {
                        inst.move_node(sourceNodeId, '#', inst.get_node('#').children.length);
                    }
                    return;
                }
                self._edgeDrop = null;
            });

            $(document).on('dnd_scroll.vakata.kwpf', function() {
                self.clearDragClasses();
                self._edgeDrop = null;
            });
        },

        /**
         * Bind core jsTree events: select, dblclick, move, ready, redraw.
         */
        _bindTreeEvents: function() {
            const self = this;

            self.tree.on('select_node.jstree', function(e, obj) {
                if (self.suppressSelectEvent) return;
                if (self._fbActive) return; // Don't navigate in bulk mode
                // Don't navigate on right-click (context menu open)
                if ($('#kookoawpf-context-menu').is(':visible')) return;
                const folderId = self.getFolderId(obj.node);
                if (folderId) self.goToFolder(folderId);
            });

            self.tree.on('dblclick.jstree', '.jstree-anchor', function() {
                const node = self.tree.jstree('get_node', this);
                if (node && node.children && node.children.length) {
                    self.tree.jstree('toggle_node', node);
                }
            });


            // ── move_node: persist folder moves to server ──
            const handleMoveNode = function(e, obj) {
                self.clearDragClasses();

                if (self._skipMoveHandler) {
                    self._skipMoveHandler = false;
                    return;
                }

                // Prevent double-fire
                if (self._lastMoveTs && (Date.now() - self._lastMoveTs) < 500) return;
                self._lastMoveTs = Date.now();

                const folderId = self.getFolderId(obj.node);
                if (!folderId) return;

                const inst = self.tree.jstree(true);
                const parentNodeId = obj.node.parent;
                let parentFid = 0;

                if (parentNodeId && parentNodeId !== '#') {
                    const parentNode = inst.get_node(parentNodeId);
                    parentFid = self.getFolderId(parentNode) || 0;
                }

                // ── Pin boundary enforcement ──
                let parentForChildren = inst.get_node(parentNodeId);
                let siblingNodeIds = parentForChildren ? parentForChildren.children : [];
                const sameParent = obj.old_parent === obj.parent;

                if (sameParent) {
                    // Reorder among siblings: check if pin order is violated, revert if so
                    let seenNonPinned = false;
                    let pinViolation = false;
                    for (let s = 0; s < siblingNodeIds.length; s++) {
                        const sNode = inst.get_node(siblingNodeIds[s]);
                        const sPin = sNode && sNode.data && sNode.data.pinned;
                        if (!sPin) seenNonPinned = true;
                        if (sPin && seenNonPinned) { pinViolation = true; break; }
                    }
                    if (pinViolation) {
                        self._skipMoveHandler = true;
                        self.suppressSelectEvent = true;
                        self.loadTree(function() {
                            if (self.tree && self.tree.jstree(true)) {
                                self.tree.jstree(true).settings.core.data = self.treeData;
                                self.tree.jstree(true).refresh(true);
                            }
                            setTimeout(function() {
                                self._skipMoveHandler = false;
                                self.suppressSelectEvent = false;
                                self.applyFolderIcons();
                                self.applyActiveFolderColor();
                            }, 300);
                        });
                        return;
                    }
                } else {
                    // Reparent: rules to enforce locally.
                    //
                    //   1. Pin boundary: pinned siblings must come before
                    //      non-pinned siblings.
                    //
                    //   2. In manual (custom) sort, distinguish HOW the folder
                    //      arrived in the new parent:
                    //      • Drop ON a folder (DnD pos 'i') or cut+paste /
                    //        copy+paste / Move to Folder → "last in tier"
                    //      • Drop BETWEEN siblings (DnD pos 'a'/'b') → keep
                    //        jsTree's natural position (user chose where)
                    //      Cut+paste et al. don't go through DnD so they leave
                    //      _lastDropPos null; since they already call move_node
                    //      with 'last', preserving order = "at end" anyway.
                    //
                    //   3. In auto sort modes (name/date/count) the dropped
                    //      node is reinserted in its sorted position by
                    //      _resortChildren below — see the post-block call.
                    const droppedNodeId     = obj.node.id;
                    const droppedNodePinned = !!( obj.node.data && obj.node.data.pinned );
                    const isCustomSort      = self._sortBy === 'custom';
                    const forceToEnd        = isCustomSort && self._lastDropPos === 'i';

                    const pinnedIds = [], nonPinnedIds = [];
                    for (let p = 0; p < siblingNodeIds.length; p++) {
                        // When forcing to end, skip the dropped node here and
                        // re-append it to its tier below.
                        if (forceToEnd && siblingNodeIds[p] === droppedNodeId) continue;
                        const pNode = inst.get_node(siblingNodeIds[p]);
                        if (pNode && pNode.data && pNode.data.pinned) {
                            pinnedIds.push(siblingNodeIds[p]);
                        } else {
                            nonPinnedIds.push(siblingNodeIds[p]);
                        }
                    }
                    if (forceToEnd) {
                        if (droppedNodePinned) pinnedIds.push(droppedNodeId);
                        else nonPinnedIds.push(droppedNodeId);
                    }
                    const correctOrder = pinnedIds.concat(nonPinnedIds);
                    // Consume the DnD hint so subsequent non-DnD moves (cut+paste
                    // etc.) see null and default to "preserve jsTree position".
                    self._lastDropPos = null;
                    // Check if order changed
                    let orderChanged = false;
                    for (let c = 0; c < correctOrder.length; c++) {
                        if (correctOrder[c] !== siblingNodeIds[c]) { orderChanged = true; break; }
                    }
                    if (orderChanged) {
                        // Directly set children array and redraw — no events fired
                        parentForChildren.children = correctOrder;
                        inst.redraw_node(parentNodeId, true);
                        siblingNodeIds = correctOrder;
                    }
                }

                // In auto sort modes (name/date/count), re-sort the destination
                // parent's children so the moved/dropped folder lands in its
                // correct sorted position immediately. This applies to drag &
                // drop, "Move to Folder" and cut+paste — they all funnel
                // through this handler. No-op in custom sort.
                self._resortChildren(parentNodeId);
                // Refresh the local siblings reference after the resort.
                parentForChildren = inst.get_node(parentNodeId);
                siblingNodeIds = parentForChildren ? parentForChildren.children : siblingNodeIds;

                const siblingOrder = [];
                for (let i = 0; i < siblingNodeIds.length; i++) {
                    const sibNode = inst.get_node(siblingNodeIds[i]);
                    const sibFid = self.getFolderId(sibNode);
                    if (sibFid) siblingOrder.push(sibFid);
                }

                // Drop animation — animate moved node; on reparent also animate the target parent
                const $moved = self.tree.find('#' + obj.node.id);
                $moved.addClass('kookoawpf-just-moved');
                setTimeout(function() { $moved.removeClass('kookoawpf-just-moved'); }, 500);
                if (!sameParent && parentNodeId && parentNodeId !== '#') {
                    const $parent = self.tree.find('#' + parentNodeId);
                    $parent.addClass('kookoawpf-just-moved');
                    setTimeout(function() { $parent.removeClass('kookoawpf-just-moved'); }, 500);
                }

                // If the node left an old parent that is now childless, refresh its
                // icon immediately (otherwise it keeps the open-folder SVG until the
                // next redraw).
                if (!sameParent && obj.old_parent && obj.old_parent !== '#') {
                    const $oldParent = self.tree.find('#' + obj.old_parent);
                    if ($oldParent.length) self.applyFolderIcons($oldParent);
                }

                self._folderListCache = null;
                if (typeof self._repopulateGridFilter === 'function') self._repopulateGridFilter();
                const isDynamic = obj.node.data && obj.node.data.is_dynamic;
                self.ajax('kookoawpf_move_folder', {
                    folder_id: folderId,
                    new_parent_id: parentFid,
                    post_type: self.data.postType,
                    sibling_order: siblingOrder
                }, function(r) {
                    if (r.success) {
                        self.showNotice(isDynamic ? self.data.strings.smartFolderMoved : self.data.strings.folderMoved, 'success');
                        self.applyFolderIcons();
                        self.applyActiveFolderColor();
                        self._updateSubfolderCards(self.currentFolder);
                    } else {
                        self.showNotice((r.data && r.data.message) || self.data.strings.errorGeneric, 'error');
                        self.refreshTree();
                    }
                });
            };

            self.tree.on('ready.jstree', function() {
                self.applyFolderIcons();
                self.applyActiveFolderColor();
                self.initContextMenu();

                // ── Bind move_node HERE inside ready — we know this code runs ──
                self.tree.on('move_node.jstree', handleMoveNode);

                // Trash view with Smart Startup Folder — open ancestors so the
                // preferred folder stays visible, but do NOT select it (no green line).
                if (self._startupRevealOnly) {
                    const revealInst = self.tree.jstree(true);
                    const revealId = 'folder_' + self._startupRevealOnly;
                    if (revealInst && revealInst.get_node(revealId)) {
                        revealInst._open_to(revealId);
                    }
                    self._startupRevealOnly = null;
                }

                if (self.currentFolder && self.currentFolder !== 'all' && self.currentFolder !== 'uncategorized') {
                    self.suppressSelectEvent = true;
                    // Open the ancestor chain so the startup folder is actually visible
                    // (needed when Smart Startup Folder selects a nested folder).
                    const startupNodeId = 'folder_' + self.currentFolder;
                    const startupInst = self.tree.jstree(true);
                    if (startupInst && startupInst.get_node(startupNodeId)) {
                        startupInst._open_to(startupNodeId);
                    }
                    self.tree.jstree('select_node', startupNodeId);
                    self.suppressSelectEvent = false;

                    // If the startup folder was applied (no URL param), reload the content
                    // view so the grid/list reflects the pre-selected folder.
                    if (self._startupApplied) {
                        self._startupApplied = false;
                        // Defer to next tick so jsTree finishes the select handler first
                        setTimeout(function() {
                            self.goToFolder(self.currentFolder);
                        }, 0);
                    }

                    // Check if initial folder is dynamic
                    const initNode = self.tree.jstree('get_node', 'folder_' + self.currentFolder);
                    if (initNode && initNode.data && initNode.data.is_dynamic) {
                        self.currentFolderIsDynamic = true;
                    } else if (self._addMediaBtnHidden) {
                        // Not dynamic — show the button we pre-hid
                        $('.page-title-action').show();
                    }
                    self._addMediaBtnHidden = false;
                }

                // Now that tree is ready, update breadcrumb and subfolder cards
                self._updateBreadcrumb(self.currentFolder);
                self._updateSubfolderCards(self.currentFolder);

                // Disable controls if no folders exist
                self._updateNoFolderState();
                self._checkScrollbar();
            });

            self.tree.on('open_node.jstree close_node.jstree after_open.jstree after_close.jstree', function(e, data) {
                setTimeout(function() {
                    const $scope = data && data.node ? $('#' + data.node.id) : null;
                    self.applyFolderIcons($scope);
                    self.applyActiveFolderColor();
                    if (self._fbActive) {
                        self._fbReapplyLocked();
                    }
                    self._checkScrollbar();
                }, 20);
            });

            self.tree.on('refresh.jstree redraw.jstree', function() {
                setTimeout(function() {
                    self.applyFolderIcons();
                    self.applyActiveFolderColor();
                    if (self._fbActive) {
                        self._fbReapplyLocked();
                    }
                    self._checkScrollbar();
                }, 20);
            });
        },

        // ========================
        // SVG Folder Icons
        // ========================

        applyFolderIcons: function($scope) {
            const self = this;
            if (!self.tree) return;

            const $container = $scope || self.tree;
            $container.find('.jstree-node').addBack('.jstree-node').each(function() {
                const $node = $(this);
                const node = self.tree.jstree('get_node', $node.attr('id'));
                if (!node || !node.data) return;

                const customIcon = node.data.icon || '';
                const color = node.data.color || '#F9A825';
                // A folder is visually "open" only when it is expanded AND has children.
                // After moving the last child out, jstree leaves state.opened = true on
                // the empty parent, which would wrongly render the open-folder SVG.
                const hasChildren = node.children && node.children.length > 0;
                const isOpen = !!(node.state && node.state.opened) && hasChildren;

                const $icon = $node.children('.jstree-anchor').children('.jstree-icon');

                $icon.css({ 'background-image': 'none', 'background-color': 'transparent' });

                if (customIcon) {
                    $icon.html('').append($('<span class="kookoawpf-emoji-icon">').text(customIcon));
                } else {
                    $icon.html(isOpen ? SVG_FOLDER_OPEN : SVG_FOLDER);
                    const $svg = $icon.find('.kookoawpf-svg-folder');
                    $svg.css('color', color);
                    // Black folder needs green stroke to be visible on dark bg
                    if (color && color.toLowerCase() === '#000000') {
                        $svg.find('path').attr('stroke', '#39FF14').attr('stroke-width', '2');
                    } else {
                        $svg.find('path').removeAttr('stroke').removeAttr('stroke-width');
                    }
                }

                // Inject count badge as direct child of node (not inside anchor)
                const count = node.data.count !== undefined ? node.data.count : '';
                const $existingCount = $node.children('.kookoawpf-count');
                if ($existingCount.length) {
                    $existingCount.text(count);
                } else {
                    $node.append($('<span class="kookoawpf-count">').text(count));
                }

                // Extension point: companion plugins can decorate the node
                // (e.g. Smart Folders add a "Rules" label + hover tooltip).
                $(document).trigger('kookoawpf:folder-node-render', [$node, node]);
            });

            // Re-apply folder ID badges if active
            if (self._moreShowIds) {
                self._applyFolderIdBadges();
            }
        },

        // ========================
        // Active Folder Highlight
        // ========================

        applyActiveFolderColor: function() {
            const self = this;
            if (!self.tree) return;

            const sel = self.tree.jstree('get_selected');
            const selId = (sel && sel.length) ? sel[0] : null;

            // Only clear styles from the previously active node (not ALL nodes)
            self.tree.find('.kookoawpf-node-active').not('#' + selId).each(function() {
                $(this).removeClass('kookoawpf-node-active');
                $(this).children('.jstree-anchor').css({
                    'background': '', 'color': '', 'border-left': '', 'padding-left': '', 'border-radius': ''
                });
            });

            if (!selId) return;
            const node = self.tree.jstree('get_node', selId);
            if (!node || !node.data) return;

            const $nodeEl = self.tree.find('#' + selId);
            if (!$nodeEl.hasClass('kookoawpf-node-active')) {
                $nodeEl.addClass('kookoawpf-node-active');
                $nodeEl.children('.jstree-anchor').css({
                    'color': '#e6edf3',
                    'border-left': '5px solid #39FF14',
                    'padding-left': '6px',
                    'border-radius': '0'
                });
            }
        },

        // ========================
        // Tree Data Loading
        // ========================

        loadTree: function(callback) {
            const self = this;
            self.ajax('kookoawpf_get_tree', { post_type: self.data.postType }, function(response) {
                if (response.success) {
                    self.treeData = response.data.tree;
                    $('#kookoawpf-folder-count').text(response.data.total_folders);
                    $('#kookoawpf-count-all').text(response.data.total_items);
                    $('#kookoawpf-count-uncategorized').text(response.data.uncategorized);
                    self._toggleEmptyState(response.data.total_folders);
                    self._totalFoldersAll = response.data.total_folders_all || 0;


                    if (response.data.sort) {
                        self._sortBy = response.data.sort.by || 'custom';
                        self._sortDir = response.data.sort.dir || 'ASC';
                        self._updateSortUI();
                    }

                    // Update bulk organize button state (depends on treeData)
                    if (self._updateListBulkBtnState) self._updateListBulkBtnState();

                    // Update free limits counter (true = use treeData, jsTree not rendered yet)
                    self._updateFreeLimits(true);
                }
                if (callback) callback();
            });
        },

        refreshTree: function(callback, options) {
            const self = this;
            const opts = options || {};
            self.suppressSelectEvent = true;
            self._folderListCache = null; // Invalidate cache

            // Save which nodes are currently opened
            const openedIds = [];
            if (self.tree && self.tree.jstree(true)) {
                self.tree.find('.jstree-open').each(function() {
                    openedIds.push(this.id);
                });
            }

            self.loadTree(function() {
                if (self.tree && self.tree.jstree(true)) {
                    const inst = self.tree.jstree(true);
                    inst.settings.core.data = self.treeData;

                    inst._model.data = {};
                    inst._model.data['#'] = {
                        id: '#', parent: null, parents: [],
                        children: [], children_d: [],
                        state: { loaded: false }
                    };
                    inst.load_node('#', function() {
                        // Restore opened state (synchronous with animation: 0)
                        for (let i = 0; i < openedIds.length; i++) {
                            if (inst.get_node(openedIds[i])) {
                                inst.open_node(openedIds[i], false, 0);
                            }
                        }

                        self.applyFolderIcons();
                        self.applyActiveFolderColor();

                        // Re-initialize droppable on new tree nodes
                        self._makeTreeNodesDroppable();

                        // Refresh breadcrumb and subfolder cards after tree reload
                        self._updateBreadcrumb(self.currentFolder);
                        self._updateSubfolderCards(self.currentFolder);

                        // Update disabled state based on folder count
                        self._updateNoFolderState();

                        // Refresh bulk organize dropdown if active
                        if ($('body').hasClass('kookoawpf-bulk-active') && typeof self._repopulateGridMoveDropdown === 'function') {
                            clearTimeout(self._repopulateMoveTimer);
                            self._repopulateMoveTimer = setTimeout(function() {
                                self._repopulateGridMoveDropdown();
                            }, 200);
                        }

                        // Refresh grid filter dropdown (toolbar) so it stays in sync
                        if (typeof self._repopulateGridFilter === 'function') {
                            self._repopulateGridFilter();
                        }

                        // If a specific node should be revealed (e.g. newly created subfolder),
                        // open its entire ancestor chain so it becomes visible.
                        if (opts.openToNodeId) {
                            const revealId = 'folder_' + opts.openToNodeId;
                            if (inst.get_node(revealId)) {
                                inst._open_to(revealId);
                            }
                        }

                        // Re-select the current folder
                        if (self.currentFolder && self.currentFolder !== 'all' && self.currentFolder !== 'uncategorized') {
                            const nodeId = 'folder_' + self.currentFolder;
                            if (inst.get_node(nodeId)) {
                                inst._open_to(nodeId);
                                inst.select_node(nodeId, true);
                            }
                            self.applyActiveFolderColor();
                        }

                        // Fire callback after a short delay to let jsTree
                        // finish any internal async work from open_node.
                        // suppressSelectEvent stays true until after the callback
                        // to prevent goToFolder from firing during the refresh.
                        if (callback) {
                            setTimeout(function() {
                                callback();
                                self.suppressSelectEvent = false;
                            }, 100);
                        } else {
                            self.suppressSelectEvent = false;
                        }
                    });
                } else {
                    self.suppressSelectEvent = false;
                    if (callback) callback();
                }
            });
        },

        // ========================
        // Navigation
        // ========================

        goToFolder: function(folder) {
            const self = this;

            // Hide metadata tooltip when changing folder
            $('#kookoawpf-metadata-tooltip').hide();

            const url = new URL(window.location.href);

            if (folder === 'all') {
                url.searchParams.delete('kookoawpf_folder');
            } else {
                url.searchParams.set('kookoawpf_folder', String(folder));
            }
            url.searchParams.delete('paged');
            url.searchParams.delete('post_status');
            window.history.replaceState({ kookoawpf_folder: folder }, '', url.toString());

            self.currentFolder = folder;

            // Track whether current folder is dynamic (smart)
            self.currentFolderIsDynamic = false;
            if (folder !== 'all' && folder !== 'uncategorized' && self.tree && self.tree.jstree(true)) {
                const fNode = self.tree.jstree('get_node', 'folder_' + folder);
                if (fNode && fNode.data && fNode.data.is_dynamic) {
                    self.currentFolderIsDynamic = true;
                }
            }

            // Hide "Add Media File" button on dynamic folders
            const $addMediaBtn = $('.page-title-action');
            if (self.currentFolderIsDynamic) {
                $addMediaBtn.hide();
            } else {
                $addMediaBtn.show();
            }

            // Update sidebar nav
            $('.kookoawpf-nav-item').removeClass('active');
            if (folder === 'all') {
                $('.kookoawpf-nav-all').addClass('active');
            } else if (folder === 'uncategorized') {
                $('.kookoawpf-nav-uncategorized').addClass('active');
            }

            // Update tree selection
            if (self.tree && self.tree.jstree(true)) {
                self.suppressSelectEvent = true;
                self.tree.jstree('deselect_all');
                if (folder !== 'all' && folder !== 'uncategorized') {
                    self.tree.jstree('select_node', 'folder_' + folder);
                }
                self.suppressSelectEvent = false;
                self.applyActiveFolderColor();
            }

            // In bulk mode, always clear selection when changing folder
            if ($('body').hasClass('kookoawpf-bulk-active') || $('body').hasClass('mode-select')) {
                if (typeof wp !== 'undefined' && wp.media && wp.media.frame) {
                    try {
                        const sel = wp.media.frame.state().get('selection');
                        if (sel) sel.reset([]);
                    } catch (e) {}
                }
                if (self._updateBulkItemButtons) self._updateBulkItemButtons();
            }

            // Suppress upload observer during folder switch to prevent
            // grid repopulation from being mistaken for a new upload
            self._suppressUploadObserver = true;
            setTimeout(function() { self._suppressUploadObserver = false; }, 3000);

            if (self._isGridMode()) {
                // Immediately disable Select All while grid reloads
                if (self._updateBulkItemButtons && ($('body').hasClass('kookoawpf-bulk-active') || $('body').hasClass('mode-select'))) {
                    $('#kookoawpf-btn-select-all').prop('disabled', true).text(self.data.strings.selectAll);
                    $('#kookoawpf-btn-delete-selected').prop('disabled', true);
                }
                self._loadGrid(folder);
                // Re-check button states after grid finishes loading
                if (self._updateBulkItemButtons && ($('body').hasClass('kookoawpf-bulk-active') || $('body').hasClass('mode-select'))) {
                    const $attachments = $('.attachments-browser .attachments');
                    if ($attachments.length) {
                        if (self._bulkObserver) self._bulkObserver.disconnect();
                        self._bulkObserver = new MutationObserver(function() {
                            self._updateBulkItemButtons();
                        });
                        self._bulkObserver.observe($attachments[0], { childList: true });
                        // Fallback in case observer doesn't fire
                        setTimeout(function() {
                            self._updateBulkItemButtons();
                            if (self._bulkObserver) self._bulkObserver.disconnect();
                        }, 3000);
                    }
                }
            } else {
                self._loadList(folder);
            }

            // Update breadcrumb and subfolder cards
            self._updateBreadcrumb(folder);
            self._updateSubfolderCards(folder);

            // Smart Startup Folder — remember the last opened folder (per user, per post type).
            self._rememberLastFolder(folder);
        },

        /**
         * Debounced save of the "last opened folder" for the current user/post type.
         * Only runs when Smart Startup Folder is in "last" mode, and never while the
         * user is inside the Trash view.
         */
        _rememberLastFolder: function(folder) {
            const self = this;
            if (self.data.startupMode !== 'last') return;
            if (!self.data.postType) return;

            // Skip trash view: the folder sidebar is hidden/neutered there and we
            // don't want "Trash" to become a sticky landing spot.
            const params = new URLSearchParams(window.location.search);
            if (params.get('post_status') === 'trash') return;
            if ($('.subsubsub .trash.current').length) return;

            let fid = (folder === 'all' || folder === 'uncategorized') ? 0 : parseInt(folder, 10);
            if (isNaN(fid)) fid = 0;

            clearTimeout(self._rememberLastFolderTimer);
            self._rememberLastFolderTimer = setTimeout(function() {
                self.ajax('kookoawpf_save_last_folder', {
                    post_type: self.data.postType,
                    folder_id: fid
                });
            }, 400);
        },

        _isGridMode: function() {
            return $('body').hasClass('upload-php') && !$('.wp-list-table').length && $('.media-frame').length > 0;
        },

        /**
         * Force-reload the current content view (grid or list) without
         * changing the active folder. Used after move/unassign/upload
         * operations so the view reflects current folder contents.
         */
        _refreshContentView: function() {
            const self = this;
            // goToFolder handles both grid and list mode reliably:
            // - Grid: uses _loadGrid which invalidates Backbone query cache
            //         and triggers a fresh fetch via props change
            // - List: uses _loadList which does an AJAX reload of the tbody
            // It also updates sidebar nav and tree selection state.
            self.goToFolder(self.currentFolder);
        },

        /**
         * Force the WP media grid to re-fetch from the server.
         * Backbone ignores props.set() when the value hasn't changed,
         * so we bust the query cache and trigger a change via the
         * kookoawpf_folder prop (unset → re-set).
         */
        _forceGridRefresh: function() {
            const self = this;

            if (typeof wp === 'undefined' || !wp.media || !wp.media.frame) {
                window.location.reload();
                return;
            }

            try {
                let library = null;

                if (wp.media.frame.state && wp.media.frame.state()) {
                    library = wp.media.frame.state().get('library');
                }

                if (!library && wp.media.frame.content && wp.media.frame.content.get()) {
                    const content = wp.media.frame.content.get();
                    if (content && content.collection) library = content.collection;
                }

                if (library && library.props) {
                    // 1) Bust Backbone query cache so it doesn't serve stale results
                    if (wp.media.model && wp.media.model.Query) {
                        wp.media.model.Query._queries = {};
                    }

                    // 2) Force a prop change cycle:
                    //    unset kookoawpf_folder silently, then re-set it.
                    //    This makes Backbone see a genuine change → triggers re-fetch.
                    const folder = self.currentFolder;
                    if (folder && folder !== 'all') {
                        library.props.unset('kookoawpf_folder', { silent: true });
                        library.props.set({ kookoawpf_folder: String(folder) });
                    } else {
                        // "All Items" / no filter — toggle a dummy prop
                        library.props.set({ _kwpfBust: +new Date() });
                        library.props.unset('_kwpfBust');
                    }

                    self._updateCounts();
                    return;
                }
            } catch (e) { /* fall through */ }

            window.location.reload();
        },

        // ========================
        // Media Grid Filter (Backbone integration)
        // ========================

        _initMediaGridFilter: function() {
            // Register the ajaxPrefilter (skip if modal already registered it)
            if (!KookoawpfMediaModal._prefilterRegistered) {
                $.ajaxPrefilter(function(options) {
                    if (!options.data) return;

                    // Backbone (wp.media.model.Query) passes options.data as a plain
                    // object. jQuery only stringifies it AFTER the prefilter chain,
                    // so we have to handle both shapes — otherwise the modal request
                    // bypasses our injection entirely.
                    const dataIsObject = (typeof options.data === 'object');
                    const dataIsString = (typeof options.data === 'string');
                    if (!dataIsObject && !dataIsString) return;

                    const isQueryAttachments = dataIsString
                        ? options.data.indexOf('action=query-attachments') !== -1
                        : (options.data.action === 'query-attachments');
                    if (!isQueryAttachments) return;

                    // Only inject folder filter if our sidebar is actually in the DOM
                    if (!$('#kookoawpf-sidebar-wrapper').length && !$('.kookoawpf-modal-sidebar').length) return;

                    let folder = null;
                    if (FO._modalCurrentFolder && FO._modalCurrentFolder !== 'all') {
                        folder = FO._modalCurrentFolder;
                    } else if ($('#kookoawpf-sidebar-wrapper').not('.kookoawpf-modal-sidebar').length && FO.currentFolder && FO.currentFolder !== 'all') {
                        folder = FO.currentFolder;
                    }
                    if (!folder) return;

                    // Inject our own nonce so the PHP filter can verify the request
                    // (wp_ajax_query_attachments itself only does a capability check;
                    // this gives the kookoawpf_folder param a real authenticity guard).
                    const kkNonce = (FO.data && FO.data.nonce) ? FO.data.nonce : '';
                    const folderStr = String(folder);

                    if (dataIsObject) {
                        options.data.kookoawpf_folder = folderStr;
                        options.data.kookoawpf_nonce = kkNonce;
                        if (!options.data.query || typeof options.data.query !== 'object') {
                            options.data.query = {};
                        }
                        options.data.query.kookoawpf_folder = folderStr;
                        return;
                    }

                    // String path (legacy: jQuery-style data already stringified)
                    options.data = options.data.replace(/&?query%5Bkookoawpf_folder%5D=[^&]*/g, '');
                    options.data = options.data.replace(/&?kookoawpf_folder=[^&]*/g, '');
                    options.data = options.data.replace(/&?kookoawpf_nonce=[^&]*/g, '');
                    options.data += '&kookoawpf_folder=' + encodeURIComponent(folderStr);
                    options.data += '&kookoawpf_nonce=' + encodeURIComponent(kkNonce);
                    options.data += '&query%5Bkookoawpf_folder%5D=' + encodeURIComponent(folderStr);
                });
            }

            // Defensive guard for WP's announceSearchResults: it dereferences
            // `this.collection.mirroring.args.s` without checking that args
            // exists. Our mirroring swap can hand WP a freshly-built query
            // collection where `.args` is still undefined (it's only assigned
            // by Attachments.fetch). Patch the prototype once so the access
            // never throws.
            if (typeof wp !== 'undefined' && wp.media && wp.media.view && wp.media.view.AttachmentsBrowser && !wp.media.view.AttachmentsBrowser._kookoawpfArgsPatched) {
                wp.media.view.AttachmentsBrowser._kookoawpfArgsPatched = true;
                const _origAnnounce = wp.media.view.AttachmentsBrowser.prototype.announceSearchResults;
                if (typeof _origAnnounce === 'function') {
                    wp.media.view.AttachmentsBrowser.prototype.announceSearchResults = function() {
                        if (this.collection && this.collection.mirroring && !this.collection.mirroring.args) {
                            this.collection.mirroring.args = {};
                        }
                        return _origAnnounce.apply(this, arguments);
                    };
                }
            }

            // Backbone Query override — ensures collection includes kookoawpf_folder in props
            if (typeof wp !== 'undefined' && wp.media && wp.media.model && wp.media.model.Query) {
                const OrigQuery = wp.media.model.Query;
                const origGet = OrigQuery.get;

                if (typeof origGet === 'function') {
                    OrigQuery.get = function(props, options) {
                        props = _.extend({}, props);

                        // Only inject from main sidebar if present (not on builder editors)
                        if ($('#kookoawpf-sidebar-wrapper').not('.kookoawpf-modal-sidebar').length && !props.kookoawpf_folder) {
                            const folder = FO.currentFolder;
                            if (folder && folder !== 'all') {
                                props.kookoawpf_folder = String(folder);
                            } else {
                                delete props.kookoawpf_folder;
                            }
                        }
                        return origGet.call(this, props, options);
                    };
                }
            }

            // Bust Backbone query cache when WP filter dropdowns change
            // (e.g. switching to/from Trash view) so stale results are never shown.
            $(document).on('change', '.media-toolbar select.attachment-filters:not(.kookoawpf-grid-filter)', function() {
                if (typeof wp !== 'undefined' && wp.media && wp.media.model && wp.media.model.Query) {
                    wp.media.model.Query._queries = {};
                }
            });

            // The grid frame already loaded its library BEFORE this prefilter was
            // registered (frame bootstraps server-side on upload.php with all 72
            // attachments pre-rendered). If the URL pointed at a specific folder,
            // bust the Backbone Query cache and force a full refetch through our
            // freshly-registered prefilter so the grid honours the filter.
            const self = this;
            if (self.currentFolder && self.currentFolder !== 'all' &&
                typeof wp !== 'undefined' && wp.media && wp.media.frame &&
                wp.media.frame.state && wp.media.frame.state()) {
                const initialLib = wp.media.frame.state().get('library');
                if (initialLib && initialLib.props) {
                    if (wp.media.model.Query._queries) {
                        wp.media.model.Query._queries = {};
                    }
                    // Mask the server-rendered "All items" grid while the
                    // filtered refetch is in flight — otherwise the user
                    // sees every attachment for a frame before our filter
                    // narrows it down to the active folder.
                    $('body').addClass('kookoawpf-grid-loading');
                    const hideMask = function() { $('body').removeClass('kookoawpf-grid-loading'); };

                    initialLib.props.set('kookoawpf_folder', String(self.currentFolder));
                    // Trigger a full re-query: build a brand new query and swap it in
                    const freshProps = _.extend({}, initialLib.props.toJSON(), { paged: 1 });
                    const freshQuery = wp.media.query(freshProps);
                    // wp.media.query() doesn't always populate .args. WP's
                    // announceSearchResults() reads `mirroring.args.s` and crashes
                    // with TypeError if args is undefined — guarantee an object.
                    if (!freshQuery.args) { freshQuery.args = {}; }
                    initialLib._hasMore = true;
                    initialLib.mirroring = freshQuery;
                    initialLib.reset();
                    // WP's toolbar spinner is bound to library:start/end events that
                    // the manual mirroring path skips, and Spinner.show() schedules
                    // an addClass with a 400ms delay — so the spinner can flip back
                    // to is-active *after* we strip the class. Reach the view so we
                    // can call hide(), which cancels the pending timeout AND removes
                    // is-active in one shot.
                    const stopToolbarSpinner = function() {
                        try {
                            const browser = wp.media.frame.content && wp.media.frame.content.get();
                            const sec = browser && browser.toolbar && browser.toolbar.secondary;
                            const spinnerView = sec && sec.get && sec.get('spinner');
                            if (spinnerView && typeof spinnerView.hide === 'function') {
                                spinnerView.hide();
                                return;
                            }
                        } catch (e) {}
                        $('.media-toolbar-secondary .spinner').removeClass('is-active');
                    };
                    const finishRefetch = function() {
                        // Mirror the fresh query's pagination state onto the library.
                        // Without this, an empty folder leaves _hasMore=true forever
                        // and WP's toolbar spinner spins indefinitely on first load.
                        initialLib._hasMore = !!freshQuery._hasMore;
                        initialLib.reset(freshQuery.models);
                        stopToolbarSpinner();
                        hideMask();
                    };
                    freshQuery.more().then(finishRefetch, function() {
                        initialLib._hasMore = false;
                        stopToolbarSpinner();
                        hideMask();
                    });
                    // Safety net in case the promise never settles (network error, plugin conflict).
                    setTimeout(hideMask, 3000);
                }
            }
        },

        /**
         * Listen for attachment deletions (trash / permanent delete) and
         * refresh the sidebar counts so they stay in sync.
         *
         * Covers:
         *  - Grid mode: intercepts WP AJAX delete-post responses
         *  - List mode: intercepts the "Delete Permanently" / "Trash" link clicks
         */
        _initDeleteListener: function() {
            const self = this;
            let _refreshTimer = null;

            // Debounced refresh — avoids hammering if multiple deletes happen quickly
            function scheduleRefresh() {
                if (_refreshTimer) clearTimeout(_refreshTimer);
                _refreshTimer = setTimeout(function() {
                    self.refreshTree();
                }, 400);
            }

            // Strategy 1: Intercept AJAX responses for delete-post / trash actions
            $(document).ajaxComplete(function(event, xhr, settings) {
                if (!settings || !settings.data || typeof settings.data !== 'string') return;

                if (settings.data.indexOf('action=delete-post') !== -1 ||
                    settings.data.indexOf('action=trash-post') !== -1) {
                    scheduleRefresh();
                }
            });

            // Strategy 2: Listen for Backbone model destroy (grid mode detail modal)
            if (typeof wp !== 'undefined' && wp.media && wp.media.model && wp.media.model.Attachment) {
                const origDestroy = wp.media.model.Attachment.prototype.destroy;
                if (typeof origDestroy === 'function') {
                    wp.media.model.Attachment.prototype.destroy = function() {
                        const result = origDestroy.apply(this, arguments);
                        scheduleRefresh();
                        return result;
                    };
                }
            }

            // Strategy 3: Click on "Delete Permanently" / "Trash" links in list view
            $(document).on('click', '.submitdelete', function() {
                // The page will reload for list view, but in case it's AJAX-ified:
                scheduleRefresh();
            });

            // Strategy 4: Intercept Trash / Restore links in list-table row-actions
            // so the sidebar counters update without a full page reload.
            if (!self._isGridMode()) {
                $(document).on('click', '.wp-list-table .row-actions a', function(e) {
                    const $link = $(this);
                    const href  = $link.attr('href');
                    if (!href) return;

                    // Only intercept trash and untrash actions
                    const isTrash   = href.indexOf('action=trash') !== -1 && href.indexOf('action=untrash') === -1;
                    const isRestore = href.indexOf('action=untrash') !== -1;
                    if (!isTrash && !isRestore) return;

                    e.preventDefault();
                    const $row = $link.closest('tr');

                    // Fire the WP action via AJAX (same URL, just fetch it)
                    $.get(href).always(function() {
                        if (isTrash) {
                            // Fade out and remove the row
                            $row.css('background', 'rgba(255,59,48,0.15)').fadeOut(300, function() {
                                $row.remove();
                                // Update the WP status bar counts
                                self._loadList(self.currentFolder);
                            });
                        } else {
                            // Restore: fade out and remove from trash view
                            $row.css('background', 'rgba(57,255,20,0.15)').fadeOut(300, function() {
                                $row.remove();
                                self._loadList(self.currentFolder);
                            });
                        }
                        // Refresh tree to update all counters (regular + dynamic)
                        scheduleRefresh();
                    });
                });
            }
        },

        /**
         * Listen for new uploads completing and refresh sidebar counts + view.
         *
         * Also injects the current folder ID into the upload request so that
         * newly uploaded files are automatically assigned to the active folder.
         *
         * Covers:
         *  - wp.Uploader (Backbone uploader used by the media grid)
         *  - ajaxComplete for async-upload.php (legacy / list mode uploads)
         */
        /**
         * True when the user has dragged files onto the page while inside a
         * smart folder (main sidebar or modal). Smart folders are virtual
         * queries — uploading into them is meaningless, so we cancel the
         * drop before plupload sees it.
         *
         * @param {DragEvent} e
         * @returns {boolean}
         */
        _dropIsFileIntoSmart: function(e) {
            if (!e || !e.dataTransfer) return false;
            const types = e.dataTransfer.types || [];
            let hasFiles = false;
            for (let i = 0; i < types.length; i++) {
                if (types[i] === 'Files') { hasFiles = true; break; }
            }
            if (!hasFiles) return false;
            if (FO._modalCurrentFolderIsDynamic) return true;
            return !!(FO.isCurrentFolderSmart && FO.isCurrentFolderSmart());
        },

        /**
         * Register capture-phase drop guards once per page.
         * Runs ahead of plupload's own drop handlers so the file never
         * enters the upload pipeline when the user is in a smart folder.
         */
        _initSmartFolderDropGuard: function() {
            const self = this;
            if (FO._smartDropGuardInstalled) return;
            FO._smartDropGuardInstalled = true;

            document.addEventListener('dragover', function(e) {
                if (!self._dropIsFileIntoSmart(e)) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                try { e.dataTransfer.dropEffect = 'none'; } catch (err) {}
            }, true);

            document.addEventListener('drop', function(e) {
                if (!self._dropIsFileIntoSmart(e)) return;
                e.preventDefault();
                e.stopImmediatePropagation();
                self.showNotice(
                    (self.data.strings && self.data.strings.smartUploadBlocked) ||
                    'Cannot upload to a smart folder. Switch to a regular folder first.',
                    'warning'
                );
            }, true);
        },

        _initUploadListener: function() {
            const self = this;
            self._initSmartFolderDropGuard();
            let _uploadTimer = null;

            function scheduleUploadRefresh() {
                if (_uploadTimer) clearTimeout(_uploadTimer);
                _uploadTimer = setTimeout(function() {
                    // If a modal is open, only refresh tree counts — the modal manages its own grid
                    if (FO._modalCurrentFolder) {
                        self.refreshTree();
                    } else {
                        self.refreshTree(function() {
                            self._refreshContentView();
                        });
                    }
                }, 800);
            }

            // Only refresh tree (counts), NOT the view — for cases where
            // the new item already appears in the grid via Backbone.
            function scheduleCountRefresh() {
                if (_uploadTimer) clearTimeout(_uploadTimer);
                _uploadTimer = setTimeout(function() {
                    self.refreshTree();
                }, 800);
            }

            // ── Strategy 1: Monitor new DOM nodes in the media grid ──
            // When WP adds a new attachment to the grid, a new .attachment
            // element appears. This is the most reliable detection method
            // because it works regardless of how the upload was performed
            // (plupload, drag-drop, etc.)
            //
            // We skip the first 3 seconds after page load to avoid triggering
            // on the initial Backbone population of the grid.
            const $gridContainer = $('.attachments-browser .attachments');
            let _observerReady = false;
            if ($gridContainer.length && typeof MutationObserver !== 'undefined') {
                setTimeout(function() { _observerReady = true; }, 3000);

                const observer = new MutationObserver(function(mutations) {
                    if (!_observerReady || self._suppressUploadObserver) return;
                    let hasNewAttachment = false;
                    for (let i = 0; i < mutations.length; i++) {
                        if (mutations[i].addedNodes && mutations[i].addedNodes.length) {
                            for (let j = 0; j < mutations[i].addedNodes.length; j++) {
                                const node = mutations[i].addedNodes[j];
                                if (node.nodeType === 1 && $(node).hasClass('attachment')) {
                                    hasNewAttachment = true;
                                    break;
                                }
                            }
                        }
                        if (hasNewAttachment) break;
                    }

                    if (hasNewAttachment) {
                        // New attachment appeared in the DOM.
                        // If we're in "all" or a specific folder, the item is already
                        // visible — just refresh the counts.
                        // If we're in "uncategorized", the new (uncategorized) upload
                        // should show, and since Backbone added it, it likely already
                        // does — but we still refresh to be safe.
                        scheduleCountRefresh();
                    }
                });

                observer.observe($gridContainer[0], { childList: true });
            }

            // ── Strategy 2: Intercept XMLHttpRequest for upload-attachment ──
            // plupload (used by WP) doesn't go through jQuery.ajax, so
            // ajaxComplete never fires. We patch XMLHttpRequest.send instead.
            // Skip if modal already patched XHR (e.g. on post.php where modal inits first)
            if (!FO._xhrUploadPatched) {
                FO._xhrUploadPatched = true;
                const _origXHROpen = XMLHttpRequest.prototype.open;
                XMLHttpRequest.prototype.open = function(method, url) {
                    this._kwpfUrl = url;
                    return _origXHROpen.apply(this, arguments);
                };

                const _origXHRSend = XMLHttpRequest.prototype.send;
                XMLHttpRequest.prototype.send = function(body) {
                    const xhr = this;
                    const url = xhr._kwpfUrl || '';

                    // Detect upload-attachment or async-upload.php
                    let isUpload = false;
                    if (url.indexOf('upload-attachment') !== -1 || url.indexOf('async-upload.php') !== -1) {
                        isUpload = true;
                    }

                    // Also check FormData body for action=upload-attachment
                    if (!isUpload && body instanceof FormData) {
                        try {
                            if (body.get && body.get('action') === 'upload-attachment') {
                                isUpload = true;
                            }
                        } catch (e) {}
                    }

                    if (isUpload) {
                        // Only inject folder if our sidebar is actually present
                        if (!$('#kookoawpf-sidebar-wrapper').length && !$('.kookoawpf-modal-sidebar').length) {
                            return _origXHRSend.apply(this, arguments);
                        }
                        // Resolve current folder + smart state up front (used for both
                        // the smart-folder upload block and the assign-on-upload path).
                        const folder = (FO._modalCurrentFolder && FO._modalCurrentFolder !== 'all')
                            ? FO._modalCurrentFolder
                            : FO.currentFolder;
                        let isDyn = false;
                        if (folder && folder !== 'all' && folder !== 'uncategorized' && !isNaN(folder)) {
                            let checkInst = null;
                            if (FO._modalCurrentFolder && FO._modalCurrentFolder !== 'all') {
                                const $modalTree = $('.kookoawpf-modal-sidebar .kookoawpf-modal-jstree');
                                if ($modalTree.length && $modalTree.jstree(true)) checkInst = $modalTree.jstree(true);
                            }
                            if (!checkInst && FO.tree && FO.tree.jstree(true)) checkInst = FO.tree.jstree(true);
                            if (checkInst) {
                                const tNode = checkInst.get_node('folder_' + folder);
                                if (tNode && tNode.data && tNode.data.is_dynamic) isDyn = true;
                            }
                        }
                        // Block uploads into a smart folder — query-based virtual views
                        // can't actually receive an assignment, so silently rerouting
                        // the file to "uncategorized" is misleading. Abort + notice.
                        if (isDyn) {
                            self.showNotice( self.data.strings.smartUploadBlocked || 'Cannot upload to a smart folder. Switch to a regular folder first.', 'warning' );
                            xhr.abort();
                            return;
                        }
                        // Inject folder ID into the upload (regular folders only).
                        if (body instanceof FormData) {
                            if (folder && folder !== 'all' && folder !== 'uncategorized' && !isNaN(folder)) {
                                body.append('kookoawpf_upload_folder', String(folder));
                            }
                        }

                        xhr.addEventListener('load', function() {
                            if (xhr.status >= 200 && xhr.status < 300) {
                                // Show upload notice (skip during bulk folder structure upload)
                                if (FO._suppressUploadNotice) {
                                    scheduleUploadRefresh();
                                    return;
                                }
                                let folderName = '';
                                const activeFolder = (FO._modalCurrentFolder && FO._modalCurrentFolder !== 'all')
                                    ? FO._modalCurrentFolder : FO.currentFolder;
                                if (activeFolder && activeFolder !== 'all' && activeFolder !== 'uncategorized') {
                                    const inst = (FO.tree && FO.tree.jstree(true)) ? FO.tree.jstree(true) : null;
                                    if (inst) {
                                        const n = inst.get_node('folder_' + activeFolder);
                                        if (n) folderName = $('<span>').html(n.text).find('.kookoawpf-folder-text').text() || n.text;
                                    }
                                }
                                if (folderName) {
                                    self.showNotice('File uploaded to "' + folderName + '"', 'success');
                                } else {
                                    self.showNotice('File uploaded', 'success');
                                }
                                scheduleUploadRefresh();
                            }
                        });
                    }

                    return _origXHRSend.apply(this, arguments);
                };
            }
        },

        _loadGrid: function(folder) {
            if (typeof wp === 'undefined' || !wp.media || !wp.media.frame) {
                window.location.href = new URL(window.location.href).toString();
                return;
            }

            try {
                let library = null;

                if (wp.media.frame.state && wp.media.frame.state()) {
                    library = wp.media.frame.state().get('library');
                }

                if (!library && wp.media.frame.content && wp.media.frame.content.get()) {
                    const content = wp.media.frame.content.get();
                    if (content && content.collection) library = content.collection;
                }

                if (library && library.props) {
                    if (wp.media.model && wp.media.model.Query) {
                        wp.media.model.Query._queries = {};
                    }

                    if (folder && folder !== 'all') {
                        library.props.unset('kookoawpf_folder', { silent: true });
                        library.props.set({ kookoawpf_folder: String(folder) });
                    } else {
                        if (library.props.get('kookoawpf_folder')) library.props.unset('kookoawpf_folder');
                        library.props.set({ ignore: +new Date() });
                        library.props.unset('ignore');
                    }

                    // Only fetch counts if they aren't already fresh from refreshTree.
                    if (!this._countsAreFresh) {
                        this._updateCounts();
                    }
                    this._countsAreFresh = false;
                    return;
                }
            } catch (e) { /* fall through */ }

            window.location.href = new URL(window.location.href).toString();
        },

        _loadList: function(folder) {
            const self = this;
            const $table = $('.wp-list-table');
            const $tbody = $table.find('tbody#the-list, tbody').first();

            $.ajax({
                url: new URL(window.location.href).toString(),
                type: 'GET',
                dataType: 'html',
                success: function(html) {
                    const $parsed = $('<div>').append($.parseHTML(html, document, false));
                    const $newBody = $parsed.find('.wp-list-table tbody#the-list, .wp-list-table tbody').first();
                    if ($newBody.length) $tbody.html($newBody.html());

                    const $newNav = $parsed.find('.tablenav.top');
                    if ($newNav.length) {
                        const $count = $newNav.find('.displaying-num');
                        if ($count.length) $('.tablenav.top .displaying-num').text($count.text());
                        const $pages = $newNav.find('.tablenav-pages');
                        if ($pages.length) $('.tablenav.top .tablenav-pages').replaceWith($pages);
                    }

                    // Update the WordPress status filter bar (All | Published | Drafts)
                    const $newSubsub = $parsed.find('.subsubsub');
                    if ($newSubsub.length) $('.subsubsub').replaceWith($newSubsub);

                    // Destroy any stale jQuery UI draggable instances before replacing
                    $table.find('tbody tr').each(function() {
                        const $r = $(this);
                        if ($r.data('ui-draggable')) {
                            try { $r.draggable('destroy'); } catch(e) {}
                        }
                    });
                    $table.find('tbody tr').removeData('fo-d');
                    // Re-initialize draggable on all new rows after DOM settles
                    setTimeout(function() {
                        self._makeListRowsDraggable();
                    }, 150);
                    if (!self._countsAreFresh) {
                        self._updateCounts();
                    }
                    self._countsAreFresh = false;
                    // Reset bulk organize button state after list reload
                    if (self._updateListBulkBtnState) self._updateListBulkBtnState();
                },
                error: function() {
                    // Silent fail — content stays as-is
                }
            });
        },

        _updateCounts: function() {
            const self = this;
            self.ajax('kookoawpf_get_tree', { post_type: self.data.postType }, function(r) {
                if (!r.success) return;
                $('#kookoawpf-folder-count').text(r.data.total_folders);
                $('#kookoawpf-count-all').text(r.data.total_items);
                $('#kookoawpf-count-uncategorized').text(r.data.uncategorized);
                self._toggleEmptyState(r.data.total_folders);
                self._totalFoldersAll = r.data.total_folders_all || 0;
                if (r.data.tree && self.tree) self._applyCounts(r.data.tree);
            });
        },

        _applyCounts: function(nodes) {
            const self = this;
            for (let i = 0; i < nodes.length; i++) {
                const n = nodes[i];
                const $el = self.tree.find('#folder_' + n.data.folder_id);
                if ($el.length) {
                    $el.children('.kookoawpf-count').text(n.data.count);
                }
                if (n.children && n.children.length) self._applyCounts(n.children);
            }
        },

        /** Clipboard for Ctrl+C / Ctrl+V folder operations */
        _clipboard: null,

        // ========================
        // Search & Resize
        // ========================

        initSearch: function() {
            const self = this;
            let timer;
            $('#kookoawpf-search-folders').on('keyup', function() {
                const val = $(this).val().trim();
                clearTimeout(timer);
                timer = setTimeout(function() {
                    if (self.tree && self.tree.jstree(true)) self.tree.jstree(true).search(val);
                }, 300);
            });
        },

        _checkScrollbar: function() {
            const el = document.querySelector('.kookoawpf-tree-container');
            const $wrapper = $('#kookoawpf-sidebar-wrapper');
            if (el && el.scrollHeight > el.clientHeight) {
                $wrapper.addClass('kookoawpf-has-scrollbar');
            } else {
                $wrapper.removeClass('kookoawpf-has-scrollbar');
            }
        },

        initResize: function() {
            const self = this;
            const $wrapper = $('#kookoawpf-sidebar-wrapper');
            const $handle = $('#kookoawpf-sidebar-resize');
            let resizing = false;

            $handle.on('mousedown', function(e) {
                resizing = true;
                $('body').css({ cursor: 'col-resize', 'user-select': 'none' });
                e.preventDefault();
            });

            $(document).on('mousemove', function(e) {
                if (!resizing) return;
                const adminMenuW = $('body').hasClass('folded') ? 36 : 160;
                const w = e.clientX - adminMenuW;
                if (w >= 300 && w <= 600) {
                    $wrapper.css('width', w + 'px');
                    // Add 30px extra for the toggle button
                    $('#wpcontent').css('padding-left', (w + 30) + 'px');
                }
            });

            $(document).on('mouseup', function() {
                if (resizing) {
                    resizing = false;
                    $('body').css({ cursor: '', 'user-select': '' });

                    // Persist the new width to the database
                    const newWidth = parseInt($wrapper.css('width'), 10);
                    if (newWidth >= 300 && newWidth <= 600) {
                        self.ajax('kookoawpf_save_sidebar_width', { width: newWidth });
                    }
                }
            });
        },

        // ========================
        // AJAX & Notifications
        // ========================

        /**
         * Ensure a dialog element exists and is inside the visible .media-modal
         * so it appears above it in the stacking context.
         * On Divi frontend, creates modals on-demand since Divi strips display:none elements.
         */
        _ensureDialogInModal: function($dlg, dlgId) {
            const id = $dlg.length ? $dlg.attr('id') : dlgId;

            // On-demand creation for builder pages where Divi strips display:none elements
            if (!$dlg.length && id && FO._builderModalData) {
                KookoawpfMediaModal._createSingleBuilderModal(id, FO._builderModalData);
                $dlg = $('#' + id);
            }

            if (!$dlg.length) return $dlg;
            const $visibleModal = $('.media-modal:visible');
            if ($visibleModal.length && !$visibleModal.find('#' + id).length) {
                $dlg.appendTo($visibleModal);
            }
            return $dlg;
        },

        ajax: function(action, data, callback) {
            data = data || {};
            data.action = action;
            data.nonce = this.data.nonce;
            $.post(this.data.ajaxUrl, data, function(r) { if (callback) callback(r); })
             .fail(function() { if (callback) callback({ success: false, data: { message: 'Failed' } }); });
        },

        showNotice: function(msg, type, persistent) {
            type = type || 'success';
            // Non-persistent notice: remove any existing persistent notice first
            if (!persistent) {
                const $old = $('.kookoawpf-notice-persistent');
                if ($old.length) {
                    $old.removeClass('kookoawpf-notice-visible');
                    setTimeout(function() { $old.remove(); }, 300);
                }
            }
            const $n = $('<div class="kookoawpf-notice kookoawpf-notice-' + type + '"></div>').text(msg);
            if (persistent) $n.addClass('kookoawpf-notice-persistent');
            // Anchor inside the visible .media-modal when one is open, mirroring
            // the smart-rule tooltip (dynamic.js). Page builders that wrap the
            // media modal in their own stacking context (Divi 5) bury a
            // body-appended notice behind the modal overlay even at the notice
            // z-index, so the toast never shows although the action ran. Hosting
            // it in the modal shares that stacking context; the explicit
            // z-index keeps it above the modal chrome (notice tier = 200002,
            // per CLAUDE.md section 8). Outside a modal nothing changes — it
            // still appends to <body>.
            const $modalHost = $('.media-modal:visible').first();
            if ($modalHost.length) {
                $n.css('z-index', 200002);
                $modalHost.append($n);
            } else {
                $('body').append($n);
            }
            setTimeout(function() { $n.addClass('kookoawpf-notice-visible'); }, 10);
            if (!persistent) {
                setTimeout(function() { $n.removeClass('kookoawpf-notice-visible'); setTimeout(function() { $n.remove(); }, 300); }, 3000);
            }
        }
    };

    // ========================
    // Boot
    // ========================

    $(document).ready(function() {
        if ($('#kookoawpf-sidebar-wrapper').length && window.kookoawpfData) FO.init();

        // Migration hint dismiss — fire-and-forget AJAX, hide the hint
        // immediately for a responsive feel. The user_meta flag persists
        // the choice so the hint stays gone after refresh.
        $(document).on('click', '#kookoawpf-sidebar-migrate-hint-dismiss', function() {
            const $hint = $('#kookoawpf-sidebar-migrate-hint');
            $.post(window.ajaxurl || '/wp-admin/admin-ajax.php', {
                action: 'kookoawpf_dismiss_sidebar_migrate_hint',
                nonce:  (FO.data && FO.data.nonce) ? FO.data.nonce : ''
            });
            $hint.fadeOut(150, function() { $hint.remove(); });
        });
    });

})(jQuery);
