import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents, getSanitizedFilename, checkOverwriteExistingData } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { getTokenCountAsync } from './tokenizers.js';
import { power_user } from './power-user.js';
import { getTagKeyForEntity } from './tags.js';
import { resolveVariable } from './variables.js';
import { debounce_timeout } from './constants.js';
import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';

export {
    world_info,
    world_info_budget,
    world_info_depth,
    world_info_min_activations,
    world_info_min_activations_depth_max,
    world_info_recursive,
    world_info_overflow_alert,
    world_info_case_sensitive,
    world_info_match_whole_words,
    world_info_character_strategy,
    world_info_budget_cap,
    world_names,
    checkWorldInfo,
    deleteWorldInfo,
    setWorldInfoSettings,
    getWorldInfoPrompt,
};

const world_info_insertion_strategy = {
    evenly: 0,
    character_first: 1,
    global_first: 2,
};

const world_info_logic = {
    AND_ANY: 0,
    NOT_ALL: 1,
    NOT_ANY: 2,
    AND_ALL: 3,
};

const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry');

let world_info = {};
let selected_world_info = [];
/** @type {string[]} */
let world_names;
let world_info_depth = 2;
let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated
let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0)

let world_info_budget = 25;
let world_info_recursive = false;
let world_info_overflow_alert = false;
let world_info_case_sensitive = false;
let world_info_match_whole_words = false;
let world_info_use_group_scoring = false;
let world_info_character_strategy = world_info_insertion_strategy.character_first;
let world_info_budget_cap = 0;
const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed);
const saveSettingsDebounced = debounce(() => {
    Object.assign(world_info, { globalSelect: selected_world_info });
    saveSettings();
}, debounce_timeout.relaxed);
const sortFn = (a, b) => b.order - a.order;
let updateEditor = (navigation, flashOnNav = true) => { console.debug('Triggered WI navigation', navigation, flashOnNav); };

// Do not optimize. updateEditor is a function that is updated by the displayWorldEntries with new data.
const worldInfoFilter = new FilterHelper(() => updateEditor());
const SORT_ORDER_KEY = 'world_info_sort_order';
const METADATA_KEY = 'world_info';

const DEFAULT_DEPTH = 4;
const DEFAULT_WEIGHT = 100;
const MAX_SCAN_DEPTH = 1000;

/**
 * Represents a scanning buffer for one evaluation of World Info.
 */
class WorldInfoBuffer {
    // Typedef area
    /**
     * @typedef {object} WIScanEntry The entry that triggered the scan
     * @property {number} [scanDepth] The depth of the scan
     * @property {boolean} [caseSensitive] If the scan is case sensitive
     * @property {boolean} [matchWholeWords] If the scan should match whole words
     * @property {boolean} [useGroupScoring] If the scan should use group scoring
     * @property {number} [uid] The UID of the entry that triggered the scan
     * @property {string[]} [key] The primary keys to scan for
     * @property {string[]} [keysecondary] The secondary keys to scan for
     * @property {number} [selectiveLogic] The logic to use for selective activation
     */
    // End typedef area

    /**
     * @type {object[]} Array of entries that need to be activated no matter what
     */
    static externalActivations = [];

    /**
     * @type {string[]} Array of messages sorted by ascending depth
     */
    #depthBuffer = [];

    /**
     * @type {string[]} Array of strings added by recursive scanning
     */
    #recurseBuffer = [];

    /**
     * @type {number} The skew of the global scan depth. Used in "min activations"
     */
    #skew = 0;

    /**
     * @type {number} The starting depth of the global scan depth. Incremented by "min activations" feature to not repeat scans. When > 0 it means a complete scan was done up to #startDepth already, and `advanceScanPosition` was called.
     */
    #startDepth = 0;

    /**
     * Initialize the buffer with the given messages.
     * @param {string[]} messages Array of messages to add to the buffer
     */
    constructor(messages) {
        this.#initDepthBuffer(messages);
    }

    /**
     * Populates the buffer with the given messages.
     * @param {string[]} messages Array of messages to add to the buffer
     * @returns {void} Hardly seen nothing down here
     */
    #initDepthBuffer(messages) {
        for (let depth = 0; depth < MAX_SCAN_DEPTH; depth++) {
            if (messages[depth]) {
                this.#depthBuffer[depth] = messages[depth].trim();
            }
            // break if last message is reached
            if (depth === messages.length - 1) {
                break;
            }
        }
    }

    /**
     * Gets a string that respects the case sensitivity setting
     * @param {string} str The string to transform
     * @param {WIScanEntry} entry The entry that triggered the scan
     * @returns {string} The transformed string
    */
    #transformString(str, entry) {
        const caseSensitive = entry.caseSensitive ?? world_info_case_sensitive;
        return caseSensitive ? str : str.toLowerCase();
    }

    /**
     * Gets all messages up to the given depth + recursion buffer.
     * @param {WIScanEntry} entry The entry that triggered the scan
     * @returns {string} A slice of buffer until the given depth (inclusive)
     */
    get(entry) {
        let depth = entry.scanDepth ?? this.getDepth();
        if (depth <= this.#startDepth) {
            return '';
        }

        if (depth < 0) {
            console.error(`Invalid WI scan depth ${depth}. Must be >= 0`);
            return '';
        }

        if (depth > MAX_SCAN_DEPTH) {
            console.warn(`Invalid WI scan depth ${depth}. Truncating to ${MAX_SCAN_DEPTH}`);
            depth = MAX_SCAN_DEPTH;
        }

        let result = this.#depthBuffer.slice(this.#startDepth, depth).join('\n');

        if (this.#recurseBuffer.length > 0) {
            result += '\n' + this.#recurseBuffer.join('\n');
        }

        return result;
    }

    /**
     * Matches the given string against the buffer.
     * @param {string} haystack The string to search in
     * @param {string} needle The string to search for
     * @param {WIScanEntry} entry The entry that triggered the scan
     * @returns {boolean} True if the string was found in the buffer
     */
    matchKeys(haystack, needle, entry) {
        // If the needle is a regex, we do regex pattern matching and override all the other options
        const keyRegex = parseRegexFromString(needle);
        if (keyRegex) {
            return keyRegex.test(haystack);
        }

        // Otherwise we do normal matching of plaintext with the chosen entry settings
        haystack = this.#transformString(haystack, entry);
        const transformedString = this.#transformString(needle, entry);
        const matchWholeWords = entry.matchWholeWords ?? world_info_match_whole_words;

        if (matchWholeWords) {
            const keyWords = transformedString.split(/\s+/);

            if (keyWords.length > 1) {
                return haystack.includes(transformedString);
            }
            else {
                // Use custom boundaries to include punctuation and other non-alphanumeric characters
                const regex = new RegExp(`(?:^|\\W)(${escapeRegex(transformedString)})(?:$|\\W)`);
                if (regex.test(haystack)) {
                    return true;
                }
            }
        } else {
            return haystack.includes(transformedString);
        }

        return false;
    }

    /**
     * Adds a message to the recursion buffer.
     * @param {string} message The message to add
     */
    addRecurse(message) {
        this.#recurseBuffer.push(message);
    }

    /**
     * Increments skew and sets startDepth to previous depth.
     */
    advanceScanPosition() {
        this.#startDepth = this.getDepth();
        this.#skew++;
    }

    /**
     * @returns {number} Settings' depth + current skew.
     */
    getDepth() {
        return world_info_depth + this.#skew;
    }

    /**
     * Check if the current entry is externally activated.
     * @param {object} entry WI entry to check
     * @returns {boolean} True if the entry is forcefully activated
     */
    isExternallyActivated(entry) {
        // Entries could be copied with structuredClone, so we need to compare them by string representation
        return WorldInfoBuffer.externalActivations.some(x => JSON.stringify(x) === JSON.stringify(entry));
    }

    /**
     * Clears the force activations buffer.
     */
    cleanExternalActivations() {
        WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length);
    }

    /**
     * Gets the match score for the given entry.
     * @param {WIScanEntry} entry Entry to check
     * @returns {number} The number of key activations for the given entry
     */
    getScore(entry) {
        const bufferState = this.get(entry);
        let numberOfPrimaryKeys = 0;
        let numberOfSecondaryKeys = 0;
        let primaryScore = 0;
        let secondaryScore = 0;

        // Increment score for every key found in the buffer
        if (Array.isArray(entry.key)) {
            numberOfPrimaryKeys = entry.key.length;
            for (const key of entry.key) {
                if (this.matchKeys(bufferState, key, entry)) {
                    primaryScore++;
                }
            }
        }

        // Increment score for every secondary key found in the buffer
        if (Array.isArray(entry.keysecondary)) {
            numberOfSecondaryKeys = entry.keysecondary.length;
            for (const key of entry.keysecondary) {
                if (this.matchKeys(bufferState, key, entry)) {
                    secondaryScore++;
                }
            }
        }

        // No keys == no score
        if (!numberOfPrimaryKeys) {
            return 0;
        }

        // Only positive logic influences the score
        if (numberOfSecondaryKeys > 0) {
            switch (entry.selectiveLogic) {
                // AND_ANY: Add both scores
                case world_info_logic.AND_ANY:
                    return primaryScore + secondaryScore;
                // AND_ALL: Add both scores if all secondary keys are found, otherwise only primary score
                case world_info_logic.AND_ALL:
                    return secondaryScore === numberOfSecondaryKeys ? primaryScore + secondaryScore : primaryScore;
            }
        }

        return primaryScore;
    }
}

export function getWorldInfoSettings() {
    return {
        world_info,
        world_info_depth,
        world_info_min_activations,
        world_info_min_activations_depth_max,
        world_info_budget,
        world_info_recursive,
        world_info_overflow_alert,
        world_info_case_sensitive,
        world_info_match_whole_words,
        world_info_character_strategy,
        world_info_budget_cap,
        world_info_use_group_scoring,
    };
}

const world_info_position = {
    before: 0,
    after: 1,
    ANTop: 2,
    ANBottom: 3,
    atDepth: 4,
    EMTop: 5,
    EMBottom: 6,
};

export const wi_anchor_position = {
    before: 0,
    after: 1,
};

const worldInfoCache = {};

/**
 * Gets the world info based on chat messages.
 * @param {string[]} chat The chat messages to scan.
 * @param {number} maxContext The maximum context size of the generation.
 * @param {boolean} isDryRun If true, the function will not emit any events.
 * @typedef {{worldInfoString: string, worldInfoBefore: string, worldInfoAfter: string, worldInfoExamples: any[], worldInfoDepth: any[]}} WIPromptResult
 * @returns {Promise<WIPromptResult>} The world info string and depth.
 */
async function getWorldInfoPrompt(chat, maxContext, isDryRun) {
    let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = '';

    const activatedWorldInfo = await checkWorldInfo(chat, maxContext);
    worldInfoBefore = activatedWorldInfo.worldInfoBefore;
    worldInfoAfter = activatedWorldInfo.worldInfoAfter;
    worldInfoString = worldInfoBefore + worldInfoAfter;

    if (!isDryRun && activatedWorldInfo.allActivatedEntries && activatedWorldInfo.allActivatedEntries.size > 0) {
        const arg = Array.from(activatedWorldInfo.allActivatedEntries);
        await eventSource.emit(event_types.WORLD_INFO_ACTIVATED, arg);
    }

    return {
        worldInfoString,
        worldInfoBefore,
        worldInfoAfter,
        worldInfoExamples: activatedWorldInfo.EMEntries ?? [],
        worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
    };
}

function setWorldInfoSettings(settings, data) {
    if (settings.world_info_depth !== undefined)
        world_info_depth = Number(settings.world_info_depth);
    if (settings.world_info_min_activations !== undefined)
        world_info_min_activations = Number(settings.world_info_min_activations);
    if (settings.world_info_min_activations_depth_max !== undefined)
        world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max);
    if (settings.world_info_budget !== undefined)
        world_info_budget = Number(settings.world_info_budget);
    if (settings.world_info_recursive !== undefined)
        world_info_recursive = Boolean(settings.world_info_recursive);
    if (settings.world_info_overflow_alert !== undefined)
        world_info_overflow_alert = Boolean(settings.world_info_overflow_alert);
    if (settings.world_info_case_sensitive !== undefined)
        world_info_case_sensitive = Boolean(settings.world_info_case_sensitive);
    if (settings.world_info_match_whole_words !== undefined)
        world_info_match_whole_words = Boolean(settings.world_info_match_whole_words);
    if (settings.world_info_character_strategy !== undefined)
        world_info_character_strategy = Number(settings.world_info_character_strategy);
    if (settings.world_info_budget_cap !== undefined)
        world_info_budget_cap = Number(settings.world_info_budget_cap);
    if (settings.world_info_use_group_scoring !== undefined)
        world_info_use_group_scoring = Boolean(settings.world_info_use_group_scoring);

    // Migrate old settings
    if (world_info_budget > 100) {
        world_info_budget = 25;
    }

    if (world_info_use_group_scoring === undefined) {
        world_info_use_group_scoring = false;
    }

    // Reset selected world from old string and delete old keys
    // TODO: Remove next release
    const existingWorldInfo = settings.world_info;
    if (typeof existingWorldInfo === 'string') {
        delete settings.world_info;
        selected_world_info = [existingWorldInfo];
    } else if (Array.isArray(existingWorldInfo)) {
        delete settings.world_info;
        selected_world_info = existingWorldInfo;
    }

    world_info = settings.world_info ?? {};

    $('#world_info_depth_counter').val(world_info_depth);
    $('#world_info_depth').val(world_info_depth);

    $('#world_info_min_activations_counter').val(world_info_min_activations);
    $('#world_info_min_activations').val(world_info_min_activations);

    $('#world_info_min_activations_depth_max_counter').val(world_info_min_activations_depth_max);
    $('#world_info_min_activations_depth_max').val(world_info_min_activations_depth_max);

    $('#world_info_budget_counter').val(world_info_budget);
    $('#world_info_budget').val(world_info_budget);

    $('#world_info_recursive').prop('checked', world_info_recursive);
    $('#world_info_overflow_alert').prop('checked', world_info_overflow_alert);
    $('#world_info_case_sensitive').prop('checked', world_info_case_sensitive);
    $('#world_info_match_whole_words').prop('checked', world_info_match_whole_words);
    $('#world_info_use_group_scoring').prop('checked', world_info_use_group_scoring);

    $(`#world_info_character_strategy option[value='${world_info_character_strategy}']`).prop('selected', true);
    $('#world_info_character_strategy').val(world_info_character_strategy);

    $('#world_info_budget_cap').val(world_info_budget_cap);
    $('#world_info_budget_cap_counter').val(world_info_budget_cap);

    world_names = data.world_names?.length ? data.world_names : [];

    // Add to existing selected WI if it exists
    selected_world_info = selected_world_info.concat(settings.world_info?.globalSelect?.filter((e) => world_names.includes(e)) ?? []);

    if (world_names.length > 0) {
        $('#world_info').empty();
    }

    world_names.forEach((item, i) => {
        $('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
        $('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
    });

    $('#world_info_sort_order').val(localStorage.getItem(SORT_ORDER_KEY) || '0');
    $('#world_info').trigger('change');
    $('#world_editor_select').trigger('change');

    eventSource.on(event_types.CHAT_CHANGED, () => {
        const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]);
        $('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
    });

    eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
        WorldInfoBuffer.externalActivations.push(...entries);
    });

    // Add slash commands
    registerWorldInfoSlashCommands();
}

