import { state, getCurrentPageState } from '../state/index.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../components/shared/BaseModelPicker.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';

export class BulkManager {
    constructor() {
        this.bulkBtn = document.getElementById('bulkOperationsBtn');
        // Remove bulk panel references since we're using context menu now
        this.bulkContextMenu = null; // Will be set by core initialization

        // Marquee selection properties
        this.isMarqueeActive = false;
        this.isDragging = false;
        this.marqueeStart = { x: 0, y: 0 };
        this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
        this.marqueeElement = null;
        this.initialSelectedModels = new Set();

        // Shift+click range anchor: last plain-clicked filepath. Set in
        // toggleCardSelection, cleared in clearSelection.
        this.bulkAnchorFilepath = null;

        // Bulk base model picker state
        this.bulkBaseModelPicker = null;
        this.bulkBaseModelValue = '';

        // Drag detection properties
        this.dragThreshold = 5; // Pixels to move before considering it a drag
        this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
        this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
        this.mouseDownTime = 0;
        this.mouseDownPosition = { x: 0, y: 0 };

        // Auto-scroll properties for marquee
        this.lastClientX = 0;
        this.lastClientY = 0;
        this.autoScrollRaf = null;

        // Model type specific action configurations
        this.actionConfig = {
            [MODEL_TYPES.LORA]: {
                addTags: true,
                sendToWorkflow: true,
                copyAll: true,
                refreshAll: true,
                checkUpdates: true,
                moveAll: true,
                autoOrganize: true,
                deleteAll: true,
                setContentRating: true,
                skipMetadataRefresh: true,
                setFavorite: true,
                unfavorite: true
            },
            [MODEL_TYPES.EMBEDDING]: {
                addTags: true,
                sendToWorkflow: true,
                copyAll: false,
                refreshAll: true,
                checkUpdates: true,
                moveAll: true,
                autoOrganize: true,
                deleteAll: true,
                setContentRating: false,
                skipMetadataRefresh: true,
                setFavorite: true,
                unfavorite: true
            },
            [MODEL_TYPES.CHECKPOINT]: {
                addTags: true,
                sendToWorkflow: false,
                copyAll: false,
                refreshAll: true,
                checkUpdates: true,
                moveAll: false,
                autoOrganize: true,
                deleteAll: true,
                setContentRating: true,
                skipMetadataRefresh: true,
                setFavorite: true,
                unfavorite: true
            },
            recipes: {
                addTags: true,
                sendToWorkflow: false,
                copyAll: false,
                refreshAll: false,
                checkUpdates: false,
                moveAll: true,
                autoOrganize: false,
                deleteAll: true,
                setContentRating: true,
                skipMetadataRefresh: false,
                setFavorite: true,
                unfavorite: true,
                repairMetadata: true,
                reimportMetadata: true,
                rematchMetadata: true
            }
        };

        this.recipeApiClient = null;

        window.addEventListener('lm:priority-tags-updated', () => {
            const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
            if (!container) {
                return;
            }
            const currentType = state.currentPageType;
            if (!currentType || currentType === 'recipes') {
                return;
            }
            getPriorityTagSuggestions(currentType).then((tags) => {
                if (!container.isConnected) {
                    return;
                }
                this.renderBulkSuggestionItems(container, tags);
                this.updateBulkSuggestionsDropdown();
            }).catch(() => {
                // Ignore refresh failures; UI will retry on next open
            });
        });
    }

    initialize() {
        // Register with event manager for coordinated event handling
        this.registerEventHandlers();

        // Initialize bulk mode state in event manager
        eventManager.setState('bulkMode', state.bulkMode || false);
    }

    getActiveApiClient() {
        if (state.currentPageType === 'recipes') {
            if (!this.recipeApiClient) {
                this.recipeApiClient = new RecipeSidebarApiClient();
            }
            return this.recipeApiClient;
        }
        return getModelApiClient();
    }

    getCurrentDisplayConfig() {
        if (state.currentPageType === 'recipes') {
            return { displayName: 'Recipe' };
        }
        return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' };
    }

    setBulkContextMenu(bulkContextMenu) {
        this.bulkContextMenu = bulkContextMenu;
    }

