import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import {
    WILDCARD_COMMANDS,
    createWildcardEmptyStateItem,
    createWildcardNoMatchesItem,
    getWildcardInsertText,
    getWildcardSearchEndpoint,
    isWildcardCommand,
    isWildcardInfoItem,
} from "./autocomplete_wildcards.js";
import {
    getAutocompleteAppendCommaPreference,
    getAutocompleteAutoFormatPreference,
    getAutocompleteAcceptKeyPreference,
    getLoraActiveFiltersAutocompletePreference,
    getPromptTagAutocompletePreference,
    getTagSpaceReplacementPreference,
    setLoraManagerSettingValue,
} from "./settings.js";
import { showToast } from "./utils.js";

// localStorage key for the one-time "how to disable" hint in the dropdown
const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed';

// Command definitions for category filtering
const TAG_COMMANDS = {
    '/character': { categories: [4, 11], label: 'Character' },
    '/artist': { categories: [1, 8], label: 'Artist' },
    '/general': { categories: [0, 7], label: 'General' },
    '/copyright': { categories: [3, 10], label: 'Copyright' },
    '/meta': { categories: [5, 14], label: 'Meta' },
    '/species': { categories: [12], label: 'Species' },
    '/lore': { categories: [15], label: 'Lore' },
    '/emb': { type: 'embedding', label: 'Embeddings' },
    '/embedding': { type: 'embedding', label: 'Embeddings' },
    ...WILDCARD_COMMANDS,
    // Autocomplete toggle commands - only show one based on current state
    '/autocomplete': {
        type: 'toggle_setting',
        settingId: 'loramanager.prompt_tag_autocomplete',
        value: true,
        label: 'Turn autocomplete ON',
        condition: () => !getPromptTagAutocompletePreference()
    },
    '/noautocomplete': {
        type: 'toggle_setting',
        settingId: 'loramanager.prompt_tag_autocomplete',
        value: false,
        label: 'Turn autocomplete OFF',
        condition: () => getPromptTagAutocompletePreference()
    },
};

// Command definitions for LoRA active-filters search
const LORAS_COMMANDS = {
    '/activefilters': {
        type: 'toggle_setting',
        settingId: 'loramanager.lora_active_filters_autocomplete',
        value: true,
        label: 'Turn active filters search ON',
        feedbackSummary: 'Active Filters Search: ON',
        feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
        condition: () => !getLoraActiveFiltersAutocompletePreference()
    },
    '/noactivefilters': {
        type: 'toggle_setting',
        settingId: 'loramanager.lora_active_filters_autocomplete',
        value: false,
        label: 'Turn active filters search OFF',
        feedbackSummary: 'Active Filters Search: OFF',
        feedbackDetail: 'LoRA autocomplete searches the full library again.',
        condition: () => getLoraActiveFiltersAutocompletePreference()
    },
};

// Category display information
const CATEGORY_INFO = {    0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
    1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
    3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
    4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
    5: { bg: 'rgba(234, 208, 132, 0.2)', text: '#f7e7c3', label: 'Meta' },
    7: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
    8: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
    10: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
    11: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
    12: { bg: 'rgba(237, 137, 54, 0.2)', text: '#f6ad55', label: 'Species' },
    14: { bg: 'rgba(234, 208, 132, 0.2)', text: '#f7e7c3', label: 'Meta' },
    15: { bg: 'rgba(72, 187, 120, 0.2)', text: '#68d391', label: 'Lore' },
};

// Format post count with K/M suffix
function formatPostCount(count) {
    if (count >= 1000000) {
        return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
    } else if (count >= 1000) {
        return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
    }
    return count.toString();
}

function parseUsageTipNumber(value) {
    if (typeof value === 'number' && Number.isFinite(value)) {
        return value;
    }
    if (typeof value === 'string') {
        const parsed = parseFloat(value);
        if (Number.isFinite(parsed)) {
            return parsed;
        }
    }
    return null;
}

function splitRelativePath(relativePath = '') {
    const parts = relativePath.split(/[/\\]+/).filter(Boolean);
    const fileName = parts.pop() ?? '';
    return {
        directories: parts,
        fileName,
    };
}

function removeGeneralExtension(fileName = '') {
    return fileName.replace(/\.[^.]+$/, '');
}

function removeLoraExtension(fileName = '') {
    return fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
}

let _loraSyntaxFormatCache = null;
let _loraSyntaxFormatRefreshPromise = null;

function _getLoraSyntaxFormat() {
    if (_loraSyntaxFormatCache !== null) {
        return _loraSyntaxFormatCache;
    }
    return 'legacy';
}

async function _fetchLoraSyntaxFormat() {
    try {
        const response = await api.fetchApi('/lm/settings');
        if (response.ok) {
            const data = await response.json();
            if (data.success && data.settings) {
                _loraSyntaxFormatCache = data.settings.lora_syntax_format || 'legacy';
                return;
            }
        }
    } catch (e) {
    }
    if (_loraSyntaxFormatCache === null) {
        _loraSyntaxFormatCache = 'legacy';
    }
}

function _triggerBackgroundRefresh() {
    if (_loraSyntaxFormatRefreshPromise) {
        return;
    }
    _loraSyntaxFormatRefreshPromise = _fetchLoraSyntaxFormat().finally(() => {
        _loraSyntaxFormatRefreshPromise = null;
    });
}

async function refreshLoraSyntaxFormat() {
    await _fetchLoraSyntaxFormat();
}

function _initLoraSyntaxFormat() {
    _triggerBackgroundRefresh();
}
_initLoraSyntaxFormat();

function _initLoraSyntaxFormatReactive() {
    window.addEventListener('storage', (e) => {
        if (e.key === 'lm:lora-syntax-format-changed') {
            _triggerBackgroundRefresh();
        }
    });

    document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible') {
            _triggerBackgroundRefresh();
        }
    });
}
_initLoraSyntaxFormatReactive();

function parseSearchTokens(term = '') {
    const include = [];
    const exclude = [];

    term.split(/\s+/).forEach((rawTerm) => {
        const token = rawTerm.trim();
        if (!token) {
            return;
        }
        if (token.startsWith('-') && token.length > 1) {
            exclude.push(token.slice(1).toLowerCase());
        } else {
            include.push(token.toLowerCase());
        }
    });

    return { include, exclude };
}

function escapePromptParentheses(text) {
    // In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
    // Tags containing literal parentheses must be escaped with backslash to prevent
    // them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
    return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}

function formatAutocompleteInsertion(text = '') {
    const trimmed = typeof text === 'string' ? text.trim() : '';
    if (!trimmed) {
        return '';
    }

    return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
}

function normalizeAutocompleteSegment(segment = '') {
    return segment.replace(/\s+/g, ' ').trim();
}

export function formatAutocompleteTextOnBlur(text = '') {
    if (typeof text !== 'string') {
        return '';
    }

    return text
        .split('\n')
        .map((line) => {
            if (!line.trim()) {
                return '';
            }

            const cleanedSegments = line
                .split(',')
                .map(normalizeAutocompleteSegment)
                .filter(Boolean);

            return cleanedSegments.join(', ');
        })
        .join('\n');
}

function shouldAcceptAutocompleteKey(key) {
    const mode = getAutocompleteAcceptKeyPreference();

    if (mode === 'tab_only') {
        return key === 'Tab';
    }

    if (mode === 'enter_only') {
        return key === 'Enter';
    }

    return key === 'Tab' || key === 'Enter';
}