function registerWorldInfoSlashCommands() {
    function reloadEditor(file) {
        const selectedIndex = world_names.indexOf(file);
        if (selectedIndex !== -1) {
            $('#world_editor_select').val(selectedIndex).trigger('change');
        }
    }

    async function getEntriesFromFile(file) {
        if (!file || !world_names.includes(file)) {
            toastr.warning('Valid World Info file name is required');
            return '';
        }

        const data = await loadWorldInfoData(file);

        if (!data || !('entries' in data)) {
            toastr.warning('World Info file has an invalid format');
            return '';
        }

        const entries = Object.values(data.entries);

        if (!entries || entries.length === 0) {
            toastr.warning('World Info file has no entries');
            return '';
        }

        return entries;
    }

    async function getChatBookCallback() {
        const chatId = getCurrentChatId();

        if (!chatId) {
            toastr.warning('Open a chat to get a name of the chat-bound lorebook');
            return '';
        }

        if (chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY])) {
            return chat_metadata[METADATA_KEY];
        }

        // Replace non-alphanumeric characters with underscores, cut to 64 characters
        const name = `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64);
        await createNewWorldInfo(name);

        chat_metadata[METADATA_KEY] = name;
        await saveMetadata();
        $('.chat_lorebook_button').addClass('world_set');
        return name;
    }

    async function findBookEntryCallback(args, value) {
        const file = resolveVariable(args.file);
        const field = args.field || 'key';

        const entries = await getEntriesFromFile(file);

        if (!entries) {
            return '';
        }

        if (typeof newEntryTemplate[field] === 'boolean') {
            const isTrue = isTrueBoolean(value);
            const isFalse = isFalseBoolean(value);

            if (isTrue) {
                value = String(true);
            }

            if (isFalse) {
                value = String(false);
            }
        }

        const fuse = new Fuse(entries, {
            keys: [{ name: field, weight: 1 }],
            includeScore: true,
            threshold: 0.3,
        });

        const results = fuse.search(value);

        if (!results || results.length === 0) {
            return '';
        }

        const result = results[0]?.item?.uid;

        if (result === undefined) {
            return '';
        }

        return result;
    }

    async function getEntryFieldCallback(args, uid) {
        const file = resolveVariable(args.file);
        const field = args.field || 'content';

        const entries = await getEntriesFromFile(file);

        if (!entries) {
            return '';
        }

        const entry = entries.find(x => String(x.uid) === String(uid));

        if (!entry) {
            toastr.warning('Valid UID is required');
            return '';
        }

        if (newEntryTemplate[field] === undefined) {
            toastr.warning('Valid field name is required');
            return '';
        }

        const fieldValue = entry[field];

        if (fieldValue === undefined) {
            return '';
        }

        if (Array.isArray(fieldValue)) {
            return fieldValue.map(x => substituteParams(x)).join(', ');
        }

        return substituteParams(String(fieldValue));
    }

    async function createEntryCallback(args, content) {
        const file = resolveVariable(args.file);
        const key = args.key;

        const data = await loadWorldInfoData(file);

        if (!data || !('entries' in data)) {
            toastr.warning('Valid World Info file name is required');
            return '';
        }

        const entry = createWorldInfoEntry(file, data);

        if (key) {
            entry.key.push(key);
            entry.addMemo = true;
            entry.comment = key;
        }

        if (content) {
            entry.content = content;
        }

        await saveWorldInfo(file, data, true);
        reloadEditor(file);

        return entry.uid;
    }

    async function setEntryFieldCallback(args, value) {
        const file = resolveVariable(args.file);
        const uid = resolveVariable(args.uid);
        const field = args.field || 'content';

        if (value === undefined) {
            toastr.warning('Value is required');
            return '';
        }

        value = value.replace(/\\([{}|])/g, '$1');

        const data = await loadWorldInfoData(file);

        if (!data || !('entries' in data)) {
            toastr.warning('Valid World Info file name is required');
            return '';
        }

        const entry = data.entries[uid];

        if (!entry) {
            toastr.warning('Valid UID is required');
            return '';
        }

        if (newEntryTemplate[field] === undefined) {
            toastr.warning('Valid field name is required');
            return '';
        }

        if (Array.isArray(entry[field])) {
            entry[field] = value.split(',').map(x => x.trim()).filter(x => x);
        } else if (typeof entry[field] === 'boolean') {
            entry[field] = isTrueBoolean(value);
        } else if (typeof entry[field] === 'number') {
            entry[field] = Number(value);
        } else {
            entry[field] = value;
        }

        if (originalDataKeyMap[field]) {
            setOriginalDataValue(data, uid, originalDataKeyMap[field], entry[field]);
        }

        await saveWorldInfo(file, data, true);
        reloadEditor(file);
        return '';
    }

    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'world',
        callback: onWorldInfoChange,
        namedArgumentList: [
            new SlashCommandNamedArgument(
                'state', 'set world state', [ARGUMENT_TYPE.STRING], false, false, null, ['off', 'toggle'],
            ),
            new SlashCommandNamedArgument(
                'silent', 'suppress toast messages', [ARGUMENT_TYPE.BOOLEAN], false,
            ),
        ],
        unnamedArgumentList: [
            new SlashCommandArgument(
                'name', [ARGUMENT_TYPE.STRING], false,
            ),
        ],
        helpString: `
            <div>
                Sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages.
            </div>
        `,
        aliases: [],
    }));
    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatbook',
        callback: getChatBookCallback,
        returns: 'lorebook name',
        helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
        aliases: ['getchatlore', 'getchatwi'],
    }));
    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry',
        aliases: ['findlore', 'findwi'],
        returns: 'UID',
        callback: findBookEntryCallback,
        namedArgumentList: [
            new SlashCommandNamedArgument(
                'file', 'bookName', ARGUMENT_TYPE.STRING, true,
            ),
            new SlashCommandNamedArgument(
                'field', 'field value for fuzzy match (default: key)', ARGUMENT_TYPE.STRING, false, false, 'key',
            ),
        ],
        unnamedArgumentList: [
            new SlashCommandArgument(
                'texts', ARGUMENT_TYPE.STRING, true, true,
            ),
        ],
        helpString: `
            <div>
                Find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe.
            </div>
            <div>
                <strong>Example:</strong>
                <ul>
                    <li>
                        <pre><code>/findentry file=chatLore field=key Shadowfang</code></pre>
                    </li>
                </ul>
            </div>
        `,
    }));
    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getentryfield',
        aliases: ['getlorefield', 'getwifield'],
        callback: getEntryFieldCallback,
        returns: 'field value',
        namedArgumentList: [
            new SlashCommandNamedArgument(
                'file', 'bookName', ARGUMENT_TYPE.STRING, true,
            ),
            new SlashCommandNamedArgument(
                'field', 'field to retrieve (default: content)', ARGUMENT_TYPE.STRING, false, false, 'content',
            ),
        ],
        unnamedArgumentList: [
            new SlashCommandArgument(
                'UID', ARGUMENT_TYPE.STRING, true,
            ),
        ],
        helpString: `
            <div>
                Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe.
            </div>
            <div>
                <strong>Example:</strong>
                <ul>
                    <li>
                        <pre><code>/getentryfield file=chatLore field=content 123</code></pre>
                    </li>
                </ul>
            </div>
        `,
    }));
    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'createentry',
        callback: createEntryCallback,
        aliases: ['createlore', 'createwi'],
        returns: 'UID of the new record',
        namedArgumentList: [
            new SlashCommandNamedArgument(
                'file', 'book name', [ARGUMENT_TYPE.STRING], true,
            ),
            new SlashCommandNamedArgument(
                'key', 'record key', [ARGUMENT_TYPE.STRING], false,
            ),
        ],
        unnamedArgumentList: [
            new SlashCommandArgument(
                'content', [ARGUMENT_TYPE.STRING], false,
            ),
        ],
        helpString: `
            <div>
                Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe.
            </div>
            <div>
                <strong>Example:</strong>
                <ul>
                    <li>
                        <pre><code>/createentry file=chatLore key=Shadowfang The sword of the king</code></pre>
                    </li>
                </ul>
            </div>
        `,
    }));
    SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'setentryfield',
        callback: setEntryFieldCallback,
        aliases: ['setlorefield', 'setwifield'],
        namedArgumentList: [
            new SlashCommandNamedArgument(
                'file', 'book name', [ARGUMENT_TYPE.STRING], true,
            ),
            new SlashCommandNamedArgument(
                'uid', 'record UID', [ARGUMENT_TYPE.STRING], true,
            ),
            new SlashCommandNamedArgument(
                'field', 'field name', [ARGUMENT_TYPE.STRING], true, false, 'content',
            ),
        ],
        unnamedArgumentList: [
            new SlashCommandArgument(
                'value', [ARGUMENT_TYPE.STRING], true,
            ),
        ],
        helpString: `
            <div>
                Set a field value (default: content) of the record with the UID from the specified book. To set multiple values for key fields, use comma-delimited list as a value.
            </div>
            <div>
                <strong>Example:</strong>
                <ul>
                    <li>
                        <pre><code>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</code></pre>
                    </li>
                </ul>
            </div>
        `,
    }));

}

// World Info Editor
async function showWorldEditor(name) {
    if (!name) {
        hideWorldEditor();
        return;
    }

    const wiData = await loadWorldInfoData(name);
    displayWorldEntries(name, wiData);
}

async function loadWorldInfoData(name) {
    if (!name) {
        return;
    }

    if (worldInfoCache[name]) {
        return worldInfoCache[name];
    }

    const response = await fetch('/api/worldinfo/get', {
        method: 'POST',
        headers: getRequestHeaders(),
        body: JSON.stringify({ name: name }),
        cache: 'no-cache',
    });

    if (response.ok) {
        const data = await response.json();
        worldInfoCache[name] = data;
        return data;
    }

    return null;
}

async function updateWorldInfoList() {
    const result = await fetch('/api/settings/get', {
        method: 'POST',
        headers: getRequestHeaders(),
        body: JSON.stringify({}),
    });

    if (result.ok) {
        var data = await result.json();
        world_names = data.world_names?.length ? data.world_names : [];
        $('#world_info').find('option[value!=""]').remove();
        $('#world_editor_select').find('option[value!=""]').remove();

        world_names.forEach((item, i) => {
            $('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
            $('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
        });
    }
}

function hideWorldEditor() {
    displayWorldEntries(null, null);
}

function getWIElement(name) {
    const wiElement = $('#world_info').children().filter(function () {
        return $(this).text().toLowerCase() === name.toLowerCase();
    });

    return wiElement;
}

/**
 * @param {any[]} data WI entries
 * @returns {any[]} Sorted data
 */
function sortEntries(data) {
    const option = $('#world_info_sort_order').find(':selected');
    const sortField = option.data('field');
    const sortOrder = option.data('order');
    const sortRule = option.data('rule');
    const orderSign = sortOrder === 'asc' ? 1 : -1;

    if (!data.length) return data;

    // If we have a search term for WI, we are sorting by weighting scores
    if (sortRule === 'search') {
        data.sort((a, b) => {
            const aScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, a.uid);
            const bScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, b.uid);
            return (aScore - bScore);
        });
    }
    else if (sortRule === 'custom') {
        // First by display index, then by order, then by uid
        data.sort((a, b) => {
            const aValue = a.displayIndex;
            const bValue = b.displayIndex;

            return (aValue - bValue || b.order - a.order || a.uid - b.uid);
        });
    } else if (sortRule === 'priority') {
        // First constant, then normal, then disabled. Then sort by order
        data.sort((a, b) => {
            const aValue = a.constant ? 0 : a.disable ? 2 : 1;
            const bValue = b.constant ? 0 : b.disable ? 2 : 1;

            return (aValue - bValue || b.order - a.order);
        });
    } else {
        const primarySort = (a, b) => {
            const aValue = a[sortField];
            const bValue = b[sortField];

            // Sort strings
            if (typeof aValue === 'string' && typeof bValue === 'string') {
                if (sortRule === 'length') {
                    // Sort by string length
                    return orderSign * (aValue.length - bValue.length);
                } else {
                    // Sort by A-Z ordinal
                    return orderSign * aValue.localeCompare(bValue);
                }
            }

            // Sort numbers
            return orderSign * (Number(aValue) - Number(bValue));
        };
        const secondarySort = (a, b) => a.order - b.order;
        const tertiarySort = (a, b) => a.uid - b.uid;

        data.sort((a, b) => {
            const primary = primarySort(a, b);

            if (primary !== 0) {
                return primary;
            }

            const secondary = secondarySort(a, b);

            if (secondary !== 0) {
                return secondary;
            }

            return tertiarySort(a, b);
        });
    }

    return data;
}

function nullWorldInfo() {
    toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true });
}

/** @type {Select2Option[]} Cache all keys as selectable dropdown option */
const worldEntryKeyOptionsCache = [];

/**
 * Update the cache and all select options for the keys with new values to display
 * @param {string[]|Select2Option[]} keyOptions - An array of options to update
 * @param {object} options - Optional arguments
 * @param {boolean?} [options.remove=false] - Whether the option was removed, so the count should be reduced - otherwise it'll be increased
 * @param {boolean?} [options.reset=false] - Whether the cache should be reset. Reset will also not trigger update of the controls, as we expect them to be redrawn anyway
 */
function updateWorldEntryKeyOptionsCache(keyOptions, { remove = false, reset = false } = {}) {
    if (!keyOptions.length) return;
    /** @type {Select2Option[]} */
    const options = keyOptions.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
    if (reset) worldEntryKeyOptionsCache.length = 0;
    options.forEach(option => {
        // Update the cache list
        let cachedEntry = worldEntryKeyOptionsCache.find(x => x.id == option.id);
        if (cachedEntry) {
            cachedEntry.count += !remove ? 1 : -1;
        } else if (!remove) {
            worldEntryKeyOptionsCache.push(option);
            cachedEntry = option;
            cachedEntry.count = 1;
        }
    });

    // Sort by count DESC and then alphabetically
    worldEntryKeyOptionsCache.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
}

function displayWorldEntries(name, data, navigation = navigation_option.none, flashOnNav = true) {
    updateEditor = (navigation, flashOnNav = true) => displayWorldEntries(name, data, navigation, flashOnNav);

    const worldEntriesList = $('#world_popup_entries_list');

    // We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
    // This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
    worldEntriesList.find('*').off();
    worldEntriesList.empty().show();

    if (!data || !('entries' in data)) {
        $('#world_popup_new').off('click').on('click', nullWorldInfo);
        $('#world_popup_name_button').off('click').on('click', nullWorldInfo);
        $('#world_popup_export').off('click').on('click', nullWorldInfo);
        $('#world_popup_delete').off('click').on('click', nullWorldInfo);
        $('#world_duplicate').off('click').on('click', nullWorldInfo);
        worldEntriesList.hide();
        $('#world_info_pagination').html('');
        return;
    }

    // Regardless of whether success is displayed or not. Make sure the delete button is available.
    // Do not put this code behind.
    $('#world_popup_delete').off('click').on('click', async () => {
        const confirmation = await callPopup(`<h3>Delete the World/Lorebook: "${name}"?</h3>This action is irreversible!`, 'confirm');

        if (!confirmation) {
            return;
        }

        if (world_info.charLore) {
            world_info.charLore.forEach((charLore, index) => {
                if (charLore.extraBooks?.includes(name)) {
                    const tempCharLore = charLore.extraBooks.filter((e) => e !== name);
                    if (tempCharLore.length === 0) {
                        world_info.charLore.splice(index, 1);
                    } else {
                        charLore.extraBooks = tempCharLore;
                    }
                }
            });

            saveSettingsDebounced();
        }

        // Selected world_info automatically refreshes
        await deleteWorldInfo(name);
    });

    // Before printing the WI, we check if we should enable/disable search sorting
    verifyWorldInfoSearchSortRule();

    function getDataArray(callback) {
        // Convert the data.entries object into an array
        let entriesArray = Object.keys(data.entries).map(uid => {
            const entry = data.entries[uid];
            entry.displayIndex = entry.displayIndex ?? entry.uid;
            return entry;
        });

        // Apply the filter and do the chosen sorting
        entriesArray = worldInfoFilter.applyFilters(entriesArray);
        entriesArray = sortEntries(entriesArray);

        // Cache keys
        const keys = entriesArray.flatMap(entry => [...entry.key, ...entry.keysecondary]);
        updateWorldEntryKeyOptionsCache(keys, { reset: true });

        // Run the callback for printing this
        typeof callback === 'function' && callback(entriesArray);
        return entriesArray;
    }

    let startPage = 1;

    if (navigation === navigation_option.previous) {
        startPage = $('#world_info_pagination').pagination('getCurrentPageNum');
    }

    const storageKey = 'WI_PerPage';
    const perPageDefault = 25;
    $('#world_info_pagination').pagination({
        dataSource: getDataArray,
        pageSize: Number(localStorage.getItem(storageKey)) || perPageDefault,
        sizeChangerOptions: [10, 25, 50, 100, 500, 1000],
        showSizeChanger: true,
        pageRange: 1,
        pageNumber: startPage,
        position: 'top',
        showPageNumbers: false,
        prevText: '<',
        nextText: '>',
        formatNavigator: PAGINATION_TEMPLATE,
        showNavigator: true,
        callback: function (/** @type {object[]} */ page) {
            // We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
            // This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
            worldEntriesList.find('*').off();
            worldEntriesList.empty();

            const keywordHeaders = `
            <div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 4.5em;">
            <small class="flex1">
            Title/Memo
        </small>
                <small style="width: calc(3.5em + 15px)">
                    Status
                </small>
                <small style="width: calc(3.5em + 30px)">
                    Position
                </small>
                <small style="width: calc(3.5em + 20px)">
                    Depth
                </small>
                <small style="width: calc(3.5em + 20px)">
                    Order
                </small>
                <small style="width: calc(3.5em + 15px)">
                    Trigger %
                </small>
            </div>`;
            const blocks = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x);
            const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom';
            if (!isCustomOrder) {
                blocks.forEach(block => {
                    block.find('.drag-handle').remove();
                });
            }
            worldEntriesList.append(keywordHeaders);
            worldEntriesList.append(blocks);
        },
        afterSizeSelectorChange: function (e) {
            localStorage.setItem(storageKey, e.target.value);
        },
        afterPaging: function () {
            $('#world_popup_entries_list textarea[name="comment"]').each(function () {
                initScrollHeight($(this));
            });
        },
    });



    if (typeof navigation === 'number' && Number(navigation) >= 0) {
        const selector = `#world_popup_entries_list [uid="${navigation}"]`;
        const data = getDataArray();
        const uidIndex = data.findIndex(x => x.uid === navigation);
        const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault;
        const page = Math.floor(uidIndex / perPage) + 1;
        $('#world_info_pagination').pagination('go', page);
        waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => {
            const element = $(selector);

            if (element.length === 0) {
                console.log(`Could not find element for uid ${navigation}`);
                return;
            }

            const elementOffset = element.offset();
            const parentOffset = element.parent().offset();
            const scrollOffset = elementOffset.top - parentOffset.top;
            $('#WorldInfo').scrollTop(scrollOffset);
            if (flashOnNav) flashHighlight(element);
        });
    }

    $('#world_popup_new').off('click').on('click', () => {
        const entry = createWorldInfoEntry(name, data);
        if (entry) updateEditor(entry.uid);
    });

    $('#world_popup_name_button').off('click').on('click', async () => {
        await renameWorldInfo(name, data);
    });

    $('#world_backfill_memos').off('click').on('click', async () => {
        let counter = 0;
        for (const entry of Object.values(data.entries)) {
            if (!entry.comment && Array.isArray(entry.key) && entry.key.length > 0) {
                entry.comment = entry.key[0];
                setOriginalDataValue(data, entry.uid, 'comment', entry.comment);
                counter++;
            }
        }

        if (counter > 0) {
            toastr.info(`Backfilled ${counter} titles`);
            await saveWorldInfo(name, data, true);
            updateEditor(navigation_option.previous);
        }
    });

    $('#world_popup_export').off('click').on('click', () => {
        if (name && data) {
            const jsonValue = JSON.stringify(data);
            const fileName = `${name}.json`;
            download(jsonValue, fileName, 'application/json');
        }
    });

    $('#world_duplicate').off('click').on('click', async () => {
        const tempName = getFreeWorldName();
        const finalName = await callPopup('<h3>Create a new World Info?</h3>Enter a name for the new file:', 'input', tempName);

        if (finalName) {
            await saveWorldInfo(finalName, data, true);
            await updateWorldInfoList();

            const selectedIndex = world_names.indexOf(finalName);
            if (selectedIndex !== -1) {
                $('#world_editor_select').val(selectedIndex).trigger('change');
            } else {
                hideWorldEditor();
            }
        }
    });

    // Check if a sortable instance exists
    if (worldEntriesList.sortable('instance') !== undefined) {
        // Destroy the instance
        worldEntriesList.sortable('destroy');
    }

    worldEntriesList.sortable({
        items: '.world_entry',
        delay: getSortableDelay(),
        handle: '.drag-handle',
        stop: async function (_event, _ui) {
            const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid');
            const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0;
            $('#world_popup_entries_list .world_entry').each(function (index) {
                const uid = $(this).data('uid');

                // Update the display index in the data array
                const item = data.entries[uid];

                if (!item) {
                    console.debug(`Could not find entry with uid ${uid}`);
                    return;
                }

                item.displayIndex = minDisplayIndex + index;
                setOriginalDataValue(data, uid, 'extensions.display_index', item.displayIndex);
            });

            console.table(Object.keys(data.entries).map(uid => data.entries[uid]).map(x => ({ uid: x.uid, key: x.key.join(','), displayIndex: x.displayIndex })));

            await saveWorldInfo(name, data, true);
        },
    });
    //$("#world_popup_entries_list").disableSelection();
}

