--[[
    Commander 3.0 - Intellisense System

    Context-aware autocomplete that parses input to determine:
    - What command is being typed
    - Which argument position the cursor is in
    - What provider to call for suggestions

    Providers return { text, description, icon, quality } objects.
]]

local ADDON_NAME, C3 = ...

-------------------------------------------------------------------------------
-- Intellisense Module
-------------------------------------------------------------------------------

local Intellisense = {}
C3.Intellisense = Intellisense

-- Use shared utilities (lazy lookup handled by Colors module)
local Colorize = function(...) return C3.Colors.Colorize(...) end
local GetThemeColor = function(...) return C3.Colors.GetColor(...) end

-- Configuration
Intellisense.MIN_CHARS_COMMAND = 1   -- Min chars before suggesting commands
Intellisense.MIN_CHARS_ARG = 1       -- Min chars before suggesting arguments
Intellisense.MAX_SUGGESTIONS = C3.Constants.MAX_SUGGESTIONS  -- Max suggestions to return

-------------------------------------------------------------------------------
-- Fuzzy Matching (based on fzy algorithm)
-- Subsequence matching with smart scoring for quality ranking
-------------------------------------------------------------------------------

-- Scoring constants
local SCORE_GAP_LEADING = -0.005     -- Penalty for chars before first match
local SCORE_GAP_TRAILING = -0.005    -- Penalty for chars after last match
local SCORE_GAP_INNER = -0.01        -- Penalty for gaps between matches
local SCORE_MATCH_CONSECUTIVE = 1.0  -- Bonus for consecutive matches
local SCORE_MATCH_WORD = 0.8         -- Bonus for match after space/dash/underscore
local SCORE_MATCH_CAPITAL = 0.7      -- Bonus for camelCase match
local SCORE_MIN = -math.huge
local SCORE_MAX = math.huge

-- Reusable tables to reduce GC pressure
local D, M, bonus = {}, {}, {}

local function isLower(c) return c:match("%l") end
local function isUpper(c) return c:match("%u") end

-- Use shared string utilities for fuzzy matching
local hasMatch = function(...) return C3.StringUtils.fuzzyMatch(...) end
local splitTokens = function(...) return C3.StringUtils.splitTokens(...) end
local normalizeForMatch = function(...) return C3.StringUtils.normalizeForMatch(...) end
local hasMultiWordMatch = function(...) return C3.StringUtils.hasMultiWordMatch(...) end

---Compute match score (higher is better)
---@param needle string Search query
---@param haystack string Text to search in
---@return number score
local function computeScore(needle, haystack)
    local n, m = #needle, #haystack

    if n == 0 then return SCORE_MAX end
    if m == 0 or n > m then return SCORE_MIN end

    -- Exact match
    if n == m and needle:lower() == haystack:lower() then
        return SCORE_MAX
    end

    -- Quick check
    if not hasMatch(needle, haystack) then
        return SCORE_MIN
    end

    -- Precompute bonuses (before lowercasing for camelCase detection)
    for k = 1, m do bonus[k] = 0 end
    local lastChar = " "
    for i = 1, m do
        local thisChar = haystack:sub(i, i)
        if lastChar == "-" or lastChar == "_" or lastChar == " " or lastChar == "'" then
            bonus[i] = SCORE_MATCH_WORD
        elseif isLower(lastChar) and isUpper(thisChar) then
            bonus[i] = SCORE_MATCH_CAPITAL
        end
        lastChar = thisChar
    end

    -- Lowercase for matching
    needle = needle:lower()
    haystack = haystack:lower()

    -- DP scoring
    for i = 1, n do
        D[i] = D[i] or {}
        M[i] = M[i] or {}
        local prevScore = SCORE_MIN
        local gapScore = (i == n) and SCORE_GAP_TRAILING or SCORE_GAP_INNER
        local needleChar = needle:sub(i, i)

        for j = 1, m do
            if needleChar == haystack:sub(j, j) then
                local score = SCORE_MIN
                if i == 1 then
                    score = ((j - 1) * SCORE_GAP_LEADING) + bonus[j]
                elseif j > 1 then
                    local a = (M[i-1][j-1] or SCORE_MIN) + bonus[j]
                    local b = (D[i-1][j-1] or SCORE_MIN) + SCORE_MATCH_CONSECUTIVE
                    score = math.max(a, b)
                end
                D[i][j] = score
                prevScore = math.max(score, prevScore + gapScore)
                M[i][j] = prevScore
            else
                D[i][j] = SCORE_MIN
                prevScore = prevScore + gapScore
                M[i][j] = prevScore
            end
        end
    end

    return M[n][m] or SCORE_MIN
end

---Compute score for multi-word queries
---@param needle string Multi-word query
---@param haystack string Text to search
---@return number score (higher is better)
local function computeMultiWordScore(needle, haystack)
    if not needle or needle == "" then return SCORE_MAX end
    if not haystack or haystack == "" then return SCORE_MIN end

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

    local tokens = splitTokens(needleNorm)

    -- Single token: use standard scoring
    if #tokens <= 1 then
        return computeScore(needle, haystack)
    end

    -- Multi-token: score each token, aggregate
    local totalScore = 0

    -- Get normalized words from haystack
    local words = {}
    for w in haystackNorm:gmatch("%S+") do
        words[#words + 1] = w
    end

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

        -- Check for word-level matches (bonus for exact word match)
        for _, word in ipairs(words) do
            if word == token then
                -- Exact word match - highest score
                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)
            end
        end

        -- Fall back to fzy scoring if no word match
        if tokenScore == SCORE_MIN then
            tokenScore = computeScore(token, haystack)
            if tokenScore == SCORE_MIN then
                return SCORE_MIN  -- Token doesn't match at all
            end
            tokenScore = tokenScore * 0.5  -- Penalty for subsequence-only match
        end

        totalScore = totalScore + tokenScore
    end

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