function normalizeAutocompleteMatchText(text = '') {
    return text.toLowerCase().replace(/[-_\s']/g, '');
}

const AUTOCOMPLETE_METADATA_VERSION = 1;

function createAutocompleteMetadataBase(textWidgetName = 'text') {
    return {
        version: AUTOCOMPLETE_METADATA_VERSION,
        textWidgetName,
    };
}

function createDefaultBehavior(modelType) {
    return {
        enablePreview: false,
        async getInsertText(_instance, relativePath) {
            const trimmed = relativePath?.trim() ?? '';
            if (!trimmed) {
                return '';
            }
            return formatAutocompleteInsertion(escapePromptParentheses(trimmed));
        },
    };
}

const MODEL_BEHAVIORS = {
    loras: {
        enablePreview: true,
        init(instance) {
            if (!instance.options.showPreview) {
                return;
            }
            instance.initPreviewTooltip({ modelType: instance.modelType });
        },
        showPreview(instance, relativePath, itemElement) {
            if (!instance.previewTooltip) {
                return;
            }
            instance.showPreviewForItem(relativePath, itemElement);
        },
        hidePreview(instance) {
            if (!instance.previewTooltip) {
                return;
            }
            instance.previewTooltip.hide();
        },
        destroy(instance) {
            if (instance.previewTooltip) {
                instance.previewTooltip.cleanup();
                instance.previewTooltip = null;
            }
        },
        async getInsertText(_instance, relativePath) {
            const { directories, fileName } = splitRelativePath(relativePath);
            const baseName = removeLoraExtension(fileName);
            const folder = directories.length ? directories.join('/') + '/' : '';
            const loraName = folder + baseName;

            const resultName = _getLoraSyntaxFormat() === 'legacy'
                ? baseName
                : loraName;

            let strength = 1.0;
            let hasStrength = false;
            let clipStrength = null;

            try {
                const response = await api.fetchApi(`/lm/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
                if (response.ok) {
                    const data = await response.json();
                    if (data.success && data.usage_tips) {
                        try {
                            const usageTips = JSON.parse(data.usage_tips);
                            const parsedStrength = parseUsageTipNumber(usageTips.strength);
                            if (parsedStrength !== null) {
                                strength = parsedStrength;
                                hasStrength = true;
                            }
                            const clipSource = usageTips.clip_strength ?? usageTips.clipStrength;
                            const parsedClipStrength = parseUsageTipNumber(clipSource);
                            if (parsedClipStrength !== null) {
                                clipStrength = parsedClipStrength;
                                if (!hasStrength) {
                                    strength = 1.0;
                                }
                            }
                        } catch (parseError) {
                            console.warn('Failed to parse usage tips JSON:', parseError);
                        }
                    }
                }
            } catch (error) {
                console.warn('Failed to fetch usage tips:', error);
            }

            if (clipStrength !== null) {
                return formatAutocompleteInsertion(`<lora:${resultName}:${strength}:${clipStrength}>`);
            }
            return formatAutocompleteInsertion(`<lora:${resultName}:${strength}>`);
        }
    },
    embeddings: {
        enablePreview: true,
        init(instance) {
            if (!instance.options.showPreview) {
                return;
            }
            instance.initPreviewTooltip({ modelType: instance.modelType });
        },
        async getInsertText(_instance, relativePath) {
            const { directories, fileName } = splitRelativePath(relativePath);
            const trimmedName = removeGeneralExtension(fileName);
            const folder = directories.length ? `${directories.join('/')}/` : '';
            return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
        },
    },
    custom_words: {
        enablePreview: false,
        async getInsertText(_instance, relativePath) {
            return formatAutocompleteInsertion(escapePromptParentheses(relativePath));
        },
    },
    prompt: {
        enablePreview: true,
        init(instance) {
            if (!instance.options.showPreview) {
                return;
            }
            instance.initPreviewTooltip({ modelType: 'embeddings' });
        },
        showPreview(instance, relativePath, itemElement) {
            if (!instance.previewTooltip || instance.searchType !== 'embeddings') {
                return;
            }
            instance.showPreviewForItem(relativePath, itemElement);
        },
        hidePreview(instance) {
            if (!instance.previewTooltip) {
                return;
            }
            instance.previewTooltip.hide();
        },
        destroy(instance) {
            if (instance.previewTooltip) {
                instance.previewTooltip.cleanup();
                instance.previewTooltip = null;
            }
        },
        async getInsertText(instance, relativePath) {
            const rawSearchTerm = instance.getSearchTerm(instance.inputElement.value);
            const match = rawSearchTerm.match(/^emb:(.*)$/i);

            if (match || instance.searchType === 'embeddings') {
                const { directories, fileName } = splitRelativePath(relativePath);
                const trimmedName = removeGeneralExtension(fileName);
                const folder = directories.length ? `${directories.join('/')}/` : '';
                return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
            } else if (instance.searchType === 'wildcards' || isWildcardCommand(instance.activeCommand)) {
                return formatAutocompleteInsertion(getWildcardInsertText(relativePath));
            } else {
                let tagText = relativePath;

                if (getTagSpaceReplacementPreference()) {
                    tagText = tagText.replace(/_/g, ' ');
                }

                tagText = escapePromptParentheses(tagText);

                return formatAutocompleteInsertion(tagText);
            }
        },
    },
};

function getModelBehavior(modelType) {
    return MODEL_BEHAVIORS[modelType] ?? createDefaultBehavior(modelType);
}

class AutoComplete {
    constructor(inputElement, modelType = 'loras', options = {}) {
        this.inputElement = inputElement;
        this.modelType = modelType;
        this.behavior = getModelBehavior(modelType);
        this.options = {
            maxItems: 100,
            pageSize: 20,
            visibleItems: 15,  // Fixed at 15 items for balanced UX
            itemHeight: 40,
            minChars: 1,
            debounceDelay: 200,
            showPreview: this.behavior.enablePreview ?? false,
            enableVirtualScroll: true,
            ...options
        };

        this.dropdown = null;
        this.selectedIndex = -1;
        this.hasManualSelection = false;
        this.items = [];
        this.debounceTimer = null;
        this.isVisible = false;
        this.currentSearchTerm = '';
        this.wildcardMeta = null;
        this.previewTooltip = null;
        this.previewTooltipPromise = null;
        this.searchType = null;
        this.suppressAutocompleteOnce = false;

        // Discoverability hints state
        this.commandListFooter = null;   // State hint shown below the slash command list
        this.firstRunHint = null;        // One-time "how to disable" bar inside the dropdown

        // Virtual scrolling state
        this.virtualScrollOffset = 0;
        this.hasMoreItems = true;
        this.isLoadingMore = false;
        this.currentPage = 0;
        this.scrollContainer = null;
        this.contentContainer = null;
        this.totalHeight = 0;

        // Command mode state
        this.activeCommand = null;  // Current active command (e.g., { categories: [4, 11], label: 'Character' })
        this.showingCommands = false;  // Whether showing command list dropdown

        // Initialize TextAreaCaretHelper
        this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);

        this.onInput = null;
        this.onKeyDown = null;
        this.onBlur = null;
        this.onDocumentClick = null;
        this.onScroll = null;

        this.init();
    }
    
    init() {
        this.createDropdown();
        this.bindEvents();
    }
    
    createDropdown() {
        this.dropdown = document.createElement('div');
        this.dropdown.className = 'comfy-autocomplete-dropdown';

        // Apply new color scheme
        this.dropdown.style.cssText = `
            position: absolute;
            z-index: 10000;
            overflow: hidden;
            background-color: rgba(40, 44, 52, 0.95);
            border: 1px solid rgba(226, 232, 240, 0.2);
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
            display: none;
            min-width: 200px;
            width: auto;
            backdrop-filter: blur(8px);
            -webkit-backdrop-filter: blur(8px);
        `;

        if (this.options.enableVirtualScroll) {
            // Create scroll container for virtual scrolling
            this.scrollContainer = document.createElement('div');
            this.scrollContainer.className = 'comfy-autocomplete-scroll-container';
            this.scrollContainer.style.cssText = `
                overflow-y: auto;
                max-height: ${this.options.visibleItems * this.options.itemHeight}px;
                position: relative;
            `;

            // Create content container for virtual items
            this.contentContainer = document.createElement('div');
            this.contentContainer.className = 'comfy-autocomplete-content';
            this.contentContainer.style.cssText = `
                position: relative;
                width: 100%;
            `;

            this.scrollContainer.appendChild(this.contentContainer);
            this.dropdown.appendChild(this.scrollContainer);
        }

        // Custom scrollbar styles with new color scheme
        const style = document.createElement('style');
        style.textContent = `
            .comfy-autocomplete-dropdown::-webkit-scrollbar {
                width: 8px;
            }
            .comfy-autocomplete-dropdown::-webkit-scrollbar-track {
                background: rgba(40, 44, 52, 0.3);
                border-radius: 4px;
            }
            .comfy-autocomplete-dropdown::-webkit-scrollbar-thumb {
                background: rgba(226, 232, 240, 0.2);
                border-radius: 4px;
            }
            .comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
                background: rgba(226, 232, 240, 0.4);
            }
            .comfy-autocomplete-scroll-container::-webkit-scrollbar {
                width: 8px;
            }
            .comfy-autocomplete-scroll-container::-webkit-scrollbar-track {
                background: rgba(40, 44, 52, 0.3);
                border-radius: 4px;
            }
            .comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb {
                background: rgba(226, 232, 240, 0.2);
                border-radius: 4px;
            }
            .comfy-autocomplete-scroll-container::-webkit-scrollbar-thumb:hover {
                background: rgba(226, 232, 240, 0.4);
            }
            .comfy-autocomplete-loading {
                padding: 12px;
                text-align: center;
                color: rgba(226, 232, 240, 0.5);
                font-size: 12px;
            }
        `;
        document.head.appendChild(style);

        // Append to body to avoid overflow issues
        document.body.appendChild(this.dropdown);

        if (typeof this.behavior.init === 'function') {
            this.behavior.init(this);
        }
    }
    
    initPreviewTooltip(options = {}) {
        if (this.previewTooltip || this.previewTooltipPromise) {
            return;
        }
        // Dynamically import and create preview tooltip
        this.previewTooltipPromise = import('./preview_tooltip.js').then(module => {
            const config = { modelType: this.modelType, ...options };
            this.previewTooltip = new module.PreviewTooltip(config);
        }).catch(err => {
            console.warn('Failed to load preview tooltip:', err);
        }).finally(() => {
            this.previewTooltipPromise = null;
        });
    }
    
    bindEvents() {
        // Handle input changes
        this.onInput = (e) => {
            if (this.suppressAutocompleteOnce) {
                this.suppressAutocompleteOnce = false;
                this.hide();
                return;
            }
            this.handleInput(e.target.value);
        };
        this.inputElement.addEventListener('input', this.onInput);

        // Handle keyboard navigation
        this.onKeyDown = (e) => {
            this.handleKeyDown(e);
        };
        this.inputElement.addEventListener('keydown', this.onKeyDown);

        // Handle focus out to hide dropdown
        this.onBlur = () => {
            if (getAutocompleteAutoFormatPreference()) {
                const formattedValue = formatAutocompleteTextOnBlur(this.inputElement.value);
                if (formattedValue !== this.inputElement.value) {
                    this.inputElement.value = formattedValue;
                    this.suppressAutocompleteOnce = true;
                    this.inputElement.dispatchEvent(new Event('input', { bubbles: true }));
                }
            }

            // Delay hiding to allow for clicks on dropdown items
            setTimeout(() => {
                this.hide();
            }, 150);
        };
        this.inputElement.addEventListener('blur', this.onBlur);

        // Handle clicks outside to hide dropdown
        this.onDocumentClick = (e) => {
            if (!this.dropdown) {
                return;
            }

            const target = e.target;
            if (!(target instanceof Node)) {
                return;
            }

            if (!this.dropdown.contains(target) && target !== this.inputElement) {
                this.hide();
            }
        };
        document.addEventListener('click', this.onDocumentClick);

        // Mark this element as having autocomplete events bound
        this.inputElement._autocompleteEventsBound = true;

        // Bind scroll event for virtual scrolling
        if (this.options.enableVirtualScroll && this.scrollContainer) {
            this.onScroll = () => {
                this.handleScroll();
            };
            this.scrollContainer.addEventListener('scroll', this.onScroll);
        }
    }

    /**
     * Check if the autocomplete is valid (input element is in DOM and events are bound)
     */
    isValid() {
        return this.inputElement &&
               document.body.contains(this.inputElement) &&
               this.inputElement._autocompleteEventsBound === true;
    }

    /**
     * Check if events need to be rebound (element exists but events not bound)
     */
    needsRebind() {
        return this.inputElement &&
               document.body.contains(this.inputElement) &&
               this.inputElement._autocompleteEventsBound !== true;
    }

    /**
     * Rebind events to the input element (useful after Vue moves the element)
     */
    rebindEvents() {
        // Remove old listeners if they exist
        if (this.onInput) {
            this.inputElement.removeEventListener('input', this.onInput);
        }
        if (this.onKeyDown) {
            this.inputElement.removeEventListener('keydown', this.onKeyDown);
        }
        if (this.onBlur) {
            this.inputElement.removeEventListener('blur', this.onBlur);
        }

        // Rebind all events
        this.bindEvents();

        console.log('[Lora Manager] Autocomplete events rebound');
    }

    /**
     * Refresh the TextAreaCaretHelper (useful after element properties change)
     */
    refreshHelper() {
        if (this.inputElement && document.body.contains(this.inputElement)) {
            this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
        }
    }
    
    handleInput(value = '') {
        // Clear previous debounce timer
        if (this.debounceTimer) {
            clearTimeout(this.debounceTimer);
        }

        // Get the search term (text after last comma / '>')
        const rawSearchTerm = this.getSearchTerm(value);
        let searchTerm = rawSearchTerm;
        let endpoint = `/lm/${this.modelType}/relative-paths`;

        // For embeddings, only trigger autocomplete when the current token
        // starts with the explicit "emb:" prefix. This avoids interrupting
        // normal prompt typing while still allowing quick manual triggering.
        if (this.modelType === 'embeddings') {
            const match = rawSearchTerm.match(/^emb:(.*)$/i);
            if (!match) {
                this.hide();
                return;
            }
            searchTerm = (match[1] || '').trim();
        }

        // For loras model type, check if we're in command mode (/activefilters, /noactivefilters)
        if (this.modelType === 'loras') {
            const commandResult = this._parseCommandInput(rawSearchTerm);

            if (commandResult.showCommands) {
                // Show command list dropdown
                this.showingCommands = true;
                this.activeCommand = null;
                this.searchType = 'commands';
                this._showCommandList(commandResult.commandFilter);
                return;
            } else if (commandResult.command?.type === 'toggle_setting') {
                // Handle toggle setting command (/activefilters, /noactivefilters)
                this._handleToggleSettingCommand(commandResult.command);
                return;
            } else if (commandResult.command) {
                // Command is active, use filtered search
                this.showingCommands = false;
                this.activeCommand = null;
                this.searchType = null;
                searchTerm = commandResult.searchTerm || rawSearchTerm;
            } else {
                // No command - regular lora search
                this.showingCommands = false;
                this.activeCommand = null;
                this.searchType = null;
                searchTerm = rawSearchTerm;
            }
        }

        // For prompt model type, check if we're searching embeddings, commands, or tags
        if (this.modelType === 'prompt') {
            const match = rawSearchTerm.match(/^emb:(.*)$/i);
            if (match) {
                // User typed "emb:" prefix - always allow embeddings search
                endpoint = '/lm/embeddings/relative-paths';
                searchTerm = (match[1] || '').trim();
                this.searchType = 'embeddings';
                this.activeCommand = null;
                this.showingCommands = false;
            } else {
                // Check for command mode FIRST (always runs, regardless of setting)
                const commandResult = this._parseCommandInput(rawSearchTerm);

                if (commandResult.showCommands) {
                    // Show command list dropdown
                    this.showingCommands = true;
                    this.activeCommand = null;
                    this.searchType = 'commands';
                    this._showCommandList(commandResult.commandFilter);
                    return;
                } else if (commandResult.command?.type === 'toggle_setting') {
                    // Handle toggle setting command (/autocomplete, /noautocomplete)
                    this._handleToggleSettingCommand(commandResult.command);
                    return;
                } else if (commandResult.command) {
                    // Command is active, use filtered search
                    this.showingCommands = false;
                    this.activeCommand = commandResult.command;
                    searchTerm = commandResult.searchTerm;

                    if (commandResult.command.type === 'embedding') {
                        // /emb or /embedding command
                        endpoint = '/lm/embeddings/relative-paths';
                        this.searchType = 'embeddings';
                    } else if (isWildcardCommand(commandResult.command)) {
                        endpoint = getWildcardSearchEndpoint();
                        this.searchType = 'wildcards';
                    } else {
                        // Category filter command
                        const categories = commandResult.command.categories.join(',');
                        endpoint = `/lm/custom-words/search?category=${categories}`;
                        this.searchType = 'custom_words';
                    }
                } else if (getPromptTagAutocompletePreference()) {
                    // No command and setting enabled - regular tag search with enriched results
                    this.showingCommands = false;
                    this.activeCommand = null;
                    endpoint = '/lm/custom-words/search?enriched=true';
                    // Use full search term for query variation generation
                    // The search() method will generate multiple query variations including:
                    // - Original query (for natural language matching)
                    // - Underscore version (e.g., "looking_to_the_side" for "looking to the side")
                    // - Last token (for backward compatibility with continuous typing)
                    searchTerm = rawSearchTerm;
                    this.searchType = 'custom_words';
                } else {
                    // No command and setting disabled - no autocomplete for direct typing.
                    // Re-enable discovery is covered by the command-list footer,
                    // the node context menu and the settings tooltip.
                    this.hide();
                    return;
                }
            }
        }

        const allowEmptyWildcardSearch =
            this.modelType === 'prompt' &&
            this.searchType === 'wildcards' &&
            searchTerm.length === 0;

        if (!allowEmptyWildcardSearch && searchTerm.length < this.options.minChars) {
            this.hide();
            return;
        }

        // Debounce the search
        this.debounceTimer = setTimeout(() => {
            this.search(searchTerm, endpoint);
        }, this.options.debounceDelay);
    }
    
    getSearchTerm(value) {
        return this.getActiveSearchRange(value).text;
    }

    getActiveSearchRange(value = null) {
        const currentValue = typeof value === 'string' ? value : this.inputElement.value;
        const caretPos = this.getCaretPosition();
        const beforeCursor = this.helper.getBeforeCursor() ?? currentValue.substring(0, caretPos);
        let start = this._getHardBoundaryStart(beforeCursor);

        if (!getAutocompleteAppendCommaPreference()) {
            const persistedBoundaryEnd = this._getPersistedBoundaryEnd(currentValue, caretPos);
            if (persistedBoundaryEnd !== null && persistedBoundaryEnd > start) {
                start = persistedBoundaryEnd;
            }
        }

        const rawText = beforeCursor.substring(start);
        const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
        const trimmedStart = start + leadingWhitespaceLength;
        const text = rawText.trim();

        if (this.modelType === 'prompt') {
            const tokenRange = this._getPromptTokenRange(rawText, trimmedStart, caretPos);
            if (tokenRange) {
                return {
                    start: tokenRange.start,
                    trimmedStart: tokenRange.trimmedStart,
                    end: caretPos,
                    beforeCursor,
                    rawText: tokenRange.rawText,
                    text: tokenRange.text,
                    tokenType: tokenRange.tokenType,
                };
            }
        }

        return {
            start,
            trimmedStart,
            end: caretPos,
            beforeCursor,
            rawText,
            text,
        };
    }

    _getPromptTokenRange(rawText = '', trimmedStart = 0, caretPos = 0) {
        const trimmedText = rawText.trim();
        if (!trimmedText) {
            return {
                start: trimmedStart,
                trimmedStart,
                rawText: '',
                text: '',
                tokenType: 'empty',
            };
        }

        const commandOffset = trimmedText.startsWith('/')
            ? 0
            : trimmedText.lastIndexOf(' /');
        if (commandOffset !== -1) {
            const normalizedCommandOffset = commandOffset === 0 ? 0 : commandOffset + 1;
            const commandText = trimmedText.slice(normalizedCommandOffset);
            const commandStart = trimmedStart + normalizedCommandOffset;
            return {
                start: commandStart,
                trimmedStart: commandStart,
                rawText: commandText,
                text: commandText,
                tokenType: commandText === '/' ? 'empty_command_trigger' : 'command',
            };
        }

        const wildcardMatch = trimmedText.match(/(?:^|\s)(__[\w\s.\-+/*\\]+?__)$/);
        if (wildcardMatch) {
            const wildcardText = wildcardMatch[1];
            const wildcardOffset = trimmedText.lastIndexOf(wildcardText);
            const wildcardStart = trimmedStart + wildcardOffset;
            return {
                start: wildcardStart,
                trimmedStart: wildcardStart,
                rawText: wildcardText,
                text: '',
                tokenType: 'wildcard_literal',
            };
        }

        const embeddingOffset = trimmedText.search(/(?:^|\s)emb:[^\s]*$/i);
        if (embeddingOffset !== -1) {
            const normalizedEmbeddingOffset = trimmedText.slice(embeddingOffset).startsWith(' ')
                ? embeddingOffset + 1
                : embeddingOffset;
            const embeddingText = trimmedText.slice(normalizedEmbeddingOffset);
            const embeddingStart = trimmedStart + normalizedEmbeddingOffset;
            return {
                start: embeddingStart,
                trimmedStart: embeddingStart,
                rawText: embeddingText,
                text: embeddingText,
                tokenType: 'embedding_literal',
            };
        }

        return {
            start: trimmedStart,
            trimmedStart,
            rawText,
            text: trimmedText,
            tokenType: 'tag_text',
        };
    }

    _getHardBoundaryStart(beforeCursor = '') {
        const lastComma = beforeCursor.lastIndexOf(',');
        const lastAngle = beforeCursor.lastIndexOf('>');
        const lastNewline = Math.max(beforeCursor.lastIndexOf('\n'), beforeCursor.lastIndexOf('\r'));
        return Math.max(lastComma, lastAngle, lastNewline) + 1;
    }

    _getMetadataWidget() {
        return this.inputElement?._autocompleteMetadataWidget
            ?? this.inputElement?._autocompleteHostWidget?.metadataWidget
            ?? null;
    }

    _getMetadataBase() {
        return createAutocompleteMetadataBase(this.inputElement?._autocompleteTextWidgetName ?? 'text');
    }

    _getAutocompleteMetadata() {
        const metadataWidget = this._getMetadataWidget();
        const value = metadataWidget?.value;

        if (!value || typeof value !== 'object' || Array.isArray(value)) {
            return this._getMetadataBase();
        }

        return {
            ...this._getMetadataBase(),
            ...value,
        };
    }

    _setAutocompleteMetadata(metadata = {}) {
        const metadataWidget = this._getMetadataWidget();
        if (!metadataWidget) {
            return;
        }

        metadataWidget.value = {
            ...this._getMetadataBase(),
            ...metadata,
        };
    }

    _clearLastAcceptedBoundary() {
        const metadataWidget = this._getMetadataWidget();
        if (!metadataWidget) {
            return;
        }

        const metadata = this._getAutocompleteMetadata();
        delete metadata.lastAccepted;
        metadataWidget.value = metadata;
    }

    _storeLastAcceptedBoundary(boundary) {
        this._setAutocompleteMetadata({ lastAccepted: boundary });
    }

    _getPersistedBoundaryEnd(currentValue, caretPos) {
        const metadata = this._getAutocompleteMetadata();
        const boundary = metadata?.lastAccepted;

        if (!boundary || typeof boundary !== 'object') {
            return null;
        }

        const { start, end, insertedText, textSnapshot } = boundary;

        if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
            this._clearLastAcceptedBoundary();
            return null;
        }

        if (end > currentValue.length || end > caretPos) {
            this._clearLastAcceptedBoundary();
            return null;
        }

        if (typeof insertedText !== 'string' || insertedText.length === 0) {
            this._clearLastAcceptedBoundary();
            return null;
        }

        if (currentValue.slice(start, end) !== insertedText) {
            this._clearLastAcceptedBoundary();
            return null;
        }

        if (typeof textSnapshot !== 'string' || currentValue.slice(0, end) !== textSnapshot) {
            this._clearLastAcceptedBoundary();
            return null;
        }

        return end;
    }

    /**
     * Extract the last space-separated token from a search term
     * Tag names don't contain spaces, so for tag autocomplete we only need the last token
     * @param {string} term - The full search term (e.g., "hello 1gi")
     * @returns {string} - The last token (e.g., "1gi"), or the original term if no spaces
     */
    _getLastSpaceToken(term) {
        const tokens = term.trim().split(/\s+/);
        return tokens[tokens.length - 1] || term;
    }

    /**
     * Generate query variations for better autocomplete matching
     * Includes original query and normalized versions (spaces to underscores, etc.)
     * @param {string} term - Original search term
     * @returns {string[]} - Array of query variations
     */
    _generateQueryVariations(term) {
        if (!term || term.length < this.options.minChars) {
            return [];
        }

        const variations = new Set();
        const trimmed = term.trim();

        // Always include original query
        variations.add(trimmed);
        variations.add(trimmed.toLowerCase());

        // Add underscore version (Danbooru convention: spaces become underscores)
        // e.g., "looking to the side" -> "looking_to_the_side"
        if (trimmed.includes(' ')) {
            const underscoreVersion = trimmed.replace(/ /g, '_');
            variations.add(underscoreVersion);
            variations.add(underscoreVersion.toLowerCase());
        }

        // Add no-space version for flexible matching
        // e.g., "blue hair" -> "bluehair"
        if (trimmed.includes(' ') || trimmed.includes('_')) {
            const noSpaceVersion = trimmed.replace(/[ _]/g, '');
            variations.add(noSpaceVersion);
            variations.add(noSpaceVersion.toLowerCase());
        }

        // Add last token only (legacy behavior for continuous typing)
        const lastToken = this._getLastSpaceToken(trimmed);
        if (lastToken !== trimmed) {
            variations.add(lastToken);
            variations.add(lastToken.toLowerCase());
        }

        return Array.from(variations).filter(v => v.length >= this.options.minChars);
    }

    _normalizeQueryForRequest(term = '') {
        return term.trim().toLowerCase();
    }

    _getQueriesToExecute(term = '') {
        const queryVariations = this._generateQueryVariations(term);
        const uniqueQueries = [];
        const seen = new Set();

        for (const query of queryVariations) {
            const normalized = this._normalizeQueryForRequest(query);
            if (!normalized || seen.has(normalized)) {
                continue;
            }

            seen.add(normalized);
            uniqueQueries.push(query);

            if (uniqueQueries.length >= 4) {
                break;
            }
        }

        return uniqueQueries;
    }

    _containsInformationalItems() {
        return this.items.some((item) => isWildcardInfoItem(item));
    }

    _isSelectableInfoItem(item) {
        if (isWildcardInfoItem(item)) {
            return true;
        }
        // Command items are not model paths — never show preview for them
        return item && typeof item === 'object' && 'command' in item;
    }

    /**
     * Get display text for an item (without extension for models)
     * @param {string|Object} item - Item to get display text from
     * @returns {string} - Display text without extension
     */
    _getDisplayText(item) {
        if (isWildcardInfoItem(item)) {
            return item.title || item.description || 'Wildcards';
        }

        const itemText = typeof item === 'object' && item.tag_name ? item.tag_name : String(item);
        // Remove extension for models to avoid matching/displaying .safetensors etc.
        if (this.modelType === 'loras' || this.searchType === 'embeddings') {
            return removeLoraExtension(itemText);
        } else if (this.modelType === 'embeddings') {
            return removeGeneralExtension(itemText);
        }
        return itemText;
    }

    /**
     * Check if an item matches a search term
     * Supports both string items and enriched items with tag_name property
     * @param {string|Object} item - Item to check
     * @param {string} searchTerm - Search term to match against
     * @returns {Object} - { matched: boolean, isExactMatch: boolean }
     */
    _matchItem(item, searchTerm) {
        const itemText = this._getDisplayText(item);
        const itemTextLower = itemText.toLowerCase();
        const searchTermLower = searchTerm.toLowerCase();

        // Exact match (case-insensitive)
        if (itemTextLower === searchTermLower) {
            return { matched: true, isExactMatch: true };
        }

        // Partial match (contains)
        if (itemTextLower.includes(searchTermLower)) {
            return { matched: true, isExactMatch: false };
        }

        // Symbol-insensitive match: remove common separators and retry
        // e.g., "blue hair" can match "blue_hair" or "bluehair"
        const normalizedItem = itemTextLower.replace(/[-_\s']/g, '');
        const normalizedSearch = searchTermLower.replace(/[-_\s']/g, '');
        if (normalizedItem.includes(normalizedSearch)) {
            return { matched: true, isExactMatch: false };
        }

        return { matched: false, isExactMatch: false };
    }

    _getLiveSearchTermForAcceptance() {
        const rawSearchTerm = this.getSearchTerm(this.inputElement.value);

        if (this.modelType === 'embeddings') {
            const match = rawSearchTerm.match(/^emb:(.*)$/i);
            return (match?.[1] || '').trim();
        }

        if (this.modelType === 'loras') {
            const commandResult = this._parseCommandInput(rawSearchTerm);
            return commandResult.searchTerm ?? rawSearchTerm;
        }

        if (this.modelType === 'prompt') {
            const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
            if (embeddingMatch) {
                return (embeddingMatch[1] || '').trim();
            }

            const commandResult = this._parseCommandInput(rawSearchTerm);
            return commandResult.searchTerm ?? rawSearchTerm;
        }

        return rawSearchTerm;
    }

    _getPreferredSelectedIndex(searchTerm = '') {
        if (!this.items?.length) {
            return -1;
        }

        if (this.showingCommands) {
            if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
                return this.selectedIndex;
            }
            return 0;
        }

        const trimmedSearchTerm = searchTerm.trim();
        if (!trimmedSearchTerm) {
            if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
                return this.selectedIndex;
            }
            return 0;
        }

        const searchLower = trimmedSearchTerm.toLowerCase();
        const normalizedSearch = normalizeAutocompleteMatchText(trimmedSearchTerm);
        let bestIndex = -1;
        let bestScore = -Infinity;

        this.items.forEach((item, index) => {
            const displayText = this._getDisplayText(item);
            const textLower = displayText.toLowerCase();
            const normalizedText = normalizeAutocompleteMatchText(displayText);
            let score = -1;

            if (textLower === searchLower) {
                score = 5000;
            } else if (normalizedText === normalizedSearch) {
                score = 4500;
            } else if (textLower.startsWith(searchLower)) {
                score = 4000;
            } else if (normalizedText.startsWith(normalizedSearch)) {
                score = 3500;
            } else if (textLower.includes(searchLower)) {
                score = 3000;
            } else if (normalizedText.includes(normalizedSearch)) {
                score = 2500;
            }

            if (score > -1) {
                score -= index;
                if (score > bestScore) {
                    bestScore = score;
                    bestIndex = index;
                }
            }
        });

        if (bestIndex !== -1) {
            return bestIndex;
        }

        if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
            return this.selectedIndex;
        }

        return 0;
    }

    _getAcceptSelectionIndex(searchTerm = '') {
        if (this.hasManualSelection && this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
            return this.selectedIndex;
        }

        return this._getPreferredSelectedIndex(searchTerm);
    }

    /**
     * Build a URL-encoded query string from the LoRA Manager page's active
     * filters in localStorage, or null when not applicable.
     */
    _getActiveLoraFilters() {
        if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
            return null;
        }
        try {
            const params = new URLSearchParams();

            const folder = localStorage.getItem('lora_manager_loras_activeFolder');
            const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
            const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';

            if (folder && folder !== 'null') {
                params.append('folder', folder);
            } else if (!recursive) {
                // Root folder with recursion disabled mirrors the page list,
                // which matches only root-level files via folder=''.
                params.append('folder', '');
            }

            const raw = localStorage.getItem('lora_manager_loras_filters');
            if (raw) {
                const filters = JSON.parse(raw);

                if (Array.isArray(filters.baseModel)) {
                    filters.baseModel.forEach((m) => m && params.append('base_model', m));
                }

                if (filters.tags && typeof filters.tags === 'object') {
                    Object.entries(filters.tags).forEach(([tag, state]) => {
                        if (state === 'include') {
                            params.append('tag_include', tag);
                        } else if (state === 'exclude') {
                            params.append('tag_exclude', tag);
                        }
                    });
                }

                if (filters.autoTags && typeof filters.autoTags === 'object') {
                    Object.entries(filters.autoTags).forEach(([tag, state]) => {
                        if (state === 'include') {
                            params.append('auto_tag_include', tag);
                        } else if (state === 'exclude') {
                            params.append('auto_tag_exclude', tag);
                        }
                    });
                }

                if (Array.isArray(filters.modelTypes)) {
                    filters.modelTypes.forEach((t) => t && params.append('model_type', t));
                }

                if (filters.tagLogic) {
                    params.append('tag_logic', filters.tagLogic);
                }

                if (filters.license) {
                    if (filters.license.noCredit === 'include') {
                        params.append('credit_required', 'false');
                    } else if (filters.license.noCredit === 'exclude') {
                        params.append('credit_required', 'true');
                    }
                    if (filters.license.allowSelling === 'include') {
                        params.append('allow_selling_generated_content', 'true');
                    } else if (filters.license.allowSelling === 'exclude') {
                        params.append('allow_selling_generated_content', 'false');
                    }
                }
            }

            // Always send recursive in filter mode — its presence also signals
            // the backend to run the filter pipeline (e.g. show_only_sfw) even
            // when no concrete filter is set, matching the list endpoint.
            params.append('recursive', String(recursive));

            return params.toString();
        } catch (error) {
            console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
            return null;
        }
    }

    async search(term = '', endpoint = null) {
        try {
            this.currentSearchTerm = term;

            // Save current search type to detect mode changes during async search
            const searchTypeAtStart = this.searchType;

            // Clear items before starting new search to avoid stale data
            // This is critical for preventing command suggestions from persisting
            // when switching from command mode to regular tag search
            this.items = [];
            this.wildcardMeta = null;

            if (!endpoint) {
                endpoint = `/lm/${this.modelType}/relative-paths`;
            }

            // Active-filter query params for loras (null when setting off or
            // model type is not loras, so appending is safe for all types)
            const activeFiltersQuery = this._getActiveLoraFilters();

            // Generate multiple query variations for better matching, but avoid
            // sending duplicate-equivalent requests that normalize to the same
            // backend search term.
            const queriesToExecute =
                this.searchType === 'wildcards' && term.length === 0
                    ? ['']
                    : this._getQueriesToExecute(term);

            if (queriesToExecute.length === 0) {
                this.items = [];
                this.hide();
                return;
            }

            // Execute all queries in parallel
            const searchPromises = queriesToExecute.map(async (query) => {
                const url = endpoint.includes('?')
                    ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
                    : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
                const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;

                try {
                    const response = await api.fetchApi(finalUrl);
                    const data = await response.json();
                    return {
                        items: data.success ? (data.relative_paths || data.words || []) : [],
                        meta: data?.meta || null,
                    };
                } catch (error) {
                    console.warn(`Search query failed for "${query}":`, error);
                    return {
                        items: [],
                        meta: null,
                    };
                }
            });

            const resultsArrays = await Promise.all(searchPromises);

            // Check if search type changed during async operation
            // If so, skip updating items to prevent stale data from showing
            if (this.searchType !== searchTypeAtStart) {
                console.log('[Lora Manager] Search type changed during search, skipping update');
                return;
            }

            // Merge and deduplicate results while preserving order from backend
            // Backend returns results sorted by relevance, so we maintain that order
            const seen = new Set();
            const mergedItems = [];

            for (const result of resultsArrays) {
                if (!this.wildcardMeta && result?.meta) {
                    this.wildcardMeta = result.meta;
                }

                const resultArray = result?.items || [];
                for (const item of resultArray) {
                    const itemKey = typeof item === 'object' && item.tag_name
                        ? item.tag_name.toLowerCase()
                        : String(item).toLowerCase();

                    if (!seen.has(itemKey)) {
                        seen.add(itemKey);
                        mergedItems.push(item);
                    }
                }
            }

            if (this.searchType === 'wildcards' && mergedItems.length === 0) {
                const meta = this.wildcardMeta || {};
                this.items = meta.has_wildcards
                    ? [createWildcardNoMatchesItem(term, meta)]
                    : [createWildcardEmptyStateItem(meta)];
                this.hasMoreItems = false;
                this.render();
                this.show();
                return;
            }

            // Use backend-sorted results directly without re-scoring
            // Backend already ranks by: FTS5 bm25 score + post count + exact prefix boost
            if (mergedItems.length > 0) {
                this.items = mergedItems;
                this.render();
                this.show();
            } else {
                this.items = [];
                this.hide();
            }
        } catch (error) {
            console.error('Autocomplete search error:', error);
            this.items = [];
            this.hide();
        }
    }

    /**
     * Return the command map for the current model type.
     * Lora model types get the active-filters toggle commands, all others
     * keep the prompt tag commands.
     */
    _getCommands() {
        return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS;
    }

    /**
     * Parse command input to detect command mode
     * @param {string} rawInput - Raw input text
     * @returns {Object} - { showCommands, commandFilter, command, searchTerm }
     */
    _parseCommandInput(rawInput) {
        const trimmed = rawInput.trim();

        // Check if input starts with "/"
        if (!trimmed.startsWith('/')) {
            return { showCommands: false, command: null, searchTerm: trimmed };
        }

        // Split into potential command and search term
        const spaceIndex = trimmed.indexOf(' ');

        if (spaceIndex === -1) {
            // Still typing command (e.g., "/cha")
            const partialCommand = trimmed.toLowerCase();

            // Check for exact command match
            if (this._getCommands()[partialCommand]) {
                const cmd = this._getCommands()[partialCommand];
                // Filter out toggle commands that don't meet their condition
                if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
                    return { showCommands: false, command: null, searchTerm: '' };
                }
                return {
                    showCommands: false,
                    command: cmd,
                    searchTerm: '',
                };
            }

            // Show command suggestions
            return {
                showCommands: true,
                commandFilter: partialCommand.slice(1), // Remove leading "/"
                command: null,
                searchTerm: '',
            };
        }

        // Command with search term (e.g., "/character miku")
        const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
        const searchPart = trimmed.slice(spaceIndex + 1).trim();

        if (this._getCommands()[commandPart]) {
            const cmd = this._getCommands()[commandPart];
            // Filter out toggle commands that don't meet their condition
            if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
                return { showCommands: false, command: null, searchTerm: trimmed };
            }
            return {
                showCommands: false,
                command: cmd,
                searchTerm: searchPart,
            };
        }

        // Unknown command, treat as regular search
        return { showCommands: false, command: null, searchTerm: trimmed };
    }

    /**
     * Show the command list dropdown
     * @param {string} filter - Optional filter for commands
     */
    _showCommandList(filter = '') {
        // Only show command list if we're in command mode
        // This prevents stale command suggestions from appearing after switching to tag search
        if (this.searchType !== 'commands' && this.showingCommands !== true) {
            return;
        }
        
        const filterLower = filter.toLowerCase();

        const commands = [];

        for (const [cmd, info] of Object.entries(this._getCommands())) {
            // Filter out toggle commands that don't meet their condition
            if (info.type === 'toggle_setting' && info.condition) {
                if (!info.condition()) continue;
            }

            if (!filter || cmd.slice(1).startsWith(filterLower)) {
                commands.push({ command: cmd, ...info });
            }
        }

        if (commands.length === 0) {
            this.hide();
            return;
        }

        this.items = commands;
        this._renderCommandList();
        this.show();
    }

    /**
     * Render the command list dropdown
     */
    _renderCommandList() {
        // Clear command list items properly based on rendering mode
        if (this.contentContainer) {
            // Virtual scrolling mode - clear content container
            this.contentContainer.innerHTML = '';
        } else {
            // Non-virtual scrolling mode - clear dropdown direct children
            this.dropdown.innerHTML = '';
        }
        this.selectedIndex = -1;
        this.hasManualSelection = false;

        this.items.forEach((item, index) => {
            const itemEl = document.createElement('div');
            itemEl.className = 'comfy-autocomplete-item comfy-autocomplete-command';
            itemEl.dataset.index = index.toString();

            const cmdSpan = document.createElement('span');
            cmdSpan.className = 'lm-autocomplete-command-name';
            cmdSpan.textContent = item.command;

            const labelSpan = document.createElement('span');
            labelSpan.className = 'lm-autocomplete-command-label';
            labelSpan.textContent = item.label;

            itemEl.appendChild(cmdSpan);
            itemEl.appendChild(labelSpan);

            itemEl.style.cssText = `
                padding: 8px 12px;
                cursor: pointer;
                color: rgba(226, 232, 240, 0.8);
                border-bottom: 1px solid rgba(226, 232, 240, 0.1);
                transition: all 0.2s ease;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                display: flex;
                justify-content: space-between;
                align-items: center;
                gap: 12px;
                height: ${this.options.itemHeight}px;
                box-sizing: border-box;
            `;

            // Prevent textarea from losing focus - same fix as createItemElement
            itemEl.addEventListener('mousedown', (e) => {
                e.preventDefault();
            });

            itemEl.addEventListener('mouseenter', () => {
                this.selectItem(index, { manual: true });
            });

            itemEl.addEventListener('click', () => {
                this._insertCommand(item.command);
            });

            // Append to correct container based on rendering mode
            if (this.contentContainer) {
                this.contentContainer.appendChild(itemEl);
            } else {
                this.dropdown.appendChild(itemEl);
            }
        });

        // Remove border from last item
        const lastChild = this.contentContainer ? this.contentContainer.lastChild : this.dropdown.lastChild;
        if (lastChild) {
            lastChild.style.borderBottom = 'none';
        }

        // Auto-select immediately so accept keys remain stable.
        // In virtual-scroll mode, calling selectItem() before the dropdown is
        // visible can see a zero-height container and incorrectly replace the
        // full command list with a partially virtualized slice.
        if (this.items.length > 0) {
            this.selectedIndex = 0;
            this.hasManualSelection = false;
            if (this.contentContainer) {
                this._applyItemSelection(0);
            } else {
                this.selectItem(0);
            }
        }

        // State hint below the command list (e.g. how to toggle autocomplete)
        this._renderCommandListFooter();

        // Update virtual scroll height for virtual scrolling mode
        if (this.contentContainer) {
            this.updateVirtualScrollHeight();
        }
    }

    /**
     * Render a state hint below the slash command list so the autocomplete
     * toggle commands explain themselves. Only applies to prompt nodes.
     */
    _renderCommandListFooter() {
        this._removeCommandListFooter();

        if (this.modelType !== 'prompt') {
            return;
        }

        const enabled = getPromptTagAutocompletePreference();
        const footer = document.createElement('div');
        footer.className = 'lm-autocomplete-command-footer';
        footer.textContent = enabled
            ? 'Tag autocomplete is ON — /noautocomplete to disable'
            : 'Tag autocomplete is OFF — /autocomplete to enable';
        footer.style.cssText = `
            padding: 6px 12px;
            font-size: 11px;
            color: rgba(226, 232, 240, 0.5);
            border-top: 1px solid rgba(226, 232, 240, 0.1);
            white-space: nowrap;
        `;
        // Keep focus in the textarea when the hint is clicked
        footer.addEventListener('mousedown', (e) => e.preventDefault());

        this.dropdown.appendChild(footer);
        this.commandListFooter = footer;
    }

    _removeCommandListFooter() {
        if (this.commandListFooter) {
            this.commandListFooter.remove();
            this.commandListFooter = null;
        }
    }

    /**
     * Show a one-time, dismissible hint inside the dropdown telling users how
     * to disable tag autocomplete. Dismissal is persisted in localStorage.
     */
    _maybeShowFirstRunHint() {
        if (this.firstRunHint) {
            return;
        }
        if (this.modelType !== 'prompt'
            || this.showingCommands
            || this.searchType !== 'custom_words'
            || this.activeCommand) {
            return;
        }

        let dismissed = false;
        try {
            dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1';
        } catch (e) {
            // localStorage unavailable - fall through and show the hint
        }
        if (dismissed) {
            return;
        }

        const hint = document.createElement('div');
        hint.className = 'lm-autocomplete-first-run-hint';
        hint.style.cssText = `
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            padding: 6px 12px;
            font-size: 11px;
            color: rgba(226, 232, 240, 0.6);
            border-bottom: 1px solid rgba(226, 232, 240, 0.1);
        `;

        const text = document.createElement('span');
        text.textContent = 'Tip: type /noautocomplete to turn off these suggestions';

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.textContent = '×';
        closeBtn.title = 'Dismiss';
        closeBtn.style.cssText = `
            background: none;
            border: none;
            color: rgba(226, 232, 240, 0.5);
            cursor: pointer;
            font-size: 14px;
            line-height: 1;
            padding: 0 2px;
        `;
        closeBtn.addEventListener('click', () => {
            try {
                localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1');
            } catch (e) {
            }
            this._removeFirstRunHint();
        });

        hint.appendChild(text);
        hint.appendChild(closeBtn);
        // Keep focus in the textarea when interacting with the hint
        hint.addEventListener('mousedown', (e) => e.preventDefault());

        this.dropdown.insertBefore(hint, this.dropdown.firstChild);
        this.firstRunHint = hint;
    }

    _removeFirstRunHint() {
        if (this.firstRunHint) {
            this.firstRunHint.remove();
            this.firstRunHint = null;
        }
    }

    /**
     * Insert a command into the input
     * @param {string} command - The command to insert (e.g., "/character")
     */
    _insertCommand(command) {
        const currentValue = this.inputElement.value;
        const activeRange = this.getActiveSearchRange(currentValue);
        const commandStartPos = activeRange.trimmedStart;

        // Insert command with trailing space
        const insertText = command + ' ';
        const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(activeRange.end);
        const newCaretPos = commandStartPos + insertText.length;

        this.inputElement.value = newValue;

        // Trigger input event
        const event = new Event('input', { bubbles: true });
        this.inputElement.dispatchEvent(event);

        this.hide();

        // Focus and position cursor
        this.inputElement.focus();
        this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
    }

    render() {
        this.selectedIndex = -1;
        this.hasManualSelection = false;

        // Command-list state hints do not belong to regular search results
        this._removeCommandListFooter();

        // Reset virtual scroll state
        this.virtualScrollOffset = 0;
        this.currentPage = 0;
        this.hasMoreItems = true;
        this.isLoadingMore = false;

        // Early return if no items to prevent empty dropdown
        if (!this.items || this.items.length === 0) {
            if (this.contentContainer) {
                this.contentContainer.innerHTML = '';
            } else {
                this.dropdown.innerHTML = '';
            }
            return;
        }

        if (this.options.enableVirtualScroll && this.contentContainer) {
            // Use virtual scrolling - always update visible items to ensure content is fresh
            // The dropdown visibility is controlled by show()/hide()
            this.updateVirtualScrollHeight();
            this.updateVisibleItems();
        } else {
            // Traditional rendering (fallback)
            this.dropdown.innerHTML = '';

            // Check if items are enriched (have tag_name, category, post_count) or command objects
            const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
            const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];

            this.items.forEach((itemData, index) => {
                const item = this.createItemElement(itemData, index, isEnriched, isCommand);
                this.dropdown.appendChild(item);
            });

            // Remove border from last item
            if (this.dropdown.lastChild) {
                this.dropdown.lastChild.style.borderBottom = 'none';
            }
        }

        // Auto-select immediately so accept keys do not fall through
        // to native focus traversal while the dropdown is visible.
        if (this.items.length > 0) {
            this.selectItem(0);
        }
    }

    /**
     * Render an enriched autocomplete item with category badge and post count
     * @param {HTMLElement} itemEl - The item element to populate
     * @param {Object} itemData - The enriched item data { tag_name, category, post_count, matched_alias? }
     * @param {string} searchTerm - The current search term for highlighting
     */
    _renderEnrichedItem(itemEl, itemData, searchTerm) {
        // Create name span with highlighted match
        const nameSpan = document.createElement('span');
        nameSpan.className = 'lm-autocomplete-name';

        // If matched via alias, show: "tag_name ← alias" with alias highlighted
        if (itemData.matched_alias) {
            const tagText = document.createTextNode(itemData.tag_name + ' ');
            nameSpan.appendChild(tagText);

            const aliasSpan = document.createElement('span');
            aliasSpan.className = 'lm-matched-alias';
            aliasSpan.innerHTML = '← ' + this.highlightMatch(itemData.matched_alias, searchTerm);
            aliasSpan.style.cssText = `
                font-size: 11px;
                color: rgba(226, 232, 240, 0.5);
            `;
            nameSpan.appendChild(aliasSpan);
        } else {
            nameSpan.innerHTML = this.highlightMatch(itemData.tag_name, searchTerm);
        }

        nameSpan.style.cssText = `
            flex: 1;
            min-width: 0;
            overflow: hidden;
            text-overflow: ellipsis;
        `;

        // Create meta container for count and badge
        const metaSpan = document.createElement('span');
        metaSpan.className = 'lm-autocomplete-meta';
        metaSpan.style.cssText = `
            display: flex;
            align-items: center;
            gap: 8px;
            flex-shrink: 0;
        `;

        // Add post count
        if (itemData.post_count > 0) {
            const countSpan = document.createElement('span');
            countSpan.className = 'lm-autocomplete-count';
            countSpan.textContent = formatPostCount(itemData.post_count);
            countSpan.style.cssText = `
                font-size: 11px;
                color: rgba(226, 232, 240, 0.5);
            `;
            metaSpan.appendChild(countSpan);
        }

        // Add category badge
        const categoryInfo = CATEGORY_INFO[itemData.category];
        if (categoryInfo) {
            const badgeSpan = document.createElement('span');
            badgeSpan.className = 'lm-autocomplete-category';
            badgeSpan.textContent = categoryInfo.label;
            badgeSpan.style.cssText = `
                font-size: 10px;
                padding: 2px 6px;
                border-radius: 10px;
                background: ${categoryInfo.bg};
                color: ${categoryInfo.text};
                white-space: nowrap;
            `;
            metaSpan.appendChild(badgeSpan);
        }

        itemEl.appendChild(nameSpan);
        itemEl.appendChild(metaSpan);
    }

    _renderInformationalItem(itemEl, itemData) {
        itemEl.classList.add('comfy-autocomplete-info-item');
        itemEl.style.cssText = `
            padding: 12px;
            color: rgba(226, 232, 240, 0.88);
            border-bottom: none;
            cursor: default;
            display: block;
            white-space: normal;
            height: auto;
        `;

        const title = document.createElement('div');
        title.className = 'lm-autocomplete-info-title';
        title.textContent = itemData.title || 'Wildcards';
        title.style.cssText = `
            font-size: 13px;
            font-weight: 600;
            margin-bottom: 6px;
        `;
        itemEl.appendChild(title);

        const description = document.createElement('div');
        description.className = 'lm-autocomplete-info-description';
        description.textContent = itemData.description || '';
        description.style.cssText = `
            font-size: 12px;
            line-height: 1.45;
            color: rgba(226, 232, 240, 0.72);
        `;
        itemEl.appendChild(description);

        if (itemData.type === 'wildcard_no_matches') {
            return;
        }

        const pathBlock = document.createElement('div');
        pathBlock.style.cssText = `
            margin-top: 10px;
            padding: 8px 10px;
            border-radius: 6px;
            background: rgba(15, 23, 42, 0.6);
            font-size: 11px;
            line-height: 1.5;
        `;
        pathBlock.innerHTML = [
            '<div style="font-weight: 600; margin-bottom: 4px;">Wildcards folder</div>',
            `<code style="word-break: break-all; color: #dbeafe;">${itemData.wildcardsDir || '(unavailable)'}</code>`,
            `<div style="margin-top: 6px; color: rgba(226, 232, 240, 0.68);">Supported formats: ${(itemData.supportedFormats || []).join(', ')}</div>`,
        ].join('');
        itemEl.appendChild(pathBlock);

        const examples = document.createElement('div');
        examples.style.cssText = `
            margin-top: 10px;
            font-size: 11px;
            line-height: 1.55;
            color: rgba(226, 232, 240, 0.72);
        `;
        examples.innerHTML = [
            '<div style="font-weight: 600; color: rgba(226, 232, 240, 0.88); margin-bottom: 4px;">Examples</div>',
            '<div><code>animals/cat.txt</code> -> use <code>__animals/cat__</code></div>',
            '<div><code>colors.yaml</code> with <code>palette: { warm: [red, orange] }</code> -> use <code>__palette/warm__</code></div>',
            '<div style="margin-top: 6px;">Text files use one option per line. YAML/JSON use nested keys ending in string arrays.</div>',
        ].join('');
        itemEl.appendChild(examples);

        const actions = document.createElement('div');
        actions.style.cssText = `
            display: flex;
            gap: 8px;
            margin-top: 12px;
            flex-wrap: wrap;
        `;

        const openButton = document.createElement('button');
        openButton.type = 'button';
        openButton.dataset.action = 'open-wildcards-folder';
        openButton.textContent = 'Open wildcards folder';
        openButton.style.cssText = `
            border: 1px solid rgba(96, 165, 250, 0.45);
            background: rgba(37, 99, 235, 0.18);
            color: #dbeafe;
            border-radius: 6px;
            padding: 6px 10px;
            font-size: 11px;
            cursor: pointer;
        `;
        openButton.addEventListener('click', async (event) => {
            event.preventDefault();
            event.stopPropagation();
            await this._openWildcardsFolder();
        });
        actions.appendChild(openButton);

        const copyButton = document.createElement('button');
        copyButton.type = 'button';
        copyButton.dataset.action = 'copy-wildcards-path';
        copyButton.textContent = 'Copy path';
        copyButton.style.cssText = `
            border: 1px solid rgba(226, 232, 240, 0.2);
            background: rgba(148, 163, 184, 0.12);
            color: rgba(226, 232, 240, 0.88);
            border-radius: 6px;
            padding: 6px 10px;
            font-size: 11px;
            cursor: pointer;
        `;
        copyButton.addEventListener('click', async (event) => {
            event.preventDefault();
            event.stopPropagation();
            await this._copyWildcardPath(itemData.wildcardsDir || '');
        });
        actions.appendChild(copyButton);

        itemEl.appendChild(actions);
    }
    
    highlightMatch(text, searchTerm) {
        const { include } = parseSearchTokens(searchTerm);
        const sanitizedTokens = include
            .filter(Boolean)
            .map((token) => token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));

        if (!sanitizedTokens.length) {
            return text;
        }

        const regex = new RegExp(`(${sanitizedTokens.join('|')})`, 'gi');
        return text.replace(
            regex,
            '<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>',
        );
    }

    async _openWildcardsFolder() {
        try {
            const response = await api.fetchApi('/lm/wildcards/open-location', { method: 'POST' });
            const data = await response.json();
            if (!response.ok || data?.success === false) {
                throw new Error(data?.error || 'Failed to open wildcards folder');
            }

            if (data?.mode === 'clipboard' && data?.path) {
                await this._copyWildcardPath(data.path);
                return;
            }

            showToast({
                severity: 'success',
                summary: 'Wildcards folder',
                detail: 'Opened wildcards folder.',
                life: 2500,
            });
        } catch (error) {
            console.error('[Lora Manager] Failed to open wildcards folder:', error);
            showToast({
                severity: 'error',
                summary: 'Error',
                detail: error?.message || 'Failed to open wildcards folder',
                life: 3000,
            });
        }
    }

    async _copyWildcardPath(path) {
        if (!path) {
            return;
        }

        try {
            if (navigator?.clipboard?.writeText) {
                await navigator.clipboard.writeText(path);
            }
            showToast({
                severity: 'info',
                summary: 'Wildcards path',
                detail: path,
                life: 3000,
            });
        } catch (error) {
            console.error('[Lora Manager] Failed to copy wildcards path:', error);
            showToast({
                severity: 'warn',
                summary: 'Wildcards path',
                detail: path,
                life: 4000,
            });
        }
    }
    
    showPreviewForItem(relativePath, itemElement) {
        if (!this.options.showPreview || !this.previewTooltip) return;
        if (typeof relativePath !== 'string' || !relativePath) return;

        // Extract filename without extension for preview
        const fileName = relativePath.split(/[/\\]/).pop();
        const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
        
        // Get item position for tooltip positioning
        const rect = itemElement.getBoundingClientRect();
        const x = rect.right + 10;
        const y = rect.top;
        
        this.previewTooltip.show(loraName, x, y, true); // Pass true for fromAutocomplete flag
    }
    
    hidePreview() {
        if (!this.options.showPreview) {
            return;
        }
        if (typeof this.behavior.hidePreview === 'function') {
            this.behavior.hidePreview(this);
        } else if (this.previewTooltip) {
            this.previewTooltip.hide();
        }
    }
    
    /**
     * Handle scroll event for virtual scrolling and loading more items
     */
    handleScroll() {
        if (!this.scrollContainer || this.isLoadingMore) {
            return;
        }

        const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
        const scrollBottom = scrollTop + clientHeight;
        const threshold = this.options.itemHeight * 2; // Load more when within 2 items of bottom

        // Check if we need to load more items
        if (scrollBottom >= scrollHeight - threshold && this.hasMoreItems) {
            this.loadMoreItems();
        }

        // Update visible items for virtual scrolling
        if (this.options.enableVirtualScroll) {
            this.updateVisibleItems();
        }
    }

    /**
     * Load more items (pagination)
     */
    async loadMoreItems() {
        if (this.isLoadingMore || !this.hasMoreItems || this.showingCommands) {
            return;
        }

        this.isLoadingMore = true;
        this.currentPage++;

        try {
            // Show loading indicator
            this.showLoadingIndicator();

            // Get the current endpoint
            let endpoint = `/lm/${this.modelType}/relative-paths`;
            if (this.modelType === 'prompt') {
                if (this.searchType === 'embeddings') {
                    endpoint = '/lm/embeddings/relative-paths';
                } else if (this.searchType === 'wildcards') {
                    endpoint = getWildcardSearchEndpoint();
                } else if (this.searchType === 'custom_words') {
                    if (this.activeCommand?.categories) {
                        const categories = this.activeCommand.categories.join(',');
                        endpoint = `/lm/custom-words/search?category=${categories}`;
                    } else {
                        endpoint = '/lm/custom-words/search?enriched=true';
                    }
                }
            }

            const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
            const offset = this.items.length;

            // Active-filter query params for loras (null when setting off)
            const activeFiltersQuery = this._getActiveLoraFilters();

            // Execute all queries in parallel with offset
            const searchPromises = queriesToExecute.map(async (query) => {
                const url = endpoint.includes('?')
                    ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
                    : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
                const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;

                try {
                    const response = await api.fetchApi(finalUrl);
                    const data = await response.json();
                    return data.success ? (data.relative_paths || data.words || []) : [];
                } catch (error) {
                    console.warn(`Search query failed for "${query}":`, error);
                    return [];
                }
            });

            const resultsArrays = await Promise.all(searchPromises);

            // Merge and deduplicate results with existing items
            const seen = new Set(this.items.map(item => {
                const itemKey = typeof item === 'object' && item.tag_name
                    ? item.tag_name.toLowerCase()
                    : String(item).toLowerCase();
                return itemKey;
            }));
            const newItems = [];

            for (const resultArray of resultsArrays) {
                for (const item of resultArray) {
                    const itemKey = typeof item === 'object' && item.tag_name
                        ? item.tag_name.toLowerCase()
                        : String(item).toLowerCase();

                    if (!seen.has(itemKey)) {
                        seen.add(itemKey);
                        newItems.push(item);
                    }
                }
            }

            // If we got fewer items than requested, we've reached the end
            if (newItems.length < this.options.pageSize) {
                this.hasMoreItems = false;
            }

            // If we got new items, append them and re-render
            // IMPORTANT: Do NOT re-sort! Backend already returns results sorted by relevance
            if (newItems.length > 0) {
                this.items.push(...newItems);

                // Update render
                if (this.options.enableVirtualScroll) {
                    this.updateVirtualScrollHeight();
                    this.updateVisibleItems();
                } else {
                    this.render();
                }
            } else {
                this.hasMoreItems = false;
            }
        } catch (error) {
            console.error('Error loading more items:', error);
            this.hasMoreItems = false;
        } finally {
            this.isLoadingMore = false;
            this.hideLoadingIndicator();
        }
    }

    /**
     * Show loading indicator at the bottom of the list
     */
    showLoadingIndicator() {
        if (!this.contentContainer) return;

        let loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
        if (!loadingEl) {
            loadingEl = document.createElement('div');
            loadingEl.className = 'comfy-autocomplete-loading';
            loadingEl.textContent = 'Loading more...';
            loadingEl.style.cssText = `
                padding: 12px;
                text-align: center;
                color: rgba(226, 232, 240, 0.5);
                font-size: 12px;
            `;
            this.contentContainer.appendChild(loadingEl);
        }
    }

    /**
     * Hide loading indicator
     */
    hideLoadingIndicator() {
        if (!this.contentContainer) return;

        const loadingEl = this.contentContainer.querySelector('.comfy-autocomplete-loading');
        if (loadingEl) {
            loadingEl.remove();
        }
    }

    /**
     * Update the total height of the virtual scroll container
     */
    updateVirtualScrollHeight() {
        if (!this.contentContainer || !this.scrollContainer) return;

        if (this._containsInformationalItems()) {
            this.totalHeight = 0;
            this.contentContainer.style.height = 'auto';
            this.scrollContainer.style.maxHeight = `${this.options.visibleItems * this.options.itemHeight}px`;
            this.scrollContainer.style.overflowY = 'hidden';
            return;
        }

        this.totalHeight = this.items.length * this.options.itemHeight;
        this.contentContainer.style.height = `${this.totalHeight}px`;
        
        // Adjust scroll container max-height based on actual content
        // Only show scrollbar when content exceeds visibleItems limit
        const maxHeight = this.options.visibleItems * this.options.itemHeight;
        const shouldShowScrollbar = this.totalHeight > maxHeight;
        
        this.scrollContainer.style.maxHeight = shouldShowScrollbar ? `${maxHeight}px` : `${this.totalHeight}px`;
        this.scrollContainer.style.overflowY = shouldShowScrollbar ? 'auto' : 'hidden';
    }

    /**
     * Update which items are visible based on scroll position
     */
    updateVisibleItems() {
        if (!this.scrollContainer || !this.contentContainer) return;

        if (this._containsInformationalItems()) {
            this.contentContainer.innerHTML = '';
            if (this.items[0]) {
                this.contentContainer.appendChild(
                    this.createItemElement(this.items[0], 0, false, false)
                );
            }
            return;
        }

        const scrollTop = this.scrollContainer.scrollTop;
        const containerHeight = this.scrollContainer.clientHeight;

        // Calculate which items should be visible with a larger buffer for smoother rendering
        // Use a fixed buffer of 5 items to ensure selected item is always rendered
        const startIndex = Math.max(0, Math.floor(scrollTop / this.options.itemHeight) - 5);
        const endIndex = Math.min(
            this.items.length - 1,
            Math.ceil((scrollTop + containerHeight) / this.options.itemHeight) + 5
        );

        // Clear current content
        this.contentContainer.innerHTML = '';

        // Create spacer for items before visible range
        if (startIndex > 0) {
            const topSpacer = document.createElement('div');
            topSpacer.style.height = `${startIndex * this.options.itemHeight}px`;
            this.contentContainer.appendChild(topSpacer);
        }

        // Render visible items
        const isEnriched = this.items[0] && typeof this.items[0] === 'object' && 'tag_name' in this.items[0];
        const isCommand = this.items[0] && typeof this.items[0] === 'object' && 'command' in this.items[0];

        for (let i = startIndex; i <= endIndex; i++) {
            const itemData = this.items[i];
            const itemEl = this.createItemElement(itemData, i, isEnriched, isCommand);
            this.contentContainer.appendChild(itemEl);
        }

        // Create spacer for items after visible range
        if (endIndex < this.items.length - 1) {
            const bottomSpacer = document.createElement('div');
            bottomSpacer.style.height = `${(this.items.length - 1 - endIndex) * this.options.itemHeight}px`;
            this.contentContainer.appendChild(bottomSpacer);
        }
        
        // Re-apply selection styling after re-rendering
        // This ensures the selected item remains highlighted even after DOM updates
        if (this.selectedIndex >= startIndex && this.selectedIndex <= endIndex) {
            const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${this.selectedIndex}"]`);
            if (selectedEl) {
                selectedEl.classList.add('comfy-autocomplete-item-selected');
                selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
            }
        }
    }

    /**
     * Create a single item element
     */
    createItemElement(itemData, index, isEnriched, isCommand = false) {
        const item = document.createElement('div');
        item.className = 'comfy-autocomplete-item';
        item.dataset.index = index.toString();
        item.style.cssText = `
            height: ${this.options.itemHeight}px;
            padding: 8px 12px;
            cursor: pointer;
            color: rgba(226, 232, 240, 0.8);
            border-bottom: 1px solid rgba(226, 232, 240, 0.1);
            transition: all 0.2s ease;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            position: relative;
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 8px;
            box-sizing: border-box;
        `;

        // Check if this is a command object (override parameter if needed)
        if (!isCommand && itemData && typeof itemData === 'object' && 'command' in itemData) {
            isCommand = true;
        }

        if (isWildcardInfoItem(itemData)) {
            this._renderInformationalItem(item, itemData);
        } else if (isCommand) {
            // Render command item
            const cmdSpan = document.createElement('span');
            cmdSpan.className = 'lm-autocomplete-command-name';
            cmdSpan.textContent = itemData.command;

            const labelSpan = document.createElement('span');
            labelSpan.className = 'lm-autocomplete-command-label';
            labelSpan.textContent = itemData.label;

            item.appendChild(cmdSpan);
            item.appendChild(labelSpan);
            item.style.gap = '12px';
        } else if (isEnriched) {
            this._renderEnrichedItem(item, itemData, this.currentSearchTerm);
        } else {
            const nameSpan = document.createElement('span');
            nameSpan.className = 'lm-autocomplete-name';
            // Use display text without extension for cleaner UI
            const displayTextWithoutExt = this._getDisplayText(itemData);
            nameSpan.innerHTML = this.highlightMatch(displayTextWithoutExt, this.currentSearchTerm);
            nameSpan.style.cssText = `
                flex: 1;
                min-width: 0;
                overflow: hidden;
                text-overflow: ellipsis;
            `;
            item.appendChild(nameSpan);
        }

        // Prevent textarea from losing focus when clicking dropdown items.
        // Without this, the blur event fires before click, and the blur handler's
        // formatAutocompleteTextOnBlur() modifies the text and triggers hide()
        // via suppressAutocompleteOnce, removing this item from the DOM before
        // the click handler can execute. This specifically breaks the case where
        // the text has a comma not followed by a space (e.g. "<lora:X:1>,search").
        item.addEventListener('mousedown', (e) => {
            e.preventDefault();
        });

        // Hover and selection handlers
        item.addEventListener('mouseenter', () => {
            this.selectItem(index, { manual: true });
        });

        item.addEventListener('mouseleave', () => {
            this.hidePreview();
        });

        // Click handler
        item.addEventListener('click', () => {
            if (isWildcardInfoItem(itemData)) {
                return;
            }

            if (isCommand) {
                this._insertCommand(itemData.command);
            } else {
                const insertPath = isEnriched ? itemData.tag_name : itemData;
                this.insertSelection(insertPath);
            }
        });

        return item;
    }

    show() {
        if (!this.items || this.items.length === 0) {
            this.hide();
            return;
        }

        this._maybeShowFirstRunHint();
        // For virtual scrolling, render items first so positionAtCursor can measure width correctly
        if (this.options.enableVirtualScroll && this.contentContainer) {
            this.dropdown.style.display = 'block';
            this.isVisible = true;
            // Skip updateVisibleItems if showing commands (already rendered by _renderCommandList)
            if (!this.showingCommands) {
                this.updateVisibleItems();
            }
            this.positionAtCursor();
        } else {
            // Position dropdown at cursor position using TextAreaCaretHelper
            this.positionAtCursor();
            this.dropdown.style.display = 'block';
            this.isVisible = true;
        }
    }
    
    positionAtCursor() {
        const position = this.helper.getCursorOffset();
        this.dropdown.style.left = (position.left ?? 0) + "px";
        this.dropdown.style.top = (position.top ?? 0) + "px";
        this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";

        // Adjust width to fit content
        // Temporarily show the dropdown to measure content width
        const originalDisplay = this.dropdown.style.display;
        this.dropdown.style.display = 'block';
        this.dropdown.style.visibility = 'hidden';

        // Temporarily remove width constraints to allow content to expand naturally
        // This prevents items.scrollWidth from being limited by a narrow container
        const originalWidth = this.dropdown.style.width;
        this.dropdown.style.width = 'auto';
        this.dropdown.style.minWidth = '200px';

        // Measure the content width
        let maxWidth = 200; // minimum width
        // For virtual scrolling, query items from contentContainer; otherwise from dropdown
        const container = this.options.enableVirtualScroll && this.contentContainer
            ? this.contentContainer
            : this.dropdown;
        const items = container.querySelectorAll('.comfy-autocomplete-item');
        items.forEach(item => {
            const itemWidth = item.scrollWidth + 24; // Add padding
            maxWidth = Math.max(maxWidth, itemWidth);
        });

        // Set the width and restore visibility
        this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px
        this.dropdown.style.minWidth = '';
        this.dropdown.style.visibility = 'visible';
        this.dropdown.style.display = originalDisplay;
    }
    
    getCaretPosition() {
        return this.inputElement.selectionStart || 0;
    }
    
    hide() {
        if (!this.dropdown) {
            return;
        }

        this.dropdown.style.display = 'none';
        this.isVisible = false;
        this.selectedIndex = -1;
        this.hasManualSelection = false;
        this.showingCommands = false;

        // Remove discoverability hints attached to the dropdown
        this._removeCommandListFooter();
        this._removeFirstRunHint();
        
        // Clear items to prevent stale data from being displayed
        // when autocomplete is shown again
        this.items = [];
        this.wildcardMeta = null;
        
        // Clear content container to prevent stale items from showing
        if (this.contentContainer) {
            // Virtual scrolling mode - clear content container
            this.contentContainer.innerHTML = '';
        } else {
            // Non-virtual scrolling mode - clear dropdown direct children
            this.dropdown.innerHTML = '';
        }

        // Reset virtual scrolling state
        this.virtualScrollOffset = 0;
        this.currentPage = 0;
        this.hasMoreItems = true;
        this.isLoadingMore = false;
        this.totalHeight = 0;

        // Reset scroll position
        if (this.scrollContainer) {
            this.scrollContainer.scrollTop = 0;
        }

        // Hide preview tooltip
        this.hidePreview();

        // Clear selection styles from all items
        const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
        items.forEach(item => {
            item.classList.remove('comfy-autocomplete-item-selected');
            item.style.backgroundColor = '';
        });
    }
    
    selectItem(index, { manual = false } = {}) {
        // Remove previous selection
        const container = this.options.enableVirtualScroll && this.contentContainer
            ? this.contentContainer
            : this.dropdown;
        const prevSelected = container.querySelector('.comfy-autocomplete-item-selected');
        if (prevSelected) {
            prevSelected.classList.remove('comfy-autocomplete-item-selected');
            prevSelected.style.backgroundColor = '';
        }

        // Add new selection
        if (index >= 0 && index < this.items.length) {
            this.selectedIndex = index;
            this.hasManualSelection = manual;

            // For virtual scrolling, we need to ensure the item is rendered
            if (this.options.enableVirtualScroll && this.scrollContainer) {
                // Calculate if the item is currently visible
                const itemTop = index * this.options.itemHeight;
                const itemBottom = itemTop + this.options.itemHeight;
                const scrollTop = this.scrollContainer.scrollTop;
                const containerHeight = this.scrollContainer.clientHeight;
                const scrollBottom = scrollTop + containerHeight;

                // If item is not visible, scroll to make it visible
                if (itemTop < scrollTop || itemBottom > scrollBottom) {
                    // Scroll to position the item in the visible area
                    // Position item at 1/3 from top for better visibility
                    const targetScrollTop = Math.max(0, itemTop - containerHeight / 3);
                    this.scrollContainer.scrollTop = targetScrollTop;
                    
                    // Re-render visible items after scroll
                    this.updateVisibleItems();
                    
                    // Apply selection after DOM is updated
                    // Use setTimeout to ensure DOM has been re-rendered
                    setTimeout(() => {
                        this._applyItemSelection(index);
                    }, 0);
                } else {
                    // Item is already visible, apply selection immediately
                    this._applyItemSelection(index);
                }
            } else {
                // Traditional rendering
                const item = container.children[index];
                if (item) {
                    item.classList.add('comfy-autocomplete-item-selected');
                    item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';

                    // Scroll into view if needed
                    item.scrollIntoView({ block: 'nearest' });

                    // Show preview for selected item
                    if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
                        if (typeof this.behavior.showPreview === 'function') {
                            this.behavior.showPreview(this, this.items[index], item);
                        } else if (this.previewTooltip) {
                            this.showPreviewForItem(this.items[index], item);
                        }
                    }
                }
            }
        }
    }

    /**
     * Apply selection styling to an item (used after virtual scroll re-render)
     * @param {number} index - Index of item to select
     */
    _applyItemSelection(index) {
        if (!this.contentContainer) return;

        // Find the item element using data-index attribute
        const selectedEl = this.contentContainer.querySelector(`.comfy-autocomplete-item[data-index="${index}"]`);

        if (selectedEl) {
            selectedEl.classList.add('comfy-autocomplete-item-selected');
            selectedEl.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';

            // Show preview for selected item
            if (this.options.showPreview && !this._isSelectableInfoItem(this.items[index])) {
                if (typeof this.behavior.showPreview === 'function') {
                    this.behavior.showPreview(this, this.items[index], selectedEl);
                } else if (this.previewTooltip) {
                    this.showPreviewForItem(this.items[index], selectedEl);
                }
            }
        }
    }
    
    handleKeyDown(e) {
        if (!this.isVisible) {
            return;
        }
        
        switch (e.key) {
            case 'ArrowDown':
                e.preventDefault();
                if (this.options.enableVirtualScroll && this.scrollContainer) {
                    // For virtual scrolling, handle boundary cases
                    if (this.selectedIndex >= this.items.length - 1) {
                        // Already at last item, try to load more
                        if (this.hasMoreItems && !this.isLoadingMore) {
                            this.loadMoreItems().then(() => {
                                // After loading more, select the next item
                                if (this.selectedIndex < this.items.length - 1) {
                                    this.selectItem(this.selectedIndex + 1, { manual: true });
                                }
                            });
                        }
                    } else {
                        this.selectItem(this.selectedIndex + 1, { manual: true });
                    }
                } else {
                    this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1), { manual: true });
                }
                break;
                
            case 'ArrowUp':
                e.preventDefault();
                if (this.options.enableVirtualScroll && this.scrollContainer) {
                    // For virtual scrolling, handle top boundary
                    if (this.selectedIndex <= 0) {
                        // Already at first item, ensure it's selected
                        this.selectItem(0, { manual: true });
                    } else {
                        this.selectItem(this.selectedIndex - 1, { manual: true });
                    }
                } else {
                    this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
                }
                break;
                
            case 'Enter':
            case 'Tab':
                if (!shouldAcceptAutocompleteKey(e.key)) {
                    break;
                }

                {
                    const liveSearchTerm = this._getLiveSearchTermForAcceptance();
                    const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
                    if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
                        this.selectItem(acceptIndex);
                    }
                }

                if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
                    e.preventDefault();
                    if (this.showingCommands) {
                        // Insert command
                        this._insertCommand(this.items[this.selectedIndex].command);
                    } else {
                        const selectedItem = this.items[this.selectedIndex];
                        if (isWildcardInfoItem(selectedItem)) {
                            if (selectedItem.type === 'wildcard_empty_state') {
                                this._openWildcardsFolder();
                            }
                            break;
                        }

                        // Insert selection (handle enriched items)
                        const insertPath = typeof selectedItem === 'object' && 'tag_name' in selectedItem
                            ? selectedItem.tag_name
                            : selectedItem;
                        this.insertSelection(insertPath);
                    }
                }
                break;
                
            case 'Escape':
                e.preventDefault();
                this.hide();
                break;
        }
    }
    
    async insertSelection(relativePath) {
        const insertText = await this.getInsertText(relativePath);
        if (!insertText) {
            this.hide();
            return;
        }

        const currentValue = this.inputElement.value;
        const activeRange = this.getActiveSearchRange(currentValue);
        const caretPos = activeRange.end;
        const fullSearchTerm = activeRange.text;
        let replaceStartPos = activeRange.trimmedStart;

        // For regular tag autocomplete (no command), only replace the last space-separated token
        // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
        // However, if the user typed a multi-word phrase that matches a tag (e.g., "looking to the side"
        // matching "looking_to_the_side"), replace the entire phrase instead of just the last word.
        // Command mode (e.g., "/character miku") should replace the entire command+search
        let searchTerm = fullSearchTerm;
        if (this.modelType === 'prompt' && this.searchType === 'custom_words' && !this.activeCommand) {
            // Check if the selectedItem exists and its tag_name matches the full search term
            // when converted to underscore format (Danbooru convention)
            const selectedItem = this.selectedIndex >= 0 ? this.items[this.selectedIndex] : null;
            const selectedTagName = selectedItem && typeof selectedItem === 'object' && 'tag_name' 
                ? selectedItem.tag_name 
                : null;
            
            // Convert full search term to underscore format and check if it matches selected tag
            // Normalize multiple spaces to single underscore for matching (e.g., "looking  to   the side" -> "looking_to_the_side")
            const underscoreVersion = fullSearchTerm.replace(/ +/g, '_').toLowerCase();
            const selectedTagLower = selectedTagName?.toLowerCase() ?? '';
            
            // If multi-word search term is a prefix or suffix of the selected tag,
            // replace the entire phrase. This handles cases where user types partial tag name.
            // Examples:
            // - "looking to the" -> "looking_to_the_side" (prefix match)
            // - "to the side" -> "looking_to_the_side" (suffix match)
            // - "looking to the side" -> "looking_to_the_side" (exact match)
            if (fullSearchTerm.includes(' ') && (
                selectedTagLower.startsWith(underscoreVersion) ||
                selectedTagLower.endsWith(underscoreVersion) ||
                underscoreVersion === selectedTagLower
            )) {
                searchTerm = fullSearchTerm;
                replaceStartPos = activeRange.trimmedStart;
            } else {
                searchTerm = this._getLastSpaceToken(fullSearchTerm);
                replaceStartPos = searchTerm === fullSearchTerm
                    ? activeRange.trimmedStart
                    : caretPos - searchTerm.length;
            }
        }

        // Only replace the search term, not everything after the last comma
        const newValue = currentValue.substring(0, replaceStartPos) + insertText + currentValue.substring(caretPos);
        const newCaretPos = replaceStartPos + insertText.length;

        this.inputElement.value = newValue;
        this._storeLastAcceptedBoundary({
            start: replaceStartPos,
            end: newCaretPos,
            insertedText: insertText,
            textSnapshot: newValue.substring(0, newCaretPos),
        });

        // Trigger input event to notify about the change
        const event = new Event('input', { bubbles: true });
        this.inputElement.dispatchEvent(event);

        this.hide();

        // Focus back to input and position cursor
        this.inputElement.focus();
        this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
    }

    async getInsertText(relativePath) {
        if (typeof this.behavior.getInsertText === 'function') {
            try {
                const result = await this.behavior.getInsertText(this, relativePath);
                if (typeof result === 'string' && result.length > 0) {
                    return result;
                }
            } catch (error) {
                console.warn('Failed to format autocomplete insertion:', error);
            }
        }

        const trimmed = typeof relativePath === 'string' ? relativePath.trim() : '';
        if (!trimmed) {
            return '';
        }
        return formatAutocompleteInsertion(trimmed);
    }

    /**
     * Check if the autocomplete instance is still valid
     * (input element exists and is in the DOM)
     * @returns {boolean}
     */
    isValid() {
        return this.inputElement && document.body.contains(this.inputElement);
    }

    /**
     * Refresh the TextAreaCaretHelper to update cached measurements
     * Useful after element is moved in DOM (e.g., Vue mode switch)
     */
    refreshCaretHelper() {
        if (this.inputElement && document.body.contains(this.inputElement)) {
            this.helper = new TextAreaCaretHelper(this.inputElement, () => app.canvas.ds.scale);
        }
    }

    /**
     * Handle toggle setting command (e.g., /autocomplete, /activefilters)
     * @param {Object} command - The toggle command with settingId and value
     */
    async _handleToggleSettingCommand(command) {
        const { settingId, value } = command;

        try {
            const success = await setLoraManagerSettingValue(settingId, value);
            if (success) {
                this._showToggleFeedback(command, value);
                this._clearCurrentToken();
            } else {
                throw new Error('settings API unavailable');
            }
        } catch (error) {
            console.error('[Lora Manager] Failed to toggle setting:', error);
            showToast({
                severity: 'error',
                summary: 'Error',
                detail: 'Failed to toggle autocomplete setting',
                life: 3000
            });
        }

        this.hide();
    }

    /**
     * Show visual feedback for toggle action using toast
     * @param {Object} command - The toggle command that was executed
     * @param {boolean} enabled - New autocomplete state
     */
    _showToggleFeedback(command, enabled) {
        showToast({
            severity: enabled ? 'success' : 'secondary',
            summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
            detail: command.feedbackDetail || (enabled
                ? 'Tag autocomplete is now ON. Type to see suggestions.'
                : 'Tag autocomplete is now OFF. Use /autocomplete or the node right-click menu to re-enable.'),
            life: 3000
        });
    }

    /**
     * Clear the current command token from input
     * Preserves leading spaces after delimiters (e.g., "1girl, /emb" -> "1girl, ")
     */
    _clearCurrentToken() {
        const currentValue = this.inputElement.value;
        const activeRange = this.getActiveSearchRange(currentValue);
        const caretPos = activeRange.end;
        const lastSegment = activeRange.rawText;
        
        // Find the command start position, preserving leading spaces
        // lastSegment includes leading spaces (e.g., " /emb"), find where command actually starts
        const commandMatch = lastSegment.match(/^(\s*)(\/\w+)/);
        if (commandMatch) {
            // commandMatch[1] is leading spaces, commandMatch[2] is the command
            const leadingSpaces = commandMatch[1].length;
            // Keep the spaces by starting after them
            const commandStartPos = activeRange.start + leadingSpaces;
            
            // Skip trailing spaces when deleting
            let endPos = caretPos;
            while (endPos < currentValue.length && currentValue[endPos] === ' ') {
                endPos++;
            }

            const newValue = currentValue.substring(0, commandStartPos) + currentValue.substring(endPos);
            const newCaretPos = commandStartPos;

            this.inputElement.value = newValue;

            // Trigger input event to notify about the change
            const event = new Event('input', { bubbles: true });
            this.inputElement.dispatchEvent(event);

            // Focus back to input and position cursor
            this.inputElement.focus();
            this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
        } else {
            // Fallback: delete the whole last segment (original behavior)
            const commandStartPos = activeRange.start;
            
            let endPos = caretPos;
            while (endPos < currentValue.length && currentValue[endPos] === ' ') {
                endPos++;
            }

            const newValue = currentValue.substring(0, commandStartPos) + currentValue.substring(endPos);
            const newCaretPos = commandStartPos;

            this.inputElement.value = newValue;

            const event = new Event('input', { bubbles: true });
            this.inputElement.dispatchEvent(event);

            this.inputElement.focus();
            this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
        }
    }

    destroy() {
        if (this.debounceTimer) {
            clearTimeout(this.debounceTimer);
        }

        if (this.onInput) {
            this.inputElement.removeEventListener('input', this.onInput);
            this.onInput = null;
        }

        if (this.onKeyDown) {
            this.inputElement.removeEventListener('keydown', this.onKeyDown);
            this.onKeyDown = null;
        }

        if (this.onBlur) {
            this.inputElement.removeEventListener('blur', this.onBlur);
            this.onBlur = null;
        }

        if (this.onDocumentClick) {
            document.removeEventListener('click', this.onDocumentClick);
            this.onDocumentClick = null;
        }

        if (this.onScroll && this.scrollContainer) {
            this.scrollContainer.removeEventListener('scroll', this.onScroll);
            this.onScroll = null;
        }

        if (typeof this.behavior.destroy === 'function') {
            this.behavior.destroy(this);
        } else if (this.previewTooltip) {
            this.previewTooltip.cleanup();
            this.previewTooltip = null;
        }
        this.previewTooltipPromise = null;
        
        if (this.dropdown && this.dropdown.parentNode) {
            this.dropdown.parentNode.removeChild(this.dropdown);
            this.dropdown = null;
        }
        
    }
}

export { AutoComplete, refreshLoraSyntaxFormat };