const originalDataKeyMap = {
    'displayIndex': 'extensions.display_index',
    'excludeRecursion': 'extensions.exclude_recursion',
    'preventRecursion': 'extensions.prevent_recursion',
    'delayUntilRecursion': 'extensions.delay_until_recursion',
    'selectiveLogic': 'selectiveLogic',
    'comment': 'comment',
    'constant': 'constant',
    'order': 'insertion_order',
    'depth': 'extensions.depth',
    'probability': 'extensions.probability',
    'position': 'extensions.position',
    'role': 'extensions.role',
    'content': 'content',
    'enabled': 'enabled',
    'key': 'keys',
    'keysecondary': 'secondary_keys',
    'selective': 'selective',
    'matchWholeWords': 'extensions.match_whole_words',
    'useGroupScoring': 'extensions.use_group_scoring',
    'caseSensitive': 'extensions.case_sensitive',
    'scanDepth': 'extensions.scan_depth',
    'automationId': 'extensions.automation_id',
    'vectorized': 'extensions.vectorized',
    'groupOverride': 'extensions.group_override',
    'groupWeight': 'extensions.group_weight',
};

/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
function verifyWorldInfoSearchSortRule() {
    const searchTerm = worldInfoFilter.getFilterData(FILTER_TYPES.WORLD_INFO_SEARCH);
    const searchOption = $('#world_info_sort_order option[data-rule="search"]');
    const selector = $('#world_info_sort_order');
    const isHidden = searchOption.attr('hidden') !== undefined;

    // If we have a search term, we are displaying the sorting option for it
    if (searchTerm && isHidden) {
        searchOption.removeAttr('hidden');
        selector.val(searchOption.attr('value') || '0');
        flashHighlight(selector);
    }
    // If search got cleared, we make sure to hide the option and go back to the one before
    if (!searchTerm && !isHidden) {
        searchOption.attr('hidden', '');
        selector.val(localStorage.getItem(SORT_ORDER_KEY) || '0');
    }
}

function setOriginalDataValue(data, uid, key, value) {
    if (data.originalData && Array.isArray(data.originalData.entries)) {
        let originalEntry = data.originalData.entries.find(x => x.uid === uid);

        if (!originalEntry) {
            return;
        }

        setValueByPath(originalEntry, key, value);
    }
}

function deleteOriginalDataValue(data, uid) {
    if (data.originalData && Array.isArray(data.originalData.entries)) {
        const originalIndex = data.originalData.entries.findIndex(x => x.uid === uid);

        if (originalIndex >= 0) {
            data.originalData.entries.splice(originalIndex, 1);
        }
    }
}

/** @typedef {import('./utils.js').Select2Option} Select2Option */

/**
 * Splits a given input string that contains one or more keywords or regexes, separated by commas.
 *
 * Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commmas inside the regex are allowed, slashes have to be escaped like this: `\/`
 * If a regex doesn't stand alone, it is not treated as a regex.
 *
 * @param {string} input - One or multiple keywords or regexes, separated by commas
 * @returns {string[]} An array of keywords and regexes
 */
function splitKeywordsAndRegexes(input) {
    /** @type {string[]} */
    let keywordsAndRegexes = [];

    // We can make this easy. Instead of writing another function to find and parse regexes,
    // we gonna utilize the custom tokenizer that also handles the input.
    // No need for validation here
    const addFindCallback = (/** @type {Select2Option} */ item) => {
        keywordsAndRegexes.push(item.text);
    };

    const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback);
    const finalTerm = term.trim();
    if (finalTerm) {
        addFindCallback({ id: getSelect2OptionId(finalTerm), text: finalTerm });
    }

    return keywordsAndRegexes;
}

/**
 * Tokenizer parsing input and splitting it into keywords and regexes
 *
 * @param {{_type: string, term: string}} input - The typed input
 * @param {{options: object}} _selection - The selection even object (?)
 * @param {function(Select2Option):void} callback - The original callback function to call if an item should be inserted
 * @returns {{term: string}} - The remaining part that is untokenized in the textbox
 */
function customTokenizer(input, _selection, callback) {
    let current = input.term;

    // Go over the input and check the current state, if we can get a token
    for (let i = 0; i < current.length; i++) {
        let char = current[i];

        // If a comma is typed, we tokenize the input.
        // unless we are inside a possible regex, which would allow commas inside
        if (char === ',') {
            // We take everything up till now and consider this a token
            const token = current.slice(0, i).trim();

            // Now how we test if this is a valid regex? And not a finished one, but a half-finished one?
            // Easy, if someone typed a comma it can't be a delimiter escape.
            // So we just check if this opening with a slash, and if so, we "close" the regex and try to parse it.
            // So if we are inside a valid regex, we can't take the token now, we continue processing until the regex is closed,
            // or this is not a valid regex anymore
            if (token.startsWith('/') && isValidRegex(token + '/')) {
                continue;
            }

            // So now the comma really means the token is done.
            // We take the token up till now, and insert it. Empty will be skipped.
            if (token) {
                const isRegex = isValidRegex(token);

                // Last chance to check for valid regex again. Because it might have been valid while typing, but now is not valid anymore and contains commas we need to split.
                if (token.startsWith('/') && !isRegex) {
                    const tokens = token.split(',').map(x => x.trim());
                    tokens.forEach(x => callback({ id: getSelect2OptionId(x), text: x }));
                } else {
                    callback({ id: getSelect2OptionId(token), text: token });
                }
            }

            // Now remove the token from the current input, and the comma too
            current = current.slice(i + 1);
            i = 0;
        }
    }

    // At the end, just return the left-over input
    return { term: current };
}

/**
 * Validates if a string is a valid slash-delimited regex, that can be parsed and executed
 *
 * This is a wrapper around `parseRegexFromString`
 *
 * @param {string} input - A delimited regex string
 * @returns {boolean} Whether this would be a valid regex that can be parsed and executed
 */
function isValidRegex(input) {
    return parseRegexFromString(input) !== null;
}

/**
 * Gets a real regex object from a slash-delimited regex string
 *
 * This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped.
 * Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`).
 *
 * @param {string} input - A delimited regex string
 * @returns {RegExp|null} The regex object, or null if not a valid regex
 */
function parseRegexFromString(input) {
    // Extracting the regex pattern and flags
    let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/);
    if (!match) {
        return null; // Not a valid regex format
    }

    let [, pattern, flags] = match;

    // If we find any unescaped slash delimiter, we also exit out.
    // JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation,
    // we have to make sure that our delimiter is correctly escaped. Or every other engine would fail.
    if (pattern.match(/(^|[^\\])\//)) {
        return null;
    }

    // Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters
    pattern = pattern.replace('\\/', '/');

    // Then we return the regex. If it fails, it was invalid syntax.
    try {
        return new RegExp(pattern, flags);
    } catch (e) {
        return null;
    }
}

