local addonName, MSUF = ...
MSUF = MSUF or _G.MSUF_NS or {}

local M = MSUF.MSUF2 or _G.MSUF2 or {}
MSUF.MSUF2 = M

local A = MSUF.Assistant or {}
MSUF.Assistant = A
M.Assistant = A

-- Low-level text helpers shared by all Assistant parser shards.
-- This file owns normalization, tokenization, fuzzy matching, and broad scope detection.
-- It deliberately has no profile writes and no UI side effects; callers should build
-- declarative parse results and let the router execute them after confirmation.
local Registry = A.Registry
local P = A.Parser or {}
A.Parser = P
local Data = A.ParserData or {}
A.ParserData = Data
local CoreData = Data.CORE_PARSER or {}
local UnitTerms = CoreData.UNIT_TERMS or {}

A.UUFBestEffortConfirmText = A.UUFBestEffortConfirmText or function()
    return "This is an UnhaltedUnitFrames profile. MSUF can import it as closely as possible. Auras are not imported, and some UUF-only options may not have an MSUF equivalent. Answer with 'yes', 'do it', or 'apply' to import anyway, or 'cancel'."
end

local function Trim(text)
    text = tostring(text or "")
    return (text:gsub("^%s+", ""):gsub("%s+$", ""))
end

local Normalize

-- Generational cache: two tables, no per-key deletes. Delete-based eviction
-- (FIFO/ring) keeps a hash table at capacity under constant insert+delete,
-- which drives Lua into rehash storms (~200us per insert, measured — it made
-- index builds 25x slower). Instead, inserts go into the hot table; when it
-- fills, the hot generation becomes the cold one and a fresh table starts.
-- Lookups check hot then cold, so the effective window is 1-2 generations.
local normalizeCacheHot = {}
local normalizeCacheCold = {}
local normalizeCacheHotCount = 0
local NORMALIZE_CACHE_LIMIT = 8192

local NORMALIZE_WORD_REPLACEMENTS = {
    wat = "what",
    whta = "what",
    wich = "which",
    whcih = "which",
    whch = "which",
    wheer = "where",
    wheree = "where",
    wher = "where",
    rsult = "result",
    rsults = "results",
    reslt = "result",
    reslts = "results",
    resut = "result",
    resuts = "results",
    reslut = "result",
    resluts = "results",
    reuslt = "result",
    reults = "results",
    playerframe = "player frame",
    targetframe = "target frame",
    focusframe = "focus frame",
    petframe = "pet frame",
    bossframe = "boss frame",
    targettargetframe = "targettarget frame",
    focustargetframe = "focustarget frame",
    unitframe = "unit frame",
    groupframe = "group frame",
    groupframes = "group frames",
    partyframe = "party frame",
    partyframes = "party frames",
    raidframe = "raid frame",
    raidframes = "raid frames",
    mythicraidframe = "mythicraid frame",
    mythicraidframes = "mythicraid frames",
    raidgroup = "raid group",
    raidgroupname = "raid group name",
    subgroupname = "subgroup name",
    groupnumber = "group number",
    readycheck = "ready check",
    readycheckicon = "ready check icon",
    raidmarker = "raid marker",
    raidmarkericon = "raid marker icon",
    roleicon = "role icon",
    leadericon = "leader icon",
    assisticon = "assist icon",
    summonicon = "summon icon",
    resurrecticon = "resurrect icon",
    resurrectionicon = "resurrection icon",
    phaseicon = "phase icon",
    pvpflag = "pvp flag",
    pvpicon = "pvp icon",
    pvpindicator = "pvp indicator",
    optino = "option",
    optinos = "options",
    opiton = "option",
    opitons = "options",
    choise = "choice",
    choises = "choices",
    alowed = "allowed",
    allowd = "allowed",
    valus = "values",
    valuees = "values",
    vaues = "values",
    supprted = "supported",
    suported = "supported",
    suppported = "supported",
    availble = "available",
    avaiable = "available",
    rang = "range",
    gradiant = "gradient",
    gradiants = "gradients",
    gradent = "gradient",
    gradents = "gradients",
    frist = "first",
    firts = "first",
    frst = "first",
    secnd = "second",
    secon = "second",
    seocnd = "second",
    sceond = "second",
    thrid = "third",
    thrd = "third",
    thierd = "third",
    tird = "third",
    fouth = "fourth",
    foruth = "fourth",
    fourthh = "fourth",
    fith = "fifth",
    fifht = "fifth",
    fifthh = "fifth",
    sixt = "sixth",
    sixthh = "sixth",
    seveth = "seventh",
    sevnth = "seventh",
    eigth = "eighth",
    eightth = "eighth",
    nineth = "ninth",
    ninthh = "ninth",
    tenthh = "tenth",
    oen = "one",
    onee = "one",
    teo = "two",
    twoo = "two",
    thre = "three",
    threee = "three",
    foor = "four",
    fourr = "four",
    fiv = "five",
    fivee = "five",
    nxt = "next",
    nex = "next",
    nextt = "next",
    previus = "previous",
    previos = "previous",
    prevous = "previous",
    prvious = "previous",
    previouss = "previous",
    shoudl = "should",
    shuld = "should",
    compair = "compare",
    compar = "compare",
    comapre = "compare",
    comapir = "compare",
    diference = "difference",
    differnce = "difference",
    diffrence = "difference",
    diferences = "differences",
    differnces = "differences",
    diffrences = "differences",
    betwen = "between",
    beetween = "between",
    trun = "turn",
    tirn = "turn",
    tunr = "turn",
    turnn = "turn",
    maek = "make",
    mkae = "make",
    chnage = "change",
    chagne = "change",
    moev = "move",
    mvoe = "move",
    adjut = "adjust",
    ajust = "adjust",
    aply = "apply",
    aplly = "apply",
    applay = "apply",
    toggel = "toggle",
    togge = "toggle",
    tggle = "toggle",
    shwo = "show",
    hsow = "show",
    hdie = "hide",
    hied = "hide",
    increse = "increase",
    increae = "increase",
    increasee = "increase",
    decrese = "decrease",
    decres = "decrease",
    decreasee = "decrease",
    chek = "check",
    chekc = "check",
    checl = "check",
    enabel = "enable",
    enble = "enable",
    enablee = "enable",
    enalbe = "enable",
    disabel = "disable",
    disble = "disable",
    disablee = "disable",
    disaable = "disable",
    porfile = "profile",
    profiel = "profile",
    profie = "profile",
    paeg = "page",
    mneu = "menu",
    improt = "import",
    exprot = "export",
    copie = "copy",
    seperat = "separat",
    seperator = "separator",
    seperators = "separators",
    delimeter = "delimiter",
    delimeters = "delimiters",
    heigth = "height",
    hieght = "height",
    hight = "height",
    widht = "width",
    widh = "width",
    opactiy = "opacity",
    opacitiy = "opacity",
    aplha = "alpha",
    alhpa = "alpha",
    fdae = "fade",
    fde = "fade",
    faed = "fade",
    transparancy = "transparency",
    transparant = "transparent",
    transperency = "transparency",
    transperancy = "transparency",
    curent = "current",
    currnet = "current",
    currnt = "current",
    vaule = "value",
    valeu = "value",
    valye = "value",
    explian = "explain",
    expalin = "explain",
    explan = "explain",
    exlain = "explain",
    locaton = "location",
    loction = "location",
    lcoation = "location",
    collor = "color",
    colour = "color",
    grey = "gray",
    fitler = "filter",
    fliter = "filter",
    filtres = "filters",
    antoher = "another",
    bgu = "bug",
    condtion = "condition",
    conditoin = "condition",
    dispelable = "dispellable",
    dispellabe = "dispellable",
    dispellble = "dispellable",
    felher = "fehler",
    restar = "restore",
    resotre = "restore",
    snappign = "snapping",
    snaping = "snapping",
    snappping = "snapping",
    taregt = "target",
    targte = "target",
    targt = "target",
    targrt = "target",
    taret = "target",
    traget = "target",
    taerget = "target",
    targett = "target",
    tagret = "target",
    targert = "target",
    targer = "target",
    targget = "target",
    tarhet = "target",
    trget = "target",
    playr = "player",
    plaer = "player",
    plyer = "player",
    palyer = "player",
    plaeyr = "player",
    playre = "player",
    pleyer = "player",
    foucs = "focus",
    focs = "focus",
    focsu = "focus",
    fcous = "focus",
    focuss = "focus",
    foccus = "focus",
    partty = "party",
    praty = "party",
    pratty = "party",
    partyy = "party",
    parties = "party",
    riad = "raid",
    raide = "raid",
    raidss = "raid",
    raids = "raid",
    mythci = "mythic",
    myhtic = "mythic",
    mithic = "mythic",
    mythc = "mythic",
    mythicc = "mythic",
    clas = "class",
    calss = "class",
    cass = "class",
    frm = "frame",
    frms = "frames",
    unitfram = "unitframe",
    unitframs = "unitframes",
    unitfames = "unitframes",
    auras = "aura",
    auara = "aura",
    auaras = "aura",
    auars = "aura",
    aurra = "aura",
    aurs = "aura",
    bufs = "buffs",
    bufff = "buff",
    debuf = "debuff",
    debufs = "debuffs",
    debufff = "debuff",
    cd = "cooldown",
    countdwon = "countdown",
    countdonw = "countdown",
    coundown = "countdown",
    pwr = "power",
    powr = "power",
    pwoer = "power",
    ressource = "resource",
    resrouce = "resource",
    resoruce = "resource",
    resorce = "resource",
    stak = "stack",
    staks = "stacks",
    privte = "private",
    prviate = "private",
    pirvate = "private",
    visiblity = "visibility",
    visibilty = "visibility",
    visable = "visible",
    visibile = "visible",
    indcator = "indicator",
    indciator = "indicator",
    indictor = "indicator",
    idnicator = "indicator",
    threshhold = "threshold",
    treshold = "threshold",
    castbr = "castbar",
    castabr = "castbar",
    castba = "castbar",
    castbra = "castbar",
    crosair = "crosshair",
    crossair = "crosshair",
    crosshir = "crosshair",
    protait = "portrait",
    portriat = "portrait",
    portrtait = "portrait",
    portait = "portrait",
    portraid = "portrait",
    helath = "health",
    haelth = "health",
    helth = "health",
    healt = "health",
    waggo = "wago",
    wagoo = "wago",
    wgao = "wago",
    rihgt = "right",
    rigth = "right",
    riht = "right",
    lef = "left",
    lft = "left",
    upp = "up",
    dn = "down",
    donw = "down",
    dwon = "down",
    smaler = "smaller",
    smalller = "smaller",
    biger = "bigger",
    lagrer = "larger",
    closser = "closer",
    clsoer = "closer",
    achor = "anchor",
    ancor = "anchor",
    ancher = "anchor",
    anchro = "anchor",
    acnhor = "anchor",
    atach = "attach",
    detatch = "detach",
    teh = "the",
    yu = "you",
    yuo = "you",
    u = "you",
    txt = "text",
    txts = "texts",
    icn = "icon",
    icns = "icons",
    icno = "icon",
    iocn = "icon",
    iconn = "icon",
    boder = "border",
    bordr = "border",
    broder = "border",
    outlne = "outline",
    outlien = "outline",
    otuline = "outline",
    oultine = "outline",
    rdy = "ready",
    readycheck = "ready check",
    readychecks = "ready checks",
    readychek = "ready check",
    readycheks = "ready checks",
    readychekc = "ready check",
    readychk = "ready check",
    readychks = "ready checks",
    raidmark = "raid marker",
    raidmarks = "raid markers",
    zlayer = "z layer",
    interupt = "interrupt",
    trennzeichen = "separator",
    trenner = "separator",
}

