--[[
    Commander 3.0 - Unified Lookup Service

    Shared logic for fuzzy finding spells, items, mounts, toys, etc.
    Used by both SecureAction and Intellisense to ensure consistent behavior.
]]

local ADDON_NAME, C3 = ...

-------------------------------------------------------------------------------
-- Lookup Service
-------------------------------------------------------------------------------

local Lookup = {}
C3.Lookup = Lookup

-- Cache configuration (separate timestamps for granular invalidation)
Lookup._spellCache = nil
Lookup._mountCache = nil
Lookup._toyCache = nil
Lookup._spellCacheTime = 0
Lookup._mountCacheTime = 0
Lookup._toyCacheTime = 0
Lookup.CACHE_DURATION = 300  -- 5 minutes

-- Trie indexes for O(K) prefix lookup
Lookup._spellTrie = nil
Lookup._mountTrie = nil

-------------------------------------------------------------------------------
-- Mount Item Builder Helper
-------------------------------------------------------------------------------

---Create a mount item object from mount info (shared by coroutine and sync builders)
---@param mountID number Mount ID
---@param name string Mount name
---@param spellID number Spell ID
---@param icon number Icon texture ID
---@param isUsable boolean Whether mount is currently usable
---@param isFavorite boolean Whether mount is favorited
---@param mountTypeID number Mount type ID for capability detection
---@return table item Mount item object
local function buildMountItem(mountID, name, spellID, icon, isUsable, isFavorite, mountTypeID)
    local nameLower = name:lower()

    -- Determine mount capabilities from mountTypeID
    local isFlying, isAquatic, isDragonriding = false, false, false
    if mountTypeID then
        local t = mountTypeID
        -- Flying: 247 (disc), 248 (standard flying)
        -- Dragonriding/Skyriding: 402, 407 (dynamic), 408, 424
        -- Aquatic: 231 (turtle), 232 (Vashj'ir seahorse), 254 (underwater), 269 (water strider)
        -- Ground only: 230, 241 (Qiraji), 284 (chauffeured)
        if t == 247 or t == 248 then
            isFlying = true
        elseif t == 402 or t == 407 or t == 408 or t == 424 then
            isDragonriding = true
            isFlying = true  -- Dragonriding mounts can also fly
        elseif t == 231 or t == 232 or t == 254 or t == 269 then
            isAquatic = true
        end
        -- Fallback: if type unknown, check usability in flying area
        if not isFlying and not isAquatic and not isDragonriding then
            if C_MountJournal.GetMountUsabilityByID then
                local canFly = C_MountJournal.GetMountUsabilityByID(mountID, true)
                if canFly then
                    isFlying = true
                end
            end
        end
    end

    return {
        name = name,
        nameLower = nameLower,
        id = mountID,
        spellID = spellID,
        icon = icon,
        isFlying = isFlying,
        isAquatic = isAquatic,
        isDragonriding = isDragonriding,
        isFavorite = isFavorite,
        isUsable = isUsable,
    }
end

-------------------------------------------------------------------------------
-- Background Prefetch
-------------------------------------------------------------------------------

---Schedule background cache building using kernel's LOW priority
function Lookup:SchedulePrefetch()
    if not C3._kernel then return end

    -- Schedule spell cache build (uses yielding version for background task)
    C3._kernel:Run(function()
        self:_buildSpellCache()
    end, { priority = C3.Kernel.PRIORITY.LOW })

    -- Schedule mount cache build (uses yielding version for background task)
    C3._kernel:Run(function()
        self:_buildMountCache()
    end, { priority = C3.Kernel.PRIORITY.LOW })
end

---Build spell cache with trie indexing (runs in coroutine)
function Lookup:_buildSpellCache()
    local now = GetTime()
    if self._spellTrie and (now - self._spellCacheTime) < self.CACHE_DURATION then
        return
    end

    local trie = C3.Trie:new()
    local cache = {}
    local count = 0

    -- Retail API
    if C_SpellBook and C_SpellBook.GetNumSpellBookSkillLines then
        local ok, numSkillLines = pcall(C_SpellBook.GetNumSpellBookSkillLines)
        if not ok then numSkillLines = 0 end

        for skillLine = 1, numSkillLines do
            local skillInfo = C_SpellBook.GetSpellBookSkillLineInfo(skillLine)
            if skillInfo then
                local offset = skillInfo.itemIndexOffset
                local numSpells = skillInfo.numSpellBookItems

                for i = offset + 1, offset + numSpells do
                    local bookInfo = C_SpellBook.GetSpellBookItemInfo(i, Enum.SpellBookSpellBank.Player)
                    if bookInfo and not bookInfo.isOffSpec then
                        -- Handle flyout entries (e.g., Summon Demon)
                        if bookInfo.itemType == Enum.SpellBookItemType.Flyout and bookInfo.actionID then
                            local flyoutID = bookInfo.actionID
                            local _, _, numSlots, flyoutKnown = GetFlyoutInfo(flyoutID)
                            if flyoutKnown and numSlots then
                                for s = 1, numSlots do
                                    local spellID, overrideSpellID, isSlotKnown = GetFlyoutSlotInfo(flyoutID, s)
                                    if isSlotKnown and spellID then
                                        local useID = overrideSpellID or spellID
                                        local spellInfo = C_Spell.GetSpellInfo(useID)
                                        if spellInfo and spellInfo.name then
                                            local item = {
                                                name = spellInfo.name,
                                                nameLower = spellInfo.name:lower(),
                                                id = useID,
                                                icon = spellInfo.iconID,
                                            }
                                            table.insert(cache, item)
                                            trie:Insert(spellInfo.name, item)
                                            count = count + 1
                                        end
                                    end
                                end
                            end
                        elseif bookInfo.spellID then
                            local spellInfo = C_Spell.GetSpellInfo(bookInfo.spellID)
                            local isPassive = bookInfo.isPassive or
                                (C_Spell.IsSpellPassive and C_Spell.IsSpellPassive(bookInfo.spellID))

                            if spellInfo and spellInfo.name and not isPassive then
                                local item = {
                                    name = spellInfo.name,
                                    nameLower = spellInfo.name:lower(),
                                    id = bookInfo.spellID,
                                    icon = spellInfo.iconID,
                                }
                                table.insert(cache, item)
                                trie:Insert(spellInfo.name, item)
                                count = count + 1
                            end
                        end
                    end
                    -- Yield every 50 spells to prevent freeze
                    if count % 50 == 0 then coroutine.yield() end
                end
            end
        end
    else
        -- Classic fallback
        local numTabs = GetNumSpellTabs and GetNumSpellTabs() or 0
        for tabIndex = 1, numTabs do
            local _, _, offset, numSpells = GetSpellTabInfo(tabIndex)
            for i = offset + 1, offset + numSpells do
                local spellName = GetSpellBookItemName(i, "spell")
                if spellName then
                    local _, spellID = GetSpellBookItemInfo(i, "spell")
                    local item = {
                        name = spellName,
                        nameLower = spellName:lower(),
                        id = spellID,
                        icon = nil,
                    }
                    table.insert(cache, item)
                    trie:Insert(spellName, item)
                    count = count + 1
                end
                if count % 50 == 0 then coroutine.yield() end
            end
        end
    end

    self._spellCache = cache
    self._spellTrie = trie
    self._spellCacheTime = now
    C3:Debug("Lookup: Built spell cache (" .. count .. " items)")
end

---Build mount cache with trie indexing (runs in coroutine)
function Lookup:_buildMountCache()
    local now = GetTime()
    if self._mountTrie and (now - self._mountCacheTime) < self.CACHE_DURATION then
        return
    end

    local trie = C3.Trie:new()
    local cache = {}
    local seen = {}  -- Track seen mount names to avoid duplicates
    local count = 0

    if C_MountJournal and C_MountJournal.GetMountIDs then
        local mountIDs = C_MountJournal.GetMountIDs()
        for _, mountID in ipairs(mountIDs) do
            local name, spellID, icon, _, isUsable, _, isFavorite, _, _, _, isCollected, mountTypeID =
                C_MountJournal.GetMountInfoByID(mountID)

            if isCollected and name then
                local nameLower = name:lower()
                -- Skip duplicates (some mounts have faction variants with same name)
                if not seen[nameLower] then
                    seen[nameLower] = true
                    local item = buildMountItem(mountID, name, spellID, icon, isUsable, isFavorite, mountTypeID)
                    table.insert(cache, item)
                    trie:Insert(name, item)
                    count = count + 1
                end
            end
            if count % 50 == 0 then coroutine.yield() end
        end

        -- Sort favorites first
        table.sort(cache, function(a, b)
            if a.isFavorite ~= b.isFavorite then
                return a.isFavorite
            end
            return a.name < b.name
        end)
    end

    self._mountCache = cache
    self._mountTrie = trie
    self._mountCacheTime = now
    C3:Debug("Lookup: Built mount cache (" .. count .. " items)")
end

-------------------------------------------------------------------------------
-- Scoring Helpers (shared with Intellisense)
-------------------------------------------------------------------------------

-- Use shared utilities for common operations
local splitTokens = function(...) return C3.StringUtils.splitTokens(...) end
local normalizeForMatch = function(...) return C3.StringUtils.normalizeForMatch(...) end

---Fuzzy subsequence match: checks if all characters of needle appear in order in haystack
---This allows for typos like "expetion" matching "expedition"
---@param needle string The search term (lowercase)
---@param haystack string The text to search in (lowercase)
---@return boolean matched Whether all characters appear in order
---@return number matchedChars How many characters from needle were matched
local function fuzzySubsequenceMatch(needle, haystack)
    if not needle or needle == "" then return true, 0 end
    if not haystack or haystack == "" then return false, 0 end

    local j = 1
    local matchedChars = 0
    for i = 1, #needle do
        local char = needle:sub(i, i)
        local foundPos = haystack:find(char, j, true)
        if not foundPos then
            return false, matchedChars
        end
        j = foundPos + 1
        matchedChars = matchedChars + 1
    end
    return true, matchedChars
end

---Check if all tokens in needle match the haystack (with fuzzy support)
---@param needle string Multi-word query (e.g., "grand yak" or "grand expetion yak")
---@param haystack string Text to search (e.g., "grand expedition yak")
---@return boolean
local function hasMultiWordMatch(needle, haystack)
    if not needle or needle == "" then return true end
    if not haystack or haystack == "" then return false end

    -- Normalize both for matching (removes apostrophes, hyphens)
    local needleNorm = normalizeForMatch(needle)
    local haystackNorm = normalizeForMatch(haystack)

    local tokens = splitTokens(needleNorm)

    -- Single token: try substring first, then fuzzy subsequence
    if #tokens <= 1 then
        if haystackNorm:find(needleNorm, 1, true) then
            return true
        end
        -- Fallback to fuzzy subsequence match (handles typos)
        return fuzzySubsequenceMatch(needleNorm, haystackNorm)
    end

    -- Multi-token: each token must match somewhere (substring or fuzzy)
    for _, token in ipairs(tokens) do
        if not haystackNorm:find(token, 1, true) then
            -- Try fuzzy subsequence match for this token
            if not fuzzySubsequenceMatch(token, haystackNorm) then
                return false
            end
        end
    end

    return true
end

---Calculate match score for fuzzy matching (supports multi-word queries and typos)
---@param needle string Search query (lowercase)
---@param haystack string Text to search (lowercase)
---@return number score (higher is better, 0 = no match)
local function calculateScore(needle, haystack)
    if not needle or needle == "" then return 100 end
    if not haystack or haystack == "" then return 0 end

    -- Normalize both for matching (removes apostrophes, hyphens)
    local needleNorm = normalizeForMatch(needle)
    local haystackNorm = normalizeForMatch(haystack)

    local tokens = splitTokens(needleNorm)

    -- Single token: scoring with fuzzy fallback
    if #tokens <= 1 then
        -- Exact match
        if needleNorm == haystackNorm then
            return 100
        end

        -- Prefix match
        if haystackNorm:find(needleNorm, 1, true) == 1 then
            return 75
        end

        -- Contains match
        if haystackNorm:find(needleNorm, 1, true) then
            return 50
        end

        -- Fuzzy subsequence match (handles typos like "expetion" → "expedition")
        local matched, matchedChars = fuzzySubsequenceMatch(needleNorm, haystackNorm)
        if matched then
            -- Score based on match quality: ratio of needle length to haystack length
            -- Shorter haystacks that match = better score
            local ratio = matchedChars / #haystackNorm
            return 25 + (ratio * 20)  -- Range: 25-45 for fuzzy matches
        end

        return 0
    end

    -- Multi-token: score each token and aggregate
    local totalScore = 0
    local haystackWords = {}
    for w in haystackNorm:gmatch("%S+") do
        haystackWords[#haystackWords + 1] = w
    end

    for _, token in ipairs(tokens) do
        local tokenScore = 0

        -- Check for word-level matches
        for _, word in ipairs(haystackWords) do
            if word == token then
                -- Exact word match
                tokenScore = math.max(tokenScore, 100)
                break
            elseif word:find(token, 1, true) == 1 then
                -- Word prefix match
                tokenScore = math.max(tokenScore, 75)
            elseif word:find(token, 1, true) then
                -- Word contains token
                tokenScore = math.max(tokenScore, 50)
            else
                -- Try fuzzy subsequence match on this word
                local matched, matchedChars = fuzzySubsequenceMatch(token, word)
                if matched then
                    -- Fuzzy word match - score based on how closely the lengths match
                    local ratio = matchedChars / #word
                    local fuzzyScore = 30 + (ratio * 15)  -- Range: 30-45
                    tokenScore = math.max(tokenScore, fuzzyScore)
                end
            end
        end

        -- If token doesn't match any word, check whole haystack
        if tokenScore == 0 then
            if haystackNorm:find(token, 1, true) then
                tokenScore = 25  -- Lower score for non-word-boundary match
            else
                -- Try fuzzy match on whole haystack
                local matched = fuzzySubsequenceMatch(token, haystackNorm)
                if matched then
                    tokenScore = 15  -- Lowest score for fuzzy whole-haystack match
                else
                    return 0  -- Token doesn't match at all
                end
            end
        end

        totalScore = totalScore + tokenScore
    end

    -- Average score + bonus for matching multiple words
    return (totalScore / #tokens) + (#tokens - 1) * 5
end

-------------------------------------------------------------------------------
-- Spell Lookup
-------------------------------------------------------------------------------

---Ensure spell cache is populated
---Uses batched building with yielding to prevent freezing
function Lookup:_ensureSpellCache()
    local now = GetTime()
    if self._spellTrie and (now - self._spellCacheTime) < self.CACHE_DURATION then
        return
    end

    -- Build cache directly with proper yielding
    -- Note: This yields to the kernel's task coroutine, not a nested one
    self:_buildSpellCacheSync()
end

-- Maximum items to process synchronously before deferring to background
Lookup.MAX_SYNC_ITEMS = 200

---Build spell cache synchronously with chunking (safe for UI callbacks)
---Processes up to MAX_SYNC_ITEMS immediately, then schedules background completion
function Lookup:_buildSpellCacheSync()
    local now = GetTime()
    if self._spellTrie and (now - self._spellCacheTime) < self.CACHE_DURATION then
        return
    end

    local trie = C3.Trie:new()
    local cache = {}
    local count = 0
    local hitLimit = false

    -- Retail API
    if C_SpellBook and C_SpellBook.GetNumSpellBookSkillLines then
        local ok, numSkillLines = pcall(C_SpellBook.GetNumSpellBookSkillLines)
        if not ok then numSkillLines = 0 end

        for skillLine = 1, numSkillLines do
            if hitLimit then break end
            local skillInfo = C_SpellBook.GetSpellBookSkillLineInfo(skillLine)
            if skillInfo then
                local offset = skillInfo.itemIndexOffset
                local numSpells = skillInfo.numSpellBookItems

                for i = offset + 1, offset + numSpells do
                    -- Check sync limit to prevent UI freeze
                    if count >= self.MAX_SYNC_ITEMS then
                        hitLimit = true
                        break
                    end

                    local bookInfo = C_SpellBook.GetSpellBookItemInfo(i, Enum.SpellBookSpellBank.Player)
                    if bookInfo and not bookInfo.isOffSpec then
                        -- Handle flyout entries (e.g., Summon Demon)
                        if bookInfo.itemType == Enum.SpellBookItemType.Flyout and bookInfo.actionID then
                            local flyoutID = bookInfo.actionID
                            local _, _, numSlots, flyoutKnown = GetFlyoutInfo(flyoutID)
                            if flyoutKnown and numSlots then
                                for s = 1, numSlots do
                                    local spellID, overrideSpellID, isSlotKnown = GetFlyoutSlotInfo(flyoutID, s)
                                    if isSlotKnown and spellID then
                                        local useID = overrideSpellID or spellID
                                        local spellInfo = C_Spell.GetSpellInfo(useID)
                                        if spellInfo and spellInfo.name then
                                            local item = {
                                                name = spellInfo.name,
                                                nameLower = spellInfo.name:lower(),
                                                id = useID,
                                                icon = spellInfo.iconID,
                                            }
                                            table.insert(cache, item)
                                            trie:Insert(spellInfo.name, item)
                                            count = count + 1
                                        end
                                    end
                                end
                            end
                        elseif bookInfo.spellID then
                            local spellInfo = C_Spell.GetSpellInfo(bookInfo.spellID)
                            local isPassive = bookInfo.isPassive or
                                (C_Spell.IsSpellPassive and C_Spell.IsSpellPassive(bookInfo.spellID))

                            if spellInfo and spellInfo.name and not isPassive then
                                local item = {
                                    name = spellInfo.name,
                                    nameLower = spellInfo.name:lower(),
                                    id = bookInfo.spellID,
                                    icon = spellInfo.iconID,
                                }
                                table.insert(cache, item)
                                trie:Insert(spellInfo.name, item)
                                count = count + 1
                            end
                        end
                    end
                end
            end
        end
    else
        -- Classic fallback
        local numTabs = GetNumSpellTabs and GetNumSpellTabs() or 0
        for tabIndex = 1, numTabs do
            if hitLimit then break end
            local _, _, offset, numSpells = GetSpellTabInfo(tabIndex)
            for i = offset + 1, offset + numSpells do
                -- Check sync limit to prevent UI freeze
                if count >= self.MAX_SYNC_ITEMS then
                    hitLimit = true
                    break
                end

                local spellName = GetSpellBookItemName(i, "spell")
                if spellName then
                    local _, spellID = GetSpellBookItemInfo(i, "spell")
                    local item = {
                        name = spellName,
                        nameLower = spellName:lower(),
                        id = spellID,
                        icon = nil,
                    }
                    table.insert(cache, item)
                    trie:Insert(spellName, item)
                    count = count + 1
                end
            end
        end
    end

    -- Store partial results immediately so intellisense works
    self._spellCache = cache
    self._spellTrie = trie
    self._spellCacheTime = now

    if hitLimit then
        -- Hit sync limit - schedule background completion for remaining spells
        C3:Debug("Lookup: Built partial spell cache (" .. count .. " items), scheduling background completion")
        self:SchedulePrefetch()
    else
        C3:Debug("Lookup: Built spell cache (" .. count .. " items)")
    end
end

---Find a spell by name (exact match only for safety)
---Intellisense should use FindSpells() for fuzzy discovery.
---Execution requires exact match to prevent casting wrong spells.
---@param searchName string Spell name to search for
---@return string|nil spellName The exact spell name if found
---@return number|nil spellID The spell ID if found
---@return number|nil icon The spell icon
function Lookup:FindSpell(searchName)
    if not searchName or searchName == "" then return nil, nil, nil end

    self:_ensureSpellCache()

    -- Trim whitespace and normalize
    local searchLower = strtrim(searchName):lower()
    if searchLower == "" then return nil, nil, nil end

    -- Only accept exact matches for execution safety
    -- Users must either type the full spell name or use intellisense
    for _, spell in ipairs(self._spellCache) do
        if searchLower == spell.nameLower then
            return spell.name, spell.id, spell.icon
        end
    end

    -- Fallback: try spell ID
    local spellID = tonumber(searchName)
    if spellID then
        local info = C_Spell and C_Spell.GetSpellInfo(spellID)
        if info then
            return info.name, spellID, info.iconID
        end
    end

    return nil, nil, nil
end

---Get all spells matching a query (for intellisense)
---@param partial string Partial spell name
---@param limit? number Max results (default 10)
---@return table[] Array of { name, id, icon }
function Lookup:FindSpells(partial, limit)
    limit = limit or 10
    self:_ensureSpellCache()

    if not partial or partial == "" then
        local results = {}
        for i = 1, math.min(#self._spellCache, limit) do
            results[i] = self._spellCache[i]
        end
        return results
    end

    local searchLower = partial:lower()
    local isMultiWord = searchLower:find(" ", 1, true) ~= nil

    -- OPTIMIZATION: Use Trie for prefix matching when possible
    -- Skip for multi-word queries (Trie only supports single-word prefix matching)
    if self._spellTrie and not isMultiWord then
        local matches = self._spellTrie:FindPrefix(searchLower)
        if #matches >= 5 or (#matches > 0 and #searchLower <= 2) then
            -- Sort alphabetically for stability
            table.sort(matches, function(a, b) return a.name < b.name end)
            local results = {}
            for i = 1, math.min(#matches, limit) do
                results[i] = matches[i]
            end
            return results
        end
    end

    -- Full scan with multi-word matching support
    local scored = {}

    for _, spell in ipairs(self._spellCache) do
        -- For multi-word queries, check if all tokens match first
        if isMultiWord then
            if hasMultiWordMatch(searchLower, spell.nameLower) then
                local score = calculateScore(searchLower, spell.nameLower)
                if score > 0 then
                    table.insert(scored, { spell = spell, score = score })
                end
            end
        else
            local score = calculateScore(searchLower, spell.nameLower)
            if score > 0 then
                table.insert(scored, { spell = spell, score = score })
            end
        end
    end

    -- Sort by score desc
    table.sort(scored, function(a, b)
        if a.score ~= b.score then
            return a.score > b.score
        end
        return #a.spell.name < #b.spell.name
    end)

    local results = {}
    for i = 1, math.min(#scored, limit) do
        results[i] = scored[i].spell
    end
    return results
end

-------------------------------------------------------------------------------
-- Mount Lookup
-------------------------------------------------------------------------------

---Ensure mount cache is populated (sync version for backward compatibility)
function Lookup:_ensureMountCache()
    local now = GetTime()
    if self._mountTrie and (now - self._mountCacheTime) < self.CACHE_DURATION then
        return
    end

    -- Build cache directly with proper yielding
    self:_buildMountCacheSync()
end

---Build mount cache synchronously (no yielding - safe for UI callbacks)
function Lookup:_buildMountCacheSync()
    local now = GetTime()
    if self._mountTrie and (now - self._mountCacheTime) < self.CACHE_DURATION then
        return
    end

    local trie = C3.Trie:new()
    local cache = {}
    local seen = {}
    local count = 0

    if C_MountJournal and C_MountJournal.GetMountIDs then
        local mountIDs = C_MountJournal.GetMountIDs()
        for _, mountID in ipairs(mountIDs) do
            local name, spellID, icon, _, isUsable, _, isFavorite, _, _, _, isCollected, mountTypeID =
                C_MountJournal.GetMountInfoByID(mountID)

            if isCollected and name then
                local nameLower = name:lower()
                if not seen[nameLower] then
                    seen[nameLower] = true
                    local item = buildMountItem(mountID, name, spellID, icon, isUsable, isFavorite, mountTypeID)
                    table.insert(cache, item)
                    trie:Insert(name, item)
                    count = count + 1
                end
            end
        end
    end

    self._mountCache = cache
    self._mountTrie = trie
    self._mountCacheTime = now
    C3:Debug("Lookup: Built mount cache (" .. count .. " items)")
end

---Find a mount by name (exact match only for safety)
---@param searchName string Mount name to search for
---@return table|nil mount { name, id, spellID, icon, isFlying, isFavorite }
function Lookup:FindMount(searchName)
    if not searchName or searchName == "" then return nil end

    self:_ensureMountCache()

    -- Trim whitespace and normalize
    local searchLower = strtrim(searchName):lower()
    if searchLower == "" then return nil end

    -- Only accept exact matches for execution safety
    for _, mount in ipairs(self._mountCache) do
        if searchLower == mount.nameLower then
            return mount
        end
    end

    return nil
end

---Get all mounts matching a query
---@param partial string Partial mount name
---@param limit? number Max results (default 10)
---@return table[] Array of mount objects
function Lookup:FindMounts(partial, limit)
    limit = limit or 10
    self:_ensureMountCache()

    if not partial or partial == "" then
        local results = {}
        for i = 1, math.min(#self._mountCache, limit) do
            results[i] = self._mountCache[i]
        end
        return results
    end

    local searchLower = partial:lower()
    local isMultiWord = searchLower:find(" ", 1, true) ~= nil

    -- OPTIMIZATION: Use Trie for prefix matching when possible
    -- Skip for multi-word queries (Trie only supports single-word prefix matching)
    if self._mountTrie and not isMultiWord then
        local matches = self._mountTrie:FindPrefix(searchLower)
        if #matches >= 5 or (#matches > 0 and #searchLower <= 2) then
            -- Sort favorites first, then alphabetically
            table.sort(matches, function(a, b)
                if a.isFavorite ~= b.isFavorite then
                    return a.isFavorite
                end
                return a.name < b.name
            end)
            local results = {}
            for i = 1, math.min(#matches, limit) do
                results[i] = matches[i]
            end
            return results
        end
    end

    -- Full scan with multi-word matching support
    local scored = {}

    for _, mount in ipairs(self._mountCache) do
        -- For multi-word queries, check if all tokens match first
        if isMultiWord then
            if hasMultiWordMatch(searchLower, mount.nameLower) then
                local score = calculateScore(searchLower, mount.nameLower)
                if score > 0 then
                    table.insert(scored, { mount = mount, score = score })
                end
            end
        else
            local score = calculateScore(searchLower, mount.nameLower)
            if score > 0 then
                table.insert(scored, { mount = mount, score = score })
            end
        end
    end

    table.sort(scored, function(a, b)
        if a.score ~= b.score then return a.score > b.score end
        return #a.mount.name < #b.mount.name
    end)

    local results = {}
    for i = 1, math.min(#scored, limit) do
        results[i] = scored[i].mount
    end
    return results
end

-------------------------------------------------------------------------------
-- Toy Lookup
-------------------------------------------------------------------------------

---Find a toy by name (exact match only for safety)
---@param searchName string Toy name to search for
---@return table|nil toy { itemID, name, icon }
function Lookup:FindToy(searchName)
    if not searchName or searchName == "" then return nil end
    if not C_ToyBox then return nil end

    -- Trim whitespace and normalize
    local searchLower = strtrim(searchName):lower()
    if searchLower == "" then return nil end

    -- Configure ToyBox filters
    C_ToyBox.SetAllSourceTypeFilters(true)
    C_ToyBox.SetCollectedShown(true)
    C_ToyBox.SetUncollectedShown(false)
    C_ToyBox.SetFilterString("")
    C_ToyBox.ForceToyRefilter()

    -- Only accept exact matches for execution safety
    local numToys = C_ToyBox.GetNumFilteredToys()
    for i = 1, numToys do
        local itemID = C_ToyBox.GetToyFromIndex(i)
        if itemID and itemID > 0 then
            local toyID, name, icon = C_ToyBox.GetToyInfo(itemID)
            if toyID and name and name:lower() == searchLower then
                return {
                    itemID = itemID,
                    name = name,
                    icon = icon,
                }
            end
        end
    end

    return nil
end

-------------------------------------------------------------------------------
-- Item Lookup (Bag Items)
-------------------------------------------------------------------------------

---Find an item in bags by name (exact match only for safety)
---@param searchName string Item name to search for
---@return table|nil item { name, itemID, bag, slot, count, quality }
function Lookup:FindBagItem(searchName)
    if not searchName or searchName == "" then return nil end

    -- Trim whitespace and normalize
    local searchLower = strtrim(searchName):lower()
    if searchLower == "" then return nil end

    -- Only accept exact matches for execution safety
    for bag = 0, 4 do
        local numSlots = C_Container.GetContainerNumSlots(bag)
        for slot = 1, numSlots do
            local itemInfo = C_Container.GetContainerItemInfo(bag, slot)
            if itemInfo and itemInfo.itemID then
                local name, _, quality = C_Item.GetItemInfo(itemInfo.itemID)
                if name and name:lower() == searchLower then
                    return {
                        name = name,
                        itemID = itemInfo.itemID,
                        bag = bag,
                        slot = slot,
                        count = itemInfo.stackCount or 1,
                        quality = quality,
                    }
                end
            end
        end
    end

    return nil
end

-------------------------------------------------------------------------------
-- Cache Management
-------------------------------------------------------------------------------

---Invalidate all caches and trigger rebuild
function Lookup:InvalidateCache()
    self._spellCache = nil
    self._mountCache = nil
    self._toyCache = nil
    self._spellTrie = nil
    self._mountTrie = nil
    self._spellCacheTime = 0
    self._mountCacheTime = 0
    self._toyCacheTime = 0

    -- Trigger background rebuild
    self:SchedulePrefetch()
end

-- Register for cache invalidation events
-- Note: We only invalidate the specific cache affected, and do NOT trigger
-- immediate rebuild. Caches rebuild on-demand when next accessed.
-- This prevents memory spikes from frequent SPELLS_CHANGED events.
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("SPELLS_CHANGED")
eventFrame:RegisterEvent("PLAYER_TALENT_UPDATE")
eventFrame:RegisterEvent("NEW_MOUNT_ADDED")
eventFrame:RegisterEvent("TOYS_UPDATED")
eventFrame:SetScript("OnEvent", function(_, event)
    if event == "SPELLS_CHANGED" or event == "PLAYER_TALENT_UPDATE" then
        -- Only invalidate spell cache
        Lookup._spellCache = nil
        Lookup._spellTrie = nil
        Lookup._spellCacheTime = 0
    elseif event == "NEW_MOUNT_ADDED" then
        -- Only invalidate mount cache
        Lookup._mountCache = nil
        Lookup._mountTrie = nil
        Lookup._mountCacheTime = 0
    elseif event == "TOYS_UPDATED" then
        -- Only invalidate toy cache
        Lookup._toyCache = nil
        Lookup._toyCacheTime = 0
    end
end)

return Lookup