function getWorldEntry(name, data, entry) {
    if (!data.entries[entry.uid]) {
        return;
    }

    const template = WI_ENTRY_EDIT_TEMPLATE.clone();
    template.data('uid', entry.uid);
    template.attr('uid', entry.uid);

    // Init default state of WI Key toggle (=> true)
    if (typeof power_user.wi_key_input_plaintext === 'undefined') power_user.wi_key_input_plaintext = true;

    /** Function to build the keys input controls @param {string} entryPropName @param {string} originalDataValueName */
    function enableKeysInput(entryPropName, originalDataValueName) {
        const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
        const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
        input.data('uid', entry.uid);
        input.on('click', function (event) {
            // Prevent closing the drawer on clicking the input
            event.stopPropagation();
        });

        function templateStyling(/** @type {Select2Option} */ item, { searchStyle = false } = {}) {
            const content = $('<span>').addClass('item').text(item.text).attr('title', `${item.text}\n\nClick to edit`);
            const isRegex = isValidRegex(item.text);
            if (isRegex) {
                content.html(highlightRegex(item.text));
                content.addClass('regex_item').prepend($('<span>').addClass('regex_icon').text('•*').attr('title', 'Regex'));
            }

            if (searchStyle && item.count) {
                // Build a wrapping element
                const wrapper = $('<span>').addClass('result_block')
                    .append(content);
                wrapper.append($('<span>').addClass('item_count').text(item.count).attr('title', `Used as a key ${item.count} ${item.count != 1 ? 'times' : 'time'} in this lorebook`));
                return wrapper;
            }

            return content;
        }

        if (isFancyInput) {
            input.select2({
                ajax: dynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache),
                tags: true,
                tokenSeparators: [','],
                tokenizer: customTokenizer,
                placeholder: input.attr('placeholder'),
                templateResult: item => templateStyling(item, { searchStyle: true }),
                templateSelection: item => templateStyling(item),
            });
            input.on('change', function (_, { skipReset, noSave } = {}) {
                const uid = $(this).data('uid');
                /** @type {string[]} */
                const keys = ($(this).select2('data')).map(x => x.text);

                !skipReset && resetScrollHeight(this);
                if (!noSave) {
                    data.entries[uid][entryPropName] = keys;
                    setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
                    saveWorldInfo(name, data);
                }
            });
            input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data]));
            input.on('select2:unselect', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true }));

            select2ChoiceClickSubscribe(input, target => {
                const key = $(target).text();
                console.debug('Editing WI key', key);

                // Remove the current key from the actual selection
                const selected = input.val();
                if (!Array.isArray(selected)) return;
                var index = selected.indexOf(getSelect2OptionId(key));
                if (index > -1) selected.splice(index, 1);
                input.val(selected).trigger('change');
                // Manually update the cache, that change event is not gonna trigger it
                updateWorldEntryKeyOptionsCache([key], { remove: true });

                // We need to "hack" the actual text input into the currently open textarea
                input.next('span.select2-container').find('textarea')
                    .val(key).trigger('input');
            }, { openDrawer: true });

            select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
        }
        else {
            // Compatibility with mobile devices. On mobile we need a text input field, not a select option control, so we need its own event handlers
            template.find(`select[name="${entryPropName}"]`).hide();
            input.show();

            input.on('input', function (_, { skipReset, noSave } = {}) {
                const uid = $(this).data('uid');
                const value = String($(this).val());
                !skipReset && resetScrollHeight(this);
                if (!noSave) {
                    data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
                    setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
                    saveWorldInfo(name, data);
                }
            });
            input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
        }
        return { isFancy: isFancyInput, control: input };
    }

    // key
    const keyInput = enableKeysInput('key', 'keys');

    // keysecondary
    const keySecondaryInput = enableKeysInput('keysecondary', 'secondary_keys');

    // draw key input switch button
    template.find('.switch_input_type_icon').on('click', function () {
        power_user.wi_key_input_plaintext = !power_user.wi_key_input_plaintext;
        saveSettingsDebounced();

        // Just redraw the panel
        const uid = ($(this).parents('.world_entry')).data('uid');
        updateEditor(uid, false);

        $(`.world_entry[uid="${uid}"] .inline-drawer-icon`).trigger('click');
        // setTimeout(() => {
        // }, debounce_timeout.standard);
    }).each((_, icon) => {
        $(icon).attr('title', $(icon).data(power_user.wi_key_input_plaintext ? 'tooltip-on' : 'tooltip-off'));
        $(icon).text($(icon).data(power_user.wi_key_input_plaintext ? 'icon-on' : 'icon-off'));
    });

    // logic AND/NOT
    const selectiveLogicDropdown = template.find('select[name="entryLogicType"]');
    selectiveLogicDropdown.data('uid', entry.uid);

    selectiveLogicDropdown.on('click', function (event) {
        event.stopPropagation();
    });

    selectiveLogicDropdown.on('input', function () {
        const uid = $(this).data('uid');
        const value = Number($(this).val());
        data.entries[uid].selectiveLogic = !isNaN(value) ? value : world_info_logic.AND_ANY;
        setOriginalDataValue(data, uid, 'selectiveLogic', data.entries[uid].selectiveLogic);
        saveWorldInfo(name, data);
    });

    template
        .find(`select[name="entryLogicType"] option[value=${entry.selectiveLogic}]`)
        .prop('selected', true)
        .trigger('input');

    // Character filter
    const characterFilterLabel = template.find('label[for="characterFilter"] > small');
    characterFilterLabel.text(entry.characterFilter?.isExclude ? 'Exclude Character(s)' : 'Filter to Character(s)');

    // exclude characters checkbox
    const characterExclusionInput = template.find('input[name="character_exclusion"]');
    characterExclusionInput.data('uid', entry.uid);
    characterExclusionInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        characterFilterLabel.text(value ? 'Exclude Character(s)' : 'Filter to Character(s)');
        if (data.entries[uid].characterFilter) {
            if (!value && data.entries[uid].characterFilter.names.length === 0 && data.entries[uid].characterFilter.tags.length === 0) {
                delete data.entries[uid].characterFilter;
            } else {
                data.entries[uid].characterFilter.isExclude = value;
            }
        } else if (value) {
            Object.assign(
                data.entries[uid],
                {
                    characterFilter: {
                        isExclude: true,
                        names: [],
                        tags: [],
                    },
                },
            );
        }

        // Verify names to exist in the system
        if (data.entries[uid]?.characterFilter?.names?.length > 0) {
            for (const name of [...data.entries[uid].characterFilter.names]) {
                if (!getContext().characters.find(x => x.avatar.replace(/\.[^/.]+$/, '') === name)) {
                    console.warn(`World Info: Character ${name} not found. Removing from the entry filter.`, entry);
                    data.entries[uid].characterFilter.names = data.entries[uid].characterFilter.names.filter(x => x !== name);
                }
            }
        }

        setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
        saveWorldInfo(name, data);
    });
    characterExclusionInput.prop('checked', entry.characterFilter?.isExclude ?? false).trigger('input');

    const characterFilter = template.find('select[name="characterFilter"]');
    characterFilter.data('uid', entry.uid);

    if (!isMobile()) {
        $(characterFilter).select2({
            width: '100%',
            placeholder: 'All characters will pull from this entry.',
            allowClear: true,
            closeOnSelect: false,
        });
    }

    const characters = getContext().characters;
    characters.forEach((character) => {
        const option = document.createElement('option');
        const name = character.avatar.replace(/\.[^/.]+$/, '') ?? character.name;
        option.innerText = name;
        option.selected = entry.characterFilter?.names?.includes(name);
        option.setAttribute('data-type', 'character');
        characterFilter.append(option);
    });

    const tags = getContext().tags;
    tags.forEach((tag) => {
        const option = document.createElement('option');
        option.innerText = `[Tag] ${tag.name}`;
        option.selected = entry.characterFilter?.tags?.includes(tag.id);
        option.value = tag.id;
        option.setAttribute('data-type', 'tag');
        characterFilter.append(option);
    });

    characterFilter.on('mousedown change', async function (e) {
        // If there's no world names, don't do anything
        if (world_names.length === 0) {
            e.preventDefault();
            return;
        }

        const uid = $(this).data('uid');
        const selected = $(this).find(':selected');
        if ((!selected || selected?.length === 0) && !data.entries[uid].characterFilter?.isExclude) {
            delete data.entries[uid].characterFilter;
        } else {
            const names = selected.filter('[data-type="character"]').map((_, e) => e instanceof HTMLOptionElement && e.innerText).toArray();
            const tags = selected.filter('[data-type="tag"]').map((_, e) => e instanceof HTMLOptionElement && e.value).toArray();
            Object.assign(
                data.entries[uid],
                {
                    characterFilter: {
                        isExclude: data.entries[uid].characterFilter?.isExclude ?? false,
                        names: names,
                        tags: tags,
                    },
                },
            );
        }
        setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
        saveWorldInfo(name, data);
    });

    // comment
    const commentInput = template.find('textarea[name="comment"]');
    const commentToggle = template.find('input[name="addMemo"]');
    commentInput.data('uid', entry.uid);
    commentInput.on('input', function (_, { skipReset } = {}) {
        const uid = $(this).data('uid');
        const value = $(this).val();
        !skipReset && resetScrollHeight(this);
        data.entries[uid].comment = value;

        setOriginalDataValue(data, uid, 'comment', data.entries[uid].comment);
        saveWorldInfo(name, data);
    });
    commentToggle.data('uid', entry.uid);
    commentToggle.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        //console.log(value)
        const commentContainer = $(this)
            .closest('.world_entry')
            .find('.commentContainer');
        data.entries[uid].addMemo = value;
        saveWorldInfo(name, data);
        value ? commentContainer.show() : commentContainer.hide();
    });

    commentInput.val(entry.comment).trigger('input', { skipReset: true });
    //initScrollHeight(commentInput);
    commentToggle.prop('checked', true /* entry.addMemo */).trigger('input');
    commentToggle.parent().hide();

    // content
    const counter = template.find('.world_entry_form_token_counter');
    const countTokensDebounced = debounce(async function (counter, value) {
        const numberOfTokens = await getTokenCountAsync(value);
        $(counter).text(numberOfTokens);
    }, debounce_timeout.relaxed);

    const contentInput = template.find('textarea[name="content"]');
    contentInput.data('uid', entry.uid);
    contentInput.on('input', function (_, { skipCount } = {}) {
        const uid = $(this).data('uid');
        const value = $(this).val();
        data.entries[uid].content = value;

        setOriginalDataValue(data, uid, 'content', data.entries[uid].content);
        saveWorldInfo(name, data);

        if (skipCount) {
            return;
        }

        // count tokens
        countTokensDebounced(counter, value);
    });
    contentInput.val(entry.content).trigger('input', { skipCount: true });
    //initScrollHeight(contentInput);

    template.find('.inline-drawer-toggle').on('click', function () {
        if (counter.data('first-run')) {
            counter.data('first-run', false);
            countTokensDebounced(counter, contentInput.val());
            if (!keyInput.isFancy) initScrollHeight(keyInput.control);
            if (!keySecondaryInput.isFancy) initScrollHeight(keySecondaryInput.control);
        }
    });

    // selective
    const selectiveInput = template.find('input[name="selective"]');
    selectiveInput.data('uid', entry.uid);
    selectiveInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].selective = value;

        setOriginalDataValue(data, uid, 'selective', data.entries[uid].selective);
        saveWorldInfo(name, data);

        const keysecondary = $(this)
            .closest('.world_entry')
            .find('.keysecondary');

        const keysecondarytextpole = $(this)
            .closest('.world_entry')
            .find('.keysecondarytextpole');

        const keyprimaryselect = $(this)
            .closest('.world_entry')
            .find('.keyprimaryselect');

        const keyprimaryHeight = keyprimaryselect.outerHeight();
        keysecondarytextpole.css('height', keyprimaryHeight + 'px');

        value ? keysecondary.show() : keysecondary.hide();

    });
    //forced on, ignored if empty
    selectiveInput.prop('checked', true /* entry.selective */).trigger('input');
    selectiveInput.parent().hide();


    // constant
    /*
    const constantInput = template.find('input[name="constant"]');
    constantInput.data("uid", entry.uid);
    constantInput.on("input", function () {
        const uid = $(this).data("uid");
        const value = $(this).prop("checked");
        data.entries[uid].constant = value;
        setOriginalDataValue(data, uid, "constant", data.entries[uid].constant);
        saveWorldInfo(name, data);
    });
    constantInput.prop("checked", entry.constant).trigger("input");
    */

    // order
    const orderInput = template.find('input[name="order"]');
    orderInput.data('uid', entry.uid);
    orderInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = Number($(this).val());

        data.entries[uid].order = !isNaN(value) ? value : 0;
        updatePosOrdDisplay(uid);
        setOriginalDataValue(data, uid, 'insertion_order', data.entries[uid].order);
        saveWorldInfo(name, data);
    });
    orderInput.val(entry.order).trigger('input');
    orderInput.css('width', 'calc(3em + 15px)');

    // group
    const groupInput = template.find('input[name="group"]');
    groupInput.data('uid', entry.uid);
    groupInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = String($(this).val()).trim();

        data.entries[uid].group = value;
        setOriginalDataValue(data, uid, 'extensions.group', data.entries[uid].group);
        saveWorldInfo(name, data);
    });
    groupInput.val(entry.group ?? '').trigger('input');
    setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1);

    // inclusion priority
    const groupOverrideInput = template.find('input[name="groupOverride"]');
    groupOverrideInput.data('uid', entry.uid);
    groupOverrideInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].groupOverride = value;
        setOriginalDataValue(data, uid, 'extensions.group_override', data.entries[uid].groupOverride);
        saveWorldInfo(name, data);
    });
    groupOverrideInput.prop('checked', entry.groupOverride).trigger('input');

    // group weight
    const groupWeightInput = template.find('input[name="groupWeight"]');
    groupWeightInput.data('uid', entry.uid);
    groupWeightInput.on('input', function () {
        const uid = $(this).data('uid');
        let value = Number($(this).val());
        const min = Number($(this).attr('min'));
        const max = Number($(this).attr('max'));

        // Clamp the value
        if (value < min) {
            value = min;
            $(this).val(min);
        } else if (value > max) {
            value = max;
            $(this).val(max);
        }

        data.entries[uid].groupWeight = !isNaN(value) ? Math.abs(value) : 1;
        setOriginalDataValue(data, uid, 'extensions.group_weight', data.entries[uid].groupWeight);
        saveWorldInfo(name, data);
    });
    groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input');

    // probability
    if (entry.probability === undefined) {
        entry.probability = null;
    }

    // depth
    const depthInput = template.find('input[name="depth"]');
    depthInput.data('uid', entry.uid);

    depthInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = Number($(this).val());

        data.entries[uid].depth = !isNaN(value) ? value : 0;
        updatePosOrdDisplay(uid);
        setOriginalDataValue(data, uid, 'extensions.depth', data.entries[uid].depth);
        saveWorldInfo(name, data);
    });
    depthInput.val(entry.depth ?? DEFAULT_DEPTH).trigger('input');
    depthInput.css('width', 'calc(3em + 15px)');

    // Hide by default unless depth is specified
    if (entry.position === world_info_position.atDepth) {
        //depthInput.parent().hide();
    }

    const probabilityInput = template.find('input[name="probability"]');
    probabilityInput.data('uid', entry.uid);
    probabilityInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = Number($(this).val());

        data.entries[uid].probability = !isNaN(value) ? value : null;

        // Clamp probability to 0-100
        if (data.entries[uid].probability !== null) {
            data.entries[uid].probability = Math.min(100, Math.max(0, data.entries[uid].probability));

            if (data.entries[uid].probability !== value) {
                $(this).val(data.entries[uid].probability);
            }
        }

        setOriginalDataValue(data, uid, 'extensions.probability', data.entries[uid].probability);
        saveWorldInfo(name, data);
    });
    probabilityInput.val(entry.probability).trigger('input');
    probabilityInput.css('width', 'calc(3em + 15px)');

    // probability toggle
    if (entry.useProbability === undefined) {
        entry.useProbability = false;
    }

    const probabilityToggle = template.find('input[name="useProbability"]');
    probabilityToggle.data('uid', entry.uid);
    probabilityToggle.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].useProbability = value;
        const probabilityContainer = $(this)
            .closest('.world_entry')
            .find('.probabilityContainer');
        saveWorldInfo(name, data);
        value ? probabilityContainer.show() : probabilityContainer.hide();

        if (value && data.entries[uid].probability === null) {
            data.entries[uid].probability = 100;
        }

        if (!value) {
            data.entries[uid].probability = null;
        }

        probabilityInput.val(data.entries[uid].probability).trigger('input');
    });
    //forced on, 100% by default
    probabilityToggle.prop('checked', true /* entry.useProbability */).trigger('input');
    probabilityToggle.parent().hide();

    // position
    if (entry.position === undefined) {
        entry.position = 0;
    }

    const positionInput = template.find('select[name="position"]');
    //initScrollHeight(positionInput);
    positionInput.data('uid', entry.uid);
    positionInput.on('click', function (event) {
        // Prevent closing the drawer on clicking the input
        event.stopPropagation();
    });
    positionInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = Number($(this).val());
        data.entries[uid].position = !isNaN(value) ? value : 0;
        if (value === world_info_position.atDepth) {
            depthInput.prop('disabled', false);
            depthInput.css('visibility', 'visible');
            //depthInput.parent().show();
            const role = Number($(this).find(':selected').data('role'));
            data.entries[uid].role = role;
        } else {
            depthInput.prop('disabled', true);
            depthInput.css('visibility', 'hidden');
            data.entries[uid].role = null;
            //depthInput.parent().hide();
        }
        updatePosOrdDisplay(uid);
        // Spec v2 only supports before_char and after_char
        setOriginalDataValue(data, uid, 'position', data.entries[uid].position == 0 ? 'before_char' : 'after_char');
        // Write the original value as extensions field
        setOriginalDataValue(data, uid, 'extensions.position', data.entries[uid].position);
        setOriginalDataValue(data, uid, 'extensions.role', data.entries[uid].role);
        saveWorldInfo(name, data);
    });

    const roleValue = entry.position === world_info_position.atDepth ? String(entry.role ?? extension_prompt_roles.SYSTEM) : '';
    template
        .find(`select[name="position"] option[value=${entry.position}][data-role="${roleValue}"]`)
        .prop('selected', true)
        .trigger('input');

    //add UID above content box (less important doesn't need to be always visible)
    template.find('.world_entry_form_uid_value').text(`(UID: ${entry.uid})`);

    // disable
    /*
    const disableInput = template.find('input[name="disable"]');
    disableInput.data("uid", entry.uid);
    disableInput.on("input", function () {
        const uid = $(this).data("uid");
        const value = $(this).prop("checked");
        data.entries[uid].disable = value;
        setOriginalDataValue(data, uid, "enabled", !data.entries[uid].disable);
        saveWorldInfo(name, data);
    });
    disableInput.prop("checked", entry.disable).trigger("input");
    */

    //new tri-state selector for constant/normal/disabled
    const entryStateSelector = template.find('select[name="entryStateSelector"]');
    entryStateSelector.data('uid', entry.uid);
    entryStateSelector.on('click', function (event) {
        // Prevent closing the drawer on clicking the input
        event.stopPropagation();
    });
    entryStateSelector.on('input', function () {
        const uid = entry.uid;
        const value = $(this).val();
        switch (value) {
            case 'constant':
                data.entries[uid].constant = true;
                data.entries[uid].disable = false;
                data.entries[uid].vectorized = false;
                setOriginalDataValue(data, uid, 'enabled', true);
                setOriginalDataValue(data, uid, 'constant', true);
                setOriginalDataValue(data, uid, 'extensions.vectorized', false);
                template.removeClass('disabledWIEntry');
                break;
            case 'normal':
                data.entries[uid].constant = false;
                data.entries[uid].disable = false;
                data.entries[uid].vectorized = false;
                setOriginalDataValue(data, uid, 'enabled', true);
                setOriginalDataValue(data, uid, 'constant', false);
                setOriginalDataValue(data, uid, 'extensions.vectorized', false);
                template.removeClass('disabledWIEntry');
                break;
            case 'vectorized':
                data.entries[uid].constant = false;
                data.entries[uid].disable = false;
                data.entries[uid].vectorized = true;
                setOriginalDataValue(data, uid, 'enabled', true);
                setOriginalDataValue(data, uid, 'constant', false);
                setOriginalDataValue(data, uid, 'extensions.vectorized', true);
                template.removeClass('disabledWIEntry');
                break;
            case 'disabled':
                data.entries[uid].constant = false;
                data.entries[uid].disable = true;
                data.entries[uid].vectorized = false;
                setOriginalDataValue(data, uid, 'enabled', false);
                setOriginalDataValue(data, uid, 'constant', false);
                setOriginalDataValue(data, uid, 'extensions.vectorized', false);
                template.addClass('disabledWIEntry');
                break;
        }
        saveWorldInfo(name, data);

    });

    const entryState = function () {
        if (entry.constant === true) {
            return 'constant';
        } else if (entry.vectorized === true) {
            return 'vectorized';
        } else if (entry.disable === true) {
            return 'disabled';
        } else {
            return 'normal';
        }
    };
    template
        .find(`select[name="entryStateSelector"] option[value=${entryState()}]`)
        .prop('selected', true)
        .trigger('input');

    saveWorldInfo(name, data);

    // exclude recursion
    const excludeRecursionInput = template.find('input[name="exclude_recursion"]');
    excludeRecursionInput.data('uid', entry.uid);
    excludeRecursionInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].excludeRecursion = value;
        setOriginalDataValue(data, uid, 'extensions.exclude_recursion', data.entries[uid].excludeRecursion);
        saveWorldInfo(name, data);
    });
    excludeRecursionInput.prop('checked', entry.excludeRecursion).trigger('input');

    // prevent recursion
    const preventRecursionInput = template.find('input[name="prevent_recursion"]');
    preventRecursionInput.data('uid', entry.uid);
    preventRecursionInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].preventRecursion = value;
        setOriginalDataValue(data, uid, 'extensions.prevent_recursion', data.entries[uid].preventRecursion);
        saveWorldInfo(name, data);
    });
    preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');

    // delay until recursion
    const delayUntilRecursionInput = template.find('input[name="delay_until_recursion"]');
    delayUntilRecursionInput.data('uid', entry.uid);
    delayUntilRecursionInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).prop('checked');
        data.entries[uid].delayUntilRecursion = value;
        setOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion);
        saveWorldInfo(name, data);
    });
    delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input');

    // duplicate button
    const duplicateButton = template.find('.duplicate_entry_button');
    duplicateButton.data('uid', entry.uid);
    duplicateButton.on('click', function () {
        const uid = $(this).data('uid');
        const entry = duplicateWorldInfoEntry(data, uid);
        if (entry) {
            saveWorldInfo(name, data);
            updateEditor(entry.uid);
        }
    });

    // delete button
    const deleteButton = template.find('.delete_entry_button');
    deleteButton.data('uid', entry.uid);
    deleteButton.on('click', function () {
        const uid = $(this).data('uid');
        deleteWorldInfoEntry(data, uid);
        deleteOriginalDataValue(data, uid);
        saveWorldInfo(name, data);
        updateEditor(navigation_option.previous);
    });

    // scan depth
    const scanDepthInput = template.find('input[name="scanDepth"]');
    scanDepthInput.data('uid', entry.uid);
    scanDepthInput.on('input', function () {
        const uid = $(this).data('uid');
        const isEmpty = $(this).val() === '';
        const value = Number($(this).val());

        // Clamp if necessary
        if (value < 0) {
            $(this).val(0).trigger('input');
            toastr.warning('Scan depth cannot be negative');
            return;
        }

        if (value > MAX_SCAN_DEPTH) {
            $(this).val(MAX_SCAN_DEPTH).trigger('input');
            toastr.warning(`Scan depth cannot exceed ${MAX_SCAN_DEPTH}`);
            return;
        }

        data.entries[uid].scanDepth = !isEmpty && !isNaN(value) && value >= 0 && value <= MAX_SCAN_DEPTH ? Math.floor(value) : null;
        setOriginalDataValue(data, uid, 'extensions.scan_depth', data.entries[uid].scanDepth);
        saveWorldInfo(name, data);
    });
    scanDepthInput.val(entry.scanDepth ?? null).trigger('input');

    // case sensitive select
    const caseSensitiveSelect = template.find('select[name="caseSensitive"]');
    caseSensitiveSelect.data('uid', entry.uid);
    caseSensitiveSelect.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).val();

        data.entries[uid].caseSensitive = value === 'null' ? null : value === 'true';
        setOriginalDataValue(data, uid, 'extensions.case_sensitive', data.entries[uid].caseSensitive);
        saveWorldInfo(name, data);
    });
    caseSensitiveSelect.val((entry.caseSensitive === null || entry.caseSensitive === undefined) ? 'null' : entry.caseSensitive ? 'true' : 'false').trigger('input');

    // match whole words select
    const matchWholeWordsSelect = template.find('select[name="matchWholeWords"]');
    matchWholeWordsSelect.data('uid', entry.uid);
    matchWholeWordsSelect.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).val();

        data.entries[uid].matchWholeWords = value === 'null' ? null : value === 'true';
        setOriginalDataValue(data, uid, 'extensions.match_whole_words', data.entries[uid].matchWholeWords);
        saveWorldInfo(name, data);
    });
    matchWholeWordsSelect.val((entry.matchWholeWords === null || entry.matchWholeWords === undefined) ? 'null' : entry.matchWholeWords ? 'true' : 'false').trigger('input');

    // use group scoring select
    const useGroupScoringSelect = template.find('select[name="useGroupScoring"]');
    useGroupScoringSelect.data('uid', entry.uid);
    useGroupScoringSelect.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).val();

        data.entries[uid].useGroupScoring = value === 'null' ? null : value === 'true';
        setOriginalDataValue(data, uid, 'extensions.use_group_scoring', data.entries[uid].useGroupScoring);
        saveWorldInfo(name, data);
    });
    useGroupScoringSelect.val((entry.useGroupScoring === null || entry.useGroupScoring === undefined) ? 'null' : entry.useGroupScoring ? 'true' : 'false').trigger('input');

    // automation id
    const automationIdInput = template.find('input[name="automationId"]');
    automationIdInput.data('uid', entry.uid);
    automationIdInput.on('input', function () {
        const uid = $(this).data('uid');
        const value = $(this).val();

        data.entries[uid].automationId = value;
        setOriginalDataValue(data, uid, 'extensions.automation_id', data.entries[uid].automationId);
        saveWorldInfo(name, data);
    });
    automationIdInput.val(entry.automationId ?? '').trigger('input');
    setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);

    template.find('.inline-drawer-content').css('display', 'none'); //entries start collapsed

    function updatePosOrdDisplay(uid) {
        // display position/order info left of keyword box
        let entry = data.entries[uid];
        let posText = entry.position;
        switch (entry.position) {
            case 0:
                posText = '↑CD';
                break;
            case 1:
                posText = 'CD↓';
                break;
            case 2:
                posText = '↑AN';
                break;
            case 3:
                posText = 'AN↓';
                break;
            case 4:
                posText = `@D${entry.depth}`;
                break;
        }
        template.find('.world_entry_form_position_value').text(`(${posText} ${entry.order})`);
    }

    return template;
}

