-- ============================================================================
-- ETB v12.38.0 - ChatAutocomplete.lua
-- ============================================================================
-- Chat editbox autocomplete: type `etb:partialname` + Tab → item link.
--
-- Activation:
--   The module hooks ChatEdit_OnTabPressed globally. No UI, no slash command.
--   Index building happens lazily on first Tab press with an `etb:` prefix —
--   zero cost until the user actually tries to use it.
--
-- Matching rules:
--   - Case-insensitive prefix match against item name.
--   - Shortest-name-wins when multiple items share a prefix (consistent with
--     Ludwig's "shortest suffix wins" — "thun" prefers "Thunder" over
--     "Thunderfury, Blessed Blade of the Windseeker").
--   - Press Tab repeatedly to cycle through remaining matches.
--
-- Data source:
--   ETB.JLoot (~12K itemIDs) is the primary source — these are all confirmed
--   transmog-eligible boss drops with names already cacheable via GetItemInfo.
--   Uncached items are skipped on index-build; the cache warms over time, so
--   subsequent invocations pick up more matches.
-- ============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB

local ChatAutocomplete = {}
ETB.ChatAutocomplete = ChatAutocomplete

local PREFIX = "etb:"
local PREFIX_LEN = #PREFIX

-- ============================================================================
-- Index state
-- ============================================================================

local indexBuilt = false
-- entries: array of { name = "lowername", itemID = 12345, displayName = "Display" }
-- sorted by lowername ascending, then by length ascending (shorter first).
local entries = nil

-- Cycling state: when the user Tabs multiple times on the same query, we
-- advance through the match list instead of re-inserting the top hit.
local lastQuery = nil
local lastMatchIndex = 0
local lastMatches = nil

-- ============================================================================
-- Index build — iterates JLoot keys and resolves names via GetItemInfo
-- ============================================================================

local function _buildIndex()
    if indexBuilt then return end
    entries = {}

    if not ETB.JLoot then
        -- JLoot not yet loaded (pre-login? or a bug?). Defer.
        return
    end

    local seenID = {}  -- dedupe itemIDs across sources
    local resolved, skipped = 0, 0

    -- Source 1: JLoot (primary — confirmed boss drops)
    for itemID in pairs(ETB.JLoot) do
        if not seenID[itemID] then
            seenID[itemID] = true
            local name = GetItemInfo(itemID)
            if name then
                entries[#entries + 1] = {
                    name = name:lower(),
                    itemID = itemID,
                    displayName = name,
                }
                resolved = resolved + 1
            else
                skipped = skipped + 1
            end
        end
    end

    -- Source 2: ClassRestrictionData (covers crafted gear, BoE drops, etc.)
    -- v12.67.0: ItemMetadata.lua is now lazy-loaded. __index metatables don't
    -- trigger on `pairs()`, so we must force-materialize before iterating.
    if ETB._ItemMetadataLoader then
        ETB._ItemMetadataLoader()
        ETB._ItemMetadataLoader = nil
    end
    if ETB.ClassRestrictionData then
        for itemID in pairs(ETB.ClassRestrictionData) do
            if not seenID[itemID] then
                seenID[itemID] = true
                local name = GetItemInfo(itemID)
                if name then
                    entries[#entries + 1] = {
                        name = name:lower(),
                        itemID = itemID,
                        displayName = name,
                    }
                    resolved = resolved + 1
                else
                    skipped = skipped + 1
                end
            end
        end
    end

    -- Sort by lowername ascending, then shorter-first (for shortest-wins).
    table.sort(entries, function(a, b)
        if a.name == b.name then
            return #a.displayName < #b.displayName
        end
        -- Guard against any unexpected type mismatch (shouldn't happen since
        -- both are lowercased GetItemInfo results).
        return tostring(a.name) < tostring(b.name)
    end)

    indexBuilt = true
    if ETB.debug then
        ETB:Debug("ChatAutocomplete", string.format(
            "Index built: %d items indexed, %d skipped (uncached), %d total keys scanned",
            resolved, skipped, resolved + skipped))
    end
end

-- Rebuild trigger — call when data changes (e.g. after a /reload that
-- warmed GetItemInfo cache significantly). Currently only used if the user
-- hits Tab after a session, no Matches were found, and the cache has
-- since warmed.
function ChatAutocomplete:InvalidateIndex()
    indexBuilt = false
    entries = nil
    lastQuery = nil
    lastMatches = nil
end

function ChatAutocomplete:GetIndexStats()
    return {
        built = indexBuilt,
        count = entries and #entries or 0,
    }
end

-- ============================================================================
-- Match lookup — binary search + linear scan on the sorted entries.
-- For a prefix search on a sorted list, the matches form a contiguous range.
-- ============================================================================

-- Binary search for the first entry whose lowered name is >= target.
-- Returns 1-based index into entries, or #entries+1 if none.
local function _lowerBound(target)
    if not entries or #entries == 0 then return 1 end
    local lo, hi = 1, #entries + 1
    while lo < hi do
        local mid = math.floor((lo + hi) / 2)
        if entries[mid].name < target then
            lo = mid + 1
        else
            hi = mid
        end
    end
    return lo
end

