EbonholdHub.AffixData = {}

EbonholdHub.AffixData.MAX_RANK = 6

EbonholdHub.AffixData.RANK_ROMAN = { "I", "II", "III", "IV", "V", "VI" }

-- WoW Character frame layout: left column | model gap | right column, weapons bottom row.
EbonholdHub.AffixData.DOLL = {
    REF_SIZE  = 40,
    LEFT_X    = 4,
    RIGHT_X   = 124,
    ROW_STEP  = 44,
    WEAPON_Y  = -352,
    WEAPON_X  = { 24, 68, 112 },
}

-- Paper-doll slot positions (offsets for REF_SIZE icons).
EbonholdHub.AffixData.SLOTS = {
    { key = "HEAD",      invSlot = 1,  label = "Head",      x = 4,   y = 0    },
    { key = "NECK",      invSlot = 2,  label = "Neck",      x = 4,   y = -44  },
    { key = "SHOULDER",  invSlot = 3,  label = "Shoulder",  x = 4,   y = -88  },
    { key = "BACK",      invSlot = 15, label = "Back",      x = 4,   y = -132 },
    { key = "CHEST",     invSlot = 5,  label = "Chest",     x = 4,   y = -176 },
    { key = "SHIRT",     invSlot = 4,  label = "Shirt",     x = 4,   y = -220 },
    { key = "TABARD",    invSlot = 19, label = "Tabard",    x = 4,   y = -264 },
    { key = "WRIST",     invSlot = 9,  label = "Wrist",     x = 4,   y = -308 },
    { key = "HANDS",     invSlot = 10, label = "Hands",     x = 124, y = 0    },
    { key = "WAIST",     invSlot = 6,  label = "Waist",     x = 124, y = -44  },
    { key = "LEGS",      invSlot = 7,  label = "Legs",      x = 124, y = -88  },
    { key = "FEET",      invSlot = 8,  label = "Feet",      x = 124, y = -132 },
    { key = "FINGER1",   invSlot = 11, label = "Ring 1",    x = 124, y = -176 },
    { key = "FINGER2",   invSlot = 12, label = "Ring 2",    x = 124, y = -220 },
    { key = "TRINKET1",  invSlot = 13, label = "Trinket 1", x = 124, y = -264 },
    { key = "TRINKET2",  invSlot = 14, label = "Trinket 2", x = 124, y = -308 },
    { key = "MAINHAND",  invSlot = 16, label = "Main Hand", x = 24,  y = -352 },
    { key = "OFFHAND",   invSlot = 17, label = "Off Hand",  x = 68,  y = -352 },
    { key = "RANGED",    invSlot = 18, label = "Ranged",    x = 112, y = -352 },
}

function EbonholdHub.AffixData.GetDollSize(slotSize)
    slotSize = slotSize or EbonholdHub.AffixData.DOLL.REF_SIZE
    local scale = slotSize / EbonholdHub.AffixData.DOLL.REF_SIZE
    local d = EbonholdHub.AffixData.DOLL
    local width = (d.RIGHT_X + d.REF_SIZE + 4) * scale
    local height = (math.abs(d.WEAPON_Y) + d.REF_SIZE + 4) * scale
    return math.ceil(width), math.ceil(height)
end

local function IsWeaponSlot(slotDef)
    return slotDef.y <= EbonholdHub.AffixData.DOLL.WEAPON_Y + 1
end

local function IsRightColumn(slotDef)
    return slotDef.x >= EbonholdHub.AffixData.DOLL.RIGHT_X
end

local WEAPON_ORDER = { MAINHAND = 1, OFFHAND = 2, RANGED = 3 }

function EbonholdHub.AffixData.PositionSlotButton(btn, parent, slotDef, slotSize, layout)
    slotSize = slotSize or EbonholdHub.AffixData.DOLL.REF_SIZE
    local scale = slotSize / EbonholdHub.AffixData.DOLL.REF_SIZE
    local d = EbonholdHub.AffixData.DOLL
    layout = layout or {}

    local pad = 4 * scale
    local w = layout.width or select(1, EbonholdHub.AffixData.GetDollSize(slotSize))
    local h = layout.height or select(2, EbonholdHub.AffixData.GetDollSize(slotSize))

    local x, y
    if IsWeaponSlot(slotDef) then
        local gap = 8 * scale
        local rowW = 3 * slotSize + 2 * gap
        local startX = (w - rowW) / 2
        local idx = WEAPON_ORDER[slotDef.key] or 1
        x = startX + (idx - 1) * (slotSize + gap)
        y = -(h - slotSize - pad)
    elseif IsRightColumn(slotDef) then
        x = w - slotSize - pad
        y = slotDef.y * scale
    else
        x = pad
        y = slotDef.y * scale
    end

    btn:SetSize(slotSize, slotSize)
    btn:ClearAllPoints()
    btn:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