/**
 * Get the inclusion groups for the autocomplete.
 * @param {any} data WI data
 * @returns {(input: any, output: any) => any} Callback function for the autocomplete
 */
function getInclusionGroupCallback(data) {
    return function (control, input, output) {
        const uid = $(control).data('uid');
        const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());
        const groups = new Set();
        for (const entry of Object.values(data.entries)) {
            // Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries
            if (entry.uid == uid) continue;
            if (entry.group) {
                entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));
            }
        }

        const haystack = Array.from(groups);
        haystack.sort((a, b) => a.localeCompare(b));
        const needle = input.term.toLowerCase();
        const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
        const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));

        output(result);
    };
}

function getAutomationIdCallback(data) {
    return function (control, input, output) {
        const uid = $(control).data('uid');
        const ids = new Set();
        for (const entry of Object.values(data.entries)) {
            // Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries
            if (entry.uid == uid) continue;
            if (entry.automationId) {
                ids.add(String(entry.automationId));
            }
        }

        if ('quickReplyApi' in window) {
            // @ts-ignore
            for (const automationId of window['quickReplyApi'].listAutomationIds()) {
                ids.add(String(automationId));
            }
        }

        const haystack = Array.from(ids);
        haystack.sort((a, b) => a.localeCompare(b));
        const needle = input.term.toLowerCase();
        const result = haystack.filter(x => x.toLowerCase().includes(needle));

        output(result);
    };
}

/**
 * Create an autocomplete for the inclusion group.
 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
 * @param {object} [options={}] - Optional arguments
 * @param {boolean} [options.allowMultiple=false] - Whether to allow multiple comma-separated values
 */