local ACTIONABLE_LEADING_PREFIXES = {
    "can you please",
    "could you please",
    "would you please",
    "will you please",
    "can you",
    "could you",
    "would you",
    "will you",
    "please",
    "pls",
    "hey",
    "hi",
    "hello",
    "assistant",
    "msuf assistant",
    "i want to",
    "i wanna",
    "i need to",
    "i would like to",
    "id like to",
    "i am trying to",
    "im trying to",
    "help me to",
    "help me",
    "let us",
    "lets",
    "bitte",
    "kannst du bitte",
    "kannst du",
    "koenntest du bitte",
    "koenntest du",
    "ich moechte",
    "ich will",
    "ich brauche",
    "hilf mir bitte",
    "hilf mir",
}

local ACTIONABLE_TRAILING_SUFFIXES = {
    "for me please",
    "for me",
    "please",
    "pls",
    "in msuf",
    "inside msuf",
    "with msuf",
    "on msuf",
    "thanks",
    "thank you",
    "danke",
    "danke dir",
    "bitte",
}

local actionableTextCache = {}
local actionableTextCacheCount = 0

local function StripPhrasePrefix(text, prefix)
    if text == prefix then return "" end
    if text:sub(1, #prefix + 1) == prefix .. " " then return Trim(text:sub(#prefix + 2)) end
    return nil
end

local function StripPhraseSuffix(text, suffix)
    if text == suffix then return "" end
    if #text > #suffix and text:sub(-#suffix) == suffix and text:sub(#text - #suffix, #text - #suffix) == " " then
        return Trim(text:sub(1, #text - #suffix - 1))
    end
    return nil
end

-- A second normalized form for matching only. It removes human chat wrappers while
-- preserving the original/raw text for string values and profile payloads.
local function ActionableText(text)
    local normalized = Normalize(text)
    if normalized == "" then return normalized end
    local cached = actionableTextCache[normalized]
    if cached ~= nil then return cached end

    local out = normalized
    local changed = true
    local loops = 0
    while changed and loops < 8 do
        loops = loops + 1
        changed = false
        for i = 1, #ACTIONABLE_LEADING_PREFIXES do
            local stripped = StripPhrasePrefix(out, ACTIONABLE_LEADING_PREFIXES[i])
            if stripped ~= nil then
                out = stripped
                changed = true
                break
            end
        end
    end

    changed = true
    loops = 0
    while changed and loops < 6 do
        loops = loops + 1
        changed = false
        for i = 1, #ACTIONABLE_TRAILING_SUFFIXES do
            local stripped = StripPhraseSuffix(out, ACTIONABLE_TRAILING_SUFFIXES[i])
            if stripped ~= nil then
                out = stripped
                changed = true
                break
            end
        end
    end

    if out == "" then out = normalized end
    if #normalized <= 180 then
        if actionableTextCacheCount > 2048 then
            actionableTextCache = {}
            actionableTextCacheCount = 0
        end
        if actionableTextCache[normalized] == nil then actionableTextCacheCount = actionableTextCacheCount + 1 end
        actionableTextCache[normalized] = out
    end
    return out
end

-- Normalization runs on every assistant query and on many registry aliases. Keep the cache
-- bounded and only cache short strings so pasted imports or bug reports cannot grow it forever.
local function CacheNormalize(raw, value)
    if #raw <= 180 then
        if normalizeCacheHot[raw] == nil then
            normalizeCacheHotCount = normalizeCacheHotCount + 1
            if normalizeCacheHotCount > NORMALIZE_CACHE_LIMIT then
                normalizeCacheCold = normalizeCacheHot
                normalizeCacheHot = {}
                normalizeCacheHotCount = 1
            end
        end
        normalizeCacheHot[raw] = value
    end
    return value
end

-- Phrase folding below only ever fires when one of these plain substrings is
-- present; a handful of allocation-free find() probes lets the common case
-- (registry aliases, most user input) skip 18 gsub passes.
local NORMALIZE_FOLD_NEEDLES = {
    "turn", "target", "focus", "cast", "power", "mana",
    "unit", "gruppen", "status", "incoming",
}

Normalize = function(text)
    local raw = tostring(text or "")
    local cached = normalizeCacheHot[raw]
    if cached ~= nil then return cached end
    cached = normalizeCacheCold[raw]
    if cached ~= nil then
        -- Promote so the entry survives the next generation swap.
        normalizeCacheHot[raw] = cached
        return cached
    end
    -- The assistant accepts English/German phrasing plus common typos. These replacements
    -- intentionally happen before phrase folding so the downstream parser can stay literal.
    text = raw:lower()
    -- Charset folding only touches bytes >= 128 plus the listed quote and
    -- punctuation characters; pure ASCII-word strings skip all 17 passes.
    if text:find("[\128-\255\"'`,;:!?%(%)]") then
        text = text:gsub("\195\131\194\164", "ae")
        text = text:gsub("\195\131\194\182", "oe")
        text = text:gsub("\195\131\194\188", "ue")
        text = text:gsub("\195\131\194\159", "ss")
        text = text:gsub("\195\164", "ae")
        text = text:gsub("\195\182", "oe")
        text = text:gsub("\195\188", "ue")
        text = text:gsub("\195\159", "ss")
        text = text:gsub("\228", "ae")
        text = text:gsub("\246", "oe")
        text = text:gsub("\252", "ue")
        text = text:gsub("\196", "ae")
        text = text:gsub("\214", "oe")
        text = text:gsub("\220", "ue")
        text = text:gsub("\223", "ss")
        text = text:gsub("[\"'`]", "")
        text = text:gsub("[,;:!?%(%)]", " ")
    end
    text = text:gsub("%s+", " ")
    text = Trim(text)
    if text ~= "" then
        local out = {}
        local changed = false
        for token in text:gmatch("%S+") do
            local replacement = NORMALIZE_WORD_REPLACEMENTS[token]
            if replacement then changed = true end
            out[#out + 1] = replacement or token
        end
        if changed then text = table.concat(out, " ") end
    end
    local needsFold = false
    for i = 1, #NORMALIZE_FOLD_NEEDLES do
        if text:find(NORMALIZE_FOLD_NEEDLES[i], 1, true) then
            needsFold = true
            break
        end
    end
    if needsFold then
        text = text:gsub("%f[%w]turn%s+of%f[%W]", "turn off")
        text = text:gsub("target%s+of%s+target", "targettarget")
        text = text:gsub("target%s+target", "targettarget")
        text = text:gsub("focus%s+target", "focustarget")
        text = text:gsub("cast%s+bar", "castbar")
        text = text:gsub("castbars", "castbar")
        text = text:gsub("cast%s+text", "castbar text")
        text = text:gsub("powerbars", "power bars")
        text = text:gsub("power%s+bars", "power bar")
        text = text:gsub("powerbar", "power bar")
        text = text:gsub("manabars", "mana bars")
        text = text:gsub("mana%s+bars", "mana bar")
        text = text:gsub("manabar", "mana bar")
        text = text:gsub("unit%s+frames", "unitframes")
        text = text:gsub("gruppen%s+frames", "gruppenframes")
        text = text:gsub("status%s+icons", "status icon")
        text = text:gsub("incoming%s+res%s+", "incoming rez ")
        text = text:gsub("incoming%s+res$", "incoming rez")
    end
    return CacheNormalize(raw, Trim(text))
end
A.Normalize = Normalize

local DISPLAY_COLOR_LABEL_FALLBACKS = {
    gray = "grey",
    grau = "grey",
    violet = "purple",
    violett = "purple",
    aqua = "cyan",
    teal = "turquoise",
    weiss = "white",
    schwarz = "black",
    rot = "red",
    gruen = "green",
    blau = "blue",
    gelb = "yellow",
    lila = "purple",
    rosa = "pink",
    tuerkis = "turquoise",
}

if type(A.DisplayColorLabel) ~= "function" then
    function A.DisplayColorLabel(label)
        label = tostring(label or "")
        if label == "" then return "" end
        local normalized = Normalize(label)
        local data = A.GlobalColorSettingsRegistryData
        local aliases = type(data) == "table" and data.COLOR_ALIASES or nil
        if type(aliases) == "table" and aliases[normalized] then return aliases[normalized] end
        return DISPLAY_COLOR_LABEL_FALLBACKS[normalized] or label
    end
end

local EXACT_ONLY_FUZZY_WORDS = {
    ["on"] = true,
    ["off"] = true,
    ["no"] = true,
    ["yes"] = true,
    ["y"] = true,
    ["ja"] = true,
    ["nein"] = true,
    ["x"] = true,
    ["y"] = true,
    ["ui"] = true,
    ["hp"] = true,
    ["id"] = true,
    ["stuck"] = true,
}

-- Short control words are too dangerous to fuzzy-match: "on", "off", "x", "y", and IDs
-- frequently decide which setting is touched. Keep fuzzy matching for longer human words.
local function IsWordToken(word)
    return type(word) == "string" and word:match("^[a-z]+$") ~= nil
end

local function Tokenize(text)
    local out = {}
    for token in tostring(text or ""):gmatch("%S+") do
        out[#out + 1] = token
    end
    return out
end

local function IsAdjacentTranspose(a, b)
    local len = #a
    if len ~= #b or len < 3 then return false end
    local first
    for i = 1, len do
        if a:sub(i, i) ~= b:sub(i, i) then
            if first then
                return i == first + 1
                    and a:sub(first, first) == b:sub(i, i)
                    and a:sub(i, i) == b:sub(first, first)
                    and a:sub(i + 1) == b:sub(i + 1)
            end
            first = i
        end
    end
    return false
end

local function IsEditDistanceOne(a, b)
    local la, lb = #a, #b
    if math.abs(la - lb) > 1 then return false end
    if math.min(la, lb) < 5 then return false end
    if a:sub(1, 1) ~= b:sub(1, 1) then return false end

    if la == lb then
        local diff = 0
        for i = 1, la do
            if a:sub(i, i) ~= b:sub(i, i) then
                diff = diff + 1
                if diff > 1 then return false end
            end
        end
        return diff == 1
    end

    local shorter, longer = a, b
    if la > lb then shorter, longer = b, a end
    if longer == shorter .. "s" then return false end
    local i, j, skipped = 1, 1, false
    while i <= #shorter and j <= #longer do
        if shorter:sub(i, i) == longer:sub(j, j) then
            i = i + 1
            j = j + 1
        elseif skipped then
            return false
        else
            skipped = true
            j = j + 1
        end
    end
    return true
end

local function FuzzyWordMatch(word, expected)
    word = tostring(word or "")
    expected = tostring(expected or "")
    if word == expected then return true end
    if EXACT_ONLY_FUZZY_WORDS[word] or EXACT_ONLY_FUZZY_WORDS[expected] then return false end
    if not IsWordToken(word) or not IsWordToken(expected) then return false end
    if math.min(#word, #expected) < 3 then return false end
    if IsAdjacentTranspose(word, expected) then return true end
    return IsEditDistanceOne(word, expected)
end

local fuzzyPhraseCacheText
local fuzzyPhraseCache = {}
local fuzzyPhraseCacheTokens = {}
local fuzzyPhraseTokenCache = {}
local fuzzyPhraseTokenCacheOrder = {}
local FUZZY_PHRASE_TOKEN_CACHE_LIMIT = 4096

-- Phrase fuzzy matching is scoped to the current input text. Registry aliases are reused
-- heavily, so the bounded token cache saves work without retaining unbounded alias text.
local function CachedFuzzyPhraseTokens(phrase)
    local phraseTokens = fuzzyPhraseTokenCache[phrase]
    if phraseTokens then return phraseTokens end
    phraseTokens = Tokenize(phrase)
    fuzzyPhraseTokenCache[phrase] = phraseTokens
    fuzzyPhraseTokenCacheOrder[#fuzzyPhraseTokenCacheOrder + 1] = phrase
    while #fuzzyPhraseTokenCacheOrder > FUZZY_PHRASE_TOKEN_CACHE_LIMIT do
        local oldPhrase = table.remove(fuzzyPhraseTokenCacheOrder, 1)
        fuzzyPhraseTokenCache[oldPhrase] = nil
    end
    return phraseTokens
end

local function FuzzyPhraseMatch(text, phrase)
    text = Normalize(text)
    phrase = Normalize(phrase)
    if text == "" or phrase == "" or #phrase < 3 then return false end
    if text ~= fuzzyPhraseCacheText then
        fuzzyPhraseCacheText = text
        fuzzyPhraseCache = {}
        fuzzyPhraseCacheTokens = Tokenize(text)
    elseif fuzzyPhraseCache[phrase] ~= nil then
        return fuzzyPhraseCache[phrase]
    end

    local phraseTokens = CachedFuzzyPhraseTokens(phrase)
    if #phraseTokens == 0 or #phraseTokens > 6 then
        fuzzyPhraseCache[phrase] = false
        return false
    end

    local textTokens = fuzzyPhraseCacheTokens
    local matched = false
    if #phraseTokens == 1 then
        for i = 1, #textTokens do
            if FuzzyWordMatch(textTokens[i], phraseTokens[1]) then
                matched = true
                break
            end
        end
    elseif #textTokens >= #phraseTokens then
        for start = 1, (#textTokens - #phraseTokens + 1) do
            local ok = true
            for offset = 1, #phraseTokens do
                if not FuzzyWordMatch(textTokens[start + offset - 1], phraseTokens[offset]) then
                    ok = false
                    break
                end
            end
            if ok then
                matched = true
                break
            end
        end
    end

    fuzzyPhraseCache[phrase] = matched
    return matched
end

A.FuzzyWordMatch = FuzzyWordMatch
A.FuzzyPhraseMatch = FuzzyPhraseMatch

local function HasPhrase(text, phrase)
    text = Normalize(text)
    phrase = Normalize(phrase)
    if phrase == "" then return false end
    return (" " .. text .. " "):find(" " .. phrase .. " ", 1, true) ~= nil
end

local function ContainsAny(text, words)
    for i = 1, #(words or {}) do
        if HasPhrase(text, words[i]) then return true end
    end
    return false
end

local FUZZY_ALIAS_ANCHOR_IGNORE = {
    a = true,
    an = true,
    ["and"] = true,
    are = true,
    as = true,
    at = true,
    by = true,
    ["for"] = true,
    from = true,
    ["in"] = true,
    is = true,
    my = true,
    of = true,
    on = true,
    the = true,
    to = true,
    with = true,
    set = true,
    change = true,
    make = true,
    turn = true,
}

local fuzzyAliasPrefilterText
local fuzzyAliasPrefilterTokens = {}
local fuzzyAliasPrefilterSet = {}

local function EnsureFuzzyAliasPrefilter(text)
    text = Normalize(text)
    if fuzzyAliasPrefilterText == text then
        return fuzzyAliasPrefilterTokens, fuzzyAliasPrefilterSet
    end
    fuzzyAliasPrefilterText = text
    fuzzyAliasPrefilterTokens = Tokenize(text)
    fuzzyAliasPrefilterSet = {}
    for i = 1, #fuzzyAliasPrefilterTokens do
        fuzzyAliasPrefilterSet[fuzzyAliasPrefilterTokens[i]] = true
    end
    return fuzzyAliasPrefilterTokens, fuzzyAliasPrefilterSet
end

local function FuzzyAliasWorthTrying(text, alias)
    text = Normalize(text)
    alias = Normalize(alias)
    if text == "" or alias == "" then return false end
    local textTokens, textSet = EnsureFuzzyAliasPrefilter(text)
    local aliasTokens = Tokenize(alias)
    if #aliasTokens == 0 or #aliasTokens > 6 or #aliasTokens > #textTokens then return false end

    if #aliasTokens > 1 then
        for i = 1, #aliasTokens do
            local token = aliasTokens[i]
            if not FUZZY_ALIAS_ANCHOR_IGNORE[token] and textSet[token] then return true end
        end
        if #aliasTokens <= 4 and #textTokens <= 10 then
            for i = 1, #aliasTokens do
                local token = aliasTokens[i]
                if not FUZZY_ALIAS_ANCHOR_IGNORE[token] and not EXACT_ONLY_FUZZY_WORDS[token] and #token >= 5 then
                    for j = 1, #textTokens do
                        if FuzzyWordMatch(textTokens[j], token) then return true end
                    end
                end
            end
        end
        return false
    end

    local expected = aliasTokens[1]
    if FUZZY_ALIAS_ANCHOR_IGNORE[expected] or EXACT_ONLY_FUZZY_WORDS[expected] or #expected < 4 then return false end
    local first = expected:sub(1, 1)
    for i = 1, #textTokens do
        local word = textTokens[i]
        if word:sub(1, 1) == first and math.abs(#word - #expected) <= 1 then return true end
    end
    return false
end

local UNIT_ORDER = { "targettarget", "focustarget", "player", "target", "focus", "pet", "boss" }
local GROUP_ORDER = { "mythicraid", "party", "raid" }
local ALL_UNITFRAMES = { "player", "target", "focus", "targettarget", "focustarget", "pet", "boss" }
local ALL_GROUPS = { "party", "raid", "mythicraid" }
-- These term buckets are shared across parser shards so "castbar", "class power", "group",
-- and global bar commands do not each invent their own meaning for the same words.
local CLASS_POWER_TERMS = {
    "class power", "class resource", "class resources", "class bar", "resource bar",
    "combo point", "combo points", "holy power", "soul shard", "soul shards",
    "chi", "arcane charge", "arcane charges", "rune", "runes",
    "klassenressource", "klassenressourcen", "klassenleiste", "klassen ressourcen",
    "ressourcenleiste", "ressourcen leiste", "kombopunkt", "kombopunkte",
    "heilige kraft", "seelensplitter", "rune", "runen",
}
local GAMEPLAY_TERMS = {
    "gameplay", "spielhilfe", "combat timer", "combat state", "combat enter", "combat leave",
    "kampf timer", "kampftimer", "kampf text", "kampfstatus", "kampfanzeige",
    "totem frame", "totemframe", "blizzard totem", "statue frame", "totem rahmen", "totemrahmen",
    "statuen rahmen", "statuenrahmen", "combat crosshair",
    "crosshair", "fadenkreuz", "melee range spell", "nahkampf zauber", "reichweiten zauber",
}
local GLOBAL_BARS_TERMS = { "bar texture", "bar background", "bar gradient", "bar gradients", "bars gradient", "gradient direction", "hp gradient", "health gradient", "power gradient", "unitframe bar gradient", "unit frame bar gradient", "absorb bar", "absorb bars", "heal prediction", "heal absorb", "bar outline", "rounded frames", "rounded frame", "rounded texture", "highlight border", "highlight priority", "custom highlight priority", "aggro border", "aggro role filter", "aggro shows for", "dispel border", "purge border", "boss target border", "dispel overlay", "power text" }
local CASTBAR_ROOT_DETAIL_TERMS = {
    "castbar time", "cast time", "time text", "timer",
    "castbar icon", "cast icon", "spell icon",
    "castbar text", "castbar name", "castbar spell name", "spell name", "spell text",
    "interrupt", "interruptible", "kick", "kickable",
    "channel ticks", "channel tick lines", "castbar ticks", "tick lines",
    "glow", "spark", "sparks", "latency", "fill direction", "unified direction", "opposite direction",
    "texture", "background texture", "outline", "border thickness", "shake", "shake strength",
    "empower", "empowered", "stage blink", "spell name shortening", "max spell name length", "reserved spell name space",
    "focus kick", "focus interrupt", "interrupt ready",
}

local PAGE_TEXT_TARGETS = {
    { page = "home", label = "Dashboard", terms = { "dashboard", "home", "main menu", "start page", "overview" } },
    { page = "profiles", label = "Profiles", terms = { "profile", "profiles", "profil", "profile import", "profile export" } },
    { page = "gameplay", label = "Gameplay", terms = GAMEPLAY_TERMS },
    { page = "classpower", label = "Class Resources", terms = CLASS_POWER_TERMS },

    { page = "gf_indicators", label = "Group Status & Indicators", terms = { "group status and indicators", "group indicators", "group indicator", "party indicators", "raid indicators", "group status icons", "raid status icons", "ready check", "summon icon", "role icon", "leader icon", "assist icon" } },
    { page = "gf_auras", label = "Group Auras", terms = { "group auras", "group aura", "party auras", "raid auras", "group buffs", "group debuffs", "party buffs", "raid debuffs" } },
    { page = "gf_bars", label = "Group Health & Text", terms = { "group health", "group text", "group bars", "party health", "party text", "party bars", "raid health", "raid text", "raid bars", "health and text" } },
    { page = "gf_layout", label = "Group Layout", terms = { "group layout", "party layout", "raid layout", "group settings", "party settings", "raid settings", "group frames", "groupframes", "party frames", "raid frames", "mythic raid", "mythicraid", "gruppenframes", "group", "party", "raid" } },

    { page = "auras3_filters", label = "Aura Filters", terms = { "aura filters", "aura filter", "filters", "blacklist", "aura blacklist", "blocked auras" } },
    { page = "auras3_styling", label = "Aura Style", terms = { "aura style", "aura styling", "aura cooldown text", "aura borders" } },
    { page = "auras3_debuffs", label = "Aura Debuffs", terms = { "debuff", "debuffs", "debuff settings", "debuff style" } },
    { page = "auras3_buffs", label = "Aura Buffs", terms = { "buff", "buffs", "buff settings", "buff style" } },
    { page = "auras3", label = "Auras", terms = { "aura", "auras", "auren", "aura settings", "auren einstellungen" } },

    { page = "opt_castbar", label = "Cast Bars", terms = { "castbar", "castbars", "zauberleiste" } },
    { page = "opt_colors", label = "Colors", terms = { "colors", "colours", "color palette", "aura colors", "aura timer colors", "farben" } },
    { page = "opt_fonts", label = "Fonts", terms = { "fonts", "font", "schrift" } },
    { page = "opt_misc", label = "Miscellaneous", terms = { "misc", "miscellaneous", "tooltips", "tooltip", "modules style", "dropdown style" } },
    { page = "opt_bars", label = "Bars", terms = GLOBAL_BARS_TERMS },
    { page = "opt_bars", label = "Bars", terms = { "bars", "textures", "bar settings", "leisten" } },
    { page = "modules", label = "Modules", terms = { "modules", "advanced", "advanced modules", "module settings" } },

    { page = "uf_targettarget", label = "Target of Target", terms = { "targettarget", "target of target", "tot" } },
    { page = "uf_focustarget", label = "Focus Target", terms = { "focustarget", "focus target" } },
    { page = "uf_player", label = "Player", terms = { "player", "spieler" } },
    { page = "uf_target", label = "Target", terms = { "target", "ziel" } },
    { page = "uf_focus", label = "Focus", terms = { "focus", "fokus" } },
    { page = "uf_pet", label = "Pet", terms = { "pet", "begleiter" } },
    { page = "uf_boss", label = "Boss", terms = { "boss", "boss frames", "bossframes" } },
    { page = "search", label = "Search", terms = { "search page", "search results" } },
}

local function AddUnique(out, value)
    if not value then return end
    for i = 1, #out do
        if out[i] == value then return end
    end
    out[#out + 1] = value
end

local function DetectUnits(text)
    local units = {}
    if HasPhrase(text, "all unitframes") or HasPhrase(text, "all unitframe") or HasPhrase(text, "every unitframe") or HasPhrase(text, "alle unitframes") then
        for i = 1, #ALL_UNITFRAMES do AddUnique(units, ALL_UNITFRAMES[i]) end
        return units
    end
    local aliases = A.UnitAliases or {}
    for i = 1, #UNIT_ORDER do
        local unit = UNIT_ORDER[i]
        local list = aliases[unit] or {}
        for j = 1, #list do
            if HasPhrase(text, list[j]) then
                AddUnique(units, unit)
                break
            end
        end
    end
    if #units == 0 then
        if ContainsAny(text, UnitTerms.player) then AddUnique(units, "player") end
        if ContainsAny(text, UnitTerms.target) then AddUnique(units, "target") end
        if ContainsAny(text, UnitTerms.focus) then AddUnique(units, "focus") end
        if ContainsAny(text, UnitTerms.pet) then AddUnique(units, "pet") end
        if ContainsAny(text, UnitTerms.boss) then AddUnique(units, "boss") end
        if ContainsAny(text, UnitTerms.targettarget) then AddUnique(units, "targettarget") end
        if ContainsAny(text, UnitTerms.focustarget) then AddUnique(units, "focustarget") end
    end
    return units
end

local function DetectGroups(text)
    local groups = {}
    if HasPhrase(text, "all group frames") or HasPhrase(text, "all groups") or HasPhrase(text, "alle gruppenframes") then
        for i = 1, #ALL_GROUPS do AddUnique(groups, ALL_GROUPS[i]) end
        return groups
    end
    -- Mythic raid contains the word "raid", so consume its aliases first before testing
    -- the generic raid scope. Otherwise a single command can accidentally target both.
    local mythicAliases = CoreData.MYTHIC_GROUP_TERMS
    local scopeText = text
    if ContainsAny(scopeText, mythicAliases) then
        AddUnique(groups, "mythicraid")
        scopeText = " " .. Normalize(scopeText) .. " "
        for i = 1, #mythicAliases do
            local alias = Normalize(mythicAliases[i])
            if alias ~= "" then scopeText = scopeText:gsub(" " .. alias:gsub("([^%w%s])", "%%%1") .. " ", " ") end
        end
        scopeText = Normalize(scopeText)
    end
    if ContainsAny(scopeText, CoreData.PARTY_GROUP_TERMS) then AddUnique(groups, "party") end
    local raidScopeText = " " .. Normalize(scopeText) .. " "
    local raidDetailTerms = CoreData.RAID_DETAIL_TERMS
    for i = 1, #raidDetailTerms do
        local alias = raidDetailTerms[i]
        alias = Normalize(alias)
        if alias ~= "" then raidScopeText = raidScopeText:gsub(" " .. alias:gsub("([^%w%s])", "%%%1") .. " ", " ") end
    end
    raidScopeText = Normalize(raidScopeText)
    if ContainsAny(raidScopeText, CoreData.RAID_GROUP_TERMS) then AddUnique(groups, "raid") end
    if #groups == 0
        and not ContainsAny(text, CoreData.GROUP_COPY_GUARD_TERMS)
        and ContainsAny(text, CoreData.GROUP_FRAME_TERMS) then
        for i = 1, #ALL_GROUPS do AddUnique(groups, ALL_GROUPS[i]) end
    end
    return groups
end

local function DetectGlobalScope(text)
    if HasPhrase(text, "all scopes") then return "shared" end
    if HasPhrase(text, "party") or HasPhrase(text, "party frames") or HasPhrase(text, "group frames") then return "gf_party" end
    if HasPhrase(text, "raid") or HasPhrase(text, "raid frames") or HasPhrase(text, "mythic raid") or HasPhrase(text, "mythicraid") then return "gf_raid" end
    local units = DetectUnits(text)
    if units[1] then return units[1] end
    if ContainsAny(text, CoreData.GLOBAL_SCOPE_TERMS) then return "shared" end
    return nil
end

local OFF_WORDS = {
    "off", "disable", "disabled", "hide", "hidden", "false", "no",
    "dont", "dont show", "do not", "do not show", "never", "never show",
    "aus", "deaktivieren", "deaktiviert", "ausschalten", "ausgeschaltet",
    "deaktiviere", "schalte aus", "mach aus", "verstecken", "versteckt",
    "verstecke", "ausblenden", "ausgeblendet", "blende aus", "nein",
}
local ON_WORDS = {
    "on", "enable", "enabled", "show", "visible", "true", "yes",
    "an", "aktivieren", "aktiviert", "einschalten", "eingeschaltet",
    "aktiviere", "schalte an", "mach an", "anzeigen", "zeige",
    "zeig", "einblenden", "eingeblendet", "blende ein", "sichtbar",
    "wieder an", "ja",
}

local TARGET_VALUE_CONNECTORS = { " to ", " as ", " is ", " be ", " into ", " value ", " = ", " auf ", " zu ", " als ", " wert " }

local function RawAfterLastConnector(text, connectors)
    text = tostring(text or "")
    if text == "" then return nil end
    connectors = connectors or TARGET_VALUE_CONNECTORS
    local padded = " " .. text .. " "
    local lower = padded:lower()
    local bestEnd
    for i = 1, #connectors do
        local connector = tostring(connectors[i] or ""):lower()
        if connector ~= "" then
            local startAt = 1
            while true do
                local _, endPos = lower:find(connector, startAt, true)
                if not endPos then break end
                if not bestEnd or endPos > bestEnd then bestEnd = endPos end
                startAt = endPos + 1
            end
        end
    end
    local tail = bestEnd and Trim(padded:sub(bestEnd + 1)) or nil
    if tail then tail = Trim(tail:gsub("^the%s+", ""):gsub("^a%s+", "")) end
    if tail == "" then return nil end
    return tail
end

local function TargetAfterLastConnector(text, connectors)
    local tail = RawAfterLastConnector(text, connectors)
    if not tail then return nil end
    tail = Normalize(tail)
    if tail == "" then return nil end
    return tail
end

local function DetectBoolean(text)
    local target = TargetAfterLastConnector(text)
    if target then
        if ContainsAny(target, OFF_WORDS) then return false end
        if ContainsAny(target, ON_WORDS) then return true end
    end
    -- Off wins over on when both appear. Commands like "do not show" include "show", and
    -- treating the negative intent as stronger avoids destructive surprise.
    if ContainsAny(text, OFF_WORDS) then return false end
    if ContainsAny(text, ON_WORDS) then return true end
    return nil
end

local function FirstNumber(text)
    local value = text:match("[-+]?%d+%.?%d*")
    return tonumber(value)
end

function A._ExplicitNumberValue(text)
    text = tostring(text or "")
    local value
    for _, pattern in ipairs({
        "%f[%w]to%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]auf%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]zu%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]as%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]is%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]be%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]value%f[%W]%s+([-+]?%d+%.?%d*)",
        "=%s*([-+]?%d+%.?%d*)",
    }) do
        for numberText in text:gmatch(pattern) do
            value = tonumber(numberText)
        end
    end
    return value
end

function A._LastNumberValue(text)
    local value
    for numberText in tostring(text or ""):gmatch("[-+]?%d+%.?%d*") do
        value = tonumber(numberText)
    end
    return value
end

function A._NumberValueForText(setting, text)
    local explicit = A._ExplicitNumberValue(text)
    if explicit ~= nil then return explicit end
    local hay = setting and (tostring(setting.key or "") .. " " .. tostring(setting.label or "") .. " " .. tostring(setting.attribute or "")) or ""
    if hay:find("%d") then return A._LastNumberValue(text) end
    return FirstNumber(text)
end

function A._RelativeNumberAmountForText(text)
    text = tostring(text or "")
    local value
    for _, pattern in ipairs({
        "%f[%w]by%f[%W]%s+([-+]?%d+%.?%d*)",
        "%f[%w]um%f[%W]%s+([-+]?%d+%.?%d*)",
    }) do
        for numberText in text:gmatch(pattern) do
            value = tonumber(numberText)
        end
    end
    return value or FirstNumber(text)
end

local function Compact(text)
    return Normalize(text):gsub("%s+", "")
end

local pluralFoldCache = {}
local pluralFoldCacheCount = 0

local PLURAL_FOLD_KEEP = {
    this = true,
    was = true,
    yes = true,
    dps = true,
}

local function PluralFoldWord(word)
    word = tostring(word or "")
    if word == "" or word:match("^[-+]?%d") or PLURAL_FOLD_KEEP[word] then return word end
    local len = #word
    if len <= 3 then return word end
    if word:sub(-3) == "ies" and len > 4 then
        return word:sub(1, -4) .. "y"
    end
    if word:sub(-4) == "izes" and len > 4 then
        return word:sub(1, -2)
    end
    if word:sub(-2) == "es" then
        local base = word:sub(1, -3)
        if base:sub(-2) == "ss" or base:sub(-2) == "ch" or base:sub(-2) == "sh"
            or base:sub(-1) == "s" or base:sub(-1) == "x" or base:sub(-1) == "z" then
            return base
        end
    end
    if word:sub(-1) == "s" and word:sub(-2) ~= "ss" and word:sub(-2) ~= "us" then
        return word:sub(1, -2)
    end
    return word
end

local function PluralFoldText(text)
    text = Normalize(text)
    local cached = pluralFoldCache[text]
    if cached ~= nil then return cached end
    local out = {}
    for word in text:gmatch("%S+") do
        out[#out + 1] = PluralFoldWord(word)
    end
    local folded = table.concat(out, " ")
    if #text <= 180 then
        if pluralFoldCacheCount > 4096 then
            pluralFoldCache = {}
            pluralFoldCacheCount = 0
        end
        if pluralFoldCache[text] == nil then pluralFoldCacheCount = pluralFoldCacheCount + 1 end
        pluralFoldCache[text] = folded
    end
    return folded
end

local aliasRelationCacheText
local aliasRelationCacheValue
local function AliasRelationText(text)
    text = Normalize(text)
    if text == aliasRelationCacheText then return aliasRelationCacheValue end
    local padded = " " .. text .. " "
    if not (padded:find(" for ", 1, true) or padded:find(" on ", 1, true) or padded:find(" of ", 1, true)
        or padded:find(" vom ", 1, true) or padded:find(" von ", 1, true) or padded:find(" fuer ", 1, true)) then
        aliasRelationCacheText = text
        aliasRelationCacheValue = text
        return text
    end
    local t = padded
    local rel = { "for", "on", "of", "vom", "von", "fuer" }
    for i = 1, #rel do
        t = t:gsub("%f[%w]" .. rel[i] .. "%f[%W]", " ")
    end
    aliasRelationCacheText = text
    aliasRelationCacheValue = Trim(t:gsub("%s+", " "))
    return aliasRelationCacheValue
end

local function TextMatchesAlias(text, relationText, alias)
    if HasPhrase(text, alias) or HasPhrase(relationText or AliasRelationText(text), alias) then return true end
    local normalizedText = Normalize(text)
    local normalizedRelation = Normalize(relationText or AliasRelationText(text))
    local normalizedAlias = Normalize(alias)
    if P._allowFuzzyAliasMatch == true then
        if FuzzyAliasWorthTrying(normalizedText, normalizedAlias) and FuzzyPhraseMatch(normalizedText, normalizedAlias) then return true end
        if FuzzyAliasWorthTrying(normalizedRelation, normalizedAlias) and FuzzyPhraseMatch(normalizedRelation, normalizedAlias) then return true end
    end
    local foldedText = PluralFoldText(normalizedText)
    local foldedRelation = PluralFoldText(normalizedRelation)
    local foldedAlias = PluralFoldText(alias)
    if foldedText == normalizedText and foldedRelation == normalizedRelation and foldedAlias == normalizedAlias then return false end
    if HasPhrase(foldedText, foldedAlias) then return true end
    return HasPhrase(foldedRelation, foldedAlias)
end

local function ExtractColor(raw, text)
    local rawTarget = RawAfterLastConnector(raw)
    local textTarget = TargetAfterLastConnector(text)
    local function hexFrom(value)
        value = tostring(value or "")
        return value:match("#(%x%x%x%x%x%x)") or value:match("0x(%x%x%x%x%x%x)")
    end
    local function rgbFrom(value)
        return tostring(value or ""):match("(%d+)%s*,%s*(%d+)%s*,%s*(%d+)")
    end
    local hex = rawTarget and hexFrom(rawTarget) or nil
    if not hex and not rawTarget then hex = hexFrom(raw) end
    if hex and A.HexToColor then
        return A.HexToColor(hex)
    end
    local rr, gg, bb = rawTarget and rgbFrom(rawTarget) or nil
    if not rr and not rawTarget then rr, gg, bb = rgbFrom(raw) end
    if rr and gg and bb then
        local r, g, b = tonumber(rr) or 255, tonumber(gg) or 255, tonumber(bb) or 255
        if r > 1 or g > 1 or b > 1 then r, g, b = r / 255, g / 255, b / 255 end
        return r, g, b, tostring(rr) .. "," .. tostring(gg) .. "," .. tostring(bb)
    end
    local colorText = textTarget or tostring(text or "")
    rr, gg, bb = tostring(colorText or ""):match("rgb%s+([-+]?%d+%.?%d*)%s+([-+]?%d+%.?%d*)%s+([-+]?%d+%.?%d*)")
    if not rr then
        rr, gg, bb = tostring(colorText or ""):match("r%s+([-+]?%d+%.?%d*)%s+g%s+([-+]?%d+%.?%d*)%s+b%s+([-+]?%d+%.?%d*)")
    end
    if rr and gg and bb then
        local r, g, b = tonumber(rr) or 1, tonumber(gg) or 1, tonumber(bb) or 1
        if r > 1 or g > 1 or b > 1 then r, g, b = r / 255, g / 255, b / 255 end
        return r, g, b, tostring(rr) .. "," .. tostring(gg) .. "," .. tostring(bb)
    end
    if A.ColorFromName then
        for word in tostring(colorText or ""):gmatch("%S+") do
            local r, g, b, label = A.ColorFromName(word)
            if r then return r, g, b, label end
        end
    end
    return nil
end

local function DetectFrameType(text, ctx)
    if ContainsAny(text, CoreData.ALT_MANA_TERMS) then return "altMana" end
    if ContainsAny(text, CLASS_POWER_TERMS) then return "classPower" end
    if ContainsAny(text, GLOBAL_BARS_TERMS) then return "globalBars" end
    if ContainsAny(text, CoreData.COMBAT_TIMER_TERMS) then return "combatTimer" end
    if ContainsAny(text, CoreData.COMBAT_STATE_TERMS) then return "combatState" end
    if ContainsAny(text, CoreData.PLAYER_TOTEMS_TERMS) then return "playerTotems" end
    if ContainsAny(text, CoreData.COMBAT_CROSSHAIR_TERMS) then return "combatCrosshair" end
    if HasPhrase(text, "castbar") or HasPhrase(text, "zauberleiste") then return "castbar" end
    if ContainsAny(text, CoreData.RAID_MARKER_FRAME_TERMS)
        and not ContainsAny(text, CoreData.RAID_MARKER_GROUP_GUARD_TERMS) then
        return "unitframe"
    end
    if HasPhrase(text, "group frames") or HasPhrase(text, "gruppenframes") or HasPhrase(text, "party") or HasPhrase(text, "raid") then return "group" end
    if (HasPhrase(text, "it") or HasPhrase(text, "that") or HasPhrase(text, "das")) and ctx and type(ctx.lastFrameType) == "string" then
        return ctx.lastFrameType
    end
    return "unitframe"
end

local function DetectDirection(text, ctx)
    local function directionIn(segment)
        if ContainsAny(segment, CoreData.DIRECTION_RIGHT_TERMS) then return "right" end
        if ContainsAny(segment, CoreData.DIRECTION_LEFT_TERMS) then return "left" end
        if ContainsAny(segment, CoreData.DIRECTION_DOWN_TERMS) then return "down" end
        if ContainsAny(segment, CoreData.DIRECTION_UP_TERMS) then return "up" end
        return nil
    end
    local target = TargetAfterLastConnector(text)
    local targetDirection = target and directionIn(target)
    if targetDirection then return targetDirection end
    local direction = directionIn(text)
    if direction then return direction end
    if ContainsAny(text, CoreData.DIRECTION_REPEAT_TERMS) and ctx and type(ctx.lastDirection) == "string" then
        return ctx.lastDirection
    end
    return nil
end

local function DetectAttribute(text, frameType)
    if ContainsAny(text, CoreData.RANGE_FADE_TERMS) then return "rangeFade" end
    if ContainsAny(text, CoreData.RAID_MARKER_ATTR_TERMS) then return "raidMarker" end
    if frameType == "castbar" and ContainsAny(text, CoreData.CASTBAR_TERMS) then
        if ContainsAny(text, CoreData.CASTBAR_ICON_TERMS) then return "icon" end
        if ContainsAny(text, CoreData.CASTBAR_TIME_TERMS) then return "time" end
        if ContainsAny(text, CoreData.CASTBAR_TEXT_TERMS) then return "text" end
        if ContainsAny(text, CoreData.CASTBAR_INTERRUPT_TERMS) then return "showInterrupt" end
    end
    if frameType == "castbar" and ContainsAny(text, CoreData.CASTBAR_TERMS) and not ContainsAny(text, CASTBAR_ROOT_DETAIL_TERMS) and not ContainsAny(text, CoreData.CASTBAR_ROOT_GUARD_TERMS) then
        return "enabled"
    end
    if ContainsAny(text, CoreData.HP_TEXT_TERMS) then return "hpText" end
    if ContainsAny(text, CoreData.POWER_TEXT_TERMS) then return "powerText" end
    if ContainsAny(text, CoreData.NAME_TEXT_TERMS) then return "name" end
    if ContainsAny(text, CoreData.WIDTH_TERMS) then return "width" end
    if ContainsAny(text, CoreData.HEIGHT_TERMS) then return "height" end
    if ContainsAny(text, CoreData.ENABLED_VERB_TERMS)
        and ContainsAny(text, CoreData.FRAME_SCOPE_TERMS)
        and not ContainsAny(text, CoreData.ENABLED_EXCLUDE_TERMS) then
        return "enabled"
    end
    return nil
end

local function PageForText(text)
    for i = 1, #PAGE_TEXT_TARGETS do
        local spec = PAGE_TEXT_TARGETS[i]
        if ContainsAny(text, spec.terms) then return spec.page, spec.label end
    end
    return nil, nil
end

local function FrameTypeForPage(page)
    if page == "profiles" then return "profiles" end
    if page == "opt_castbar" then return "castbar" end
    if page == "auras3" or page == "auras3_buffs" or page == "auras3_debuffs" or page == "auras3_rendering" or page == "auras3_styling" or page == "auras3_filters" then return "aura" end
    if page == "gf_layout" or page == "gf_auras" or page == "gf_indicators" then return "group" end
    if page == "opt_colors" then return "colors" end
    if page == "opt_fonts" then return "fonts" end
    if page == "opt_bars" then return "globalBars" end
    if page == "gameplay" then return "gameplay" end
    if page == "classpower" then return "classPower" end
    if type(page) == "string" and page:find("^uf_") then return "unitframe" end
    return nil
end

local function UnitPageKey(unit)
    if unit == "player" then return "uf_player" end
    if unit == "target" then return "uf_target" end
    if unit == "focus" then return "uf_focus" end
    if unit == "targettarget" then return "uf_targettarget" end
    if unit == "focustarget" then return "uf_focustarget" end
    if unit == "pet" then return "uf_pet" end
    if unit == "boss" then return "uf_boss" end
    return nil
end

P.Trim = Trim
P.Normalize = Normalize
P.ActionableText = ActionableText
P.FuzzyWordMatch = FuzzyWordMatch
P.FuzzyPhraseMatch = FuzzyPhraseMatch
P.HasPhrase = HasPhrase
P.ContainsAny = ContainsAny
P.UNIT_ORDER = UNIT_ORDER
P.GROUP_ORDER = GROUP_ORDER
P.ALL_UNITFRAMES = ALL_UNITFRAMES
P.ALL_GROUPS = ALL_GROUPS
P.CLASS_POWER_TERMS = CLASS_POWER_TERMS
P.GAMEPLAY_TERMS = GAMEPLAY_TERMS
P.GLOBAL_BARS_TERMS = GLOBAL_BARS_TERMS
P.CASTBAR_ROOT_DETAIL_TERMS = CASTBAR_ROOT_DETAIL_TERMS
P.PAGE_TEXT_TARGETS = PAGE_TEXT_TARGETS
P.AddUnique = AddUnique
P.DetectUnits = DetectUnits
P.DetectGroups = DetectGroups
P.DetectGlobalScope = DetectGlobalScope
P.OFF_WORDS = OFF_WORDS
P.ON_WORDS = ON_WORDS
P.RawAfterLastConnector = RawAfterLastConnector
P.TargetAfterLastConnector = TargetAfterLastConnector
P.DetectBoolean = DetectBoolean
P.FirstNumber = FirstNumber
P.Compact = Compact
P.AliasRelationText = AliasRelationText
P.TextMatchesAlias = TextMatchesAlias
P.PluralFoldWord = PluralFoldWord
P.PluralFoldText = PluralFoldText
P.ExtractColor = ExtractColor
P.DetectFrameType = DetectFrameType
P.DetectDirection = DetectDirection
P.DetectAttribute = DetectAttribute
P.PageForText = PageForText
P.FrameTypeForPage = FrameTypeForPage
P.UnitPageKey = UnitPageKey