end

function EbonholdHub.AffixData.RefreshDollLayout(dollFrame, slotButtons, slotSize)
    if not dollFrame or not slotButtons then return end
    local w = dollFrame:GetWidth()
    local h = dollFrame:GetHeight()
    if not w or w <= 0 or not h or h <= 0 then return end
    local layout = { width = w, height = h }
    for _, slotDef in ipairs(EbonholdHub.AffixData.SLOTS) do
        local btn = slotButtons[slotDef.key]
        if btn then
            EbonholdHub.AffixData.PositionSlotButton(btn, dollFrame, slotDef, slotSize, layout)
        end
    end
end

function EbonholdHub.AffixData.UpdateDollModel(model, dollFrame, slotSize)
    if not model or not dollFrame then return end
    slotSize = slotSize or EbonholdHub.AffixData.DOLL.REF_SIZE
    local scale = slotSize / EbonholdHub.AffixData.DOLL.REF_SIZE
    local pad = slotSize + 8 * scale
    local bottom = slotSize + 12 * scale
    model:ClearAllPoints()
    model:SetPoint("TOPLEFT", dollFrame, "TOPLEFT", pad, -8 * scale)
    model:SetPoint("BOTTOMRIGHT", dollFrame, "BOTTOMRIGHT", -pad, bottom)
    if model.SetUnit then
        model:SetUnit("player")
        if model.RefreshUnit then
            model:RefreshUnit()
        end
    end
    if model.SetRotation then
        model:SetRotation(0.45)
    elseif model.SetFacing then
        model:SetFacing(0.45)
    end
end

local function NormalizeId(name)
    return (name:lower():gsub("[^a-z0-9]+", "_"):gsub("^_", ""):gsub("_$", ""))
end

local catalogById = {}
local catalogList = {}
local liveCatalogCache = nil
local liveCatalogAt = 0
local enchantLookup = nil

-- Icon cache: canonical display name -> texture path/id.
-- Populated from ExtractionService.learnedAffixes and persisted in
-- EbonholdHubDB.affixIcons so icons survive sessions even without PE loaded.
local iconStore = {}

local function InvalidateEnchantLookup()
    enchantLookup = nil
end

local function SaveIconStore()
    if not EbonholdHubDB then return end
    EbonholdHubDB.affixIcons = EbonholdHubDB.affixIcons or {}
    for name, tex in pairs(iconStore) do
        EbonholdHubDB.affixIcons[name] = tex
    end
end

local function SetCatalogIcon(name, tex)
    if not name or not tex or tex == "" then return end
    iconStore[name] = tex
    for _, entry in ipairs(catalogList) do
        if entry.name == name then entry.icon = tex end
    end
    if liveCatalogCache then
        for _, entry in ipairs(liveCatalogCache) do
            if entry.name == name then entry.icon = tex end
        end
    end
end

local function BuildEnchantLookup()
    if enchantLookup then return enchantLookup end
    enchantLookup = {}
    for _, entry in ipairs(EbonholdHub.AffixData.GetCatalog()) do
        local rankMap = entry.rankAffixIds or entry.ranks
        if rankMap then
            for rank, affixId in pairs(rankMap) do
                enchantLookup[affixId] = { entry = entry, rank = rank }
            end
        end
        if entry.spellId and not enchantLookup[entry.spellId] then
            enchantLookup[entry.spellId] = { entry = entry, rank = 5 }
        end
    end
    return enchantLookup
end