    /**
     * Register all event handlers with the centralized event manager
     */
    registerEventHandlers() {
        // Register keyboard shortcuts with high priority
        eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
            return this.handleGlobalKeyboard(e);
        }, {
            priority: 100,
            skipWhenModalOpen: true
        });

        // Register marquee selection events
        eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
            return this.handleMarqueeStart(e);
        }, {
            priority: 80,
            skipWhenModalOpen: true,
            targetSelector: '.page-content',
            excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu',
            button: 0 // Left mouse button only
        });

        eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
            // Only track marquee/drag while the left button is physically held.
            // mouseup can be missed (release outside the window, focus loss, driver quirks),
            // so mousemove must verify the button state itself instead of relying on it.
            if (!(e.buttons & 1)) {
                if (this.isMarqueeActive) {
                    this.endMarqueeSelection(e);
                } else {
                    this.mouseDownTime = 0;
                    this.isDragging = false;
                }
                return false;
            }

            if (this.isMarqueeActive) {
                this.lastClientX = e.clientX;
                this.lastClientY = e.clientY;
                this.updateMarqueeSelection(e);
                this.startAutoScroll();
            } else if (this.mouseDownTime && !this.isDragging) {
                // Check if we've moved enough to consider it a drag
                const dx = e.clientX - this.mouseDownPosition.x;
                const dy = e.clientY - this.mouseDownPosition.y;
                const distance = Math.sqrt(dx * dx + dy * dy);

                // Require both enough movement AND enough hold time so quick
                // click jitter from micro-movement input devices is not a marquee.
                const heldTime = Date.now() - this.mouseDownTime;
                if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
                    this.isDragging = true;
                    this.startMarqueeSelection(e, true);
                }
            }
        }, {
            priority: 90,
            skipWhenModalOpen: true
        });

        eventManager.addHandler('mouseup', 'bulkManager-marquee-end', (e) => {
            if (this.isMarqueeActive) {
                this.endMarqueeSelection(e);
                return true; // Stop propagation
            }

            // Reset drag detection if we had a mousedown but didn't drag
            if (this.mouseDownTime) {
                this.mouseDownTime = 0;
                return false; // Allow other handlers to process the click
            }
        }, {
            priority: 90
        });

        eventManager.addHandler('contextmenu', 'bulkManager-marquee-prevent', (e) => {
            if (this.isMarqueeActive) {
                e.preventDefault();
                return true; // Stop propagation
            }
        }, {
            priority: 100
        });

        // Modified: Clear selection and exit bulk mode on left-click page-content blank area
        // Lower priority to avoid interfering with context menu interactions
        eventManager.addHandler('mousedown', 'bulkManager-clear-on-blank', (e) => {
            // Only handle left mouse button
            if (e.button !== 0) return false;
            // Only if in bulk mode and there are selected models
            if (state.bulkMode && state.selectedModels && state.selectedModels.size > 0) {
                // Check if click is on blank area (not on a model card or excluded elements)
                this.clearSelection();
                this.toggleBulkMode();
                // Prevent further handling
                return true;
            }
            return false;
        }, {
            priority: 70, // Lower priority to let context menu events process first
            onlyInBulkMode: true,
            skipWhenModalOpen: true,
            targetSelector: '.page-content',
            excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu, .context-menu *',
            button: 0 // Left mouse button only
        });
    }

    /**
     * Clean up event handlers
     */
    cleanup() {
        this.stopAutoScroll();
        eventManager.removeAllHandlersForSource('bulkManager-keyboard');
        eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
        eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
        eventManager.removeAllHandlersForSource('bulkManager-marquee-end');
        eventManager.removeAllHandlersForSource('bulkManager-marquee-prevent');
        eventManager.removeAllHandlersForSource('bulkManager-clear-on-blank');
    }

    /**
     * Handle global keyboard events through the event manager
     */
    handleGlobalKeyboard(e) {
        // Skip if modal is open (handled by event manager conditions)
        if (this.isEditingTextInputContext(e.target)) {
            return false; // Don't handle, allow default behavior
        }

        if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
            e.preventDefault();
            if (!state.bulkMode) {
                this.toggleBulkMode();
                setTimeout(() => this.selectAllVisibleModels(), 50);
            } else {
                this.selectAllVisibleModels();
            }
            return true; // Stop propagation
        } else if (e.key === 'Escape' && state.bulkMode) {
            this.toggleBulkMode();
            return true; // Stop propagation
        } else if (e.key.toLowerCase() === 'b') {
            this.toggleBulkMode();
            return true; // Stop propagation
        }

        return false; // Continue with other handlers
    }

    isEditingTextInputContext(target) {
        const activeElement = document.activeElement;
        const candidate = target instanceof Element ? target : activeElement;
        if (!candidate) {
            return false;
        }

        const tagName = candidate.tagName?.toLowerCase();
        if (
            candidate.isContentEditable
            || tagName === 'input'
            || tagName === 'textarea'
            || tagName === 'select'
        ) {
            return true;
        }

        return Boolean(candidate.closest?.('#filterPanel'));
    }

    toggleBulkMode() {
        state.bulkMode = !state.bulkMode;

        // Update event manager state
        eventManager.setState('bulkMode', state.bulkMode);

        if (this.bulkBtn) {
            this.bulkBtn.classList.toggle('active', state.bulkMode);
        }

        updateCardsForBulkMode(state.bulkMode);

        if (!state.bulkMode) {
            this.clearSelection();

            // Hide context menu when exiting bulk mode
            if (this.bulkContextMenu) {
                this.bulkContextMenu.hideMenu();
            }
        }
    }

    clearSelection() {
        document.querySelectorAll('.model-card.selected').forEach(card => {
            card.classList.remove('selected');
        });
        state.selectedModels.clear();
        this.bulkAnchorFilepath = null;

        // Update context menu header if visible
        if (this.bulkContextMenu) {
            this.bulkContextMenu.updateSelectedCountHeader();
        }
    }

    toggleCardSelection(card, extendSelection = false) {
        const filepath = card.dataset.filepath;

        if (extendSelection && this.selectRangeFromAnchor(filepath)) {
            return;
        }

        if (card.classList.contains('selected')) {
            card.classList.remove('selected');
            state.selectedModels.delete(filepath);
        } else {
            card.classList.add('selected');
            state.selectedModels.add(filepath);

            // Cache the metadata for this model
            this.updateMetadataCacheFromCard(filepath, card);
        }

        this.bulkAnchorFilepath = filepath;

        // Update context menu header if visible
        if (this.bulkContextMenu) {
            this.bulkContextMenu.updateSelectedCountHeader();
        }
    }

    /**
     * Select exactly the items between the shift anchor and the target
     * (inclusive), following list order. Explorer-style range semantics:
     * selections outside the new range are dropped, and consecutive shifts
     * re-derive the range from the same anchor. Returns false when there is
     * no usable anchor so the caller can fall back to a single-card toggle.
     */
    selectRangeFromAnchor(targetFilepath) {
        const scroller = state.virtualScroller;
        if (!scroller || !scroller.items || !this.bulkAnchorFilepath) {
            return false;
        }

        const anchorIndex = scroller.findIndexByFilePath(this.bulkAnchorFilepath);
        const targetIndex = scroller.findIndexByFilePath(targetFilepath);
        if (anchorIndex === -1 || targetIndex === -1) {
            return false;
        }

        const startIndex = Math.min(anchorIndex, targetIndex);
        const endIndex = Math.max(anchorIndex, targetIndex);
        const metadataCache = this.getMetadataCache();
        const rangePaths = new Set();

        for (let i = startIndex; i <= endIndex; i++) {
            const item = scroller.items[i];
            if (!item || !item.file_path) {
                continue;
            }

            rangePaths.add(item.file_path);

            if (!metadataCache.has(item.file_path)) {
                const modelId = this.parseModelId(item?.civitai?.modelId);
                metadataCache.set(item.file_path, {
                    fileName: item.file_name,
                    folder: item.folder || '',
                    usageTips: item.usage_tips || '{}',
                    modelName: item.name || item.file_name,
                    ...(modelId !== null ? { modelId } : {})
                });
            }

            state.selectedModels.add(item.file_path);
        }

        for (const filepath of [...state.selectedModels]) {
            if (!rangePaths.has(filepath)) {
                state.selectedModels.delete(filepath);
            }
        }
        this.applySelectionState();

        if (this.bulkContextMenu) {
            this.bulkContextMenu.updateSelectedCountHeader();
        }

        if (this.isStripVisible) {
            this.updateThumbnailStrip();
        }

        return true;
    }

    getMetadataCache() {
        const currentType = state.currentPageType;
        const pageState = getCurrentPageState();

        // Initialize metadata cache if it doesn't exist
        if (currentType === MODEL_TYPES.LORA) {
            if (!state.loraMetadataCache) {
                state.loraMetadataCache = new Map();
            }
            return state.loraMetadataCache;
        } else {
            if (!pageState.metadataCache) {
                pageState.metadataCache = new Map();
            }
            return pageState.metadataCache;
        }
    }

    parseModelId(value) {
        if (value === undefined || value === null || value === '') {
            return null;
        }

        const parsed = Number.parseInt(value, 10);
        return Number.isNaN(parsed) ? null : parsed;
    }

    updateMetadataCacheFromCard(filepath, card) {
        if (!card) {
            return;
        }

        const metadataCache = this.getMetadataCache();
        const existing = metadataCache.get(filepath) || {};
        const modelId = this.parseModelId(card.dataset.modelId);

        const updated = {
            ...existing,
            fileName: card.dataset.file_name ?? existing.fileName,
            folder: card.dataset.folder ?? existing.folder,
            usageTips: card.dataset.usage_tips ?? existing.usageTips,
            modelName: card.dataset.name ?? existing.modelName,
        };

        if (modelId !== null) {
            updated.modelId = modelId;
        }

        metadataCache.set(filepath, updated);
    }

    escapeAttributeValue(value) {
        if (value === undefined || value === null) {
            return '';
        }

        return String(value)
            .replace(/\\/g, '\\\\')
            .replace(/"/g, '\\"');
    }

    getModelIdForFilePath(filePath) {
        const metadataCache = this.getMetadataCache();
        const cached = metadataCache.get(filePath);
        if (cached && typeof cached.modelId === 'number') {
            return cached.modelId;
        }

        const escapedPath = this.escapeAttributeValue(filePath);
        const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
        if (!card) {
            return null;
        }

        this.updateMetadataCacheFromCard(filePath, card);
        const updated = metadataCache.get(filePath);
        return updated && typeof updated.modelId === 'number' ? updated.modelId : null;
    }

    collectSelectedModelIds() {
        const metadataCache = this.getMetadataCache();
        const ids = [];
        let missingCount = 0;

        for (const filepath of state.selectedModels) {
            const cached = metadataCache.get(filepath);
            let modelId = cached && typeof cached.modelId === 'number' ? cached.modelId : null;
            if (modelId === null) {
                modelId = this.getModelIdForFilePath(filepath);
            }

            if (typeof modelId === 'number') {
                ids.push(modelId);
            } else {
                missingCount++;
            }
        }

        const uniqueIds = Array.from(new Set(ids));
        return { ids: uniqueIds, missingCount };
    }

    applySelectionState() {
        if (!state.bulkMode) return;

        document.querySelectorAll('.model-card').forEach(card => {
            const filepath = card.dataset.filepath;
            if (state.selectedModels.has(filepath)) {
                card.classList.add('selected');

                this.updateMetadataCacheFromCard(filepath, card);
            } else {
                card.classList.remove('selected');
            }
        });
    }

    async copyAllModelsSyntax() {
        if (state.currentPageType !== MODEL_TYPES.LORA) {
            showToast('toast.loras.copyOnlyForLoras', {}, 'warning');
            return;
        }

        if (state.selectedModels.size === 0) {
            showToast('toast.loras.noLorasSelected', {}, 'warning');
            return;
        }

        const loraSyntaxes = [];
        const missingLoras = [];
        const metadataCache = this.getMetadataCache();

        for (const filepath of state.selectedModels) {
            const metadata = metadataCache.get(filepath);

            if (metadata) {
                const usageTips = JSON.parse(metadata.usageTips || '{}');
                const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
                loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
            } else {
                missingLoras.push(filepath);
            }
        }

        if (missingLoras.length > 0) {
            console.warn('Missing metadata for some selected loras:', missingLoras);
            showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
        }

        if (loraSyntaxes.length === 0) {
            showToast('toast.loras.noValidLorasToCopy', {}, 'error');
            return;
        }

        await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
    }

    async sendAllModelsToWorkflow(replaceMode = false) {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        if (state.currentPageType === MODEL_TYPES.EMBEDDING) {
            return this._sendAllEmbeddingsToWorkflow();
        }

        if (state.currentPageType !== MODEL_TYPES.LORA) {
            showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
            return;
        }

        const loraSyntaxes = [];
        const missingLoras = [];
        const metadataCache = this.getMetadataCache();

        for (const filepath of state.selectedModels) {
            const metadata = metadataCache.get(filepath);

            if (metadata) {
                const usageTips = JSON.parse(metadata.usageTips || '{}');
                const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
                loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
            } else {
                missingLoras.push(filepath);
            }
        }

        if (missingLoras.length > 0) {
            console.warn('Missing metadata for some selected loras:', missingLoras);
            showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
        }

        if (loraSyntaxes.length === 0) {
            showToast('toast.loras.noValidLorasToSend', {}, 'error');
            return;
        }

        const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
        await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
    }

    async _sendAllEmbeddingsToWorkflow() {
        const embeddingCodes = [];
        for (const filepath of state.selectedModels) {
            const escapedPath = CSS.escape(filepath);
            const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
            if (card) {
                const folder = card.dataset.folder || '';
                const name = card.dataset.file_name || '';
                const code = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
                embeddingCodes.push(code);
            }
        }

        if (embeddingCodes.length === 0) {
            showToast('No valid embedding data found', {}, 'warning');
            return;
        }

        const joinedCode = embeddingCodes.join(', ');
        const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
        await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
    }

    showBulkDeleteModal() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const count = state.selectedModels.size;
        const isRecipes = state.currentPageType === 'recipes';
        const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';

        const titleEl = document.querySelector('#bulkDeleteModal h2');
        if (titleEl) {
            titleEl.textContent = translate(`${keyPrefix}.title`);
        }

        const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
        if (messageEl) {
            messageEl.textContent = translate(`${keyPrefix}.message`);
        }

        const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
        if (countInfoEl) {
            countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
        }

        modalManager.showModal('bulkDeleteModal');
    }

    async confirmBulkDelete() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            modalManager.closeModal('bulkDeleteModal');
            return;
        }

        modalManager.closeModal('bulkDeleteModal');

        try {
            const apiClient = this.getActiveApiClient();
            const filePaths = Array.from(state.selectedModels);

            const result = await apiClient.bulkDeleteModels(filePaths);

            if (result?.cancelled) {
                showToast('toast.api.operationCancelled', {}, 'info');
            } else if (result.success) {
                const currentConfig = this.getCurrentDisplayConfig();
                const isRecipes = state.currentPageType === 'recipes';
                const refreshFn = isRecipes
                    ? () => window.recipeManager.loadRecipes(true)
                    : () => resetAndReload(true);

                if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
                    // One undo action for the whole bulk action — the backend
                    // merges staged per-file batches into a single batch, with
                    // a batch_ids fallback array when the merge failed
                    const onAction = result.batch_id
                        ? () => handleUndoDelete(result.batch_id, refreshFn)
                        : async () => {
                            for (const id of result.batch_ids) {
                                const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
                                if (!succeeded) {
                                    showToast('toast.undo.failed', { error: '' }, 'error');
                                    return;
                                }
                            }
                            refreshFn();
                            showToast('toast.undo.restored', {}, 'success');
                        };
                    showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
                        actionText: translate('toast.undo.action'),
                        onAction,
                    });
                } else {
                    showToast('toast.models.deletedSuccessfully', {
                        count: result.deleted_count,
                        type: currentConfig.displayName.toLowerCase()
                    }, 'success');
                }

                filePaths.forEach(path => {
                    state.virtualScroller.removeItemByFilePath(path);
                });
                if (state.bulkMode) this.toggleBulkMode();

                if (window.modelDuplicatesManager) {
                    window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
                }
            } else {
                showToast('toast.models.deleteFailed', { error: result.error || 'Failed to delete models' }, 'error');
            }
        } catch (error) {
            console.error('Error during bulk delete:', error);
            showToast('toast.models.deleteFailedGeneral', {}, 'error');
        }
    }

    deselectItem(filepath) {
        const escapedPath = this.escapeAttributeValue(filepath);
        const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
        if (card) {
            card.classList.remove('selected');
        }

        state.selectedModels.delete(filepath);
    }

    selectAllVisibleModels() {
        if (!state.virtualScroller || !state.virtualScroller.items) {
            showToast('toast.bulk.unableToSelectAll', {}, 'error');
            return;
        }

        const oldCount = state.selectedModels.size;
        const metadataCache = this.getMetadataCache();

        state.virtualScroller.items.forEach(item => {
            if (item && item.file_path) {
                state.selectedModels.add(item.file_path);

                if (!metadataCache.has(item.file_path)) {
                    const modelId = this.parseModelId(item?.civitai?.modelId);
                    metadataCache.set(item.file_path, {
                        fileName: item.file_name,
                        folder: item.folder || '',
                        usageTips: item.usage_tips || '{}',
                        modelName: item.name || item.file_name,
                        ...(modelId !== null ? { modelId } : {})
                    });
                }
            }
        });

        this.applySelectionState();

        const newlySelected = state.selectedModels.size - oldCount;
        const currentConfig = this.getCurrentDisplayConfig();
        showToast('toast.models.selectedAdditional', {
            count: newlySelected,
            type: currentConfig.displayName.toLowerCase()
        }, 'success');

        if (this.isStripVisible) {
            this.updateThumbnailStrip();
        }
    }

    async reimportSelectedRecipes() {
        if (state.selectedModels.size === 0) {
            showToast('toast.recipes.noRecipesSelected', {}, 'warning');
            return;
        }

        if (state.currentPageType !== 'recipes') {
            showToast('This operation is only available for recipes', {}, 'warning');
            return;
        }

        const filePaths = Array.from(state.selectedModels);
        const total = filePaths.length;
        let completed = 0;
        let failed = 0;

        const recipeMap = new Map();
        if (state.virtualScroller?.items) {
            for (const item of state.virtualScroller.items) {
                if (item.file_path && item.id) {
                    recipeMap.set(item.file_path, item);
                }
            }
        }

        const progressUI = state.loadingManager.showEnhancedProgress(
            `Re-importing recipe 1/${total}...`
        );

        try {
            for (let i = 0; i < filePaths.length; i++) {
                const filePath = filePaths[i];
                const recipeItem = recipeMap.get(filePath);
                const recipeId = recipeItem?.id;
                const recipeName = recipeItem?.title || recipeId || 'Unknown';

                progressUI.updateProgress(
                    Math.floor((i / total) * 100),
                    recipeName,
                    `Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
                );

                if (!recipeId) {
                    failed++;
                    continue;
                }

                try {
                    const response = await fetch(
                        `/api/lm/recipe/${recipeId}/reimport`,
                        { method: 'POST' }
                    );
                    const result = await response.json();
                    if (result.success) {
                        completed++;
                    } else {
                        failed++;
                    }
                } catch {
                    failed++;
                }
            }
            if (completed > 0) {
                await progressUI.complete(
                    `Re-import complete: ${completed} re-imported, ${failed} failed`
                );
                const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
                this.clearSelection();
                if (state.bulkMode) this.toggleBulkMode();
                recipeResetAndReload(false, { preserveScroll: false });
            } else {
                state.loadingManager.hide();
                showToast('toast.recipes.reimportBulkFailed', {}, 'error');
            }
        } catch (error) {
            console.error('[reimportSelectedRecipes] outer catch:', error);
            state.loadingManager.hide();
            showToast('toast.recipes.reimportBulkFailed', {}, 'error');
        }
    }

    async repairSelectedRecipes() {
        if (state.selectedModels.size === 0) {
            showToast('toast.recipes.noRecipesSelected', {}, 'warning');
            return;
        }

        if (state.currentPageType !== 'recipes') {
            showToast('This operation is only available for recipes', {}, 'warning');
            return;
        }

        try {
            const apiClient = this.getActiveApiClient();
            const filePaths = Array.from(state.selectedModels);

            if (typeof apiClient.repairBulkModels !== 'function') {
                showToast('Bulk repair is not supported for this model type', {}, 'error');
                return;
            }

            state.loadingManager.showSimpleLoading('Repairing recipe metadata...');

            const result = await apiClient.repairBulkModels(filePaths);

            if (result.success) {
                const total = result.total || filePaths.length;
                const repaired = result.repaired || 0;
                const skipped = result.skipped || 0;

                const recipes = result.recipes || [];
                for (const recipe of recipes) {
                    if (recipe.file_path) {
                        state.virtualScroller.updateSingleItem(
                            recipe.file_path,
                            recipe
                        );
                    }
                }

                if (repaired > 0) {
                    showToast(
                        'toast.recipes.repairBulkComplete',
                        { repaired, skipped, total },
                        'success'
                    );
                } else {
                    showToast(
                        'toast.recipes.repairBulkSkipped',
                        { total },
                        'info'
                    );
                }

                if (state.bulkMode) this.toggleBulkMode();
            } else {
                throw new Error(result.error || 'Bulk repair failed');
            }
        } catch (error) {
            console.error('Error during bulk recipe repair:', error);
            showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
        } finally {
            if (state.loadingManager?.hide) {
                state.loadingManager.hide();
            }
            if (typeof state.loadingManager?.restoreProgressBar === 'function') {
                state.loadingManager.restoreProgressBar();
            }
        }
    }

    async rematchSelectedRecipes() {
        if (state.selectedModels.size === 0) {
            showToast('toast.recipes.noRecipesSelected', {}, 'warning');
            return;
        }

        if (state.currentPageType !== 'recipes') {
            showToast('This operation is only available for recipes', {}, 'warning');
            return;
        }

        try {
            const apiClient = this.getActiveApiClient();
            const filePaths = Array.from(state.selectedModels);

            if (typeof apiClient.rematchBulkModels !== 'function') {
                showToast('Bulk rematch is not supported for this model type', {}, 'error');
                return;
            }

            state.loadingManager.showSimpleLoading('Rematching recipes to local models...');

            const result = await apiClient.rematchBulkModels(filePaths);

            if (result.success) {
                const total = result.total || filePaths.length;
                // Unified counters from the backend; legacy fields fall back
                // for older backends: `rematched` (entry count) for
                // matched_entries, `total` (selection size) for
                // matched_recipes.
                const rematched = result.rematched || 0;
                const skipped = result.skipped || 0;
                const matchedRecipes = result.matched_recipes || result.total || 0;
                const matchedEntries = result.matched_entries || rematched;
                const failures = result.errors || 0;
                const unresolvedEntries = result.unresolved_entries || 0;
                const unresolvedRecipes = result.unresolved_recipes || 0;

                const recipes = result.recipes || [];
                for (const recipe of recipes) {
                    if (recipe.file_path) {
                        state.virtualScroller.updateSingleItem(
                            recipe.file_path,
                            recipe
                        );
                    }
                }

                if (matchedEntries > 0) {
                    const hasFailures = failures > 0;
                    const toastKey = hasFailures
                        ? 'toast.recipes.rematchCompleteErrors'
                        : 'toast.recipes.rematchComplete';
                    showToast(
                        toastKey,
                        { rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
                        hasFailures ? 'warning' : 'success'
                    );
                } else if (failures > 0) {
                    // Nothing matched and at least one recipe errored —
                    // "no rematch needed" would be actively misleading here.
                    showToast(
                        'toast.recipes.rematchAllFailed',
                        { total, failures },
                        'error'
                    );
                } else if (unresolvedEntries > 0) {
                    // Entries existed but have no local model — expected for
                    // models deleted from Civitai; informational, not an error.
                    showToast(
                        'toast.recipes.rematchUnmatched',
                        { entries: unresolvedEntries, recipes: unresolvedRecipes, total },
                        'info'
                    );
                } else {
                    showToast(
                        'toast.recipes.rematchSkipped',
                        { total },
                        'info'
                    );
                }

                if (state.bulkMode) this.toggleBulkMode();
            } else {
                throw new Error(result.error || 'Bulk rematch failed');
            }
        } catch (error) {
            console.error('Error during bulk recipe rematch:', error);
            showToast('toast.recipes.rematchFailed', { message: error.message }, 'error');
        } finally {
            if (state.loadingManager?.hide) {
                state.loadingManager.hide();
            }
            if (typeof state.loadingManager?.restoreProgressBar === 'function') {
                state.loadingManager.restoreProgressBar();
            }
        }
    }

    async refreshAllMetadata() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        try {
            const apiClient = getModelApiClient();
            const filePaths = Array.from(state.selectedModels);

            const result = await apiClient.refreshBulkModelMetadata(filePaths);

            if (result.success) {
                const metadataCache = this.getMetadataCache();
                for (const filepath of state.selectedModels) {
                    const metadata = metadataCache.get(filepath);
                    if (metadata) {
                        const escapedPath = this.escapeAttributeValue(filepath);
                        const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
                        if (card) {
                            this.updateMetadataCacheFromCard(filepath, card);
                        }
                    }
                }

                if (this.isStripVisible) {
                    this.updateThumbnailStrip();
                }

                if (state.bulkMode) this.toggleBulkMode();
            }

        } catch (error) {
            console.error('Error during bulk metadata refresh:', error);
            showToast('toast.models.refreshMetadataFailed', {}, 'error');
        }
    }

    async checkUpdatesForSelectedModels() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const currentConfig = this.getCurrentDisplayConfig();
        const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();

        const { ids: modelIds, missingCount } = this.collectSelectedModelIds();

        if (modelIds.length === 0) {
            showToast('toast.models.bulkUpdatesMissing', { type: typeLabel }, 'warning');
            return;
        }

        if (missingCount > 0) {
            showToast('toast.models.bulkUpdatesPartialMissing', { missing: missingCount, type: typeLabel }, 'info');
        }

        const apiClient = getModelApiClient();
        if (!apiClient || typeof apiClient.refreshUpdatesForModels !== 'function') {
            console.warn('Model API client does not support refreshUpdatesForModels');
            showToast('toast.models.bulkUpdatesFailed', { type: typeLabel, message: 'Operation not supported' }, 'error');
            return;
        }

        const loadingMessage = translate(
            'toast.models.bulkUpdatesChecking',
            { count: state.selectedModels.size, type: typeLabel },
            `Checking selected ${typeLabel}(s) for updates...`
        );
        state.loadingManager?.showSimpleLoading?.(loadingMessage);

        try {
            const response = await apiClient.refreshUpdatesForModels(modelIds);
            const records = Array.isArray(response?.records) ? response.records : [];
            const updatesCount = records.length;

            if (updatesCount > 0) {
                showToast('toast.models.bulkUpdatesSuccess', { count: updatesCount, type: typeLabel }, 'success');
            } else {
                showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
            }

            if (state.bulkMode) this.toggleBulkMode();
            await resetAndReload(false);
        } catch (error) {
            console.error('Error checking updates for selected models:', error);
            showToast(
                'toast.models.bulkUpdatesFailed',
                { type: typeLabel, message: error?.message ?? 'Unknown error' },
                'error'
            );
        } finally {
            if (state.loadingManager?.hide) {
                state.loadingManager.hide();
            }
            if (typeof state.loadingManager?.restoreProgressBar === 'function') {
                state.loadingManager.restoreProgressBar();
            }
        }
    }

    showBulkAddTagsModal() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const countElement = document.getElementById('bulkAddTagsCount');
        if (countElement) {
            countElement.textContent = state.selectedModels.size;
        }

        // Clear any existing tags in the modal
        const tagsContainer = document.getElementById('bulkTagsItems');
        if (tagsContainer) {
            tagsContainer.innerHTML = '';
        }

        modalManager.showModal('bulkAddTagsModal', null, null, () => {
            // Cleanup when modal is closed
            this.cleanupBulkAddTagsModal();
        });

        // Initialize the bulk tags editing interface
        this.initializeBulkTagsInterface();
    }

    initializeBulkTagsInterface() {
        // Setup tag input behavior
        const tagInput = document.querySelector('.bulk-metadata-input');
        if (tagInput) {
            tagInput.focus();
            tagInput.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    e.preventDefault();
                    this.addBulkTag(e.target.value.trim());
                    e.target.value = '';
                    // Update dropdown to show added indicator
                    this.updateBulkSuggestionsDropdown();
                }
            });
        }

        // Create suggestions dropdown
        const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
        if (tagForm) {
            const suggestionsDropdown = this.createBulkSuggestionsDropdown();
            tagForm.appendChild(suggestionsDropdown);
        }

        // Setup save button
        const appendBtn = document.querySelector('.bulk-append-tags-btn');
        const replaceBtn = document.querySelector('.bulk-replace-tags-btn');

        if (appendBtn) {
            appendBtn.addEventListener('click', () => {
                this.saveBulkTags('append');
            });
        }

        if (replaceBtn) {
            replaceBtn.addEventListener('click', () => {
                this.saveBulkTags('replace');
            });
        }
    }

    createBulkSuggestionsDropdown() {
        const dropdown = document.createElement('div');
        dropdown.className = 'metadata-suggestions-dropdown';

        const header = document.createElement('div');
        header.className = 'metadata-suggestions-header';
        header.innerHTML = `
            <span>Suggested Tags</span>
            <small>Click to add</small>
        `;
        dropdown.appendChild(header);

        const container = document.createElement('div');
        container.className = 'metadata-suggestions-container';
        container.innerHTML = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;

        const currentType = state.currentPageType;
        if (!currentType || currentType === 'recipes') {
            container.innerHTML = '';
        } else {
            getPriorityTagSuggestions(currentType).then((tags) => {
                if (!container.isConnected) {
                    return;
                }
                this.renderBulkSuggestionItems(container, tags);
                this.updateBulkSuggestionsDropdown();
            }).catch(() => {
                if (container.isConnected) {
                    container.innerHTML = '';
                }
            });
        }

        dropdown.appendChild(container);
        return dropdown;
    }

    renderBulkSuggestionItems(container, tags) {
        container.innerHTML = '';

        tags.forEach(tag => {
            const existingTags = this.getBulkExistingTags();
            const isAdded = existingTags.includes(tag);

            const item = document.createElement('div');
            item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
            item.title = tag;
            item.innerHTML = `
                <span class="metadata-suggestion-text">${tag}</span>
                ${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
            `;

            if (!isAdded) {
                item.addEventListener('click', () => {
                    this.addBulkTag(tag);
                    const input = document.querySelector('.bulk-metadata-input');
                    if (input) {
                        input.value = tag;
                        input.focus();
                    }
                    this.updateBulkSuggestionsDropdown();
                });
            }

            container.appendChild(item);
        });
    }

    addBulkTag(tag) {
        tag = tag.trim().toLowerCase();
        if (!tag) return;

        const tagsContainer = document.getElementById('bulkTagsItems');
        if (!tagsContainer) return;

        // Validation: Check length
        if (tag.length > 30) {
            showToast('modelTags.validation.maxLength', {}, 'error');
            return;
        }

        // Validation: Check total number
        const currentTags = tagsContainer.querySelectorAll('.metadata-item');
        if (currentTags.length >= 30) {
            showToast('modelTags.validation.maxCount', {}, 'error');
            return;
        }

        // Validation: Check for duplicates
        const existingTags = Array.from(currentTags).map(tagEl => tagEl.dataset.tag);
        if (existingTags.includes(tag)) {
            showToast('modelTags.validation.duplicate', {}, 'error');
            return;
        }

        // Create new tag
        const newTag = document.createElement('div');
        newTag.className = 'metadata-item';
        newTag.dataset.tag = tag;
        newTag.innerHTML = `
            <span class="metadata-item-content">${tag}</span>
            <button class="metadata-delete-btn">
                <i class="fas fa-times"></i>
            </button>
        `;

        // Add delete button event listener
        const deleteBtn = newTag.querySelector('.metadata-delete-btn');
        deleteBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            newTag.remove();
            // Update dropdown to show/hide added indicator
            this.updateBulkSuggestionsDropdown();
        });

        tagsContainer.appendChild(newTag);
    }

    /**
     * Get existing tags in the bulk tags container
     * @returns {Array} Array of existing tag strings
     */
    getBulkExistingTags() {
        const tagsContainer = document.getElementById('bulkTagsItems');
        if (!tagsContainer) return [];

        const currentTags = tagsContainer.querySelectorAll('.metadata-item');
        return Array.from(currentTags).map(tag => tag.dataset.tag);
    }

    /**
     * Update status of items in the bulk suggestions dropdown
     */
    updateBulkSuggestionsDropdown() {
        const dropdown = document.querySelector('.metadata-suggestions-dropdown');
        if (!dropdown) return;

        // Get all current tags
        const existingTags = this.getBulkExistingTags();

        // Update status of each item in dropdown
        dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
            const tagText = item.querySelector('.metadata-suggestion-text').textContent;
            const isAdded = existingTags.includes(tagText);

            if (isAdded) {
                item.classList.add('already-added');

                // Add indicator if it doesn't exist
                let indicator = item.querySelector('.added-indicator');
                if (!indicator) {
                    indicator = document.createElement('span');
                    indicator.className = 'added-indicator';
                    indicator.innerHTML = '<i class="fas fa-check"></i>';
                    item.appendChild(indicator);
                }

                // Remove click event
                item.onclick = null;
                item.removeEventListener('click', item._clickHandler);
            } else {
                // Re-enable items that are no longer in the list
                item.classList.remove('already-added');

                // Remove indicator if it exists
                const indicator = item.querySelector('.added-indicator');
                if (indicator) indicator.remove();

                // Restore click event if not already set
                if (!item._clickHandler) {
                    item._clickHandler = () => {
                        this.addBulkTag(tagText);
                        const input = document.querySelector('.bulk-metadata-input');
                        if (input) {
                            input.value = tagText;
                            input.focus();
                        }
                        // Update dropdown to show added indicator
                        this.updateBulkSuggestionsDropdown();
                    };
                    item.addEventListener('click', item._clickHandler);
                }
            }
        });
    }

    async saveBulkTags(mode = 'append') {
        const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
        let tags = Array.from(tagElements).map(tag => tag.dataset.tag);

        // Flush uncommitted input as a tag so it's not silently lost on save
        const tagInput = document.querySelector('.bulk-metadata-input');
        if (tagInput) {
            const pendingTag = tagInput.value.trim().toLowerCase();
            if (pendingTag && !tags.includes(pendingTag)) {
                tags.push(pendingTag);
            }
            tagInput.value = '';
        }

        if (tags.length === 0) {
            showToast('toast.models.noTagsToAdd', {}, 'warning');
            return;
        }

        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        try {
            const apiClient = getModelApiClient();
            const filePaths = Array.from(state.selectedModels);
            let successCount = 0;
            let failCount = 0;
            let cancelled = false;

            state.loadingManager.showSimpleLoading(translate('toast.models.bulkTagsUpdating', { count: filePaths.length }));
            state.loadingManager.showCancelButton(() => {
                cancelled = true;
            });

            const isRecipes = state.currentPageType === 'recipes';

            // Add or replace tags for each selected model based on mode
            for (const filePath of filePaths) {
                if (cancelled) {
                    showToast('toast.api.operationCancelled', {}, 'info');
                    break;
                }
                try {
                    if (isRecipes) {
                        await this._saveRecipeTags(filePath, tags, mode);
                    } else if (mode === 'replace') {
                        await apiClient.saveModelMetadata(filePath, { tags: tags });
                    } else {
                        await apiClient.addTags(filePath, { tags: tags });
                    }
                    successCount++;
                } catch (error) {
                    console.error(`Failed to ${mode} tags for ${filePath}:`, error);
                    failCount++;
                }
            }

            modalManager.closeModal('bulkAddTagsModal');

            if (successCount > 0) {
                const currentConfig = this.getCurrentDisplayConfig();
                const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
                showToast(toastKey, {
                    count: successCount,
                    tagCount: tags.length,
                    type: currentConfig.displayName.toLowerCase()
                }, 'success');
            }

            if (failCount > 0) {
                const toastKey = mode === 'replace' ? 'toast.models.tagsReplaceFailed' : 'toast.models.tagsAddFailed';
                showToast(toastKey, { count: failCount }, 'warning');
            }

            if (state.bulkMode) this.toggleBulkMode();

        } catch (error) {
            console.error('Error during bulk tag operation:', error);
            const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
            showToast(toastKey, {}, 'error');
        } finally {
            state.loadingManager.hide();
            state.loadingManager.restoreProgressBar();
        }
    }

    async _saveRecipeTags(filePath, newTags, mode) {
        const recipeId = extractRecipeId(filePath);
        if (!recipeId) throw new Error('Unable to determine recipe ID');

        let finalTags = newTags;
        if (mode === 'append') {
            const recipeItem = state.virtualScroller?.items?.find(
                item => item.file_path === filePath
            );
            const existingTags = recipeItem?.tags || [];
            finalTags = [...new Set([...existingTags, ...newTags])];
        }

        const response = await fetch(
            `/api/lm/recipe/${encodeURIComponent(recipeId)}/update`,
            {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ tags: finalTags }),
            }
        );
        const data = await response.json();
        if (!data.success) {
            throw new Error(data.error || 'Failed to update recipe tags');
        }

        state.virtualScroller.updateSingleItem(filePath, { tags: finalTags });
    }

    cleanupBulkAddTagsModal() {
        // Clear tags container
        const tagsContainer = document.getElementById('bulkTagsItems');
        if (tagsContainer) {
            tagsContainer.innerHTML = '';
        }

        // Clear input
        const input = document.querySelector('.bulk-metadata-input');
        if (input) {
            input.value = '';
        }

        // Remove event listeners (they will be re-added when modal opens again)
        const appendBtn = document.querySelector('.bulk-append-tags-btn');
        if (appendBtn) {
            appendBtn.replaceWith(appendBtn.cloneNode(true));
        }

        const replaceBtn = document.querySelector('.bulk-replace-tags-btn');
        if (replaceBtn) {
            replaceBtn.replaceWith(replaceBtn.cloneNode(true));
        }

        // Remove the suggestions dropdown
        const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
        if (tagForm) {
            const dropdown = tagForm.querySelector('.metadata-suggestions-dropdown');
            if (dropdown) {
                dropdown.remove();
            }
        }
    }

    async setBulkFavorites(value) {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const totalCount = state.selectedModels.size;
        const isRecipesPage = state.currentPageType === 'recipes';

        state.loadingManager.showSimpleLoading(
            translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
        );
        let cancelled = false;
        state.loadingManager.showCancelButton(() => {
            cancelled = true;
        });

        let successCount = 0;
        let failureCount = 0;

        try {
            for (const filePath of state.selectedModels) {
                if (cancelled) {
                    showToast('toast.api.operationCancelled', {}, 'info');
                    break;
                }
                try {
                    if (isRecipesPage) {
                        await updateRecipeMetadata(filePath, { favorite: value });
                    } else {
                        const apiClient = getModelApiClient();
                        await apiClient.saveModelMetadata(filePath, { favorite: value });
                    }
                    successCount++;
                } catch (error) {
                    failureCount++;
                    console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
                }
            }
        } finally {
            state.loadingManager?.hide?.();
        }

        if (successCount === totalCount) {
            const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
            showToast(toastKey, {}, 'success');
        } else if (successCount > 0) {
            const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
            showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
        } else {
            showToast('toast.models.bulkFavoriteFailed', {}, 'error');
        }

        if (state.bulkMode) this.toggleBulkMode();
    }

    /**
     * Show bulk base model modal
     */
    showBulkBaseModelModal() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const countElement = document.getElementById('bulkBaseModelCount');
        if (countElement) {
            countElement.textContent = state.selectedModels.size;
        }

        modalManager.showModal('bulkBaseModelModal', null, null, () => {
            this.cleanupBulkBaseModelModal();
        });

        // Initialize the bulk base model interface
        this.initializeBulkBaseModelInterface();
    }

    showBulkContentRatingSelector() {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const selector = getNsfwLevelSelector();
        if (!selector) {
            console.warn('NSFW level selector not found');
            return;
        }

        const filePaths = Array.from(state.selectedModels);
        const selectedCards = Array.from(document.querySelectorAll('.model-card.selected'));
        const levels = new Set();

        selectedCards.forEach((card) => {
            let level = 0;
            try {
                const metaData = JSON.parse(card.dataset.meta || '{}');
                if (typeof metaData.preview_nsfw_level === 'number') {
                    level = metaData.preview_nsfw_level;
                }
            } catch (error) {
                console.warn('Failed to parse metadata for card', error);
            }

            if (!level && card.dataset.nsfwLevel) {
                const parsed = parseInt(card.dataset.nsfwLevel, 10);
                if (!Number.isNaN(parsed)) {
                    level = parsed;
                }
            }

            levels.add(level);
        });

        let highlightLevel = null;
        if (levels.size === 1) {
            highlightLevel = levels.values().next().value;
        }

        selector.show({
            currentLevel: highlightLevel || 0,
            multipleLabel: levels.size > 1 ? translate('modals.contentRating.multiple', {}, 'Multiple values') : '',
            onSelect: async (level) => {
                await this.setBulkContentRating(level, filePaths);
                // Always allow selector to close after attempting the update
                return true;
            }
        });
    }

    async setBulkContentRating(level, filePaths = null) {
        const targets = Array.isArray(filePaths) ? filePaths : Array.from(state.selectedModels);

        if (!targets || targets.length === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return false;
        }

        const totalCount = targets.length;
        const levelName = getNSFWLevelName(level);

        state.loadingManager.showSimpleLoading(translate('toast.models.bulkContentRatingUpdating', { count: totalCount }));
        let cancelled = false;
        state.loadingManager.showCancelButton(() => {
            cancelled = true;
        });

        let successCount = 0;
        let failureCount = 0;

        try {
            const isRecipesPage = state.currentPageType === 'recipes';
            for (const filePath of targets) {
                if (cancelled) {
                    showToast('toast.api.operationCancelled', {}, 'info');
                    break;
                }
                try {
                    if (isRecipesPage) {
                        await updateRecipeMetadata(filePath, { preview_nsfw_level: level });
                    } else {
                        await getModelApiClient().saveModelMetadata(filePath, { preview_nsfw_level: level });
                    }
                    successCount++;
                } catch (error) {
                    failureCount++;
                    console.error(`Failed to set content rating for ${filePath}:`, error);
                }
            }
        } finally {
            state.loadingManager?.hide?.();
        }

        if (successCount === totalCount) {
            showToast('toast.models.bulkContentRatingSet', { count: successCount, level: levelName }, 'success');
        } else if (successCount > 0) {
            showToast('toast.models.bulkContentRatingPartial', {
                success: successCount,
                failed: failureCount,
                level: levelName
            }, 'warning');
        } else {
            showToast('toast.models.bulkContentRatingFailed', {}, 'error');
        }

        if (state.bulkMode) this.toggleBulkMode();

        return successCount > 0;
    }

    async setSkipMetadataRefresh(value) {
        if (state.selectedModels.size === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        const totalCount = state.selectedModels.size;

        state.loadingManager.showSimpleLoading(
            translate('toast.models.skipMetadataRefreshUpdating', { count: totalCount })
        );
        let cancelled = false;
        state.loadingManager.showCancelButton(() => {
            cancelled = true;
        });

        let successCount = 0;
        let failureCount = 0;

        try {
            const apiClient = getModelApiClient();
            for (const filePath of state.selectedModels) {
                if (cancelled) {
                    showToast('toast.api.operationCancelled', {}, 'info');
                    break;
                }
                try {
                    await apiClient.saveModelMetadata(filePath, { skip_metadata_refresh: value });
                    successCount++;
                } catch (error) {
                    failureCount++;
                    console.error(`Failed to set skip_metadata_refresh for ${filePath}:`, error);
                }
            }
        } finally {
            state.loadingManager?.hide?.();
        }

        if (successCount === totalCount) {
            const toastKey = value
                ? 'toast.models.skipMetadataRefreshSet'
                : 'toast.models.skipMetadataRefreshCleared';
            showToast(toastKey, { count: successCount }, 'success');
        } else if (successCount > 0) {
            showToast('toast.models.skipMetadataRefreshPartial', {
                success: successCount,
                failed: failureCount
            }, 'warning');
        } else {
            showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
        }

        if (state.bulkMode) this.toggleBulkMode();
    }

    /**
     * Initialize bulk base model interface
     */
    initializeBulkBaseModelInterface() {
        const container = document.getElementById('bulkBaseModelPicker');
        if (!container) return;

        // Reset any previous picker instance
        this.cleanupBulkBaseModelModal();
        container.innerHTML = '';

        const suggestions = inferBaseModelsFromFilepaths(Array.from(state.selectedModels));
        this.bulkBaseModelValue = '';
        this.bulkBaseModelPicker = createBaseModelPicker({
            suggestions,
            mode: 'change',
            onChange: (value) => {
                this.bulkBaseModelValue = value;
            },
        });
        container.appendChild(this.bulkBaseModelPicker.element);
        this.bulkBaseModelPicker.element.querySelector('.base-model-search-input')?.focus();
    }

    /**
     * Save bulk base model changes
     */
    async saveBulkBaseModel() {
        const newBaseModel = (this.bulkBaseModelValue || this.bulkBaseModelPicker?.getValue() || '').trim();
        if (!newBaseModel) {
            showToast('toast.models.baseModelNotSelected', {}, 'warning');
            return;
        }
        const selectedCount = state.selectedModels.size;

        if (selectedCount === 0) {
            showToast('toast.models.noModelsSelected', {}, 'warning');
            return;
        }

        modalManager.closeModal('bulkBaseModelModal');

        try {
            let successCount = 0;
            let errorCount = 0;
            const errors = [];
            let cancelled = false;

            state.loadingManager.showSimpleLoading(translate('toast.models.bulkBaseModelUpdating'));
            state.loadingManager.showCancelButton(() => {
                cancelled = true;
            });

            const isRecipesPage = state.currentPageType === 'recipes';

            for (const filepath of state.selectedModels) {
                if (cancelled) {
                    showToast('toast.api.operationCancelled', {}, 'info');
                    break;
                }
                try {
                    if (isRecipesPage) {
                        await updateRecipeMetadata(filepath, { base_model: newBaseModel });
                    } else {
                        await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
                    }
                    successCount++;
                } catch (error) {
                    errorCount++;
                    errors.push({ filepath, error: error.message });
                    console.error(`Failed to update base model for ${filepath}:`, error);
                }
            }

            // Show results
            if (errorCount === 0) {
                showToast('toast.models.bulkBaseModelUpdateSuccess', { count: successCount }, 'success');
            } else if (successCount > 0) {
                showToast('toast.models.bulkBaseModelUpdatePartial', {
                    success: successCount,
                    failed: errorCount
                }, 'warning');
            } else {
                showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
            }

            if (state.bulkMode) this.toggleBulkMode();

        } catch (error) {
            console.error('Error during bulk base model operation:', error);
            showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
        } finally {
            state.loadingManager?.hide?.();
        }
    }

    /**
     * Cleanup bulk base model modal
     */
    cleanupBulkBaseModelModal() {
        if (this.bulkBaseModelPicker) {
            this.bulkBaseModelPicker.destroy();
            this.bulkBaseModelPicker = null;
        }
        this.bulkBaseModelValue = '';
        const container = document.getElementById('bulkBaseModelPicker');
        if (container) {
            container.innerHTML = '';
        }
    }

    /**
     * Auto-organize selected models based on current path template settings
     */
    async autoOrganizeSelectedModels() {
        if (state.selectedModels.size === 0) {
            showToast('toast.loras.noModelsSelected', {}, 'error');
            return;
        }

        try {
            // Get selected file paths
            const filePaths = Array.from(state.selectedModels);

            // Get the API client for the current model type
            const apiClient = getModelApiClient();

            // Call the auto-organize method with selected file paths
            await apiClient.autoOrganizeModels(filePaths);

            if (state.bulkMode) this.toggleBulkMode();
            resetAndReload(true);
        } catch (error) {
            console.error('Error during bulk auto-organize:', error);
            showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
        }
    }

    /**
     * Handle marquee start through event manager
     */
    handleMarqueeStart(e) {
        // Store mousedown info for potential drag detection
        this.mouseDownTime = Date.now();
        this.mouseDownPosition = { x: e.clientX, y: e.clientY };
        this.isDragging = false;

        // Don't start marquee yet - wait to see if user is dragging
        return false;
    }

    /**
     * Start marquee selection
     * @param {MouseEvent} e - Mouse event
     * @param {boolean} isDragging - Whether this is triggered from a drag operation
     */
    startMarqueeSelection(e, isDragging = false) {
        // Store initial mouse position (viewport coordinates for visual element)
        this.marqueeStart.x = this.mouseDownPosition.x;
        this.marqueeStart.y = this.mouseDownPosition.y;

        // Store initial mouse position in document coordinates (for logical selection)
        const container = document.querySelector('.page-content');
        this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
        this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);

        // Store initial selection state
        this.initialSelectedModels = new Set(state.selectedModels);

        // Enter bulk mode if not already active and we're actually dragging
        if (isDragging && !state.bulkMode) {
            this.toggleBulkMode();
        }

        // Create marquee element
        this.createMarqueeElement();

        this.isMarqueeActive = true;

        // Update event manager state
        eventManager.setState('marqueeActive', true);

        // Add visual feedback class to body
        document.body.classList.add('marquee-selecting');
    }

    /**
     * Create the visual marquee selection rectangle
     */
    createMarqueeElement() {
        this.marqueeElement = document.createElement('div');
        this.marqueeElement.className = 'marquee-selection';
        this.marqueeElement.style.cssText = `
            position: fixed;
            border: 2px dashed var(--lora-accent, #007bff);
            background: rgba(0, 123, 255, 0.1);
            pointer-events: none;
            z-index: 9999;
            left: ${this.marqueeStart.x}px;
            top: ${this.marqueeStart.y}px;
            width: 0;
            height: 0;
        `;
        document.body.appendChild(this.marqueeElement);
    }

    /**
     * Update marquee selection rectangle and selected items
     */
    updateMarqueeSelection(e) {
        if (!this.marqueeElement) return;
        this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
    }

    /**
     * Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
     */
    updateMarqueeSelectionFromPosition(clientX, clientY) {
        if (!this.marqueeElement) return;

        const container = document.querySelector('.page-content');
        const scrollX = container?.scrollLeft || 0;
        const scrollY = container?.scrollTop || 0;

        // Current position in document coordinates
        const currentDocX = clientX + scrollX;
        const currentDocY = clientY + scrollY;

        // Calculate marquee rectangle in document coordinates
        const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
        const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
        const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
        const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);

        // Update visual marquee element (position: fixed, so subtract scroll offset)
        this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
        this.marqueeElement.style.top = (docTop - scrollY) + 'px';
        this.marqueeElement.style.width = (docRight - docLeft) + 'px';
        this.marqueeElement.style.height = (docBottom - docTop) + 'px';

        // Check which cards intersect with marquee
        this.updateCardSelection(docLeft, docTop, docRight, docBottom);
    }

    /**
     * Update card selection based on marquee bounds (document coordinates).
     * Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
     */
    updateCardSelection(docLeft, docTop, docRight, docBottom) {
        const vs = state.virtualScroller;
        const container = document.querySelector('.page-content');
        const scrollX = container?.scrollLeft || 0;
        const scrollY = container?.scrollTop || 0;
        const newSelection = new Set(this.initialSelectedModels);
        const visibleFilepaths = new Set();

        // Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
        document.querySelectorAll('.model-card').forEach(card => {
            const filepath = card.dataset.filepath;
            if (!filepath) return;
            visibleFilepaths.add(filepath);

            const rect = card.getBoundingClientRect();
            const cardLeft = rect.left + scrollX;
            const cardTop = rect.top + scrollY;
            const cardRight = rect.right + scrollX;
            const cardBottom = rect.bottom + scrollY;

            const intersects = !(cardRight < docLeft || cardLeft > docRight ||
                                 cardBottom < docTop || cardTop > docBottom);

            if (intersects) {
                newSelection.add(filepath);
                card.classList.add('selected');

                // Cache metadata if not already cached
                const metadataCache = this.getMetadataCache();
                if (!metadataCache.has(filepath)) {
                    this.updateMetadataCacheFromCard(filepath, card);
                }
            } else if (!this.initialSelectedModels.has(filepath)) {
                newSelection.delete(filepath);
                card.classList.remove('selected');
            }
        });

        // Step 2: Process off-screen cards via VirtualScroller layout calculation.
        // Since VirtualScroller removes off-screen DOM elements, we compute
        // each card's position from its index and the VS layout parameters.
        if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
            const gridRect = vs.gridElement.getBoundingClientRect();
            // Grid origin in scroll-container content coordinates
            const originX = gridRect.left + scrollX;
            const originY = gridRect.top + scrollY;

            for (let i = 0; i < vs.items.length; i++) {
                const filepath = vs.items[i]?.file_path;
                if (!filepath || visibleFilepaths.has(filepath)) continue;

                const row = Math.floor(i / vs.columnsCount);
                const col = i % vs.columnsCount;

                const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
                const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
                const cRight = cLeft + vs.itemWidth;
                const cBottom = cTop + vs.itemHeight;

                const intersects = !(cRight < docLeft || cLeft > docRight ||
                                     cBottom < docTop || cTop > docBottom);

                if (intersects) {
                    newSelection.add(filepath);
                } else if (!this.initialSelectedModels.has(filepath)) {
                    newSelection.delete(filepath);
                }
            }
        }

        // Update global selection state
        state.selectedModels = newSelection;

        // Update context menu header if visible
        if (this.bulkContextMenu) {
            this.bulkContextMenu.updateSelectedCountHeader();
        }
    }

    /**
     * End marquee selection
     */
    endMarqueeSelection(e) {
        // First, mark as inactive to prevent double processing
        this.isMarqueeActive = false;
        this.isDragging = false;
        this.mouseDownTime = 0;

        // Stop any active auto-scroll
        this.stopAutoScroll();

        // Update event manager state
        eventManager.setState('marqueeActive', false);

        // Remove marquee element
        if (this.marqueeElement) {
            this.marqueeElement.remove();
            this.marqueeElement = null;
        }

        // Remove visual feedback class
        document.body.classList.remove('marquee-selecting');

        // Compute the actual drag box size in document coordinates, matching how
        // updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
        // size would wrongly flag auto-scroll marquees (tiny pointer movement,
        // large document-space box) as accidental clicks.
        const container = document.querySelector('.page-content');
        const scrollX = container?.scrollLeft || 0;
        const scrollY = container?.scrollTop || 0;
        const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
        const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
        const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;

        // Get selection count
        const selectionCount = state.selectedModels.size;

        // A tiny box (e.g. click jitter that happened to graze a card) is treated
        // as an accidental click: undo any selection and leave bulk mode.
        if (isTinyMarquee) {
            this.clearSelection();
            if (state.bulkMode) {
                this.toggleBulkMode();
            }
            this.initialSelectedModels.clear();
            return;
        }

        // If no models were selected, exit bulk mode
        if (selectionCount === 0) {
            if (state.bulkMode) {
                this.toggleBulkMode();
            }
        }

        // Clear initial selection state
        this.initialSelectedModels.clear();
    }

    /**
     * Start auto-scroll loop when mouse approaches viewport edge during marquee
     */
    startAutoScroll() {
        if (this.autoScrollRaf) return;
        this.autoScrollLoop();
    }

    /**
     * Stop auto-scroll loop
     */
    stopAutoScroll() {
        if (this.autoScrollRaf) {
            cancelAnimationFrame(this.autoScrollRaf);
            this.autoScrollRaf = null;
        }
    }

    /**
     * Auto-scroll loop: scrolls the page when mouse is near viewport edges
     * and re-evaluates marquee selection after each scroll.
     */
    autoScrollLoop() {
        if (!this.isMarqueeActive) {
            this.autoScrollRaf = null;
            return;
        }

        const container = document.querySelector('.page-content');
        if (!container) {
            this.autoScrollRaf = null;
            return;
        }

        const MARGIN = 30;      // Px from edge to trigger scroll
        const BASE_SPEED = 12;   // Pixels per frame at edge boundary
        const MAX_SPEED = 40;    // Maximum scroll speed
        const rect = container.getBoundingClientRect();
        let dx = 0;
        let dy = 0;

        // Vertical auto-scroll - speed increases the further the cursor is past the edge
        if (this.lastClientY !== undefined) {
            if (this.lastClientY < rect.top + MARGIN) {
                const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
                dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
            } else if (this.lastClientY > rect.bottom - MARGIN) {
                const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
                dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
            }
        }

        // Horizontal auto-scroll
        if (this.lastClientX !== undefined) {
            if (this.lastClientX < rect.left + MARGIN) {
                const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
                dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
            } else if (this.lastClientX > rect.right - MARGIN) {
                const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
                dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
            }
        }

        if (dx !== 0 || dy !== 0) {
            container.scrollBy(dx, dy);
            // Re-evaluate marquee selection with the new scroll position
            this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
            this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
        } else {
            this.autoScrollRaf = null;
        }
    }
}

export const bulkManager = new BulkManager();