-- Return an array of up to `cap` entries whose lowered name starts with `prefix`.
local function _findMatches(prefix, cap)
    if not entries then return {} end
    cap = cap or 50  -- reasonable upper bound to avoid runaway scans
    local start = _lowerBound(prefix)
    local matches = {}
    local plen = #prefix
    for i = start, #entries do
        local e = entries[i]
        if e.name:sub(1, plen) ~= prefix then break end
        matches[#matches + 1] = e
        if #matches >= cap then break end
    end
    return matches
end

-- ============================================================================
-- Chat editbox extraction — find the `etb:query` token that the cursor is in.
-- Returns startPos, endPos, query (all nil if no token found).
-- ============================================================================

local function _extractToken(text, cursorPos)
    if not text or text == "" then return nil end

    -- Scan backward from cursor for the start of the token (first `etb:`).
    -- Tokens are separated by whitespace.
    local searchEnd = cursorPos or #text
    local before = text:sub(1, searchEnd)

    -- Find the last occurrence of `etb:` in the text-before-cursor that
    -- is either at start of string or preceded by whitespace.
    local tokenStart
    local s = 1
    while true do
        local hit = before:find(PREFIX, s, true)
        if not hit then break end
        if hit == 1 or before:sub(hit - 1, hit - 1):match("%s") then
            tokenStart = hit
        end
        s = hit + 1
    end

    if not tokenStart then return nil end

    -- Find the end of the token (next whitespace after tokenStart, or end of text).
    local tokenEnd = text:find("%s", tokenStart + PREFIX_LEN)
    tokenEnd = (tokenEnd and (tokenEnd - 1)) or #text

    local query = text:sub(tokenStart + PREFIX_LEN, tokenEnd)
    return tokenStart, tokenEnd, query
end

-- ============================================================================
-- Tab handler — called when the user hits Tab in a chat editbox.
-- Returns true if we consumed the Tab (performed autocomplete), false otherwise.
-- ============================================================================

local function _handleTab(editBox)
    if not editBox or not editBox.GetText then return false end
    local text = editBox:GetText()
    if not text or text == "" then return false end

    local cursorPos = editBox:GetCursorPosition()
    local tokenStart, tokenEnd, query = _extractToken(text, cursorPos)
    if not query or query == "" then return false end

    -- Build index on first use (lazy).
    if not indexBuilt then _buildIndex() end
    if not entries or #entries == 0 then return false end

    local queryLower = query:lower()

    -- Cycling: if the user pressed Tab again with the same query, advance
    -- to the next match instead of re-inserting the top hit.
    if lastQuery == queryLower and lastMatches and #lastMatches > 0 then
        lastMatchIndex = (lastMatchIndex % #lastMatches) + 1
    else
        lastMatches = _findMatches(queryLower, 50)
        if #lastMatches == 0 then
            -- Nothing matched. Don't consume the Tab.
            lastQuery = nil
            return false
        end
        lastQuery = queryLower
        lastMatchIndex = 1
    end

    local match = lastMatches[lastMatchIndex]
    if not match then return false end

    -- Resolve a proper item link. GetItemInfo returns (name, link, ...).
    local _, itemLink = GetItemInfo(match.itemID)
    if not itemLink then
        -- Item fell out of cache between index build and here. Skip.
        return false
    end

    -- Replace the `etb:query` token with the item link.
    local before = text:sub(1, tokenStart - 1)
    local after = text:sub(tokenEnd + 1)
    local newText = before .. itemLink .. after
    editBox:SetText(newText)
    editBox:SetCursorPosition(#before + #itemLink)
    return true
end

-- ============================================================================
-- Hook installation — runs at module load. ChatEdit_OnTabPressed is the
-- canonical extension point; hooksecurefunc doesn't work because we need to
-- override default behavior when we match. Use the pattern Ludwig uses:
-- wrap ChatEdit_CustomTabPressed.
-- ============================================================================
--
-- MoP Classic (5.4.8) behavior: ChatEdit_OnTabPressed calls
-- ChatEdit_CustomTabPressed(self) first, and if it returns true the default
-- handler is skipped. So we hook that.

function ChatAutocomplete:Initialize()
    -- Defensive: if ChatEdit_CustomTabPressed doesn't exist (e.g. in some
    -- heavily-modified UI replacement), create it. Blizzard's default
    -- returns nil, which we match.
    if not _G.ChatEdit_CustomTabPressed then
        _G.ChatEdit_CustomTabPressed = function() end
    end

    local original = _G.ChatEdit_CustomTabPressed
    _G.ChatEdit_CustomTabPressed = function(editBox, ...)
        -- Try our handler first. If we consumed the Tab, short-circuit.
        if _handleTab(editBox) then return true end
        -- Otherwise, defer to whatever was there before.
        return original(editBox, ...)
    end

    if ETB.debug then
        ETB:Debug("ChatAutocomplete", "Hooked ChatEdit_CustomTabPressed")
    end
end

-- ============================================================================
-- Stats hook for /etb stats
-- ============================================================================

function ChatAutocomplete:PrintStats()
    local s = ChatAutocomplete:GetIndexStats()
    if not s.built then
        ETB:Print("ChatAutocomplete: index not yet built (will build on first Tab)")
    else
        ETB:Print(string.format("ChatAutocomplete: %d items indexed", s.count))
    end
end
