/**
 * KooKoa Folders — Context Menu
 *
 * Extracted from admin.js (blocco 6).
 * Adds context menu methods directly to FO (window.KookoaWPF).
 *
 * Dependencies: admin.js (FO base), media-modal.js (KookoawpfMediaModal — optional)
 */
(function($) {
    'use strict';

    const FO = window.KookoaWPF;

    /**
     * Count total nodes from jsTree internal model without serializing
     * the entire tree via get_json. Used to detect the "only one folder"
     * case where a move submenu should be empty.
     */
    function _countFromModel(inst) {
        const model = inst._model.data;
        let total = 0;
        for (const id in model) {
            if (id === '#') continue;
            total++;
        }
        return { total: total };
    }

    // ========================
    // Context Menu
    // ========================

    FO.initContextMenu = function() {
        const self = this;

        if (!$('#kookoawpf-context-menu').length) {
            $('body').append('<div id="kookoawpf-context-menu" class="kookoawpf-context-menu" style="display:none;"></div>');
        }

        self.tree.on('contextmenu.jstree', '.jstree-anchor', function(e) {
            e.preventDefault();
            e.stopPropagation();
            // No context menu in bulk folder mode
            if (self._fbActive) return;
            // Suppress folder navigation — right-click should not change active folder
            self.suppressSelectEvent = true;
            setTimeout(function() { self.suppressSelectEvent = false; }, 50);
            // Remove previous context highlight
            $('#kookoawpf-jstree .kookoawpf-context-active').removeClass('kookoawpf-context-active');
            const $node = $(this).closest('.jstree-node');
            $node.addClass('kookoawpf-context-active');
            const node = self.tree.jstree('get_node', $node.attr('id'));
            // Viewport coordinates: the context menu is `position: fixed`, so it must
            // be positioned relative to the viewport, not the document. Using pageX/pageY
            // would break when the body scrolls (e.g. long media grids).
            if (node) self._showContextMenu(e.clientX, e.clientY, node);
        });

        $(document).on('click', function(e) {
            if (!$(e.target).closest('#kookoawpf-context-menu').length) {
                $('#kookoawpf-context-menu').hide();
                $('#kookoawpf-jstree .kookoawpf-context-active').removeClass('kookoawpf-context-active');
                $('.kookoawpf-subfolder-card.kookoawpf-hover-sync').removeClass('kookoawpf-hover-sync');
            }
        });
        $(document).on('keydown', function(e) {
            if (e.keyCode === 27) {
                $('#kookoawpf-context-menu').hide();
                $('#kookoawpf-jstree .kookoawpf-context-active').removeClass('kookoawpf-context-active');
                $('.kookoawpf-subfolder-card.kookoawpf-hover-sync').removeClass('kookoawpf-hover-sync');
            }
        });

        // Unified click handler (shared with modal)
        self._bindCtxClickHandler();
    };

    FO._executeContextAction = function(action, fid, node, ctx, fromShortcut) {
        const self = this;
        ctx = ctx || self._ctxContext();
        const isModal = ctx.source === 'modal';

        switch (action) {
            case 'subfolder':
                if (isModal) {
                    KookoawpfMediaModal._openModalFolderDialog('create', '', fid, ctx.$sidebar, ctx.$frame);
                } else {
                    self.openFolderModal('create', '', fid);
                }
                break;
            case 'rename': {
                const name = node ? $('<div>').html(node.text).find('.kookoawpf-count').remove().end().text().trim() : '';
                if (isModal) {
                    KookoawpfMediaModal._openModalFolderDialog('rename', name, fid, ctx.$sidebar, ctx.$frame);
                } else {
                    self.openFolderModal('rename', name, fid);
                }
                break;
            }
            case 'color':
                if (isModal) {
                    // No refreshFn — updateFolderColor already updates both trees optimistically
                    KookoawpfMediaModal._boostAndPoll('#kookoawpf-modal-color', fid,
                        function(id) { FO.openColorModal(id); }, function() {});
                } else {
                    self.openColorModal(fid);
                }
                break;
            case 'icon':
                if (isModal) {
                    // No refreshFn — updateFolderIcon already updates both trees optimistically
                    KookoawpfMediaModal._boostAndPoll('#kookoawpf-modal-icon', fid,
                        function(id) { FO.openIconModal(id); }, function() {});
                } else {
                    self.openIconModal(fid);
                }
                break;
            case 'duplicate':
                if (isModal) {
                    FO.ajax('kookoawpf_duplicate_folder', { folder_id: fid, post_type: ctx.postType }, function(r) {
                        if (r.success) {
                            FO.showNotice(FO.data.strings.folderDuplicated || 'Folder duplicated.', 'success');
                            ctx.refreshFn();
                        } else {
                            FO.showNotice((r.data && r.data.message) || FO.data.strings.errorGeneric, 'error');
                        }
                    });
                } else {
                    self.duplicateFolder(fid);
                }
                break;
            case 'copy': {
                const copyName = node ? $('<div>').html(node.text).find('.kookoawpf-count').remove().end().text().trim() : '';
                ctx.setClipboard({ id: fid, name: copyName, cut: false });
                self.showNotice('"' + copyName + '" copied', 'success');
                break;
            }
            case 'cut': {
                const cutName = node ? $('<div>').html(node.text).find('.kookoawpf-count').remove().end().text().trim() : '';
                ctx.setClipboard({ id: fid, name: cutName, cut: true });
                self.showNotice('"' + cutName + '" cut', 'success');
                break;
            }
            case 'paste': {
                if (!ctx.clipboard) break;
                if (node && node.data && node.data.is_dynamic) {
                    self.showNotice('Cannot paste into a smart folder', 'error'); break;
                }
                if (node && node.data && node.data.locked) {
                    self.showNotice('This folder is locked', 'error'); break;
                }
                const clipNodeId = 'folder_' + ctx.clipboard.id;
                if (node && node.id === clipNodeId) {
                    self.showNotice('Cannot paste folder into itself', 'error'); break;
                }
                const pInst = ctx.tree ? ctx.tree.jstree(true) : null;
                if (pInst) {
                    const ancestors = pInst.get_path(node, false, true);
                    if (ancestors && ancestors.indexOf(clipNodeId) !== -1) {
                        self.showNotice('Cannot paste into a descendant', 'error'); break;
                    }
                    // Cut + paste into current parent is a no-op — block silently.
                    if (ctx.clipboard.cut) {
                        const _cutSrcNode = pInst.get_node(clipNodeId);
                        if (_cutSrcNode && _cutSrcNode.parent === node.id) break;
                    }
                }
                if (ctx.clipboard.cut) {
                    const cutInst = ctx.tree ? ctx.tree.jstree(true) : null;
                    if (!isModal && cutInst && cutInst.get_node(clipNodeId) && cutInst.get_node(node.id)) {
                        // Main sidebar: use jsTree move_node for optimistic move + animation
                        // (move_node.jstree listener handles AJAX persistence and shows
                        // the "folder moved" notice — no need for a separate notice here).
                        // Pass 'last' as position so the cut folder lands at the end of
                        // the destination's children — consistent with create / copy-paste
                        // / drag & drop. Default jstree position is 0 (first child).
                        ctx.setClipboard(null);
                        cutInst.move_node(clipNodeId, node.id, 'last');
                        cutInst.open_node(node.id, false, 0);
                    } else {
                        // Modal or node not in tree: explicit AJAX call
                        self.ajax('kookoawpf_move_folder', {
                            folder_id: ctx.clipboard.id,
                            new_parent_id: fid,
                            post_type: ctx.postType
                        }, function(r) {
                            if (r.success) {
                                self.showNotice('"' + ctx.clipboard.name + '" moved', 'success');
                                ctx.setClipboard(null);
                                ctx.refreshFn();
                            }
                        });
                    }
                } else {
                    const copyName = ctx.clipboard.name;
                    const pasteTargetId = node.id;
                    const cpSrcInst = ctx.tree ? ctx.tree.jstree(true) : null;
                    const cpSrcNode = cpSrcInst ? cpSrcInst.get_node('folder_' + ctx.clipboard.id) : null;
                    const cpSrcData = cpSrcNode && cpSrcNode.data ? cpSrcNode.data : {};
                    // If source has children, optimistic create_node would only show
                    // the top-level copy (server duplicates the whole subtree but we
                    // don't know the new child IDs). Fall back to refreshFn in that case.
                    const cpHasChildren = !!(cpSrcNode && cpSrcNode.children && cpSrcNode.children.length);
                    self.ajax('kookoawpf_duplicate_folder', {
                        folder_id: ctx.clipboard.id,
                        post_type: ctx.postType,
                        target_parent: fid
                    }, function(r) {
                        if (r.success) {
                            self.showNotice('"' + copyName + '" pasted', 'success');
                            ctx.setClipboard(null);
                            const cpInst = ctx.tree ? ctx.tree.jstree(true) : null;
                            const newId = r.data && r.data.term_id ? r.data.term_id : null;
                            const dupName = r.data && r.data.name ? r.data.name : copyName + ' (Copy)';
                            if (cpInst && newId && cpInst.get_node(pasteTargetId) && !cpHasChildren) {
                                const newNode = {
                                    id: 'folder_' + newId,
                                    text: '<span class="kookoawpf-folder-text">' + jQuery('<span>').text(dupName).html() + '</span>',
                                    data: { folder_id: newId, color: cpSrcData.color || ((FO.data && FO.data.defaultColor) ? FO.data.defaultColor : '#39FF14'), locked: false, pinned: false, count: 0, icon: cpSrcData.icon || '', is_dynamic: false, original_text: dupName },
                                    icon: 'kookoawpf-folder-icon',
                                    state: { opened: false }
                                };
                                cpInst.create_node(pasteTargetId, newNode, 'last', function(n) {
                                    cpInst.open_node(pasteTargetId, false, 0);
                                    FO.applyFolderIcons();
                                    if (FO._makeTreeNodesDroppable) FO._makeTreeNodesDroppable();
                                    FO._folderListCache = null;
                                    if (FO._repopulateGridFilter) FO._repopulateGridFilter();
                                    FO._toggleEmptyState(1);
                                    FO._updateFreeLimits();
                                    FO._updateNoFolderState();
                                    // In auto sort, snap the new node to its sorted spot.
                                    if (FO._resortChildren) FO._resortChildren(pasteTargetId, cpInst);
                                    const $n = jQuery('#' + n.id);
                                    $n.addClass('kookoawpf-just-moved');
                                    setTimeout(function() { $n.removeClass('kookoawpf-just-moved'); }, 500);
                                });
                            } else {
                                ctx.refreshFn();
                            }
                        } else {
                            FO.showNotice((r.data && r.data.message) || FO.data.strings.errorGeneric, 'error');
                        }
                    });
                }
                break;
            }
            case 'pin':
                if (isModal) {
                    const _pnDyn = node && node.data && node.data.is_dynamic;
                    FO.ajax('kookoawpf_toggle_pin', { folder_id: fid, post_type: ctx.postType }, function(r) {
                        if (r.success) {
                            let _pnMsg = r.data.message || 'Done';
                            if (_pnDyn) _pnMsg = r.data.pinned ? FO.data.strings.smartPinnedToTop : FO.data.strings.smartUnpinned;
                            FO.showNotice(_pnMsg, 'success'); ctx.refreshFn();
                        }
                    });
                } else {
                    self.togglePin(fid);
                }
                break;
            case 'lock':
                if (isModal) {
                    const _lkDyn = node && node.data && node.data.is_dynamic;
                    FO.ajax('kookoawpf_toggle_lock', { folder_id: fid, post_type: ctx.postType }, function(r) {
                        if (r.success) {
                            let _lkMsg = r.data.message || 'Done';
                            if (_lkDyn) _lkMsg = r.data.locked ? FO.data.strings.smartFolderLocked : FO.data.strings.smartFolderUnlocked;
                            FO.showNotice(_lkMsg, 'success'); ctx.refreshFn();
                        }
                    });
                } else {
                    self.toggleLock(fid);
                }
                break;
            case 'move-top': case 'move-up': case 'move-down': case 'move-bottom':
                if (isModal) {
                    self._reorderFolder(ctx, node, action, fid);
                } else {
                    self.moveFolderPosition(fid, action);
                }
                break;
            case 'move-to-folder':
                if (isModal) {
                    KookoawpfMediaModal._boostAndPoll('#kookoawpf-modal-move-to-folder', fid,
                        function(id) { FO.openMoveToFolderModal(id, 'attachment'); }, ctx.refreshFn);
                } else {
                    self.openMoveToFolderModal(fid);
                }
                break;
            case 'delete':
                if (isModal) {
                    KookoawpfMediaModal._boostAndPoll('#kookoawpf-modal-delete', fid,
                        function(id) { FO.openDeleteModal(id); }, ctx.refreshFn);
                } else {
                    self.openDeleteModal(fid);
                }
                break;
        }
    };

    FO._showContextMenu = function(x, y, node, ctx) {
        const self = this;
        // Close sort and more dropdowns when context menu opens
        $('#kookoawpf-sort-dropdown').hide();
        $('#kookoawpf-more-dropdown').hide();
        ctx = ctx || self._ctxContext();
        const fid = self.getFolderId(node);
        const isLocked = node.data && node.data.locked;
        const isPinned = node.data && node.data.pinned;
        const isDynamic = node.data && node.data.is_dynamic;
        const hasCustomIcon = node.data && node.data.icon && node.data.icon !== '';
        const S = self.data.strings;
        const inst = ctx.tree ? ctx.tree.jstree(true) : null;

        let items = [];
        const isModal = ctx.source === 'modal';

        // Check if delete should be locked (folder locked or has locked descendant)
        const deleteIsLocked = isLocked || self._hasLockedDescendant(node, inst);

        if (isDynamic) {
            // Dynamic folder context menu — simplified.
            // No icon/color change, no duplicate, no copy (cloning a saved
            // query is semantically useless). Cut IS allowed because it's a
            // move, not a clone — consistent with "Move to Folder".
            // Order mirrors the regular folder menu: Clipboard → Move → Properties → Delete.
            // "Edit Rules" entry is injected via the kookoawpf:context-menu-build
            // event by companion plugins (keeps the FREE context menu feature-agnostic).
            items.push(self._ctxItem('rename', 'edit', S.rename, false, isLocked, false, isLocked));
            // Block 2: Clipboard (cut only — copy would mean duplicate)
            items.push({ sep: true });
            items.push(self._ctxItem('cut', 'migrate', S.cutFolder, false, isLocked, false, isLocked));
            // Block 3: Move
            const moveSubmenu = self._buildMoveSubmenu(node, ctx);
            if (moveSubmenu.length) {
                items.push({ sep: true });
                items.push({ icon: 'move', label: S.moveFolder, submenu: moveSubmenu });
            }
            // Block 4: Properties
            items.push({ sep: true });
            items.push(self._ctxItem('pin', 'star-' + (isPinned ? 'filled' : 'empty'), isPinned ? S.unpinFolder : S.pinFolder));
            items.push(self._ctxItem('lock', isLocked ? 'unlock' : 'lock', isLocked ? S.unlockFolder : S.lockFolder));
            // Block 5: Delete
            items.push({ sep: true });
            items.push(self._ctxItem('delete', 'trash', S['delete'], true, false, deleteIsLocked));
        } else {
            // Regular folder context menu
            // Block 1: Create / Edit
            // "New Folder" removed — the "+ Folder" button in the sidebar header
            // is always visible and serves the same purpose. Context menu now only
            // offers "New Subfolder" (contextual to the clicked folder).
            if (!isLocked) {
                items.push(self._ctxItem('subfolder', 'plus-alt2', S.newSubfolder));
            }
            items.push(self._ctxItem('rename', 'edit', S.rename, false, isLocked, false, isLocked));
            // Block 2: Appearance
            items.push({ sep: true });
            if (!hasCustomIcon) {
                items.push(self._ctxItem('color', 'art', S.iconColor, false, isLocked, false, isLocked));
            }
            items.push(self._ctxItem('icon', 'images-alt2', S.changeIcon, false, isLocked, false, isLocked));
            // Block 3: Clipboard
            items.push({ sep: true });
            items.push(self._ctxItem('duplicate', 'admin-page', S.duplicateFolder));
            items.push(self._ctxItem('copy', 'clipboard', S.copyFolder));
            // Cut is always shown — if the folder is locked, display it
            // disabled with a lock icon, consistent with "Move to Folder".
            items.push(self._ctxItem('cut', 'migrate', S.cutFolder, false, isLocked, false, isLocked));
            // Paste on a locked folder uses the single-lock disabled style
            // (not the strikethrough+triple-lock reserved for Delete).
            // Also disabled when cutting a folder onto its own current parent
            // (no-op: the source is already a direct child of the target).
            let pasteNoop = false;
            if (ctx.clipboard && ctx.clipboard.cut && inst) {
                const _cutSrc = inst.get_node('folder_' + ctx.clipboard.id);
                if (_cutSrc && _cutSrc.parent === node.id) pasteNoop = true;
            }
            const pasteDisabled = !ctx.clipboard || isLocked || pasteNoop;
            items.push(self._ctxItem('paste', 'category', S.pasteFolder, false, pasteDisabled, false, isLocked && !!ctx.clipboard));
            // Block 4: Move
            const moveSubmenu = self._buildMoveSubmenu(node, ctx);
            if (moveSubmenu.length) {
                items.push({ sep: true });
                items.push({ icon: 'move', label: S.moveFolder, submenu: moveSubmenu });
            }
            // Block 5: Properties
            items.push({ sep: true });
            items.push(self._ctxItem('pin', 'star-' + (isPinned ? 'filled' : 'empty'), isPinned ? S.unpinFolder : S.pinFolder));
            items.push(self._ctxItem('lock', isLocked ? 'unlock' : 'lock', isLocked ? S.unlockFolder : S.lockFolder));
            // Block 6: Delete
            items.push({ sep: true });
            items.push(self._ctxItem('delete', 'trash', S['delete'], true, false, deleteIsLocked));
        }

        // Extension point: companion plugins can listen and splice their
        // own entries into `items` (passed by reference) before the
        // disabled-items filter runs.
        $(document).trigger('kookoawpf:context-menu-build', [items, node, ctx]);

        // Filter out disabled context menu items
        const ctxOff = self.data.ctxDisabled || [];
        if (ctxOff.length) {
            items = self._filterCtxItems(items, ctxOff);
        }

        const $menu = $('#kookoawpf-context-menu');

        // In modal context, move context menu inside .media-modal for z-index stacking
        if (isModal && ctx.$frame) {
            const $modalParent = ctx.$frame.closest('.media-modal');
            if ($modalParent.length && !$modalParent.find('#kookoawpf-context-menu').length) {
                $menu.appendTo($modalParent);
            }
        } else if (!isModal && $menu.closest('.media-modal').length) {
            // Move back to body when used from main sidebar
            $menu.appendTo('body');
        }

        $menu.html('<ul class="kookoawpf-ctx-list">' + self._renderCtxItems(items) + '</ul>')
            .data('folder-id', fid)
            .data('ctx', ctx);

        // Mobile: CSS handles bottom-sheet positioning entirely — just show the menu
        if ($('body').hasClass('kookoawpf-mobile')) {
            $menu.removeClass('kookoawpf-ctx-scroll')
                 .css({ display: 'block', left: '', top: '', maxHeight: '', overflowY: '' })
                 .scrollTop(0);
            return;
        }

        // Position: hybrid — prefer below cursor, flip above if it fits, otherwise clamp
        // to viewport + internal scroll. This preserves the natural flip behavior on
        // 2K/4K monitors and only kicks in the scroll fallback on small viewports where
        // the menu is taller than both the space below and above the cursor.
        // x, y are viewport coordinates (clientX/clientY), since the menu is `position: fixed`.
        $menu.removeClass('kookoawpf-ctx-scroll')
             .css({ display: 'block', left: 0, top: 0, maxHeight: '', overflowY: '' });
        // Clear any leftover inline styles on submenus from a previous clamp open
        $menu.find('.kookoawpf-ctx-submenu').css({ position: '', top: '', left: '', right: '', zIndex: '' });
        const mw = $menu.outerWidth(), mh = $menu.outerHeight();
        const ww = $(window).width(), wh = $(window).height();

        // Horizontal: prefer right of cursor, flip to left if overflow
        const finalLeft = (x + mw > ww) ? (x - mw) : x;

        // Vertical: try below → flip above → clamp + scroll
        const fitsBelow = (y + mh <= wh);
        const fitsAbove = (y - mh >= 0);
        let finalTop;
        if (fitsBelow) {
            finalTop = y;
        } else if (fitsAbove) {
            finalTop = y - mh;
        } else {
            // Menu is taller than both spaces above and below the cursor: clamp to the
            // top of the viewport and enable internal scroll so every item stays reachable.
            finalTop = 4;
            $menu.addClass('kookoawpf-ctx-scroll')
                 .css({ maxHeight: (wh - 8) + 'px', overflowY: 'auto' });
        }

        $menu.css({ left: finalLeft + 'px', top: finalTop + 'px' });

        // Submenu delay — prevent close on diagonal mouse movement
        let subTimer = null;
        $menu.find('.kookoawpf-ctx-has-sub').on('mouseenter.kwpfSub', function() {
            clearTimeout(subTimer);
            $menu.find('.kookoawpf-ctx-has-sub').removeClass('kookoawpf-ctx-sub-open');
            $(this).addClass('kookoawpf-ctx-sub-open');
        }).on('mouseleave.kwpfSub', function() {
            const $el = $(this);
            subTimer = setTimeout(function() {
                $el.removeClass('kookoawpf-ctx-sub-open');
            }, 100);
        });
    };

    FO._ctxItem = function(action, icon, label, isDanger, isDisabled, lockedDelete, lockedItem) {
        return { action: action, icon: icon, label: label, danger: !!isDanger, disabled: !!isDisabled, lockedDelete: !!lockedDelete, lockedItem: !!lockedItem };
    };

    FO._filterCtxItems = function(items, disabled) {
        const filtered = [];
        for (let i = 0; i < items.length; i++) {
            const it = items[i];
            if (it.sep) { filtered.push(it); continue; }
            // Check action key or submenu parent (move → action key stored in icon field for submenu parents)
            const key = it.action || (it.submenu ? it.icon : '');
            if (key && disabled.indexOf(key) !== -1) continue;
            filtered.push(it);
        }
        // Clean up: remove leading/trailing/double separators
        const clean = [];
        for (let j = 0; j < filtered.length; j++) {
            if (filtered[j].sep) {
                if (clean.length === 0) continue;
                if (clean[clean.length - 1].sep) continue;
                clean.push(filtered[j]);
            } else {
                clean.push(filtered[j]);
            }
        }
        // Remove trailing separator
        if (clean.length && clean[clean.length - 1].sep) clean.pop();
        return clean;
    };

    FO._renderCtxItems = function(items) {
        // Helper to escape strings for safe HTML insertion
        function esc(str) {
            return $('<span>').text(str || '').html();
        }
        let html = '';
        for (let i = 0; i < items.length; i++) {
            const item = items[i];
            if (item.sep) { html += '<li class="kookoawpf-ctx-sep"></li>'; continue; }
            const safeIcon = esc(item.icon);
            const safeLabel = esc(item.label);
            const safeAction = esc(item.action);
            if (item.submenu) {
                html += '<li class="kookoawpf-ctx-item kookoawpf-ctx-has-sub">' +
                    '<span class="dashicons dashicons-' + safeIcon + '"></span>' +
                    '<span>' + safeLabel + '</span>' +
                    '<span class="kookoawpf-ctx-arrow dashicons dashicons-arrow-right-alt2"></span>' +
                    '<ul class="kookoawpf-ctx-submenu">' + FO._renderCtxItems(item.submenu) + '</ul></li>';
                continue;
            }
            const cls = 'kookoawpf-ctx-item' + (item.danger ? ' kookoawpf-ctx-danger' : '') + (item.disabled ? ' kookoawpf-ctx-disabled' : '') + (item.lockedDelete ? ' kookoawpf-ctx-locked-delete' : '');
            const labelHtml = item.lockedDelete
                ? '<span class="kookoawpf-ctx-strike">' + safeLabel + '</span>' +
                  '<span class="kookoawpf-ctx-locks">' +
                    '<span class="dashicons dashicons-lock"></span>' +
                    '<span class="dashicons dashicons-lock"></span>' +
                    '<span class="dashicons dashicons-lock"></span>' +
                  '</span>'
                : item.lockedItem
                ? '<span>' + safeLabel + '</span><span class="kookoawpf-ctx-locks"><span class="dashicons dashicons-lock"></span></span>'
                : '<span>' + safeLabel + '</span>';
            html += '<li class="' + cls + '" data-action="' + safeAction + '">' +
                '<span class="dashicons dashicons-' + safeIcon + '"></span>' +
                labelHtml + '</li>';
        }
        return html;
    };

    FO._buildMoveSubmenu = function(node, ctx) {
        const self = this, S = self.data.strings;
        const tree = (ctx && ctx.tree) ? ctx.tree : self.tree;
        const inst = tree ? tree.jstree(true) : null;
        if (!inst) return [];

        // If there's only one folder in the entire tree, no move is possible
        const _mc = _countFromModel(inst);
        if (_mc.total <= 1) return [];

        const parentNode = inst.get_node(node.parent);
        const siblings = parentNode ? parentNode.children : [];
        const nodeIdx = siblings.indexOf(node.id);
        const isLocked = node.data && node.data.locked;
        const isPinned = node.data && node.data.pinned;

        // Compute pin boundary: index of first non-pinned sibling
        let pinBoundary = 0;
        for (let p = 0; p < siblings.length; p++) {
            const sibNode = inst.get_node(siblings[p]);
            if (sibNode && sibNode.data && sibNode.data.pinned) {
                pinBoundary = p + 1;
            } else {
                break;
            }
        }

        const sub = [];
        if (isPinned) {
            // Pinned: can only move within pinned group (0 .. pinBoundary-1)
            if (nodeIdx > 1 && pinBoundary > 2)          sub.push(self._ctxItem('move-top', 'arrow-up-alt', S.moveToTop));
            if (nodeIdx > 0)                               sub.push(self._ctxItem('move-up', 'arrow-up-alt2', S.moveUp));
            if (nodeIdx < pinBoundary - 1)                 sub.push(self._ctxItem('move-down', 'arrow-down-alt2', S.moveDown));
            if (nodeIdx < pinBoundary - 2)                 sub.push(self._ctxItem('move-bottom', 'arrow-down-alt', S.moveToBottom));
        } else {
            // Non-pinned: can only move within non-pinned group (pinBoundary .. end)
            const relIdx = nodeIdx - pinBoundary;
            const groupLen = siblings.length - pinBoundary;
            if (relIdx > 1)                                sub.push(self._ctxItem('move-top', 'arrow-up-alt', S.moveToTop));
            if (relIdx > 0)                                sub.push(self._ctxItem('move-up', 'arrow-up-alt2', S.moveUp));
            if (relIdx < groupLen - 1)                     sub.push(self._ctxItem('move-down', 'arrow-down-alt2', S.moveDown));
            if (relIdx < groupLen - 2)                     sub.push(self._ctxItem('move-bottom', 'arrow-down-alt', S.moveToBottom));
        }
        // Reparent: move into another folder (disabled with lock icon if locked)
        if (sub.length) sub.push({ sep: true });
        sub.push(self._ctxItem('move-to-folder', 'migrate', S.moveToFolder, false, isLocked, false, isLocked));
        return sub;
    };

    /**
     * Build a context descriptor for the context menu.
     * @param {object} opts Overrides (source, tree, postType, clipboard, setClipboard, refreshFn, $sidebar, $frame)
     */
    FO._ctxContext = function(opts) {
        const self = this;
        return $.extend({
            source: 'main',
            tree: self.tree,
            postType: self.data ? self.data.postType : 'attachment',
            clipboard: self._clipboard,
            setClipboard: function(val) { self._clipboard = val; },
            refreshFn: function() { self.refreshTree(); },
            $sidebar: null,
            $frame: null
        }, opts || {});
    };

    FO._ctxClickBound = false;

    /**
     * Register the unified click handler for context menu items (once only).
     */
    FO._bindCtxClickHandler = function() {
        if (this._ctxClickBound) return;
        this._ctxClickBound = true;
        const self = this;

        // Enter key triggers click on focused/hovered context menu item
        $(document).on('keydown', function(e) {
            if (e.keyCode !== 13) return;
            const $menu = $('#kookoawpf-context-menu');
            if (!$menu.is(':visible')) return;
            const $hovered = $menu.find('.kookoawpf-ctx-item:hover');
            if ($hovered.length) {
                e.preventDefault();
                $hovered.trigger('click');
            }
        });

        // Submenu repositioning when the main context menu is in clamp+scroll mode:
        // the `<ul>` has `overflow-y: auto`, which clips absolutely-positioned submenus
        // and also forces `overflow-x: auto` per spec (causing a horizontal scrollbar).
        // Fix: use `position: fixed` for the submenu so it escapes the clipping ancestor,
        // and compute its coordinates from the parent `<li>` bounding rect on hover.
        // Bound here (shared init) so it works in both main sidebar and media modal,
        // since `initContextMenu` only runs in the main sidebar path.
        $(document).on('mouseenter', '.kookoawpf-ctx-scroll .kookoawpf-ctx-has-sub', function() {
            const $li = $(this);
            const $sub = $li.children('.kookoawpf-ctx-submenu');
            if (!$sub.length) return;
            const rect = $li[0].getBoundingClientRect();
            const ww = $(window).width();
            const wh = $(window).height();
            // `position: fixed` with its own `z-index` creates a new global stacking
            // context, so we must raise it above the WP media modal (160000) and the
            // main context menu (200000) to stay visible in modal context.
            $sub.css({ position: 'fixed', top: '-9999px', left: '-9999px', right: 'auto', zIndex: 200001, display: 'block' });
            const sw = $sub.outerWidth();
            const sh = $sub.outerHeight();
            let top = rect.top - 6;
            let left = rect.right;
            if (left + sw > ww) left = Math.max(4, rect.left - sw);
            if (top + sh > wh) top = Math.max(4, wh - sh - 4);
            if (top < 4) top = 4;
            $sub.css({ top: top + 'px', left: left + 'px', display: '' });
        });
        $(document).on('mouseleave', '.kookoawpf-ctx-scroll .kookoawpf-ctx-has-sub', function() {
            $(this).children('.kookoawpf-ctx-submenu').css({ position: '', top: '', left: '', right: '', zIndex: '', display: '' });
        });

        $(document).on('click', '.kookoawpf-ctx-item', function(e) {
            if ($(this).hasClass('kookoawpf-ctx-has-sub')) { e.stopPropagation(); return; }
            if ($(this).hasClass('kookoawpf-ctx-disabled')) { e.stopPropagation(); return; }
            if ($(this).hasClass('kookoawpf-ctx-locked-delete')) { e.stopPropagation(); return; }
            const action = $(this).data('action');
            const $menu = $('#kookoawpf-context-menu');
            const fid = $menu.data('folder-id');
            const ctx = $menu.data('ctx') || self._ctxContext();
            const inst = ctx.tree ? ctx.tree.jstree(true) : null;
            const node = inst ? inst.get_node('folder_' + fid) : null;
            $menu.hide();
            // Clear context highlight from the appropriate tree
            if (ctx.source === 'modal') {
                ctx.tree.find('.kookoawpf-context-active').removeClass('kookoawpf-context-active');
            } else {
                $('#kookoawpf-jstree .kookoawpf-context-active').removeClass('kookoawpf-context-active');
            }
            $menu.removeData('ctx');
            self._executeContextAction(action, fid, node, ctx);
        });
    };

    /**
     * Reorder a folder (move-top/up/down/bottom) using a context.
     * Used by modal context when self.moveFolderPosition is not available.
     */
    FO._reorderFolder = function(ctx, node, action, fid) {
        const inst = ctx.tree ? ctx.tree.jstree(true) : null;
        if (!inst || !node) return;
        const parent = inst.get_node(node.parent);
        const sibs = parent ? parent.children.slice() : [];
        const idx = sibs.indexOf(node.id);
        if (idx === -1) return;

        const isPinned = node.data && node.data.pinned;

        // Compute pin boundary
        let pinBoundary = 0;
        for (let p = 0; p < sibs.length; p++) {
            const sn = inst.get_node(sibs[p]);
            if (sn && sn.data && sn.data.pinned) { pinBoundary = p + 1; } else { break; }
        }

        const groupStart = isPinned ? 0 : pinBoundary;
        const groupEnd = isPinned ? pinBoundary : sibs.length;

        // Compute new order within group bounds
        sibs.splice(idx, 1);
        switch (action) {
            case 'move-top':    sibs.splice(groupStart, 0, node.id); break;
            case 'move-up':     sibs.splice(Math.max(groupStart, idx - 1), 0, node.id); break;
            case 'move-down':   sibs.splice(Math.min(groupEnd - 1, idx + 1), 0, node.id); break;
            case 'move-bottom': sibs.splice(groupEnd - 1, 0, node.id); break;
        }
        const sibOrder = [];
        for (let i = 0; i < sibs.length; i++) {
            const sid = (sibs[i] || '').replace('folder_', '');
            if (sid) sibOrder.push(sid);
        }

        // Optimistic: reorder in jsTree immediately
        parent.children = sibs;
        inst.redraw_node(parent, true);
        // Drop animation (same as drag & drop)
        const $moved = $('#' + node.id);
        $moved.addClass('kookoawpf-just-moved');
        setTimeout(function() { $moved.removeClass('kookoawpf-just-moved'); }, 500);
        if (typeof KookoawpfMediaModal !== 'undefined') {
            const $mt = ctx.tree;
            if ($mt && $mt.length) KookoawpfMediaModal._applyModalFolderIcons($mt);
        }

        const parentId = (parent && parent.id !== '#') ? parent.id.replace('folder_', '') : '0';
        const isDynamic = node.data && node.data.is_dynamic;
        FO.ajax('kookoawpf_move_folder', {
            folder_id: fid, new_parent_id: parentId,
            post_type: ctx.postType, sibling_order: sibOrder
        }, function(r) {
            if (r.success) {
                FO.showNotice(isDynamic ? FO.data.strings.smartFolderMoved : FO.data.strings.folderMoved, 'success');
            } else {
                FO.showNotice((r.data && r.data.message) || FO.data.strings.errorGeneric, 'error');
                ctx.refreshFn(); // Revert on failure
            }
        });
    };

})(jQuery);