function createEntryInputAutocomplete(input, callback, { allowMultiple = false } = {}) {
    const handleSelect = (event, ui) => {
        // Prevent default autocomplete select, so we can manually set the value
        event.preventDefault();
        if (!allowMultiple) {
            $(input).val(ui.item.value).trigger('input').trigger('blur');
        } else {
            var terms = String($(input).val()).split(/,\s*/);
            terms.pop(); // remove the current input
            terms.push(ui.item.value); // add the selected item
            $(input).val(terms.filter(x => x).join(', ')).trigger('input').trigger('blur');
        }
    };

    $(input).autocomplete({
        minLength: 0,
        source: function (request, response) {
            if (!allowMultiple) {
                callback(input, request, response);
            } else {
                const term = request.term.split(/,\s*/).pop();
                request.term = term;
                callback(input, request, response);
            }
        },
        select: handleSelect,
    });

    $(input).on('focus click', function () {
        $(input).autocomplete('search', allowMultiple ? String($(input).val()).split(/,\s*/).pop() : $(input).val());
    });
}


/**
 * Duplicated a WI entry by copying all of its properties and assigning a new uid
 * @param {*} data - The data of the book
 * @param {number} uid - The uid of the entry to copy in this book
 * @returns {*} The new WI duplicated entry
 */
function duplicateWorldInfoEntry(data, uid) {
    if (!data || !('entries' in data) || !data.entries[uid]) {
        return;
    }

    // Exclude uid and gather the rest of the properties
    const { uid: _, ...originalData } = data.entries[uid];

    // Create new entry and copy over data
    const entry = createWorldInfoEntry(data.name, data);
    Object.assign(entry, originalData);

    return entry;
}

/**
 * Deletes a WI entry, with a user confirmation dialog
 * @param {*[]} data - The data of the book
 * @param {number} uid - The uid of the entry to copy in this book
 */
function deleteWorldInfoEntry(data, uid) {
    if (!data || !('entries' in data)) {
        return;
    }

    if (!confirm(`Delete the entry with UID: ${uid}? This action is irreversible!`)) {
        throw new Error('User cancelled deletion');
    }

    delete data.entries[uid];
}

const newEntryTemplate = {
    key: [],
    keysecondary: [],
    comment: '',
    content: '',
    constant: false,
    vectorized: false,
    selective: true,
    selectiveLogic: world_info_logic.AND_ANY,
    addMemo: false,
    order: 100,
    position: 0,
    disable: false,
    excludeRecursion: false,
    preventRecursion: false,
    delayUntilRecursion: false,
    probability: 100,
    useProbability: true,
    depth: DEFAULT_DEPTH,
    group: '',
    groupOverride: false,
    groupWeight: DEFAULT_WEIGHT,
    scanDepth: null,
    caseSensitive: null,
    matchWholeWords: null,
    useGroupScoring: null,
    automationId: '',
    role: 0,
};

function createWorldInfoEntry(_name, data) {
    const newUid = getFreeWorldEntryUid(data);

    if (!Number.isInteger(newUid)) {
        console.error('Couldn\'t assign UID to a new entry');
        return;
    }

    const newEntry = { uid: newUid, ...structuredClone(newEntryTemplate) };
    data.entries[newUid] = newEntry;

    return newEntry;
}

async function _save(name, data) {
    await fetch('/api/worldinfo/edit', {
        method: 'POST',
        headers: getRequestHeaders(),
        body: JSON.stringify({ name: name, data: data }),
    });
    eventSource.emit(event_types.WORLDINFO_UPDATED, name, data);
}

async function saveWorldInfo(name, data, immediately) {
    if (!name || !data) {
        return;
    }

    delete worldInfoCache[name];

    if (immediately) {
        return await _save(name, data);
    }

    saveWorldDebounced(name, data);
}

async function renameWorldInfo(name, data) {
    const oldName = name;
    const newName = await callPopup('<h3>Rename World Info</h3>Enter a new name:', 'input', oldName);

    if (oldName === newName || !newName) {
        console.debug('World info rename cancelled');
        return;
    }

    const entryPreviouslySelected = selected_world_info.findIndex((e) => e === oldName);

    await saveWorldInfo(newName, data, true);
    await deleteWorldInfo(oldName);

    const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(oldName));
    if (existingCharLores && existingCharLores.length > 0) {
        existingCharLores.forEach((charLore) => {
            const tempCharLore = charLore.extraBooks.filter((e) => e !== oldName);
            tempCharLore.push(newName);
            charLore.extraBooks = tempCharLore;
        });
        saveSettingsDebounced();
    }

    if (entryPreviouslySelected !== -1) {
        const wiElement = getWIElement(newName);
        wiElement.prop('selected', true);
        $('#world_info').trigger('change');
    }

    const selectedIndex = world_names.indexOf(newName);
    if (selectedIndex !== -1) {
        $('#world_editor_select').val(selectedIndex).trigger('change');
    }
}

/**
 * Deletes a world info with the given name
 *
 * @param {string} worldInfoName - The name of the world info to delete
 * @returns {Promise<boolean>} A promise that resolves to true if the world info was successfully deleted, false otherwise
 */
async function deleteWorldInfo(worldInfoName) {
    if (!world_names.includes(worldInfoName)) {
        return false;
    }

    const response = await fetch('/api/worldinfo/delete', {
        method: 'POST',
        headers: getRequestHeaders(),
        body: JSON.stringify({ name: worldInfoName }),
    });

    if (!response.ok) {
        return false;
    }

    const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName);
    if (existingWorldIndex !== -1) {
        selected_world_info.splice(existingWorldIndex, 1);
        saveSettingsDebounced();
    }

    await updateWorldInfoList();
    $('#world_editor_select').trigger('change');

    if ($('#character_world').val() === worldInfoName) {
        $('#character_world').val('').trigger('change');
        setWorldInfoButtonClass(undefined, false);
        if (menu_type != 'create') {
            saveCharacterDebounced();
        }
    }

    return true;
}

function getFreeWorldEntryUid(data) {
    if (!data || !('entries' in data)) {
        return null;
    }

    const MAX_UID = 1_000_000; // <- should be safe enough :)
    for (let uid = 0; uid < MAX_UID; uid++) {
        if (uid in data.entries) {
            continue;
        }
        return uid;
    }

    return null;
}

function getFreeWorldName() {
    const MAX_FREE_NAME = 100_000;
    for (let index = 1; index < MAX_FREE_NAME; index++) {
        const newName = `New World (${index})`;
        if (world_names.includes(newName)) {
            continue;
        }
        return newName;
    }

    return undefined;
}

/**
 * Creates a new world info/lorebook with the given name.
 * Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog.
 *
 * @param {string} worldName - The name of the new world info
 * @param {Object} options - Optional parameters
 * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world
 * @returns {Promise<boolean>} - True if the world info was successfully created, false otherwise
 */
async function createNewWorldInfo(worldName, { interactive = false } = {}) {
    const worldInfoTemplate = { entries: {} };

    if (!worldName) {
        return false;
    }

    const sanitizedWorldName = await getSanitizedFilename(worldName);

    const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) });
    if (!allowed) {
        return false;
    }

    await saveWorldInfo(worldName, worldInfoTemplate, true);
    await updateWorldInfoList();

    const selectedIndex = world_names.indexOf(worldName);
    if (selectedIndex !== -1) {
        $('#world_editor_select').val(selectedIndex).trigger('change');
    } else {
        hideWorldEditor();
    }

    return true;
}

async function getCharacterLore() {
    const character = characters[this_chid];
    const name = character?.name;
    let worldsToSearch = new Set();

    const baseWorldName = character?.data?.extensions?.world;
    if (baseWorldName) {
        worldsToSearch.add(baseWorldName);
    } else {
        console.debug(`Character ${name}'s base world could not be found or is empty! Skipping...`);
        return [];
    }

    // TODO: Maybe make the utility function not use the window context?
    const fileName = getCharaFilename(this_chid);
    const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
    if (extraCharLore) {
        worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]);
    }

    let entries = [];
    for (const worldName of worldsToSearch) {
        if (selected_world_info.includes(worldName)) {
            console.debug(`Character ${name}'s world ${worldName} is already activated in global world info! Skipping...`);
            continue;
        }

        if (chat_metadata[METADATA_KEY] === worldName) {
            console.debug(`Character ${name}'s world ${worldName} is already activated in chat lore! Skipping...`);
            continue;
        }

        const data = await loadWorldInfoData(worldName);
        const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
        entries = entries.concat(newEntries);
    }

    console.debug(`Character ${characters[this_chid]?.name} lore (${baseWorldName}) has ${entries.length} world info entries`);
    return entries;
}

async function getGlobalLore() {
    if (!selected_world_info) {
        return [];
    }

    let entries = [];
    for (const worldName of selected_world_info) {
        const data = await loadWorldInfoData(worldName);
        const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
        entries = entries.concat(newEntries);
    }

    console.debug(`Global world info has ${entries.length} entries`);

    return entries;
}

async function getChatLore() {
    const chatWorld = chat_metadata[METADATA_KEY];

    if (!chatWorld) {
        return [];
    }

    if (selected_world_info.includes(chatWorld)) {
        console.debug(`Chat world ${chatWorld} is already activated in global world info! Skipping...`);
        return [];
    }

    const data = await loadWorldInfoData(chatWorld);
    const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: chatWorld })) : [];

    console.debug(`Chat lore has ${entries.length} entries`);

    return entries;
}

export async function getSortedEntries() {
    try {
        const globalLore = await getGlobalLore();
        const characterLore = await getCharacterLore();
        const chatLore = await getChatLore();

        let entries;

        switch (Number(world_info_character_strategy)) {
            case world_info_insertion_strategy.evenly:
                entries = [...globalLore, ...characterLore].sort(sortFn);
                break;
            case world_info_insertion_strategy.character_first:
                entries = [...characterLore.sort(sortFn), ...globalLore.sort(sortFn)];
                break;
            case world_info_insertion_strategy.global_first:
                entries = [...globalLore.sort(sortFn), ...characterLore.sort(sortFn)];
                break;
            default:
                console.error('Unknown WI insertion strategy: ', world_info_character_strategy, 'defaulting to evenly');
                entries = [...globalLore, ...characterLore].sort(sortFn);
                break;
        }

        // Chat lore always goes first
        entries = [...chatLore.sort(sortFn), ...entries];

        console.debug(`Sorted ${entries.length} world lore entries using strategy ${world_info_character_strategy}`);

        // Need to deep clone the entries to avoid modifying the cached data
        return structuredClone(entries);
    }
    catch (e) {
        console.error(e);
        return [];
    }
}

/**
 * Performs a scan on the chat and returns the world info activated.
 * @param {string[]} chat The chat messages to scan.
 * @param {number} maxContext The maximum context size of the generation.
 * @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated
 * @returns {Promise<WIActivated>} The world info activated.
 */