local function CloneCatalogEntry(entry)
    local copy = {
        id              = entry.id,
        name            = entry.name,
        spellId         = entry.spellId,
        learned         = entry.learned,
        maxLearnedCount = entry.maxLearnedCount,
        weapon          = entry.weapon,
        icon            = entry.icon,
        rankAffixIds    = {},
        ranks           = {},
    }
    if entry.rankAffixIds then
        for rank, affixId in pairs(entry.rankAffixIds) do
            copy.rankAffixIds[rank] = affixId
            copy.ranks[rank] = affixId
        end
    end
    return copy
end

-- Builds a full catalog from ExtractionService.learnedAffixes.
-- Groups per-rank entries by base name (strips roman numeral suffix).
-- Returns a sorted list of catalog entries, or nil if ES is unavailable.
local function BuildCatalogFromExtractionService()
    local svc = _G.ExtractionService
    if not svc or type(svc.learnedAffixes) ~= "table" or #svc.learnedAffixes == 0 then
        return nil
    end

    -- Longest-first so "VI" matches before "V", "IV" before "I", etc.
    local romanSuffixes = { "VI", "V", "IV", "III", "II", "I" }

    local byKey = {}
    local list  = {}

    for _, a in ipairs(svc.learnedAffixes) do
        if type(a.name) == "string" and a.name ~= "" and type(a.id) == "number" then
            local baseName = a.name
            local rank     = a.difficulty

            if not a.weaponOnly then
                for _, roman in ipairs(romanSuffixes) do
                    local stripped = a.name:match("^(.-)%s+" .. roman .. "$")
                    if stripped then
                        baseName = stripped
                        break
                    end
                end
            end

            local key   = NormalizeId(baseName)
            local entry = byKey[key]
            if not entry then
                entry = {
                    id           = key,
                    name         = baseName,
                    weapon       = a.weaponOnly or false,
                    rankAffixIds = {},
                }
                byKey[key] = entry
                list[#list + 1] = entry
            end

            if a.weaponOnly then
                if not entry.spellId then entry.spellId = a.id end
            elseif type(rank) == "number" and rank > 0 then
                entry.rankAffixIds[rank] = a.id
                if not entry._maxRank or rank > entry._maxRank then
                    entry.spellId  = a.id
                    entry._maxRank = rank
                end
            end

            if a.icon and a.icon ~= "" and not entry.icon then
                entry.icon = a.icon
            end
        end
    end

    for _, entry in ipairs(list) do
        entry._maxRank = nil
        -- mirror into .ranks for legacy callers
        for r, id in pairs(entry.rankAffixIds) do
            entry.ranks = entry.ranks or {}
            entry.ranks[r] = id
        end
    end

    table.sort(list, function(a, b) return a.name < b.name end)
    return #list > 0 and list or nil
end

-- Replaces the in-memory catalog with the given list and persists it to DB.
-- Merges descriptions from the static AffixCatalog if the live entry has none.
local function ApplyBuiltCatalog(list)
    -- Index static catalog descriptions so live entries inherit them.
    local staticDesc = {}
    local staticSource = EbonholdHub.AffixCatalog and EbonholdHub.AffixCatalog.ENTRIES
    if staticSource then
        for _, e in ipairs(staticSource) do
            if e.id and e.description and e.description ~= "" then
                staticDesc[e.id] = e.description
            end
        end
    end

    catalogById  = {}
    catalogList  = {}
    liveCatalogCache = nil
    for _, entry in ipairs(list) do
        local row = CloneCatalogEntry(entry)
        if not row.description or row.description == "" then
            row.description = staticDesc[row.id]
        end
        catalogById[row.id]   = row
        catalogList[#catalogList + 1] = row
        if row.icon then SetCatalogIcon(row.name, row.icon) end
    end
    InvalidateEnchantLookup()
    if EbonholdHubDB then
        EbonholdHubDB.affixCatalog = list
        SaveIconStore()
    end
end

-- Loads the catalog previously saved to EbonholdHubDB.affixCatalog.
-- Called from Init() before ExtractionService is queried.
local function LoadSavedCatalog()
    if not EbonholdHubDB or type(EbonholdHubDB.affixCatalog) ~= "table" then return false end
    local source = EbonholdHubDB.affixCatalog
    if #source == 0 then return false end
    catalogById = {}
    catalogList = {}
    for _, entry in ipairs(source) do
        local row = CloneCatalogEntry(entry)
        catalogById[row.id]   = row
        catalogList[#catalogList + 1] = row
        if row.icon then SetCatalogIcon(row.name, row.icon) end
    end
    table.sort(catalogList, function(a, b) return a.name < b.name end)
    InvalidateEnchantLookup()
    return true
end

-- Rebuilds the catalog from ExtractionService.learnedAffixes.
-- On success: replaces the in-memory catalog and persists it to EbonholdHubDB.
-- Falls back to icon-only seeding when ES data has unexpected structure.
-- Returns true if ExtractionService was available and had data.
local function SeedFromExtractionService()
    -- Try a full catalog rebuild first.
    local built = BuildCatalogFromExtractionService()
    if built then
        ApplyBuiltCatalog(built)
        return true
    end

    -- ES present but learnedAffixes empty or structured differently — seed icons only.
    local svc = _G.ExtractionService
    if not svc or type(svc.learnedAffixes) ~= "table" or #svc.learnedAffixes == 0 then
        return false
    end
    local updated = false
    for _, a in ipairs(svc.learnedAffixes) do
        if type(a.name) == "string" and a.name ~= "" then
            local baseName = a.name:match("^(.-)%s+[IVXLCivxlc]+$") or a.name
            local entry = EbonholdHub.AffixData.GetByName(baseName)
            if entry then
                if a.icon and a.icon ~= "" and not iconStore[entry.name] then
                    SetCatalogIcon(entry.name, a.icon)
                    updated = true
                end
                if a.weaponOnly and not entry.spellId and a.id then
                    entry.spellId = a.id
                end
            end
        end
    end
    if updated then SaveIconStore() end
    return true
end

local function LoadOfflineCatalog()
    catalogById = {}
    catalogList = {}
    local source = EbonholdHub.AffixCatalog and EbonholdHub.AffixCatalog.ENTRIES
    if not source then return end
    for _, entry in ipairs(source) do
        local row = CloneCatalogEntry(entry)
        catalogById[row.id] = row
        catalogList[#catalogList + 1] = row
    end
    table.sort(catalogList, function(a, b) return a.name < b.name end)
end

LoadOfflineCatalog()

local ROMAN_TO_RANK = { I = 1, II = 2, III = 3, IV = 4, V = 5, VI = 6 }
local RANKS_BY_LENGTH = { "VI", "V", "IV", "III", "II", "I" }

-- Item-tooltip text that differs from the canonical spell name.
-- Keys are lowercase; values are the canonical catalog name.
local ITEM_ALIASES = {
    ["precision"]      = "Cold",
    ["swift footwork"] = "Feral Grace",
    ["keen strike"]    = "Keen Strikes",
    ["block"]          = "Shield Block",
    ["inner light"]    = "Spirit Surge",
    ["enduring flesh"] = "Ironhide",
    ["judgment"]       = "Judgement",   -- item text vs spell name spelling
}

local function StripColorCodes(text)
    if not text then return "" end
    return text
        :gsub("|cff%x%x%x%x%x%x", "")
        :gsub("|c%x%x%x%x%x%x%x%x", "")
        :gsub("|r", "")
end

function EbonholdHub.AffixData.ParseBookLabel(raw)
    local learned = raw and not tostring(raw):find("|cff999999", 1, true)
    raw = StripColorCodes(strtrim(raw or ""))
    if raw == "" then return nil end

    local count = raw:match(" %([x]([%d]+)[)]$")
    if count then
        raw = strtrim(raw:gsub(" %([x]([%d]+)[)]$", ""))
        count = tonumber(count)
    end

    for _, rankRoman in ipairs(RANKS_BY_LENGTH) do
        local name = raw:match("^(.+) " .. rankRoman .. "$")
        if name then
            return {
                name  = strtrim(name),
                rank  = ROMAN_TO_RANK[rankRoman],
                count = count,
                learned = learned,
            }
        end
    end

    return { name = raw, count = count, learned = learned }
end

function EbonholdHub.AffixData.RomanToRank(roman)
    return roman and ROMAN_TO_RANK[roman] or nil
end

function EbonholdHub.AffixData.BuildCatalogFromBook()
    local panel = _G.EbonholdAffixBookPanel
    if type(panel) ~= "table" or type(panel.affixRows) ~= "table" then
        return nil
    end

    local byName = {}
    for _, row in pairs(panel.affixRows) do
        if type(row) == "table" and row.affixId then
            local label = nil
            if row.nameText then
                local ok, text = pcall(function() return row.nameText:GetText() end)
                if ok and text and text ~= "" then label = text end
            end
            local parsed = label and EbonholdHub.AffixData.ParseBookLabel(label) or nil
            local displayName = parsed and parsed.name or ("Affix " .. tostring(row.affixId))
            local key = NormalizeId(displayName)
            local entry = byName[key]
            if not entry then
                entry = {
                    id    = key,
                    name  = displayName,
                    ranks = {},
                    rankAffixIds = {},
                }
                byName[key] = entry
            end
            local affixId = row.affixId
            if parsed and parsed.rank then
                entry.ranks[parsed.rank] = affixId
                entry.rankAffixIds[parsed.rank] = affixId
            end
            if affixId >= 102000 and affixId < 103000 then
                entry.spellId = affixId
            end
            if row.icon then
                local ok, tex = pcall(function() return row.icon:GetTexture() end)
                if ok and tex then entry.icon = tex end
            end
            if parsed and parsed.count then
                entry.learnedCount = math.max(entry.learnedCount or 0, parsed.count)
            end
        end
    end

    local list = {}
    for _, entry in pairs(byName) do
        list[#list + 1] = entry
    end
    table.sort(list, function(a, b) return a.name < b.name end)
    return #list > 0 and list or nil
end

function EbonholdHub.AffixData.GetCatalog(forceRefresh)
    if forceRefresh then
        liveCatalogCache = nil
        InvalidateEnchantLookup()
    end
    if not liveCatalogCache or (GetTime and (GetTime() - liveCatalogAt) > 2) then
        local live = EbonholdHub.AffixData.BuildCatalogFromBook()
        if live and #live > 0 then
            liveCatalogCache = live
            liveCatalogAt = GetTime and GetTime() or 0
        end
    end
    if liveCatalogCache and #liveCatalogCache > 0 then
        return liveCatalogCache
    end
    if #catalogList == 0 then
        LoadOfflineCatalog()
    end
    return catalogList
end

function EbonholdHub.AffixData.GetAffixIdForRank(affix, rank)
    if not affix then return nil end
    if affix.rankAffixIds and affix.rankAffixIds[rank] then
        return affix.rankAffixIds[rank]
    end
    if affix.ranks and affix.ranks[rank] then
        return affix.ranks[rank]
    end
    if rank == 1 and affix.spellId then
        return affix.spellId
    end
    return affix.spellId
end

function EbonholdHub.AffixData.GetById(id)
    if not id then return nil end
    if catalogById[id] then return catalogById[id] end
    for _, entry in ipairs(EbonholdHub.AffixData.GetCatalog()) do
        if entry.id == id then return entry end
    end
    return nil
end

function EbonholdHub.AffixData.GetByName(name)
    if not name or name == "" then return nil end
    local key = NormalizeId(name)
    for _, entry in ipairs(EbonholdHub.AffixData.GetCatalog()) do
        if entry.id == key then return entry end
    end
    return catalogById[key]
end

function EbonholdHub.AffixData.RankToRoman(rank)
    return EbonholdHub.AffixData.RANK_ROMAN[rank] or tostring(rank or "?")
end

-- Returns true if the affix is a rankless weapon affix (goes on weapons only).
function EbonholdHub.AffixData.IsWeaponAffix(affixOrName)
    local affix
    if type(affixOrName) == "table" then
        affix = affixOrName
    else
        affix = EbonholdHub.AffixData.GetByName(tostring(affixOrName or ""))
    end
    return affix ~= nil and affix.weapon == true
end

local function ResolveCatalogEntry(affixOrName)
    if type(affixOrName) ~= "table" then
        return EbonholdHub.AffixData.GetByName(tostring(affixOrName))
    end
    if affixOrName.icon or affixOrName.spellId or affixOrName.rankAffixIds then
        return affixOrName
    end
    if affixOrName.id then
        local byId = EbonholdHub.AffixData.GetById(affixOrName.id)
        if byId then return byId end
    end
    if affixOrName.name then
        return EbonholdHub.AffixData.GetByName(affixOrName.name)
    end
    return affixOrName
end

function EbonholdHub.AffixData.GetIcon(affixOrName)
    local affix = ResolveCatalogEntry(affixOrName)
    if affix and affix.icon then return affix.icon end
    if affix and affix.name and iconStore[affix.name] then return iconStore[affix.name] end
    if affix and affix.spellId then
        local _, _, icon = GetSpellInfo(affix.spellId)
        if icon then return icon end
    end
    if type(affixOrName) == "table" and affixOrName.affixId and affixOrName.affixId >= 102000 then
        local _, _, icon = GetSpellInfo(affixOrName.affixId)
        if icon then return icon end
    end
    local name = affix and affix.name or tostring(affixOrName)
    local _, _, icon = GetSpellInfo(name)
    if icon then return icon end
    return "Interface\\Icons\\INV_Enchant_DustStrange"
end

function EbonholdHub.AffixData.GetSlotTexture(invSlot)
    if GetInventorySlotInfo then
        local _, texture = GetInventorySlotInfo(({
            [1]  = "HeadSlot",           [2]  = "NeckSlot",
            [3]  = "ShoulderSlot",       [4]  = "ShirtSlot",
            [5]  = "ChestSlot",          [6]  = "WaistSlot",
            [7]  = "LegsSlot",           [8]  = "FeetSlot",
            [9]  = "WristSlot",          [10] = "HandsSlot",
            [11] = "Finger0Slot",        [12] = "Finger1Slot",
            [13] = "Trinket0Slot",       [14] = "Trinket1Slot",
            [15] = "BackSlot",           [16] = "MainHandSlot",
            [17] = "SecondaryHandSlot",  [18] = "RangedSlot",
            [19] = "TabardSlot",
        })[invSlot])
        if texture then return texture end
    end
    return "Interface\\Buttons\\UI-EmptySlot"
end

function EbonholdHub.AffixData.FindSlot(key)
    for _, slot in ipairs(EbonholdHub.AffixData.SLOTS) do
        if slot.key == key then return slot end
    end
    return nil
end

function EbonholdHub.AffixData.ParseItemEnchantId(link)
    if not link or link == "" then return nil end
    local itemPayload = link:match("|H(item:%d+:[-%d:]*)") or link:match("(item:%d+:[-%d:]*)")
    if not itemPayload then return nil end

    local fields = {}
    for part in itemPayload:gmatch("[^:]+") do
        fields[#fields + 1] = part
    end
    -- item:<itemId>:<enchant>:<gem1>:... — affix may live in any numeric field.
    for i = 3, #fields do
        local id = tonumber(fields[i])
        if id and id > 0 and EbonholdHub.AffixData.IsAffixEnchantId(id) then
            return id
        end
    end
    return nil
end

function EbonholdHub.AffixData.ParseAffixFromItemName(text)
    if not text or text == "" then return nil end
    local plain = StripColorCodes(strtrim(text))
    plain = plain:match("^%[(.-)%]$") or plain
    if plain == "" then return nil end

    local catalog = EbonholdHub.AffixData.GetCatalog()
    local sorted = {}
    for i = 1, #catalog do sorted[i] = catalog[i] end
    table.sort(sorted, function(a, b) return #a.name > #b.name end)

    -- Pass 1: ranked affixes — "of <name> <roman>"
    for _, affix in ipairs(sorted) do
        if not affix.weapon then
            for _, rankRoman in ipairs(RANKS_BY_LENGTH) do
                local suffix = " of " .. affix.name .. " " .. rankRoman
                if plain:sub(-#suffix) == suffix then
                    return { name = affix.name, rank = ROMAN_TO_RANK[rankRoman], learned = true }
                end
            end
        end
    end

    -- Pass 2: weapon affixes — "of <name>" (rankless)
    for _, affix in ipairs(sorted) do
        if affix.weapon then
            local suffix = " of " .. affix.name
            if plain:sub(-#suffix) == suffix then
                return { name = affix.name, rank = nil, learned = true }
            end
        end
    end

    -- Pass 3: aliases — "of <aliasText>" or "of <aliasText> <roman>"
    local plainLower = plain:lower()
    for aliasLower, canonicalName in pairs(ITEM_ALIASES) do
        local affix = EbonholdHub.AffixData.GetByName(canonicalName)
        if affix then
            if affix.weapon then
                local suffix = " of " .. aliasLower
                if plainLower:sub(-#suffix) == suffix then
                    return { name = affix.name, rank = nil, learned = true }
                end
            else
                for _, rankRoman in ipairs(RANKS_BY_LENGTH) do
                    local suffix = " of " .. aliasLower .. " " .. rankRoman:lower()
                    if plainLower:sub(-#suffix) == suffix then
                        return { name = affix.name, rank = ROMAN_TO_RANK[rankRoman], learned = true }
                    end
                end
            end
        end
    end

    return nil
end

function EbonholdHub.AffixData.ParseTooltipAffixLine(text)
    if not text or text == "" then return nil end

    local fromName = EbonholdHub.AffixData.ParseAffixFromItemName(text)
    if fromName then return fromName end

    local parsed = EbonholdHub.AffixData.ParseBookLabel(text)
    if parsed and parsed.rank and parsed.name and parsed.name ~= ""
        and EbonholdHub.AffixData.GetByName(parsed.name) then
        return parsed
    end

    local plain = StripColorCodes(strtrim(text))
    if plain == "" then return nil end

    fromName = EbonholdHub.AffixData.ParseAffixFromItemName(plain)
    if fromName then return fromName end

    parsed = EbonholdHub.AffixData.ParseBookLabel(plain)
    if parsed and parsed.rank and parsed.name and parsed.name ~= ""
        and EbonholdHub.AffixData.GetByName(parsed.name) then
        return parsed
    end

    for _, affix in ipairs(EbonholdHub.AffixData.GetCatalog()) do
        local pos = plain:find(affix.name, 1, true)
        if pos then
            local tail = strtrim(plain:sub(pos + #affix.name))
            if affix.weapon and tail == "" then
                return { name = affix.name, rank = nil, learned = true }
            end
            for _, rankRoman in ipairs(RANKS_BY_LENGTH) do
                if tail == rankRoman or tail:match("^" .. rankRoman .. "[^A-Za-z]") then
                    return { name = affix.name, rank = ROMAN_TO_RANK[rankRoman], learned = true }
                end
            end
        end
    end
    return nil
end

function EbonholdHub.AffixData.MakeLoadoutEntryFromParsed(parsed)
    if not parsed or not parsed.name then return nil end
    local affix = EbonholdHub.AffixData.GetByName(parsed.name)
    if not affix then return nil end
    -- Weapon affixes are rankless; ranked affixes require a rank.
    if not affix.weapon and not parsed.rank then return nil end
    local rank = affix.weapon and nil or parsed.rank
    return {
        id = affix.id,
        name = affix.name,
        rank = rank,
        affixId = rank and EbonholdHub.AffixData.GetAffixIdForRank(affix, rank) or affix.spellId,
    }
end

local scanTooltip

local function GetScanTooltip()
    if scanTooltip then return scanTooltip end
    scanTooltip = _G.EbonholdAffixScanTooltip
    if type(scanTooltip) ~= "table" or not scanTooltip.SetOwner then
        scanTooltip = CreateFrame("GameTooltip", "EbonholdHubAffixScanTooltip", UIParent, "GameTooltipTemplate")
    end
    return scanTooltip
end

local function GetTooltipLineText(tooltip, index, side)
    side = side or "Left"
    local name = tooltip:GetName()
    if not name then return nil end
    local line = _G[name .. "Text" .. side .. index]
    if line then return line:GetText() end
    return nil
end

local function ReadAffixFromTooltipLines(tooltip)
    local numLines = tooltip:NumLines() or 0
    for i = 1, numLines do
        for _, side in ipairs({ "Left", "Right" }) do
            local text = GetTooltipLineText(tooltip, i, side)
            if text then
                local parsed = EbonholdHub.AffixData.ParseTooltipAffixLine(text)
                if parsed then
                    local entry = EbonholdHub.AffixData.MakeLoadoutEntryFromParsed(parsed)
                    if entry then return entry end
                end
            end
        end
    end
    return nil
end

function EbonholdHub.AffixData.ReadAffixFromLink(link)
    if not link or link == "" then return nil end

    local affixId = EbonholdHub.AffixData.ParseItemEnchantId(link)
    if affixId then
        local entry = EbonholdHub.AffixData.LookupByEnchantId(affixId)
        if entry then return entry end
    end

    local linkName = link:match("%[(.-)%]")
    if linkName then
        local parsed = EbonholdHub.AffixData.ParseAffixFromItemName(linkName)
        if parsed then
            local entry = EbonholdHub.AffixData.MakeLoadoutEntryFromParsed(parsed)
            if entry then return entry end
        end
    end

    local tooltip = GetScanTooltip()
    tooltip:SetOwner(UIParent, "ANCHOR_NONE")
    tooltip:ClearLines()
    local ok = pcall(function() tooltip:SetHyperlink(link) end)
    if ok then
        local entry = ReadAffixFromTooltipLines(tooltip)
        if entry then return entry end
    end
    return nil
end

function EbonholdHub.AffixData.ReadAffixFromInventorySlot(unit, invSlot)
    local link = GetInventoryItemLink(unit, invSlot)
    if link then
        local entry = EbonholdHub.AffixData.ReadAffixFromLink(link)
        if entry then return entry end
    end

    if GetInventoryItemTexture and not GetInventoryItemTexture(unit, invSlot) then
        return nil
    end

    local tooltip = GetScanTooltip()
    tooltip:SetOwner(UIParent, "ANCHOR_NONE")
    tooltip:ClearLines()
    local ok = pcall(function()
        tooltip:SetInventoryItem(unit, invSlot)
    end)
    if not ok then return nil end
    return ReadAffixFromTooltipLines(tooltip)
end

function EbonholdHub.AffixData.IsAffixEnchantId(enchantId)
    if type(enchantId) ~= "number" then return false end
    if enchantId >= 900925 and enchantId <= 901034 then return true end  -- ranks I–IV
    if enchantId >= 102200 and enchantId <= 102226 then return true end  -- rank V spellIds
    if enchantId >= 102257 and enchantId <= 102282 then return true end  -- rank VI spellIds
    if enchantId >= 700078 and enchantId <= 700130 then return true end  -- weapon affix spellIds
    return false
end

function EbonholdHub.AffixData.LookupByEnchantId(enchantId)
    if not enchantId or not EbonholdHub.AffixData.IsAffixEnchantId(enchantId) then
        return nil
    end
    local hit = BuildEnchantLookup()[enchantId]
    if not hit then return nil end
    local affix = hit.entry
    return {
        id = affix.id,
        name = affix.name,
        rank = hit.rank,
        affixId = enchantId,
    }
end

-- Called once from Init.lua after EbonholdHubDB is ready.
-- Priority: 1) catalog saved from previous session  2) ExtractionService live data  3) static AffixCatalog.lua fallback
function EbonholdHub.AffixData.Init()
    -- Restore icons saved in previous sessions.
    if EbonholdHubDB and EbonholdHubDB.affixIcons then
        for name, tex in pairs(EbonholdHubDB.affixIcons) do
            SetCatalogIcon(name, tex)
        end
    end

    -- Load catalog persisted by a previous session (fast path — no ES needed).
    local hasSaved = LoadSavedCatalog()

    -- Try to build/refresh from ExtractionService (replaces saved or static catalog).
    if not SeedFromExtractionService() then
        -- ExtractionService not ready yet — schedule retries.
        -- C_Timer.After is not available in WotLK 3.3.0; use OnUpdate frames instead.
        local function DelayedSeed(delay)
            local elapsed = 0
            local f = CreateFrame("Frame")
            f:SetScript("OnUpdate", function(self, dt)
                elapsed = elapsed + dt
                if elapsed >= delay then
                    self:SetScript("OnUpdate", nil)
                    SeedFromExtractionService()
                end
            end)
        end
        DelayedSeed(2)
        DelayedSeed(6)
    end

    -- If no saved catalog and ES wasn't available, static catalog is already loaded.
    _ = hasSaved  -- suppress unused warning
end

-- Call after ProjectEbonhold sends async affix data to pick up any new affixes/ranks.
function EbonholdHub.AffixData.RefreshFromServer()
    SeedFromExtractionService()
    if EbonholdHub.AffixOwnership and EbonholdHub.AffixOwnership.Invalidate then
        EbonholdHub.AffixOwnership.Invalidate()
    end
end