---Filter and sort items by fuzzy match quality
---@param items table[] Array of items
---@param needle string Search query
---@param getText function Function to get searchable text from item
---@param limit number? Max results to return (default: 10)
---@return table[] Sorted results
local function fuzzyFilter(items, needle, getText, limit)
    limit = limit or 10  -- Default limit

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

    local scored = {}
    for _, item in ipairs(items) do
        local text = getText(item)
        if text and hasMultiWordMatch(needle, text) then
            local score = computeMultiWordScore(needle, text)
            if score > SCORE_MIN then
                scored[#scored + 1] = { item = item, score = score, len = #text }
            end
        end
    end

    -- Sort by score desc, then by length asc (shorter names first on tie)
    table.sort(scored, function(a, b)
        if a.score ~= b.score then
            return a.score > b.score
        end
        return a.len < b.len
    end)

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

-------------------------------------------------------------------------------
-- Match Highlighting
-------------------------------------------------------------------------------

---Get match ranges for multi-word query highlighting
---Returns array of {from, to} byte positions for matched portions
---@param needle string Search query (can be multi-word)
---@param haystack string Text to highlight
---@return table[] ranges Array of {from, to} ranges (1-based, inclusive)
local function getMatchRanges(needle, haystack)
    if not needle or needle == "" or not haystack or haystack == "" then
        return {}
    end

    local ranges = {}
    local tokens = splitTokens(needle)
    local haystackLower = haystack:lower()
    local haystackNorm = normalizeForMatch(haystackLower)

    if #tokens <= 1 then
        -- Single token: find substring match first, fall back to subsequence
        local needleLower = needle:lower()
        local needleNorm = normalizeForMatch(needleLower)
        local start = haystackLower:find(needleLower, 1, true)
        if start then
            -- Contiguous substring match in original
            ranges[1] = { from = start, to = start + #needle - 1 }
        elseif haystackNorm:find(needleNorm, 1, true) then
            -- Match found in normalized - use subsequence to find chars in original
            -- This handles "mimirons" matching "Mimiron's" by highlighting each char
            local j = 1
            for i = 1, #needleLower do
                local char = needleLower:sub(i, i)
                local found = haystackLower:find(char, j, true)
                if found then
                    if #ranges > 0 and ranges[#ranges].to == found - 1 then
                        ranges[#ranges].to = found
                    else
                        ranges[#ranges + 1] = { from = found, to = found }
                    end
                    j = found + 1
                end
            end
        else
            -- Pure subsequence match: find each character
            local j = 1
            for i = 1, #needleLower do
                local char = needleLower:sub(i, i)
                local found = haystackLower:find(char, j, true)
                if found then
                    if #ranges > 0 and ranges[#ranges].to == found - 1 then
                        ranges[#ranges].to = found
                    else
                        ranges[#ranges + 1] = { from = found, to = found }
                    end
                    j = found + 1
                end
            end
        end
    else
        -- Multi-word: find each token as substring or fuzzy subsequence
        for _, token in ipairs(tokens) do
            local tokenLower = token:lower()
            local start = haystackLower:find(tokenLower, 1, true)
            if start then
                -- Contiguous substring match
                ranges[#ranges + 1] = { from = start, to = start + #token - 1 }
            else
                -- Try subsequence match (handles typos like "expetion" → "expedition")
                -- This works for both normalized matches and pure fuzzy matches
                local j = 1
                local tokenRanges = {}
                for ci = 1, #tokenLower do
                    local char = tokenLower:sub(ci, ci)
                    local found = haystackLower:find(char, j, true)
                    if found then
                        if #tokenRanges > 0 and tokenRanges[#tokenRanges].to == found - 1 then
                            tokenRanges[#tokenRanges].to = found
                        else
                            tokenRanges[#tokenRanges + 1] = { from = found, to = found }
                        end
                        j = found + 1
                    end
                end
                for _, r in ipairs(tokenRanges) do
                    ranges[#ranges + 1] = r
                end
            end
        end

        -- Sort ranges by position
        table.sort(ranges, function(a, b) return a.from < b.from end)

        -- Merge overlapping ranges
        if #ranges > 1 then
            local merged = { ranges[1] }
            for i = 2, #ranges do
                local last = merged[#merged]
                local curr = ranges[i]
                if curr.from <= last.to + 1 then
                    last.to = math.max(last.to, curr.to)
                else
                    merged[#merged + 1] = curr
                end
            end
            ranges = merged
        end
    end

    return ranges
end

---Highlight matched portions of text with color codes
---@param text string Text to highlight
---@param needle string Search query
---@param color string? Hex color without # (default: theme accent.primary or "88C0D0")
---@return string Highlighted text with WoW color codes
local function highlightMatches(text, needle, color)
    if not text or text == "" then return "" end
    if not needle or needle == "" then return text end

    -- Default to cyan (accent.primary)
    color = color or GetThemeColor("accent.primary") or "88C0D0"

    local ranges = getMatchRanges(needle, text)
    if #ranges == 0 then return text end

    -- Build highlighted string
    local result = {}
    local lastEnd = 0

    for _, range in ipairs(ranges) do
        -- Add unmatched portion before this range
        if range.from > lastEnd + 1 then
            result[#result + 1] = text:sub(lastEnd + 1, range.from - 1)
        end

        -- Add highlighted portion
        local matched = text:sub(range.from, range.to)
        result[#result + 1] = string.format("|cFF%s%s|r", color, matched)

        lastEnd = range.to
    end

    -- Add remaining unmatched portion
    if lastEnd < #text then
        result[#result + 1] = text:sub(lastEnd + 1)
    end

    return table.concat(result)
end

-- Export for use by providers
Intellisense.hasMatch = hasMatch
Intellisense.hasMultiWordMatch = hasMultiWordMatch
Intellisense.splitTokens = splitTokens
Intellisense.computeScore = computeScore
Intellisense.computeMultiWordScore = computeMultiWordScore
Intellisense.fuzzyFilter = fuzzyFilter
Intellisense.getMatchRanges = getMatchRanges
Intellisense.highlightMatches = highlightMatches

-------------------------------------------------------------------------------
-- Context Parsing
-------------------------------------------------------------------------------

-- Operators that expect field names as arguments (including aliases)
local FIELD_OPERATORS = {
    -- where and aliases
    where = true,
    filter = true,
    grep = true,
    -- sort and aliases
    sort = true,
    order = true,
    orderby = true,
    -- select and aliases
    select = true,
    pluck = true,
    pick = true,
    -- group and aliases
    group = true,
    groupby = true,
    -- unique and aliases
    unique = true,
    distinct = true,
    uniq = true,
    -- aggregation operators
    sum = true,
    avg = true,
    average = true,
    mean = true,
    min = true,
    minimum = true,
    max = true,
    maximum = true,
}

-- Operators used in where expressions (after field name)
local WHERE_OPERATORS = {
    ["=="] = true,
    ["="] = true,
    ["~"] = true,  -- contains
    [">"] = true,
    ["<"] = true,
    [">="] = true,
    ["<="] = true,
    ["!="] = true,
    ["~="] = true,
}

-- Operator suggestions with descriptions
local WHERE_OPERATOR_SUGGESTIONS = {
    { text = "==", description = "equals" },
    { text = "~", description = "contains" },
    { text = ">", description = "greater than" },
    { text = "<", description = "less than" },
    { text = ">=", description = "greater or equal" },
    { text = "<=", description = "less or equal" },
    { text = "!=", description = "not equals" },
}

-- Sort direction suggestions
local SORT_DIRECTION_SUGGESTIONS = {
    { text = "asc", description = "ascending (A-Z, 0-9)" },
    { text = "desc", description = "descending (Z-A, 9-0)" },
}

-- Operators that are sort-like (take field + optional direction)
local SORT_OPERATORS = {
    sort = true,
    order = true,
    orderby = true,
}

-- Operators that are where-like (take field + operator + value)
local WHERE_LIKE_OPERATORS = {
    where = true,
    filter = true,
    grep = true,
}

-------------------------------------------------------------------------------
-- Pipeline Type Resolution
-- Walks the pipeline to determine the output type at each stage
-------------------------------------------------------------------------------

---Resolve the output type at a specific point in a pipeline
---@param pipelineText string The pipeline text up to (but not including) the current operator
---@return string? typeName The resolved type name, or nil if unknown
function Intellisense:ResolvePipelineType(pipelineText)
    if not pipelineText or pipelineText == "" then
        return nil
    end

    -- Split by pipe character
    local segments = {}
    for segment in pipelineText:gmatch("[^|]+") do
        segment = segment:match("^%s*(.-)%s*$")  -- trim whitespace
        if segment ~= "" then
            segments[#segments + 1] = segment
        end
    end

    if #segments == 0 then
        return nil
    end

    -- Get initial type from first command
    local firstCmd = segments[1]:match("^(%S+)")
    if not firstCmd then
        return nil
    end

    local cmdDef = C3.Command:Get(firstCmd:lower())
    if not cmdDef then
        return nil
    end

    local currentType = cmdDef.producesType

    -- Walk through subsequent operators to track type transformations
    for i = 2, #segments do
        local opName = segments[i]:match("^(%S+)")
        if not opName then
            break
        end

        local opDef = C3.Command:Get(opName:lower())
        if not opDef then
            break
        end

        if opDef.passthrough then
            -- Passthrough operator preserves the current type
            -- currentType stays the same
        elseif opDef.producesType then
            -- Operator transforms to a new type
            currentType = opDef.producesType
        else
            -- Unknown transformation - type information lost
            currentType = nil
        end
    end

    return currentType
end

---Parse input text to determine autocomplete context
---@param input string The full input text
---@param cursorPos number Cursor position (0-based from start)
---@return table context { command, argIndex, partial, fullInput, isFirstWord, inPipeline, pipelineOperator, upstreamCommand, expectsFieldName }
function Intellisense:GetContext(input, cursorPos)
    -- WoW escapes | to || in EditBox. Normalize for consistent parsing.
    -- This also adjusts cursor position to match the normalized string.
    local normalized = input:gsub("||", "|")
    local normalizedCursorPos = cursorPos

    -- Adjust cursor position: for each || before cursor, subtract 1
    local beforeRaw = input:sub(1, cursorPos)
    local pipeCount = select(2, beforeRaw:gsub("||", "")) or 0
    normalizedCursorPos = cursorPos - pipeCount

    -- Default context
    local ctx = {
        command = nil,
        argIndex = 0,
        partial = "",
        fullInput = normalized,
        isFirstWord = true,
        beforeCursor = normalized:sub(1, normalizedCursorPos),
        afterCursor = normalized:sub(normalizedCursorPos + 1),
        -- Store original values for InsertCompletion to use
        rawInput = input,
        rawCursorPos = cursorPos,
        -- Pipeline context
        inPipeline = false,
        pipelineOperator = nil,
        upstreamCommand = nil,
        expectsFieldName = false,
        -- Where expression context
        expectsOperator = false,   -- for "where subtype " -> suggest ==, ~, >, etc.
        expectsFieldValue = false, -- for "where subtype == " -> suggest values
        fieldName = nil,
        whereOperator = nil,
        -- Sort direction context
        expectsSortDirection = false,  -- for "sort name " -> suggest asc/desc
    }

    -- Empty input
    if not input or input == "" then
        return ctx
    end

    local beforeCursor = ctx.beforeCursor

    -- Check for pipeline: find pipe positions
    local firstPipePos = beforeCursor:find("|", 1, true)

    if firstPipePos then
        ctx.inPipeline = true
        ctx.isFirstWord = false  -- Not typing first command anymore

        -- Extract upstream command (first command before any pipe)
        local firstPart = beforeCursor:sub(1, firstPipePos - 1)
        local firstCmd = firstPart:match("^%s*(%S+)")
        ctx.upstreamCommand = firstCmd and firstCmd:lower()

        -- Find the last pipe to get current segment
        local lastPipePos = firstPipePos
        local searchPos = firstPipePos + 1
        while true do
            local nextPipe = beforeCursor:find("|", searchPos, true)
            if not nextPipe then break end
            lastPipePos = nextPipe
            searchPos = nextPipe + 1
        end

        -- Resolve the type at this point in the pipeline
        -- Use text up to (but not including) the last pipe
        local pipeChain = beforeCursor:sub(1, lastPipePos - 1)
        ctx.resolvedType = self:ResolvePipelineType(pipeChain)

        -- Extract text after last pipe
        local afterLastPipe = beforeCursor:sub(lastPipePos + 1)

        -- Tokenize segment after last pipe
        local segmentTokens = {}
        for token in afterLastPipe:gmatch("%S+") do
            segmentTokens[#segmentTokens + 1] = token
        end

        local isTypingWord = afterLastPipe:match("%S$") ~= nil

        if #segmentTokens == 0 then
            -- Just typed pipe, no operator yet - suggest operators
            ctx.partial = ""
            ctx.pipelineOperator = nil
            return ctx
        elseif #segmentTokens == 1 and isTypingWord then
            -- Typing the operator name
            ctx.partial = segmentTokens[1]
            ctx.pipelineOperator = nil
            return ctx
        else
            -- Have an operator
            ctx.pipelineOperator = segmentTokens[1]:lower()

            -- Check if this operator expects field names
            if FIELD_OPERATORS[ctx.pipelineOperator] then
                -- For 'where' operator, check if we're past the field name
                -- Pattern: where <field> [op] [value]
                if WHERE_LIKE_OPERATORS[ctx.pipelineOperator] and #segmentTokens >= 2 then
                    -- segmentTokens[1] = "where"
                    -- segmentTokens[2] = field name (e.g., "subtype")
                    -- segmentTokens[3] = operator (e.g., "==", "~", ">")
                    -- segmentTokens[4] = value (e.g., "Junk")

                    local fieldName = segmentTokens[2]
                    local possibleOp = segmentTokens[3]
                    local possibleValue = segmentTokens[4]

                    -- Check if we have a field name and are past it
                    if fieldName and not isTypingWord and #segmentTokens == 2 then
                        -- "where subtype " - just finished typing field, now expect operator
                        ctx.expectsOperator = true
                        ctx.expectsFieldName = false
                        ctx.fieldName = fieldName
                        ctx.partial = ""
                    elseif fieldName and isTypingWord and #segmentTokens == 3 then
                        -- "where subtype =" or "where subtype ~" - typing operator
                        ctx.expectsOperator = true
                        ctx.expectsFieldName = false
                        ctx.fieldName = fieldName
                        ctx.partial = possibleOp
                    elseif fieldName and possibleOp and WHERE_OPERATORS[possibleOp] then
                        -- Have a valid operator
                        ctx.fieldName = fieldName
                        ctx.whereOperator = possibleOp
                        if not isTypingWord and #segmentTokens == 3 then
                            -- "where subtype == " - expect value after operator
                            ctx.expectsFieldValue = true
                            ctx.expectsFieldName = false
                            ctx.partial = ""
                        elseif isTypingWord and #segmentTokens == 4 then
                            -- "where subtype == Ju" - typing value
                            ctx.expectsFieldValue = true
                            ctx.expectsFieldName = false
                            ctx.partial = possibleValue
                        elseif not isTypingWord and #segmentTokens == 4 then
                            -- "where subtype == Junk " - expression complete
                            ctx.expectsFieldValue = false
                            ctx.expectsFieldName = false
                        else
                            ctx.expectsFieldValue = false
                            ctx.expectsFieldName = false
                        end
                    else
                        -- Still typing field name
                        ctx.expectsFieldName = true
                        if isTypingWord then
                            ctx.partial = segmentTokens[#segmentTokens]
                        else
                            ctx.partial = ""
                        end
                    end
                elseif SORT_OPERATORS[ctx.pipelineOperator] then
                    -- Sort operators: sort <field> [asc|desc]
                    -- segmentTokens[1] = "sort"
                    -- segmentTokens[2] = field name
                    -- segmentTokens[3] = direction (optional)

                    if #segmentTokens == 1 then
                        -- "sort " - no field yet, suggest fields
                        ctx.expectsFieldName = true
                        ctx.partial = ""
                    elseif #segmentTokens == 2 and isTypingWord then
                        -- "sort na" - typing field name
                        ctx.expectsFieldName = true
                        ctx.partial = segmentTokens[2]
                    elseif #segmentTokens == 2 and not isTypingWord then
                        -- "sort name " - finished field, suggest direction
                        ctx.expectsSortDirection = true
                        ctx.expectsFieldName = false
                        ctx.fieldName = segmentTokens[2]
                        ctx.partial = ""
                    elseif #segmentTokens == 3 and isTypingWord then
                        -- "sort name de" - typing direction
                        ctx.expectsSortDirection = true
                        ctx.expectsFieldName = false
                        ctx.fieldName = segmentTokens[2]
                        ctx.partial = segmentTokens[3]
                    else
                        -- "sort name desc " - complete, no more suggestions
                        ctx.expectsFieldName = false
                        ctx.expectsSortDirection = false
                    end
                else
                    -- Other field operators (select, group, sum, avg, min, max, unique)
                    -- These just take field name(s), no second argument
                    if #segmentTokens == 1 then
                        -- "sum " - no field yet, suggest fields
                        ctx.expectsFieldName = true
                        ctx.partial = ""
                    elseif isTypingWord then
                        -- "sum na" - typing field name
                        ctx.expectsFieldName = true
                        ctx.partial = segmentTokens[#segmentTokens]
                    else
                        -- "sum name " - finished field (for operators that take one field)
                        -- select takes comma-separated, but we can still suggest more fields
                        ctx.expectsFieldName = true
                        ctx.partial = ""
                    end
                end
            end

            return ctx
        end
    end

    -- Not in pipeline - original logic
    -- Tokenize the input before cursor
    local tokens = {}
    for token in beforeCursor:gmatch("%S+") do
        table.insert(tokens, token)
    end

    -- Check if we're in the middle of typing a word
    local isTypingWord = beforeCursor:match("%S$") ~= nil

    if #tokens == 0 then
        -- Empty, suggest commands
        ctx.isFirstWord = true
        ctx.partial = ""
        return ctx
    end

    if #tokens == 1 and isTypingWord then
        -- Typing first word (command)
        ctx.isFirstWord = true
        ctx.partial = tokens[1]
        return ctx
    end

    -- We have a command
    ctx.command = tokens[1]:lower()
    ctx.isFirstWord = false

    -- For single-argument commands (mount, cast, toy, etc.), the "argument"
    -- is everything after the command, which may contain spaces (e.g., "Red Drake")
    -- Combine all tokens after the command into the partial
    local argTokens = {}
    for i = 2, #tokens do
        argTokens[#argTokens + 1] = tokens[i]
    end
    ctx.partial = table.concat(argTokens, " ")
    ctx.argIndex = 1  -- Treat as single argument

    return ctx
end

-------------------------------------------------------------------------------
-- Suggestion Generation
-------------------------------------------------------------------------------

---Get suggestions for the current input context
---@param input string The full input text
---@param cursorPos number Cursor position
---@return table[] suggestions Array of { text, description, icon, quality }
function Intellisense:GetSuggestions(input, cursorPos)
    local ctx = self:GetContext(input, cursorPos)

    -- First word: suggest commands
    if ctx.isFirstWord then
        if #ctx.partial < self.MIN_CHARS_COMMAND then
            return {}
        end
        return self:_suggestCommands(ctx.partial)
    end

    -- Pipeline context: suggest pipeable commands or fields
    if ctx.inPipeline then
        -- No command yet after pipe: suggest all pipeable commands
        if not ctx.pipelineOperator then
            return self.Providers.PipeableCommands(ctx.partial, ctx)
        end

        -- Operator context: suggest comparison operators (==, ~, >, etc.)
        if ctx.expectsOperator then
            return self.Providers.WhereOperators(ctx.partial, ctx)
        end

        -- Field value context: suggest possible values for the field
        if ctx.expectsFieldValue and ctx.upstreamCommand and ctx.fieldName then
            return self.Providers.FieldValues(ctx.partial, ctx)
        end

        -- Sort direction context: suggest asc/desc
        if ctx.expectsSortDirection then
            return self.Providers.SortDirections(ctx.partial, ctx)
        end

        -- Field-expecting operator: suggest fields from upstream type
        if ctx.expectsFieldName and ctx.upstreamCommand then
            -- Allow suggestions even with 0 chars for field names (show all fields)
            return self.Providers.Fields(ctx.partial, ctx)
        end

        -- Other operators (take, skip, etc.): no suggestions
        return {}
    end

    -- Not in pipeline: original argument provider logic
    -- Get command definition (checking session aliases if not found)
    local cmdDef = C3.Command:Get(ctx.command)

    -- If command not found directly, check if it's a session alias
    if not cmdDef then
        local session = C3:GetSession()
        if session then
            local aliasedCmd = session:GetAlias(ctx.command)
            if aliasedCmd then
                -- Extract the actual command name from the alias
                -- e.g., "mount" from "mount" or "guild --online" from alias "gm"
                local actualCmdName = aliasedCmd:match("^(%S+)")
                if actualCmdName then
                    cmdDef = C3.Command:Get(actualCmdName)
                end
            end
        end
    end

    if not cmdDef then
        return {}
    end

    -- Check if the argument at this position has a provider
    local argDef = cmdDef.args and cmdDef.args[ctx.argIndex]
    if argDef and argDef.provider then
        -- Providers handle their own filtering, call even with empty partial
        return argDef.provider(ctx.partial, ctx) or {}
    end

    -- No provider defined, enforce minimum characters
    if #ctx.partial < self.MIN_CHARS_ARG then
        return {}
    end

    return {}
end

---Suggest commands matching partial
---Filters out query operators (where, sort, etc.) since those only make sense after a pipe
---Also includes session aliases
---@param partial string
---@return table[]
function Intellisense:_suggestCommands(partial)
    local allSuggestions = C3.Command:Autocomplete(partial)

    -- Filter out query operators - they should only appear after a pipe
    local suggestions = {}
    for _, suggestion in ipairs(allSuggestions) do
        if suggestion.category ~= "query" then
            suggestions[#suggestions + 1] = suggestion
        end
    end

    -- Also include session aliases that match
    local session = C3:GetSession()
    if session and session.aliases then
        local partialLower = partial:lower()
        for aliasName, aliasCmd in pairs(session.aliases) do
            if aliasName:lower():sub(1, #partialLower) == partialLower then
                suggestions[#suggestions + 1] = {
                    text = aliasName,
                    description = aliasCmd .. " (alias)",
                    category = "alias",
                }
            end
        end
    end

    -- Sort by text
    table.sort(suggestions, function(a, b) return a.text < b.text end)

    -- Limit results
    if #suggestions > self.MAX_SUGGESTIONS then
        local limited = {}
        for i = 1, self.MAX_SUGGESTIONS do
            limited[i] = suggestions[i]
        end
        return limited
    end

    return suggestions
end

-------------------------------------------------------------------------------
-- Built-in Providers
-------------------------------------------------------------------------------

Intellisense.Providers = {}

-------------------------------------------------------------------------------
-- Provider Registry (Declarative "Negative Space" Pattern)
-- Allows commands to reference providers by string name instead of functions
-------------------------------------------------------------------------------

Intellisense._providers = {}

---Register a reusable autocomplete provider
---@param name string Unique ID (e.g., "Mounts", "Spells")
---@param fn function(partial, ctx) -> table[]
function Intellisense:RegisterProvider(name, fn)
    self._providers[name] = fn
end

---Get a provider by name
---@param name string
---@return function?
function Intellisense:GetProvider(name)
    return self._providers[name]
end

---Factory: Create a provider from a static list of strings
---@param values table Array of strings (e.g., {"info", "warn", "error"})
---@return function
function Intellisense.CreateEnumProvider(values)
    return function(partial)
        local results = {}
        for _, v in ipairs(values) do
            results[#results + 1] = { text = v, name = v }
        end
        return fuzzyFilter(results, partial, function(item) return item.text end)
    end
end

-- NOTE: Data source providers (Spells, Mounts, Items, Toys, Quests, Specs,
-- EquipmentSets, Players) are now registered in their respective datasource files.
-- See: datasources/*.lua

-- REMOVED: Mounts, Spells, Items, Specs, Toys, Quests, EquipmentSets, Players
-- (now in datasources/)

-------------------------------------------------------------------------------
-- Pipeline Operators Providers (remain in core)
-------------------------------------------------------------------------------

---Pipeable commands provider (for pipe completions)
---Shows pipeline operators (query category commands that accept input)
---@param partial string Partial command name
---@param ctx table Context
---@return table[] suggestions
function Intellisense.Providers.PipeableCommands(partial, ctx)
    -- Get commands in the "query" category (actual pipeline operators)
    local queryCommands = C3.Command:GetByCategory("query")

    -- Build suggestion list - only include query operators
    local items = {}
    for _, def in ipairs(queryCommands) do
        if def.acceptsInput then
            items[#items + 1] = {
                name = def.name,
                description = def.description or "",
            }
        end
    end

    -- Use higher limit for pipe completions (more options to show)
    local limit = partial == "" and 50 or Intellisense.MAX_SUGGESTIONS

    local filtered = fuzzyFilter(
        items,
        partial,
        function(cmd) return cmd.name end,
        limit
    )

    local results = {}
    for _, cmd in ipairs(filtered) do
        results[#results + 1] = {
            text = cmd.name,
            description = cmd.description,
        }
    end
    return results
end

-- Alias for backwards compatibility
Intellisense.Providers.QueryOperators = Intellisense.Providers.PipeableCommands

---Field provider for query operators (where, sort, select, etc.)
---@param partial string Partial field name
---@param ctx table Context with upstreamCommand and resolvedType
---@return table[] suggestions
function Intellisense.Providers.Fields(partial, ctx)
    local types = {}

    -- Prefer resolved type from pipeline type tracking
    if ctx.resolvedType then
        local typeDef = C3.Types:Get(ctx.resolvedType)
        if typeDef then
            types = { typeDef }
        end
    end

    -- Fallback to original upstream command lookup
    if #types == 0 and ctx.upstreamCommand then
        types = C3.Types:GetTypesForCommand(ctx.upstreamCommand)
    end

    if #types == 0 then
        return {}
    end

    -- Collect all field names from matching types
    local fields = {}
    local seen = {}

    for _, typeDef in ipairs(types) do
        for _, prop in ipairs(typeDef.properties or {}) do
            local name = prop.name
            if name and not seen[name] then
                fields[#fields + 1] = {
                    name = name,
                    description = prop.desc or typeDef.name,
                }
                seen[name] = true
            end
        end
    end

    -- Filter by partial using fuzzy matching
    local filtered = fuzzyFilter(
        fields,
        partial,
        function(f) return f.name end,
        Intellisense.MAX_SUGGESTIONS
    )

    -- Format as suggestions
    local results = {}
    for _, field in ipairs(filtered) do
        results[#results + 1] = {
            text = field.name,
            description = field.description,
        }
    end
    return results
end

---Where operator provider (==, ~, >, <, etc.)
---@param partial string Partial operator being typed
---@param ctx table Context
---@return table[] suggestions
function Intellisense.Providers.WhereOperators(partial, ctx)
    if not partial or partial == "" then
        -- Return all operators
        return WHERE_OPERATOR_SUGGESTIONS
    end

    -- Filter operators that start with partial
    local results = {}
    for _, op in ipairs(WHERE_OPERATOR_SUGGESTIONS) do
        if op.text:sub(1, #partial) == partial then
            results[#results + 1] = op
        end
    end
    return results
end

---Sort direction provider (asc, desc)
---@param partial string Partial direction being typed
---@param ctx table Context
---@return table[] suggestions
function Intellisense.Providers.SortDirections(partial, ctx)
    if not partial or partial == "" then
        -- Return all directions
        return SORT_DIRECTION_SUGGESTIONS
    end

    -- Use fuzzy filter for consistency
    return fuzzyFilter(SORT_DIRECTION_SUGGESTIONS, partial, function(item)
        return item.text
    end, 2)
end

-------------------------------------------------------------------------------
-- Known Field Values (static hints for common fields)
-------------------------------------------------------------------------------

-- Pre-defined values for common fields (faster than sampling data)
-- Note: 'type' and 'subtype' are NOT included here - they're sampled from actual data
-- because the values depend on what's actually in the player's bags
local KNOWN_FIELD_VALUES = {
    -- Quality names
    qualityName = {
        "Poor", "Common", "Uncommon", "Rare", "Epic", "Legendary",
        "Artifact", "Heirloom",
    },
    -- Reputation standing names (classic and friendship ranks)
    standingName = {
        "Hated", "Hostile", "Unfriendly", "Neutral",
        "Friendly", "Honored", "Revered", "Exalted",
        -- Common friendship ranks
        "Stranger", "Acquaintance", "Buddy", "Friend", "Good Friend", "Best Friend",
        "Apprentice", "Journeyman", "Expert", "Artisan", "Master", "Grandmaster",
        "Cohort", "Ally", "Confidant", "Mastermind",
    },
    -- Reputation type
    repType = {
        "classic", "renown", "friendship",
    },
    -- Equipment slots
    equipSlot = {
        "INVTYPE_HEAD", "INVTYPE_NECK", "INVTYPE_SHOULDER", "INVTYPE_CHEST",
        "INVTYPE_WAIST", "INVTYPE_LEGS", "INVTYPE_FEET", "INVTYPE_WRIST",
        "INVTYPE_HAND", "INVTYPE_FINGER", "INVTYPE_TRINKET", "INVTYPE_CLOAK",
        "INVTYPE_WEAPON", "INVTYPE_2HWEAPON", "INVTYPE_WEAPONOFFHAND",
        "INVTYPE_HOLDABLE", "INVTYPE_RANGED", "INVTYPE_SHIELD",
    },
    -- Mount types
    mountType = {
        "Ground", "Flying", "Aquatic", "Dragonriding",
    },
    -- Boolean fields
    isBound = { "true", "false" },
    isLocked = { "true", "false" },
    isUsable = { "true", "false" },
    isCollected = { "true", "false" },
    isFavorite = { "true", "false" },
    isFlying = { "true", "false" },
    isGround = { "true", "false" },
    isAquatic = { "true", "false" },
    isPassive = { "true", "false" },
    isOnCooldown = { "true", "false" },
    -- Reputation booleans
    isParagon = { "true", "false" },
    isWatched = { "true", "false" },
    isHeader = { "true", "false" },
    isTopLevel = { "true", "false" },
    hasRewardPending = { "true", "false" },
    -- Quality (numeric)
    quality = { "0", "1", "2", "3", "4", "5", "6", "7" },
    -- Spell schools
    school = {
        "Physical", "Holy", "Fire", "Nature", "Frost", "Shadow", "Arcane",
    },
}

---Field value provider for where expressions
---Suggests possible values for a field in the upstream data
---@param partial string Partial value being typed
---@param ctx table Context with upstreamCommand and fieldName
---@return table[] suggestions
function Intellisense.Providers.FieldValues(partial, ctx)
    if not ctx.upstreamCommand or not ctx.fieldName then
        return {}
    end

    local fieldName = ctx.fieldName:lower()
    local values = {}
    local seen = {}

    -- Check if field is numeric or boolean (no quotes needed)
    local numericFields = { quality = true, ilvl = true, level = true, count = true, stackCount = true, maxStack = true, bag = true, slot = true, standing = true, current = true, max = true, percent = true, paragonLevel = true, renownLevel = true }
    local booleanFields = { isBound = true, isLocked = true, isUsable = true, isCollected = true, isFavorite = true, isFlying = true, isGround = true, isAquatic = true, isPassive = true, isOnCooldown = true, isParagon = true, isWatched = true, isHeader = true, isTopLevel = true, hasRewardPending = true }
    local needsQuotes = not numericFields[ctx.fieldName] and not booleanFields[ctx.fieldName]

    -- First, check if we have known values for this field
    local knownValues = KNOWN_FIELD_VALUES[ctx.fieldName] or KNOWN_FIELD_VALUES[fieldName]
    if knownValues then
        for _, value in ipairs(knownValues) do
            if not seen[value] then
                -- Add quotes for string values in where expressions
                local displayValue = needsQuotes and ('"' .. value .. '"') or value
                values[#values + 1] = {
                    value = displayValue,
                    rawValue = value,  -- For filtering
                    description = ctx.fieldName,
                }
                seen[value] = true
            end
        end
    else
        -- No known values - try to sample from actual data
        -- This is more expensive but works for any field
        local sampleData = Intellisense:_sampleFieldValues(ctx.upstreamCommand, ctx.fieldName)
        for _, value in ipairs(sampleData) do
            local strValue = tostring(value)
            if strValue and strValue ~= "" and not seen[strValue] then
                -- Add quotes for string values in where expressions
                local displayValue = needsQuotes and ('"' .. strValue .. '"') or strValue
                values[#values + 1] = {
                    value = displayValue,
                    rawValue = strValue,  -- For filtering
                    description = "from " .. ctx.upstreamCommand,
                }
                seen[strValue] = true
            end
        end
    end

    -- Strip quotes from partial if user started typing them
    local filterPartial = partial
    if filterPartial:match('^"') then
        filterPartial = filterPartial:sub(2)
    end

    -- Filter by partial using fuzzy matching (match against raw value)
    local filtered = fuzzyFilter(
        values,
        filterPartial,
        function(v) return v.rawValue or v.value end,
        Intellisense.MAX_SUGGESTIONS
    )

    -- Format as suggestions
    local results = {}
    for _, item in ipairs(filtered) do
        results[#results + 1] = {
            text = item.value,
            description = item.description,
        }
    end
    return results
end

---Sample unique values for a field from actual command data
---@param command string Command name (e.g., "bags")
---@param fieldName string Field name to sample (e.g., "subtype")
---@return table values Array of unique values
function Intellisense:_sampleFieldValues(command, fieldName)
    local values = {}
    local seen = {}
    local count = 0
    local MAX_SAMPLE = 50  -- Limit sampling to prevent lag

    -- Special handling for common commands
    if command == "bags" or command == "inventory" or command == "inv" then
        -- Sample from bag items
        for bag = 0, 4 do
            if count >= MAX_SAMPLE then break end
            local numSlots = C_Container.GetContainerNumSlots(bag)
            for slot = 1, numSlots do
                if count >= MAX_SAMPLE then break end
                local itemInfo = C_Container.GetContainerItemInfo(bag, slot)
                if itemInfo and itemInfo.itemID then
                    local name, _, quality, ilvl, reqLevel, itemType, itemSubType = C_Item.GetItemInfo(itemInfo.itemID)
                    local value = nil
                    -- Map field name to actual data
                    if fieldName == "type" then value = itemType
                    elseif fieldName == "subtype" then value = itemSubType
                    elseif fieldName == "quality" then value = quality and tostring(quality)
                    elseif fieldName == "qualityName" then value = quality and KNOWN_FIELD_VALUES.qualityName[quality + 1]
                    elseif fieldName == "name" then value = name
                    end
                    if value and not seen[value] then
                        values[#values + 1] = value
                        seen[value] = true
                        count = count + 1
                    end
                end
            end
        end
    elseif command == "equipped" or command == "gear" or command == "wearing" then
        -- Sample from equipped items
        local EQUIP_SLOTS = {1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}
        for _, slotId in ipairs(EQUIP_SLOTS) do
            if count >= MAX_SAMPLE then break end
            local itemID = GetInventoryItemID("player", slotId)
            if itemID then
                local name, _, quality, ilvl, reqLevel, itemType, itemSubType = C_Item.GetItemInfo(itemID)
                local value = nil
                if fieldName == "type" then value = itemType
                elseif fieldName == "subtype" then value = itemSubType
                elseif fieldName == "quality" then value = quality and tostring(quality)
                elseif fieldName == "qualityName" then value = quality and KNOWN_FIELD_VALUES.qualityName[quality + 1]
                elseif fieldName == "name" then value = name
                end
                if value and not seen[value] then
                    values[#values + 1] = value
                    seen[value] = true
                    count = count + 1
                end
            end
        end
    elseif command == "rep" or command == "reputation" or command == "factions" then
        -- Sample from reputation data
        if C_Reputation and C_Reputation.GetNumFactions then
            local numFactions = C_Reputation.GetNumFactions()
            local currentExpansion = nil
            for i = 1, numFactions do
                if count >= MAX_SAMPLE then break end
                local data = C_Reputation.GetFactionDataByIndex(i)
                if data then
                    if data.isHeader and not data.isChild then
                        currentExpansion = data.name
                    elseif not data.isHeader then
                        local value = nil
                        if fieldName == "expansion" then value = currentExpansion
                        elseif fieldName == "name" then value = data.name
                        elseif fieldName == "standingName" then
                            local STANDINGS = { [1]="Hated", [2]="Hostile", [3]="Unfriendly", [4]="Neutral", [5]="Friendly", [6]="Honored", [7]="Revered", [8]="Exalted" }
                            value = STANDINGS[data.reaction]
                        elseif fieldName == "standing" then value = data.reaction and tostring(data.reaction)
                        end
                        if value and not seen[value] then
                            values[#values + 1] = value
                            seen[value] = true
                            count = count + 1
                        end
                    end
                end
            end
        end
    end

    return values
end

-------------------------------------------------------------------------------
-- Cache Invalidation (delegates to shared Lookup service)
-------------------------------------------------------------------------------

---Invalidate caches (call on relevant events)
function Intellisense:InvalidateCache()
    -- Delegate to shared Lookup service
    if C3.Lookup then
        C3.Lookup:InvalidateCache()
    end
end

return Intellisense