async function checkWorldInfo(chat, maxContext) {
    const context = getContext();
    const buffer = new WorldInfoBuffer(chat);

    // Combine the chat

    // Add the depth or AN if enabled
    // Put this code here since otherwise, the chat reference is modified
    for (const key of Object.keys(context.extensionPrompts)) {
        if (context.extensionPrompts[key]?.scan) {
            const prompt = getExtensionPromptByName(key);
            if (prompt) {
                buffer.addRecurse(prompt);
            }
        }
    }

    let needsToScan = true;
    let token_budget_overflowed = false;
    let count = 0;
    let allActivatedEntries = new Set();
    let failedProbabilityChecks = new Set();
    let allActivatedText = '';

    let budget = Math.round(world_info_budget * maxContext / 100) || 1;

    if (world_info_budget_cap > 0 && budget > world_info_budget_cap) {
        console.debug(`Budget ${budget} exceeds cap ${world_info_budget_cap}, using cap`);
        budget = world_info_budget_cap;
    }

    console.debug(`Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`);
    const sortedEntries = await getSortedEntries();

    if (sortedEntries.length === 0) {
        return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
    }

    while (needsToScan) {
        // Track how many times the loop has run
        count++;

        let activatedNow = new Set();

        for (let entry of sortedEntries) {
            // Check if this entry applies to the character or if it's excluded
            if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {
                const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());
                const filtered = entry.characterFilter.isExclude ? nameIncluded : !nameIncluded;

                if (filtered) {
                    console.debug(`WI entry ${entry.uid} filtered out by character`);
                    continue;
                }
            }

            if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) {
                const tagKey = getTagKeyForEntity(this_chid);

                if (tagKey) {
                    const tagMapEntry = context.tagMap[tagKey];

                    if (Array.isArray(tagMapEntry)) {
                        // If tag map intersects with the tag exclusion list, skip
                        const includesTag = tagMapEntry.some((tag) => entry.characterFilter.tags.includes(tag));
                        const filtered = entry.characterFilter.isExclude ? includesTag : !includesTag;

                        if (filtered) {
                            console.debug(`WI entry ${entry.uid} filtered out by tag`);
                            continue;
                        }
                    }
                }
            }

            if (failedProbabilityChecks.has(entry)) {
                continue;
            }

            if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion) || (count == 1 && entry.delayUntilRecursion)) {
                continue;
            }

            if (entry.constant || buffer.isExternallyActivated(entry)) {
                activatedNow.add(entry);
                continue;
            }

            if (Array.isArray(entry.key) && entry.key.length) { //check for keywords existing
                // If selectiveLogic isn't found, assume it's AND, only do this once per entry
                const selectiveLogic = entry.selectiveLogic ?? 0;

                primary: for (let key of entry.key) {
                    const substituted = substituteParams(key);
                    const textToScan = buffer.get(entry);

                    if (substituted && buffer.matchKeys(textToScan, substituted.trim(), entry)) {
                        console.debug(`WI UID ${entry.uid} found by primary match: ${substituted}.`);

                        //selective logic begins
                        if (
                            entry.selective && //all entries are selective now
                            Array.isArray(entry.keysecondary) && //always true
                            entry.keysecondary.length //ignore empties
                        ) {
                            console.debug(`WI UID:${entry.uid} found. Checking logic: ${entry.selectiveLogic}`);
                            let hasAnyMatch = false;
                            let hasAllMatch = true;
                            secondary: for (let keysecondary of entry.keysecondary) {
                                const secondarySubstituted = substituteParams(keysecondary);
                                const hasSecondaryMatch = secondarySubstituted && buffer.matchKeys(textToScan, secondarySubstituted.trim(), entry);
                                console.debug(`WI UID:${entry.uid}: Filtering for secondary keyword - "${secondarySubstituted}".`);

                                if (hasSecondaryMatch) {
                                    hasAnyMatch = true;
                                }

                                if (!hasSecondaryMatch) {
                                    hasAllMatch = false;
                                }

                                // Simplified AND ANY / NOT ALL if statement. (Proper fix for PR#1356 by Bronya)
                                // If AND ANY logic and the main checks pass OR if NOT ALL logic and the main checks do not pass
                                if ((selectiveLogic === world_info_logic.AND_ANY && hasSecondaryMatch) || (selectiveLogic === world_info_logic.NOT_ALL && !hasSecondaryMatch)) {
                                    // Differ both logic statements in the debugger
                                    if (selectiveLogic === world_info_logic.AND_ANY) {
                                        console.debug(`(AND ANY Check) Activating WI Entry ${entry.uid}. Found match for word: ${substituted} ${secondarySubstituted}`);
                                    } else {
                                        console.debug(`(NOT ALL Check) Activating WI Entry ${entry.uid}. Found match for word "${substituted}" without secondary keyword: ${secondarySubstituted}`);
                                    }
                                    activatedNow.add(entry);
                                    break secondary;
                                }
                            }

                            // Handle NOT ANY logic
                            if (selectiveLogic === world_info_logic.NOT_ANY && !hasAnyMatch) {
                                console.debug(`(NOT ANY Check) Activating WI Entry ${entry.uid}, no secondary keywords found.`);
                                activatedNow.add(entry);
                            }

                            // Handle AND ALL logic
                            if (selectiveLogic === world_info_logic.AND_ALL && hasAllMatch) {
                                console.debug(`(AND ALL Check) Activating WI Entry ${entry.uid}, all secondary keywords found.`);
                                activatedNow.add(entry);
                            }
                        } else {
                            // Handle cases where secondary is empty
                            console.debug(`WI UID ${entry.uid}: Activated without filter logic.`);
                            activatedNow.add(entry);
                            break primary;
                        }
                    }
                }
            }
        }

        needsToScan = world_info_recursive && activatedNow.size > 0;
        const newEntries = [...activatedNow]
            .sort((a, b) => sortedEntries.indexOf(a) - sortedEntries.indexOf(b));
        let newContent = '';
        const textToScanTokens = await getTokenCountAsync(allActivatedText);
        const probabilityChecksBefore = failedProbabilityChecks.size;

        filterByInclusionGroups(newEntries, allActivatedEntries, buffer);

        console.debug('-- PROBABILITY CHECKS BEGIN --');
        for (const entry of newEntries) {
            const rollValue = Math.random() * 100;

            if (entry.useProbability && rollValue > entry.probability) {
                console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`);
                failedProbabilityChecks.add(entry);
                continue;
            } else { console.debug(`uid:${entry.uid} passed probability check, inserting to prompt`); }

            // Substitute macros inline, for both this checking and also future processing
            entry.content = substituteParams(entry.content);
            newContent += `${entry.content}\n`;

            if ((textToScanTokens + (await getTokenCountAsync(newContent))) >= budget) {
                console.debug('WI budget reached, stopping');
                if (world_info_overflow_alert) {
                    console.log('Alerting');
                    toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info');
                }
                needsToScan = false;
                token_budget_overflowed = true;
                break;
            }

            allActivatedEntries.add(entry);
            console.debug('WI entry activated:', entry);
        }

        const probabilityChecksAfter = failedProbabilityChecks.size;

        if ((probabilityChecksAfter - probabilityChecksBefore) === activatedNow.size) {
            console.debug('WI probability checks failed for all activated entries, stopping');
            needsToScan = false;
        }

        if (newEntries.length === 0) {
            console.debug('No new entries activated, stopping');
            needsToScan = false;
        }

        if (needsToScan) {
            const text = newEntries
                .filter(x => !failedProbabilityChecks.has(x))
                .filter(x => !x.preventRecursion)
                .map(x => x.content).join('\n');
            buffer.addRecurse(text);
            allActivatedText = (text + '\n' + allActivatedText);
        }

        // world_info_min_activations
        if (!needsToScan && !token_budget_overflowed) {
            if (world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations)) {
                let over_max = (
                    world_info_min_activations_depth_max > 0 &&
                    buffer.getDepth() > world_info_min_activations_depth_max
                ) || (buffer.getDepth() > chat.length);

                if (!over_max) {
                    needsToScan = true; // loop
                    buffer.advanceScanPosition();
                }
            }
        }
    }

    // Forward-sorted list of entries for joining
    const WIBeforeEntries = [];
    const WIAfterEntries = [];
    const EMEntries = [];
    const ANTopEntries = [];
    const ANBottomEntries = [];
    const WIDepthEntries = [];

    // Appends from insertion order 999 to 1. Use unshift for this purpose
    // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays
    [...allActivatedEntries].sort(sortFn).forEach((entry) => {
        const regexDepth = entry.position === world_info_position.atDepth ? (entry.depth ?? DEFAULT_DEPTH) : null;
        const content = getRegexedString(entry.content, regex_placement.WORLD_INFO, { depth: regexDepth, isMarkdown: false, isPrompt: true });

        if (!content) {
            console.debug('Skipping adding WI entry to prompt due to empty content:', entry);
            return;
        }

        switch (entry.position) {
            case world_info_position.before:
                WIBeforeEntries.unshift(substituteParams(content));
                break;
            case world_info_position.after:
                WIAfterEntries.unshift(substituteParams(content));
                break;
            case world_info_position.EMTop:
                EMEntries.unshift(
                    { position: wi_anchor_position.before, content: content },
                );
                break;
            case world_info_position.EMBottom:
                EMEntries.unshift(
                    { position: wi_anchor_position.after, content: content },
                );
                break;
            case world_info_position.ANTop:
                ANTopEntries.unshift(content);
                break;
            case world_info_position.ANBottom:
                ANBottomEntries.unshift(content);
                break;
            case world_info_position.atDepth: {
                const existingDepthIndex = WIDepthEntries.findIndex((e) => e.depth === (entry.depth ?? DEFAULT_DEPTH) && e.role === (entry.role ?? extension_prompt_roles.SYSTEM));
                if (existingDepthIndex !== -1) {
                    WIDepthEntries[existingDepthIndex].entries.unshift(content);
                } else {
                    WIDepthEntries.push({
                        depth: entry.depth,
                        entries: [content],
                        role: entry.role ?? extension_prompt_roles.SYSTEM,
                    });
                }
                break;
            }
            default:
                break;
        }
    });

    const worldInfoBefore = WIBeforeEntries.length ? WIBeforeEntries.join('\n') : '';
    const worldInfoAfter = WIAfterEntries.length ? WIAfterEntries.join('\n') : '';

    if (shouldWIAddPrompt) {
        const originalAN = context.extensionPrompts[NOTE_MODULE_NAME].value;
        const ANWithWI = `${ANTopEntries.join('\n')}\n${originalAN}\n${ANBottomEntries.join('\n')}`;
        context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
    }

    buffer.cleanExternalActivations();

    return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries };
}

/**
 * Only leaves entries with the highest key matching score in each group.
 * @param {Record<string, WIScanEntry[]>} groups The groups to filter
 * @param {WorldInfoBuffer} buffer The buffer to use for scoring
 * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry
 */
function filterGroupsByScoring(groups, buffer, removeEntry) {
    for (const [key, group] of Object.entries(groups)) {
        // Group scoring is disabled both globally and for the group entries
        if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) {
            console.debug(`Skipping group scoring for group '${key}'`);
            continue;
        }

        const scores = group.map(entry => buffer.getScore(entry));
        const maxScore = Math.max(...scores);
        console.debug(`Group '${key}' max score: ${maxScore}`);
        //console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] })));

        for (let i = 0; i < group.length; i++) {
            const isScored = group[i].useGroupScoring ?? world_info_use_group_scoring;

            if (!isScored) {
                continue;
            }

            if (scores[i] < maxScore) {
                console.debug(`Removing score loser from inclusion group '${key}' entry '${group[i].uid}'`, group[i]);
                removeEntry(group[i]);
                group.splice(i, 1);
                scores.splice(i, 1);
                i--;
            }
        }
    }
}

/**
 * Filters entries by inclusion groups.
 * @param {object[]} newEntries Entries activated on current recursion level
 * @param {Set<object>} allActivatedEntries Set of all activated entries
 * @param {WorldInfoBuffer} buffer The buffer to use for scanning
 */
function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) {
    console.debug('-- INCLUSION GROUP CHECKS BEGIN --');
    const grouped = newEntries.filter(x => x.group).reduce((acc, item) => {
        item.group.split(/,\s*/).filter(x => x).forEach(group => {
            if (!acc[group]) {
                acc[group] = [];
            }
            acc[group].push(item);
        });
        return acc;
    }, {});

    if (Object.keys(grouped).length === 0) {
        console.debug('No inclusion groups found');
        return;
    }

    const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1);
    function removeAllBut(group, chosen, logging = true) {
        for (const entry of group) {
            if (entry === chosen) {
                continue;
            }

            if (logging) console.debug(`Removing loser from inclusion group '${entry.group}' entry '${entry.uid}'`, entry);
            removeEntry(entry);
        }
    }

    filterGroupsByScoring(grouped, buffer, removeEntry);

    for (const [key, group] of Object.entries(grouped)) {
        console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group);

        if (Array.from(allActivatedEntries).some(x => x.group === key)) {
            console.debug(`Skipping inclusion group check, group already activated '${key}'`);
            // We need to forcefully deactivate all other entries in the group
            removeAllBut(group, null, false);
            continue;
        }

        if (!Array.isArray(group) || group.length <= 1) {
            console.debug('Skipping inclusion group check, only one entry');
            continue;
        }

        // Check for group prio
        const prios = group.filter(x => x.groupOverride).sort(sortFn);
        if (prios.length) {
            console.debug(`Activated inclusion group '${key}' with by prio winner entry '${prios[0].uid}'`, prios[0]);
            removeAllBut(group, prios[0]);
            continue;
        }

        // Do weighted random using entry's weight
        const totalWeight = group.reduce((acc, item) => acc + (item.groupWeight ?? DEFAULT_WEIGHT), 0);
        const rollValue = Math.random() * totalWeight;
        let currentWeight = 0;
        let winner = null;

        for (const entry of group) {
            currentWeight += (entry.groupWeight ?? DEFAULT_WEIGHT);

            if (rollValue <= currentWeight) {
                console.debug(`Activated inclusion group '${key}' with roll winner entry '${entry.uid}'`, entry);
                winner = entry;
                break;
            }
        }

        if (!winner) {
            console.debug(`Failed to activate inclusion group '${key}', no winner found`);
            continue;
        }

        // Remove every group item from newEntries but the winner
        removeAllBut(group, winner);
    }
}

function convertAgnaiMemoryBook(inputObj) {
    const outputObj = { entries: {} };

    inputObj.entries.forEach((entry, index) => {
        outputObj.entries[index] = {
            ...newEntryTemplate,
            uid: index,
            key: entry.keywords,
            keysecondary: [],
            comment: entry.name,
            content: entry.entry,
            constant: false,
            selective: false,
            vectorized: false,
            selectiveLogic: world_info_logic.AND_ANY,
            order: entry.weight,
            position: 0,
            disable: !entry.enabled,
            addMemo: !!entry.name,
            excludeRecursion: false,
            delayUntilRecursion: false,
            displayIndex: index,
            probability: 100,
            useProbability: true,
            group: '',
            groupOverride: false,
            groupWeight: DEFAULT_WEIGHT,
            scanDepth: null,
            caseSensitive: null,
            matchWholeWords: null,
            useGroupScoring: null,
            automationId: '',
            role: extension_prompt_roles.SYSTEM,
        };
    });

    return outputObj;
}

function convertRisuLorebook(inputObj) {
    const outputObj = { entries: {} };

    inputObj.data.forEach((entry, index) => {
        outputObj.entries[index] = {
            ...newEntryTemplate,
            uid: index,
            key: entry.key.split(',').map(x => x.trim()),
            keysecondary: entry.secondkey ? entry.secondkey.split(',').map(x => x.trim()) : [],
            comment: entry.comment,
            content: entry.content,
            constant: entry.alwaysActive,
            selective: entry.selective,
            vectorized: false,
            selectiveLogic: world_info_logic.AND_ANY,
            order: entry.insertorder,
            position: world_info_position.before,
            disable: false,
            addMemo: true,
            excludeRecursion: false,
            delayUntilRecursion: false,
            displayIndex: index,
            probability: entry.activationPercent ?? 100,
            useProbability: entry.activationPercent ?? true,
            group: '',
            groupOverride: false,
            groupWeight: DEFAULT_WEIGHT,
            scanDepth: null,
            caseSensitive: null,
            matchWholeWords: null,
            useGroupScoring: null,
            automationId: '',
            role: extension_prompt_roles.SYSTEM,
        };
    });

    return outputObj;
}

function convertNovelLorebook(inputObj) {
    const outputObj = {
        entries: {},
    };

    inputObj.entries.forEach((entry, index) => {
        const displayName = entry.displayName;
        const addMemo = displayName !== undefined && displayName.trim() !== '';

        outputObj.entries[index] = {
            ...newEntryTemplate,
            uid: index,
            key: entry.keys,
            keysecondary: [],
            comment: displayName || '',
            content: entry.text,
            constant: false,
            selective: false,
            vectorized: false,
            selectiveLogic: world_info_logic.AND_ANY,
            order: entry.contextConfig?.budgetPriority ?? 0,
            position: 0,
            disable: !entry.enabled,
            addMemo: addMemo,
            excludeRecursion: false,
            delayUntilRecursion: false,
            displayIndex: index,
            probability: 100,
            useProbability: true,
            group: '',
            groupOverride: false,
            groupWeight: DEFAULT_WEIGHT,
            scanDepth: null,
            caseSensitive: null,
            matchWholeWords: null,
            useGroupScoring: null,
            automationId: '',
            role: extension_prompt_roles.SYSTEM,
        };
    });

    return outputObj;
}

function convertCharacterBook(characterBook) {
    const result = { entries: {}, originalData: characterBook };

    characterBook.entries.forEach((entry, index) => {
        // Not in the spec, but this is needed to find the entry in the original data
        if (entry.id === undefined) {
            entry.id = index;
        }

        result.entries[entry.id] = {
            ...newEntryTemplate,
            uid: entry.id,
            key: entry.keys,
            keysecondary: entry.secondary_keys || [],
            comment: entry.comment || '',
            content: entry.content,
            constant: entry.constant || false,
            selective: entry.selective || false,
            order: entry.insertion_order,
            position: entry.extensions?.position ?? (entry.position === 'before_char' ? world_info_position.before : world_info_position.after),
            excludeRecursion: entry.extensions?.exclude_recursion ?? false,
            preventRecursion: entry.extensions?.prevent_recursion ?? false,
            delayUntilRecursion: entry.extensions?.delay_until_recursion ?? false,
            disable: !entry.enabled,
            addMemo: entry.comment ? true : false,
            displayIndex: entry.extensions?.display_index ?? index,
            probability: entry.extensions?.probability ?? 100,
            useProbability: entry.extensions?.useProbability ?? true,
            depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
            selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
            group: entry.extensions?.group ?? '',
            groupOverride: entry.extensions?.group_override ?? false,
            groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
            scanDepth: entry.extensions?.scan_depth ?? null,
            caseSensitive: entry.extensions?.case_sensitive ?? null,
            matchWholeWords: entry.extensions?.match_whole_words ?? null,
            useGroupScoring: entry.extensions?.use_group_scoring ?? null,
            automationId: entry.extensions?.automation_id ?? '',
            role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
            vectorized: entry.extensions?.vectorized ?? false,
        };
    });

    return result;
}

export function setWorldInfoButtonClass(chid, forceValue = undefined) {
    if (forceValue !== undefined) {
        $('#set_character_world, #world_button').toggleClass('world_set', forceValue);
        return;
    }

    if (!chid) {
        return;
    }

    const world = characters[chid]?.data?.extensions?.world;
    const worldSet = Boolean(world && world_names.includes(world));
    $('#set_character_world, #world_button').toggleClass('world_set', worldSet);
}

export function checkEmbeddedWorld(chid) {
    $('#import_character_info').hide();

    if (chid === undefined) {
        return false;
    }

    if (characters[chid]?.data?.character_book) {
        $('#import_character_info').data('chid', chid).show();

        // Only show the alert once per character
        const checkKey = `AlertWI_${characters[chid].avatar}`;
        const worldName = characters[chid]?.data?.extensions?.world;
        if (!localStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) {
            localStorage.setItem(checkKey, 'true');

            if (power_user.world_import_dialog) {
                const html = `<h3>This character has an embedded World/Lorebook.</h3>
                <h3>Would you like to import it now?</h3>
                <div class="m-b-1">If you want to import it later, select "Import Card Lore" in the "More..." dropdown menu on the character panel.</div>`;
                const checkResult = (result) => {
                    if (result) {
                        importEmbeddedWorldInfo(true);
                    }
                };
                callPopup(html, 'confirm', '', { okButton: 'Yes' }).then(checkResult);
            }
            else {
                toastr.info(
                    'To import and use it, select "Import Card Lore" in the "More..." dropdown menu on the character panel.',
                    `${characters[chid].name} has an embedded World/Lorebook`,
                    { timeOut: 5000, extendedTimeOut: 10000, positionClass: 'toast-top-center' },
                );
            }
        }
        return true;
    }

    return false;
}

export async function importEmbeddedWorldInfo(skipPopup = false) {
    const chid = $('#import_character_info').data('chid');

    if (chid === undefined) {
        return;
    }

    const bookName = characters[chid]?.data?.character_book?.name || `${characters[chid]?.name}'s Lorebook`;
    const confirmationText = (`<h3>Are you sure you want to import "${bookName}"?</h3>`) + (world_names.includes(bookName) ? 'It will overwrite the World/Lorebook with the same name.' : '');

    if (!skipPopup) {
        const confirmation = await callPopup(confirmationText, 'confirm');

        if (!confirmation) {
            return;
        }
    }

    const convertedBook = convertCharacterBook(characters[chid].data.character_book);

    await saveWorldInfo(bookName, convertedBook, true);
    await updateWorldInfoList();
    $('#character_world').val(bookName).trigger('change');

    toastr.success(`The world "${bookName}" has been imported and linked to the character successfully.`, 'World/Lorebook imported');

    const newIndex = world_names.indexOf(bookName);
    if (newIndex >= 0) {
        //show&draw the WI panel before..
        $('#WIDrawerIcon').trigger('click');
        //..auto-opening the new imported WI
        $('#world_editor_select').val(newIndex).trigger('change');
    }

    setWorldInfoButtonClass(chid, true);
}

function onWorldInfoChange(args, text) {
    if (args !== '__notSlashCommand__') { // if it's a slash command
        const silent = isTrueBoolean(args.silent);
        if (text.trim() !== '') { // and args are provided
            const slashInputSplitText = text.trim().toLowerCase().split(',');

            slashInputSplitText.forEach((worldName) => {
                const wiElement = getWIElement(worldName);
                if (wiElement.length > 0) {
                    const name = wiElement.text();
                    switch (args.state) {
                        case 'off': {
                            if (selected_world_info.includes(name)) {
                                selected_world_info.splice(selected_world_info.indexOf(name), 1);
                                wiElement.prop('selected', false);
                                if (!silent) toastr.success(`Deactivated world: ${name}`);
                            } else {
                                if (!silent) toastr.error(`World was not active: ${name}`);
                            }
                            break;
                        }
                        case 'toggle': {
                            if (selected_world_info.includes(name)) {
                                selected_world_info.splice(selected_world_info.indexOf(name), 1);
                                wiElement.prop('selected', false);
                                if (!silent) toastr.success(`Deactivated world: ${name}`);
                            } else {
                                selected_world_info.push(name);
                                wiElement.prop('selected', true);
                                if (!silent) toastr.success(`Activated world: ${name}`);
                            }
                            break;
                        }
                        default: {
                            selected_world_info.push(name);
                            wiElement.prop('selected', true);
                            if (!silent) toastr.success(`Activated world: ${name}`);
                        }
                    }
                } else {
                    if (!silent) toastr.error(`No world found named: ${worldName}`);
                }
            });
            $('#world_info').trigger('change');
        } else { // if no args, unset all worlds
            if (!silent) toastr.success('Deactivated all worlds');
            selected_world_info = [];
            $('#world_info').val(null).trigger('change');
        }
    } else { //if it's a pointer selection
        let tempWorldInfo = [];
        let selectedWorlds = $('#world_info').val().map((e) => Number(e)).filter((e) => !isNaN(e));
        if (selectedWorlds.length > 0) {
            selectedWorlds.forEach((worldIndex) => {
                const existingWorldName = world_names[worldIndex];
                if (existingWorldName) {
                    tempWorldInfo.push(existingWorldName);
                } else {
                    const wiElement = getWIElement(existingWorldName);
                    wiElement.prop('selected', false);
                    toastr.error(`The world with ${existingWorldName} is invalid or corrupted.`);
                }
            });
        }
        selected_world_info = tempWorldInfo;
    }

    saveSettingsDebounced();
    eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
}

export async function importWorldInfo(file) {
    if (!file) {
        return;
    }

    const formData = new FormData();
    formData.append('avatar', file);

    try {
        let jsonData;

        if (file.name.endsWith('.png')) {
            const buffer = new Uint8Array(await getFileBuffer(file));
            jsonData = extractDataFromPng(buffer, 'naidata');
        } else {
            // File should be a JSON file
            jsonData = await parseJsonFile(file);
        }

        if (jsonData === undefined || jsonData === null) {
            toastr.error(`File is not valid: ${file.name}`);
            return;
        }

        // Convert Novel Lorebook
        if (jsonData.lorebookVersion !== undefined) {
            console.log('Converting Novel Lorebook');
            formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData)));
        }

        // Convert Agnai Memory Book
        if (jsonData.kind === 'memory') {
            console.log('Converting Agnai Memory Book');
            formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData)));
        }

        // Convert Risu Lorebook
        if (jsonData.type === 'risu') {
            console.log('Converting Risu Lorebook');
            formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData)));
        }
    } catch (error) {
        toastr.error(`Error parsing file: ${error}`);
        return;
    }

    const worldName = file.name.substr(0, file.name.lastIndexOf("."));
    const sanitizedWorldName = await getSanitizedFilename(worldName);
    const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) });
    if (!allowed) {
        return false;
    }

    jQuery.ajax({
        type: 'POST',
        url: '/api/worldinfo/import',
        data: formData,
        beforeSend: () => { },
        cache: false,
        contentType: false,
        processData: false,
        success: async function (data) {
            if (data.name) {
                await updateWorldInfoList();

                const newIndex = world_names.indexOf(data.name);
                if (newIndex >= 0) {
                    $('#world_editor_select').val(newIndex).trigger('change');
                }

                toastr.success(`World Info "${data.name}" imported successfully!`);
            }
        },
        error: (_jqXHR, _exception) => { },
    });
}

function assignLorebookToChat() {
    const selectedName = chat_metadata[METADATA_KEY];
    const template = $('#chat_world_template .chat_world').clone();

    const worldSelect = template.find('select');
    const chatName = template.find('.chat_name');
    chatName.text(getCurrentChatId());

    for (const worldName of world_names) {
        const option = document.createElement('option');
        option.value = worldName;
        option.innerText = worldName;
        option.selected = selectedName === worldName;
        worldSelect.append(option);
    }

    worldSelect.on('change', function () {
        const worldName = $(this).val();

        if (worldName) {
            chat_metadata[METADATA_KEY] = worldName;
            $('.chat_lorebook_button').addClass('world_set');
        } else {
            delete chat_metadata[METADATA_KEY];
            $('.chat_lorebook_button').removeClass('world_set');
        }

        saveMetadata();
    });

    callPopup(template, 'text');
}

jQuery(() => {

    $('#world_info').on('mousedown change', async function (e) {
        // If there's no world names, don't do anything
        if (world_names.length === 0) {
            e.preventDefault();
            return;
        }

        onWorldInfoChange('__notSlashCommand__');
    });

    //**************************WORLD INFO IMPORT EXPORT*************************//
    $('#world_import_button').on('click', function () {
        $('#world_import_file').trigger('click');
    });

    $('#world_import_file').on('change', async function (e) {
        if (!(e.target instanceof HTMLInputElement)) {
            return;
        }

        const file = e.target.files[0];

        await importWorldInfo(file);

        // Will allow to select the same file twice in a row
        e.target.value = '';
    });

    $('#world_create_button').on('click', async () => {
        const tempName = getFreeWorldName();
        const finalName = await callPopup('<h3>Create a new World Info?</h3>Enter a name for the new file:', 'input', tempName);

        if (finalName) {
            await createNewWorldInfo(finalName, { interactive: true });
        }
    });

    $('#world_editor_select').on('change', async () => {
        $('#world_info_search').val('');
        worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, '', true);
        const selectedIndex = String($('#world_editor_select').find(':selected').val());

        if (selectedIndex === '') {
            hideWorldEditor();
        } else {
            const worldName = world_names[selectedIndex];
            showWorldEditor(worldName);
        }
    });

    const saveSettings = () => {
        saveSettingsDebounced();
        eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
    };

    $('#world_info_depth').on('input', function () {
        world_info_depth = Number($(this).val());
        $('#world_info_depth_counter').val($(this).val());
        saveSettings();
    });

    $('#world_info_min_activations').on('input', function () {
        world_info_min_activations = Number($(this).val());
        $('#world_info_min_activations_counter').val($(this).val());
        saveSettings();
    });

    $('#world_info_min_activations_depth_max').on('input', function () {
        world_info_min_activations_depth_max = Number($(this).val());
        $('#world_info_min_activations_depth_max_counter').val($(this).val());
        saveSettings();
    });

    $('#world_info_budget').on('input', function () {
        world_info_budget = Number($(this).val());
        $('#world_info_budget_counter').val($(this).val());
        saveSettings();
    });

    $('#world_info_recursive').on('input', function () {
        world_info_recursive = !!$(this).prop('checked');
        saveSettings();
    });

    $('#world_info_case_sensitive').on('input', function () {
        world_info_case_sensitive = !!$(this).prop('checked');
        saveSettings();
    });

    $('#world_info_match_whole_words').on('input', function () {
        world_info_match_whole_words = !!$(this).prop('checked');
        saveSettings();
    });

    $('#world_info_character_strategy').on('change', function () {
        world_info_character_strategy = Number($(this).val());
        saveSettings();
    });

    $('#world_info_overflow_alert').on('change', function () {
        world_info_overflow_alert = !!$(this).prop('checked');
        saveSettingsDebounced();
    });

    $('#world_info_use_group_scoring').on('change', function () {
        world_info_use_group_scoring = !!$(this).prop('checked');
        saveSettingsDebounced();
    });

    $('#world_info_budget_cap').on('input', function () {
        world_info_budget_cap = Number($(this).val());
        $('#world_info_budget_cap_counter').val(world_info_budget_cap);
        saveSettings();
    });

    $('#world_button').on('click', async function (event) {
        const chid = $('#set_character_world').data('chid');

        if (chid) {
            const worldName = characters[chid]?.data?.extensions?.world;
            const hasEmbed = checkEmbeddedWorld(chid);
            if (worldName && world_names.includes(worldName) && !event.shiftKey) {
                if (!$('#WorldInfo').is(':visible')) {
                    $('#WIDrawerIcon').trigger('click');
                }
                const index = world_names.indexOf(worldName);
                $('#world_editor_select').val(index).trigger('change');
            } else if (hasEmbed && !event.shiftKey) {
                await importEmbeddedWorldInfo();
                saveCharacterDebounced();
            }
            else {
                $('#char-management-dropdown').val($('#set_character_world').val()).trigger('change');
            }
        }
    });

    const debouncedWorldInfoSearch = debounce((searchQuery) => {
        worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, searchQuery);
    });
    $('#world_info_search').on('input', function () {
        const searchQuery = $(this).val();
        debouncedWorldInfoSearch(searchQuery);
    });

    $('#world_refresh').on('click', () => {
        updateEditor(navigation_option.previous);
    });

    $('#world_info_sort_order').on('change', function () {
        const value = String($(this).find(':selected').val());
        // Save sort order, but do not save search sorting, as this is a temporary sorting option
        if (value !== 'search') localStorage.setItem(SORT_ORDER_KEY, value);
        updateEditor(navigation_option.none);
    });

    $(document).on('click', '.chat_lorebook_button', assignLorebookToChat);

    // Not needed on mobile
    if (!isMobile()) {
        $('#world_info').select2({
            width: '100%',
            placeholder: 'No Worlds active. Click here to select.',
            allowClear: true,
            closeOnSelect: false,
        });

        // Subscribe world loading to the select2 multiselect items (We need to target the specific select2 control)
        select2ChoiceClickSubscribe($('#world_info'), target => {
            const name = $(target).text();
            const selectedIndex = world_names.indexOf(name);
            if (selectedIndex !== -1) {
                $('#world_editor_select').val(selectedIndex).trigger('change');
                console.log('Quick selection of world', name);
            }
        }, { buttonStyle: true, closeDrawer: true });
    }

    $('#WorldInfo').on('scroll', () => {
        $('.world_entry input[name="group"], .world_entry input[name="automationId"]').each((_, el) => {
            const instance = $(el).autocomplete('instance');

            if (instance !== undefined) {
                $(el).autocomplete('close');
            }
        });
    });
});
