local _, ns = ...
local L = ns.L or {}
local addonName = ns.ADDON_NAME or "Vaultloom"

local BACKDROP_TEMPLATE = BackdropTemplateMixin and "BackdropTemplate" or nil
local ASSET_ROOT = "Interface\\AddOns\\" .. tostring(addonName) .. "\\Assets\\"
local GATHERING_ICON_ROOT = ASSET_ROOT .. "GatheringNodes\\"

local FEATURE_ID = "gathering_nodes"
local WORLD_MAP_PIN_TEMPLATE = "VaultloomGatheringNodeWorldMapPinTemplate"
local BUTTON_NAME = "VaultloomGatheringNodesButton"
local OPTIONS_FRAME_NAME = "VaultloomGatheringNodesOptionsFrame"
local BUTTON_POSITION_KEY = "gathering_nodes_button_position"
local BUTTON_SCALE_KEY = "gathering_nodes_button_scale"
local BUTTON_SCALE_MIN = 0.65
local BUTTON_SCALE_MAX = 1.75
local BUTTON_SCALE_STEP = 0.05
local BUTTON_SIZE = 42
local WORLD_PIN_SIZE = 15
local WORLD_PIN_CLUSTER_RADIUS = 0.011
local MINIMAP_PIN_SIZE = 13
local MINIMAP_FULL_UPDATE_INTERVAL = 2
local MINIMAP_POSITION_UPDATE_INTERVAL = 0.03
local MINIMAP_RANGE_MULTIPLIER = 0.74
local MINIMAP_TRACK_CIRCLE_COLOR = { 1.00, 0.17, 0.10, 1 }
local RECENT_LOOT_SECONDS = 10
local RECENT_LOOT_MAX = 8
local FISHING_LOOT_SECONDS = 10
local LOOT_ITEM_SCORE_MIN = 10
local NODE_MERGE_RADIUS = 0.0025
local CRAFTING_LOOT_MERGE_RADIUS = 0.0065
local TOOLTIP_STACK_RADIUS = CRAFTING_LOOT_MERGE_RADIUS
local TRADE_GOODS_CLASS_ID = 7
local RING_TEXTURE = ASSET_ROOT .. "dock_ring_clean.tga"
local PIN_RING_TEXTURE = ASSET_ROOT .. "gathering_pin_ring.tga"
local WORLD_PIN_RING_TEXTURE = ASSET_ROOT .. "gathering_pin_ring_world.tga"
local MASK_TEXTURE = "Interface\\CHARACTERFRAME\\TempPortraitAlphaMask"
local WHITE_TEXTURE = "Interface\\Buttons\\WHITE8X8"

local COLORS = {
    gold = { 0.95, 0.76, 0.28, 1 },
    text = { 0.92, 0.90, 0.84, 1 },
    muted = { 0.70, 0.70, 0.74, 1 },
    off = { 0.38, 0.38, 0.40, 1 },
    mining = { 0.92, 0.72, 0.36, 1 },
    herbalism = { 0.42, 0.86, 0.45, 1 },
    cooking = { 0.95, 0.55, 0.30, 1 },
    leather = { 0.78, 0.52, 0.32, 1 },
    wood = { 0.58, 0.42, 0.22, 1 },
    fish = { 0.38, 0.70, 0.96, 1 },
}

local GATHER_KINDS = {
    mining = {
        icon = 136248,
        fallbackName = "Mining",
        skillLines = { [186] = true },
        spellIDs = { 2575, 195122, 423341, 471013 },
    },
    herbalism = {
        icon = 136065,
        fallbackName = "Herbalism",
        skillLines = { [182] = true },
        spellIDs = { 2366 },
    },
    cooking = {
        icon = 133971,
        fallbackName = "Cooking",
        skillLines = { [185] = true },
        spellIDs = {},
    },
    leather = {
        icon = 134366,
        fallbackName = "Leather & Hides",
        skillLines = { [165] = true, [393] = true },
        spellIDs = {},
    },
    wood = {
        icon = 134063,
        fallbackName = "Wood",
        skillLines = {},
        spellIDs = {},
    },
    fish = {
        icon = 133889,
        fallbackName = "Fishing",
        skillLines = { [356] = true },
        spellIDs = {},
    },
}

local CRAFTING_LOOT_KINDS = {
    cooking = true,
    leather = true,
    wood = true,
    fish = true,
}

local MINIMAP_HIDDEN_KINDS = {
    cooking = true,
}

local FISHING_SPELL_IDS = {
    [7620] = true,
    [131474] = true,
}

local function isCraftingLootKind(kind)
    return CRAFTING_LOOT_KINDS[kind] == true
end

local SPELL_ID_TO_KIND = {}
local SPELL_NAME_TO_KIND = {}
local spellLookupReady = false

local Runtime = {
    enabled = false,
    providerAdded = false,
    eventFrame = nil,
    updateFrame = nil,
    button = nil,
    optionsFrame = nil,
    minimapPins = {},
    minimapPool = {},
    minimapShownPins = {},
    minimapRefreshToken = 0,
    worldMapRefreshToken = 0,
    worldMapHooked = false,
    worldMapDirty = false,
    pendingCast = nil,
    recentFishingCastAt = nil,
    recentNode = nil,
    recentLootItems = {},
    professions = {},
    professionSummary = "",
}

ns.GatheringNodesRuntime = Runtime

local basePinMixin = type(MapCanvasPinMixin) == "table" and MapCanvasPinMixin or {}
local WorldMapPinMixin = type(CreateFromMixins) == "function" and CreateFromMixins(basePinMixin) or {}
_G.VaultloomGatheringNodeWorldMapPinMixin = WorldMapPinMixin

local WorldMapDataProvider = type(CreateFromMixins) == "function"
    and type(MapCanvasDataProviderMixin) == "table"
    and CreateFromMixins(MapCanvasDataProviderMixin)
    or nil

local function now()
    return type(time) == "function" and time() or 0
end

local function getKindColor(kind)
    return COLORS[kind] or COLORS.gold
end

local function getKindLabel(kind)
    if kind == "mining" then
        return L.FEATURE_GATHERING_NODES_KIND_MINING or "Mining"
    elseif kind == "herbalism" then
        return L.FEATURE_GATHERING_NODES_KIND_HERBALISM or "Herbalism"
    elseif kind == "cooking" then
        return L.FEATURE_GATHERING_NODES_KIND_COOKING or "Cooking"
    elseif kind == "leather" then
        return L.FEATURE_GATHERING_NODES_KIND_LEATHER or "Leather & Hides"
    elseif kind == "wood" then
        return L.FEATURE_GATHERING_NODES_KIND_WOOD or "Wood"
    elseif kind == "fish" then
        return L.FEATURE_GATHERING_NODES_KIND_FISH or "Fish"
    end
    return tostring(kind or "")
end

local function getSpellInfo(spellID)
    spellID = tonumber(spellID)
    if not spellID then
        return nil
    end

    if C_Spell and type(C_Spell.GetSpellInfo) == "function" then
        local ok, info = pcall(C_Spell.GetSpellInfo, spellID)
        if ok and type(info) == "table" then
            return info.name, info.iconID
        elseif ok and type(info) == "string" then
            return info, nil
        end
    end

    if type(GetSpellInfo) == "function" then
        local ok, name, _, icon = pcall(GetSpellInfo, spellID)
        if ok then
            return name, icon
        end
    end

    return nil
end

local FISHING_SPELL_NAMES = {}
local fishingSpellNamesReady = false

local function buildFishingSpellNames()
    if fishingSpellNamesReady then
        return
    end

    for spellID in pairs(FISHING_SPELL_IDS) do
        local spellName = getSpellInfo(spellID)
        if type(spellName) == "string" and spellName ~= "" then
            FISHING_SPELL_NAMES[spellName] = true
        end
    end

    fishingSpellNamesReady = true
end

local function isFishingSpell(spellID, spellName)
    spellID = tonumber(spellID)
    if spellID and FISHING_SPELL_IDS[spellID] then
        return true
    end

    buildFishingSpellNames()
    return type(spellName) == "string" and FISHING_SPELL_NAMES[spellName] == true
end

local function getKindIcon(kind)
    local definition = GATHER_KINDS[kind]
    if type(definition) ~= "table" then
        return 134400
    end

    for _, spellID in ipairs(definition.spellIDs or {}) do
        local _, icon = getSpellInfo(spellID)
        if icon then
            return icon
        end
    end

    return definition.icon or 134400
end

local function buildSpellLookups()
    if spellLookupReady then
        return
    end

    for kind, definition in pairs(GATHER_KINDS) do
        for _, spellID in ipairs(definition.spellIDs or {}) do
            SPELL_ID_TO_KIND[tonumber(spellID)] = kind
            local name = getSpellInfo(spellID)
            if type(name) == "string" and name ~= "" then
                SPELL_NAME_TO_KIND[name] = kind
            end
        end
    end

    spellLookupReady = true
end

local function safeTrimText(value)
    if value == nil then
        return nil
    end

    local valueType = type(value)
    if valueType ~= "string" and valueType ~= "number" then
        return nil
    end

    local okText, text = pcall(tostring, value)
    if not okText or type(text) ~= "string" then
        return nil
    end

    local okTrim, trimmed = pcall(function()
        return text:gsub("^%s+", ""):gsub("%s+$", "")
    end)
    if not okTrim or type(trimmed) ~= "string" then
        return nil
    end
    return trimmed
end

local function lowerText(value)
    value = safeTrimText(value) or ""
    if value == "" then
        return ""
    end

    local ok, lowered = pcall(string.lower, value)
    return ok and lowered or ""
end

local function textContains(text, needle)
    text = lowerText(text)
    needle = lowerText(needle)
    return text ~= "" and needle ~= "" and text:find(needle, 1, true) ~= nil
end

local function simpleHash(text)
    text = tostring(text or "")
    local hash = 5381
    for index = 1, #text do
        hash = ((hash * 33) + text:byte(index)) % 4294967296
    end
    return hash
end

local function roundCoord(value)
    return math.floor((tonumber(value) or 0) * 10000 + 0.5)
end

local function buildNodeKey(kind, name, x, y)
    return string.format("%s:%d:%d:%08x", tostring(kind or "node"), roundCoord(x), roundCoord(y), simpleHash(name))
end

local function getRuntimeSeconds()
    return type(GetTime) == "function" and GetTime() or now()
end

local function getVectorXY(vector)
    if not vector then
        return nil, nil
    end
    if type(vector.GetXY) == "function" then
        local ok, x, y = pcall(vector.GetXY, vector)
        if ok then
            return tonumber(x), tonumber(y)
        end
    end
    return tonumber(vector.x), tonumber(vector.y)
end

local function createVector2D(x, y)
    if type(CreateVector2D) == "function" then
        local ok, vector = pcall(CreateVector2D, x, y)
        if ok and vector then
            return vector
        end
    end
    return { x = x, y = y }
end

local function resolveWorldPosition(mapID, x, y)
    if not C_Map or type(C_Map.GetWorldPosFromMapPos) ~= "function" then
        return nil, nil, nil
    end

    local ok, continentID, worldPosition = pcall(C_Map.GetWorldPosFromMapPos, mapID, createVector2D(x, y))
    if not ok then
        return nil, nil, nil
    end

    local worldX, worldY = getVectorXY(worldPosition)
    if not worldX or not worldY then
        return tonumber(continentID), nil, nil
    end

    return tonumber(continentID), worldX, worldY
end

local function getMapWorldSize(mapID)
    if not C_Map or type(C_Map.GetMapWorldSize) ~= "function" then
        return nil, nil
    end

    local ok, width, height = pcall(C_Map.GetMapWorldSize, mapID)
    width = ok and tonumber(width) or nil
    height = ok and tonumber(height) or nil
    if not width or not height or width <= 0 or height <= 0 then
        return nil, nil
    end

    return width, height
end

local function isPlayerInInstance()
    if type(IsInInstance) ~= "function" then
        return false
    end

    local ok, inInstance = pcall(IsInInstance)
    return ok and inInstance == true
end

local function getPlayerMapSnapshot(reuse)
    if isPlayerInInstance() then
        return nil
    end

    if not C_Map
        or type(C_Map.GetBestMapForUnit) ~= "function"
        or type(C_Map.GetPlayerMapPosition) ~= "function"
    then
        return nil
    end

    local okMap, mapID = pcall(C_Map.GetBestMapForUnit, "player")
    mapID = okMap and tonumber(mapID) or nil
    if not mapID then
        return nil
    end

    local okPosition, position = pcall(C_Map.GetPlayerMapPosition, mapID, "player")
    if not okPosition or not position then
        return nil
    end

    local x, y = getVectorXY(position)
    if not x or not y or x <= 0 or y <= 0 then
        return nil
    end

    local continentID, worldX, worldY = resolveWorldPosition(mapID, x, y)
    local mapWidth, mapHeight = getMapWorldSize(mapID)
    local snapshot = type(reuse) == "table" and reuse or {}
    snapshot.mapID = mapID
    snapshot.x = x
    snapshot.y = y
    snapshot.mapWidth = mapWidth
    snapshot.mapHeight = mapHeight
    snapshot.continentID = continentID
    snapshot.worldX = worldX
    snapshot.worldY = worldY
    return snapshot
end

local function getTooltipNodeName()
    local line = _G and _G.GameTooltipTextLeft1
    local text = line and type(line.GetText) == "function" and line:GetText() or nil
    if type(text) ~= "string" or text == "" then
        return nil
    end
    if type(issecretvalue) == "function" and issecretvalue(text) then
        return nil
    end
    return text
end

local function parseItemLinkFromText(text)
    if type(text) ~= "string" or text == "" then
        return nil
    end

    local itemString, itemName = text:match("|H(item:[^|]+)|h%[([^%]]+)%]|h")
    local itemID = tonumber(itemString and itemString:match("item:(%d+)"))
    if not itemID then
        return nil
    end

    return {
        itemID = itemID,
        itemString = itemString,
        itemLink = string.format("|H%s|h[%s]|h", itemString, itemName or tostring(itemID)),
        itemName = itemName,
    }
end

local function getItemIcon(itemID, itemLink)
    itemID = tonumber(itemID)
    if C_Item and type(C_Item.GetItemIconByID) == "function" and itemID then
        local ok, icon = pcall(C_Item.GetItemIconByID, itemID)
        if ok and icon then
            return icon
        end
    end

    local itemInfo = itemLink or itemID
    if C_Item and type(C_Item.GetItemInfoInstant) == "function" and itemInfo then
        local ok, _, _, _, _, icon = pcall(C_Item.GetItemInfoInstant, itemInfo)
        if ok and icon then
            return icon
        end
    end

    if type(GetItemInfoInstant) == "function" and itemInfo then
        local ok, _, _, _, _, icon = pcall(GetItemInfoInstant, itemInfo)
        if ok and icon then
            return icon
        end
    end

    return nil
end

local function cleanNodeName(name, fallback)
    name = safeTrimText(name) or ""
    if name == "" then
        name = safeTrimText(fallback) or ""
    end
    return name ~= "" and name or nil
end

local function enrichItemInfo(itemInfo)
    if type(itemInfo) ~= "table" or not itemInfo.itemID then
        return nil
    end

    itemInfo.itemIcon = itemInfo.itemIcon or getItemIcon(itemInfo.itemID, itemInfo.itemLink)

    local lookup = itemInfo.itemLink or itemInfo.itemID
    if C_Item and type(C_Item.GetItemInfoInstant) == "function" and lookup then
        local ok, _, itemType, itemSubType, _, icon, classID, subClassID = pcall(C_Item.GetItemInfoInstant, lookup)
        if ok then
            itemInfo.itemType = itemInfo.itemType or itemType
            itemInfo.itemSubType = itemInfo.itemSubType or itemSubType
            itemInfo.itemIcon = itemInfo.itemIcon or icon
            itemInfo.classID = tonumber(itemInfo.classID) or tonumber(classID)
            itemInfo.subClassID = tonumber(itemInfo.subClassID) or tonumber(subClassID)
        end
    elseif type(GetItemInfoInstant) == "function" and lookup then
        local ok, _, itemType, itemSubType, _, icon, classID, subClassID = pcall(GetItemInfoInstant, lookup)
        if ok then
            itemInfo.itemType = itemInfo.itemType or itemType
            itemInfo.itemSubType = itemInfo.itemSubType or itemSubType
            itemInfo.itemIcon = itemInfo.itemIcon or icon
            itemInfo.classID = tonumber(itemInfo.classID) or tonumber(classID)
            itemInfo.subClassID = tonumber(itemInfo.subClassID) or tonumber(subClassID)
        end
    end

    return itemInfo
end

local function getLootSlotItemInfo(slotIndex)
    if type(GetLootSlotLink) ~= "function" then
        return nil
    end

    local okLink, itemLink = pcall(GetLootSlotLink, slotIndex)
    if not okLink or type(itemLink) ~= "string" then
        return nil
    end

    local itemInfo = parseItemLinkFromText(itemLink)
    if not itemInfo then
        return nil
    end

    if type(GetLootSlotInfo) == "function" then
        local okInfo, texture, itemName, quantity = pcall(GetLootSlotInfo, slotIndex)
        if okInfo then
            itemInfo.itemName = cleanNodeName(itemInfo.itemName or itemName, nil)
            itemInfo.itemIcon = itemInfo.itemIcon or texture
            itemInfo.quantity = tonumber(quantity)
        end
    end

    return enrichItemInfo(itemInfo)
end

local function normalizedGatherText(value)
    local text = lowerText(value)
    text = text:gsub("%b()", " ")
    text = text:gsub("[%-_/]", " ")
    text = text:gsub("[%p%c]", " ")
    text = text:gsub("%s+", " ")
    text = text:gsub("^%s+", ""):gsub("%s+$", "")
    return text
end

local COOKING_INGREDIENT_WORDS = {
    "fleisch",
    "meat",
    "fisch",
    "fish",
    "filet",
    "fillet",
    "steak",
    "rippe",
    "rib",
    "brust",
    "breast",
    "flanke",
    "flank",
    "speck",
    "bacon",
    "schinken",
    "ham",
    "wurst",
    "sausage",
    "egg",
    "mehl",
    "flour",
    "gewuerz",
    "spice",
    "pfeffer",
    "pepper",
    "salz",
    "salt",
    "zucker",
    "sugar",
    "honig",
    "honey",
    "milch",
    "milk",
    "kaese",
    "cheese",
    "oel",
    "oil",
    "muschel",
    "clam",
    "krabbe",
    "crab",
    "garnele",
    "shrimp",
    "lachs",
    "salmon",
    "salm",
    "forelle",
    "trout",
    "hering",
    "herring",
    "aal",
    "eel",
    "thunfisch",
    "tuna",
    "makrele",
    "mackerel",
}

local FISH_INGREDIENT_WORDS = {
    "fisch",
    "fish",
    "filet",
    "fillet",
    "lachs",
    "salmon",
    "forelle",
    "trout",
    "hering",
    "herring",
    "aal",
    "eel",
    "thunfisch",
    "tuna",
    "makrele",
    "mackerel",
    "poisson",
    "pez",
    "pescado",
    "pesce",
    "peixe",
    "trucha",
    "arenque",
    "anguila",
    "atun",
    "tonno",
    "truta",
    "enguia",
    "atum",
    "clam",
    "muschel",
    "krabbe",
    "crab",
    "garnele",
    "shrimp",
}

local LEATHER_MATERIAL_WORDS = {
    "leder",
    "haut",
    "haeut",
    "h\195\164ut",
    "balg",
    "fell",
    "pelz",
    "schuppe",
    "leather",
    "hide",
    "hides",
    "pelt",
    "pelts",
    "scale",
    "scales",
    "carapace",
    "chitin",
    "fur",
    "skin",
    "feder",
    "federn",
    "daune",
    "daunen",
    "feather",
    "feathers",
    "plume",
    "plumes",
    "plumage",
    "piuma",
    "piume",
    "pluma",
    "plumas",
    "pena",
    "penas",
    "cuir",
    "peau",
    "cuero",
    "piel",
    "escama",
    "cuoio",
    "pelle",
    "scaglia",
    "couro",
    "pele",
}

local WOOD_MATERIAL_WORDS = {
    "holz",
    "bauholz",
    "stamm",
    "ast",
    "planke",
    "wood",
    "timber",
    "lumber",
    "log",
    "logs",
    "plank",
    "planks",
    "branch",
    "branches",
    "bois",
    "madera",
    "legno",
    "madeira",
    "tronco",
    "planche",
}

local function stripItemQualityDecorations(value)
    local text = tostring(value or "")
    text = text:gsub("|A:[^|]+|a", " ")
    text = text:gsub("|T[^|]+|t", " ")
    text = text:gsub("|c%x%x%x%x%x%x%x%x", "")
    text = text:gsub("|r", "")
    text = text:gsub("|H.-|h%[([^%]]+)%]|h", "%1")
    return text
end

local function getGatherGroupName(value, kind)
    return normalizedGatherText(stripItemQualityDecorations(value))
end

local function getMaterialMatchKey(value, kind)
    local text = getGatherGroupName(value, kind)
    if text == "" then
        return ""
    end

    local words = {}
    for token in text:gmatch("%S+") do
        token = token:gsub("vorkommen$", "")
        token = token:gsub("deposit$", "")
        token = token:gsub("mineral$", "")
        token = token:gsub("vein$", "")
        token = token:gsub("ader$", "")
        token = token:gsub("seam$", "")
        token = token:gsub("erz$", "")
        token = token:gsub("ore$", "")
        if token ~= ""
            and token ~= "vorkommen"
            and token ~= "deposit"
            and token ~= "vein"
            and token ~= "ader"
            and token ~= "seam"
            and token ~= "erz"
            and token ~= "ore"
        then
            words[#words + 1] = token
        end
    end

    return table.concat(words, " ")
end

local function normalizeIconText(value)
    local text = getGatherGroupName(value, nil)
    text = text:gsub("\195\132", "a"):gsub("\195\164", "a")
    text = text:gsub("\195\150", "o"):gsub("\195\182", "o")
    text = text:gsub("\195\156", "u"):gsub("\195\188", "u")
    text = text:gsub("\195\159", "ss")
    return text
end

local function texturePath(folder, fileName)
    return GATHERING_ICON_ROOT .. folder .. "\\" .. fileName
end

local MINING_ICON_RULES = {
    { { "refulgent", "copper" }, "refulgentcopper.tga" },
    { { "glanz", "kupfer" }, "refulgentcopper.tga" },
    { { "brilliant", "silver" }, "brilliantsilver.tga" },
    { { "brillant", "silber" }, "brilliantsilver.tga" },
    { { "brilliant" }, "brilliantsilver.tga" },
    { { "brillant" }, "brilliantsilver.tga" },
    { { "umbral", "tin" }, "umbraltin.tga" },
    { { "umbral", "zinn" }, "umbraltin.tga" },
    { { "bismuth" }, "bismuth.tga" },
    { { "bismut" }, "bismuth.tga" },
    { { "aqirite" }, "aqirite.tga" },
    { { "ironclaw" }, "ironclaw.tga" },
    { { "eisenklaue" }, "ironclaw.tga" },
    { { "karesh" }, "karesh.tga" },
    { { "desolate" }, "karesh.tga" },
    { { "serevite" }, "serevite.tga" },
    { { "draconium" }, "draconium.tga" },
    { { "laestrite" }, "laestrite.tga" },
    { { "phaedrum" }, "phaedrite.tga" },
    { { "oxxein" }, "oxxein.tga" },
    { { "elethium" }, "elethium.tga" },
    { { "solenium" }, "solenium.tga" },
    { { "sinvyr" }, "sinvyr.tga" },
    { { "monelite" }, "monelite.tga" },
    { { "platinum" }, "platinum.tga" },
    { { "storm", "silver" }, "stormsilver.tga" },
    { { "sturm", "silber" }, "stormsilver.tga" },
    { { "osmenite" }, "osmenite.tga" },
    { { "leystone" }, "leystone.tga" },
    { { "ley" }, "leystone.tga" },
    { { "felslate" }, "felslate.tga" },
    { { "teufelsschiefer" }, "felslate.tga" },
    { { "empyrium" }, "empyrium.tga" },
    { { "true", "iron" }, "trueiron.tga" },
    { { "blackrock" }, "blackrock.tga" },
    { { "ghost", "iron" }, "ghostiron.tga" },
    { { "geistereisen" }, "ghostiron.tga" },
    { { "kyparite" }, "kyparite.tga" },
    { { "trillium" }, "white_trillium.tga" },
    { { "pyrite" }, "pyrite.tga" },
    { { "pyrit" }, "pyrite.tga" },
    { { "elementium" }, "elementium.tga" },
    { { "obsidium" }, "obsidian.tga" },
    { { "obsidian" }, "obsidian.tga" },
    { { "cobalt" }, "cobalt.tga" },
    { { "kobalt" }, "cobalt.tga" },
    { { "saronite" }, "saronite.tga" },
    { { "saronit" }, "saronite.tga" },
    { { "titanium" }, "titanium.tga" },
    { { "titan" }, "titanium.tga" },
    { { "khorium" }, "khorium.tga" },
    { { "adamantite" }, "adamantium.tga" },
    { { "adamantit" }, "adamantium.tga" },
    { { "fel", "iron" }, "feliron.tga" },
    { { "teufelseisen" }, "feliron.tga" },
    { { "dark", "iron" }, "darkiron.tga" },
    { { "dunkeleisen" }, "darkiron.tga" },
    { { "truesilver" }, "truesilver.tga" },
    { { "echtsilber" }, "truesilver.tga" },
    { { "thorium" }, "thorium.tga" },
    { { "mithril" }, "mithril.tga" },
    { { "gold" }, "gold.tga" },
    { { "silver" }, "silver.tga" },
    { { "silber" }, "silver.tga" },
    { { "iron" }, "iron.tga" },
    { { "eisen" }, "iron.tga" },
    { { "tin" }, "tin.tga" },
    { { "zinn" }, "tin.tga" },
    { { "copper" }, "copper.tga" },
    { { "kupfer" }, "copper.tga" },
}

local HERB_ICON_RULES = {
    { { "hochenblume" }, "hochenblume.tga" },
    { { "mycobloom" }, "mycobloom.tga" },
    { { "bubble", "poppy" }, "bubblepoppy.tga" },
    { { "saxifrage" }, "saxifrage.tga" },
    { { "writhebark" }, "writhebark.tga" },
    { { "blessing", "blossom" }, "blessing.tga" },
    { { "luredrop" }, "luredrop.tga" },
    { { "orbinid" }, "orbinid.tga" },
    { { "arathor" }, "arathorsspear.tga" },
    { { "phantombloom" }, "phantombloom.tga" },
    { { "argentleaf" }, "argentleaf.tga" },
    { { "azeroot" }, "azeroot.tga" },
    { { "manalily" }, "manalily.tga" },
    { { "sanguithorn" }, "sanguithorn.tga" },
    { { "tranquility" }, "tranquilitybloom.tga" },
    { { "death", "blossom" }, "deathblossom.tga" },
    { { "nightshade" }, "nightshade.tga" },
    { { "marrowroot" }, "marrowroot.tga" },
    { { "vigil", "torch" }, "vigilstorch.tga" },
    { { "rising", "glory" }, "risingglory.tga" },
    { { "widowbloom" }, "widowbloom.tga" },
    { { "anchor", "weed" }, "anchor_weed.tga" },
    { { "riverbud" }, "riverbud.tga" },
    { { "sea", "stalk" }, "seastalk.tga" },
    { { "siren", "pollen" }, "sirens_pollen.tga" },
    { { "star", "moss" }, "star_moss.tga" },
    { { "winter", "kiss" }, "winters_kiss.tga" },
    { { "zinanthid" }, "zinanthid.tga" },
    { { "akunda" }, "akundas_bite.tga" },
    { { "astral", "glory" }, "astralglory.tga" },
    { { "dreamleaf" }, "dreamleaf.tga" },
    { { "felwort" }, "felwort.tga" },
    { { "fjarnskaggl" }, "fjarnskaggl.tga" },
    { { "foxflower" }, "foxflower.tga" },
    { { "starlight", "rose" }, "starlightrose.tga" },
    { { "aethril" }, "aethril.tga" },
    { { "green", "tea" }, "green_tea_leaf.tga" },
    { { "fool", "cap" }, "fools_cap.tga" },
    { { "snow", "lily" }, "snow_lily.tga" },
    { { "silkweed" }, "silkweed.tga" },
    { { "rain", "poppy" }, "rain_poppy.tga" },
    { { "sha" }, "shaherb.tga" },
    { { "cinderbloom" }, "cinderbloom.tga" },
    { { "stormvine" }, "stormvine.tga" },
    { { "heartblossom" }, "heartblossom.tga" },
    { { "twilight", "jasmine" }, "twilightjasmine.tga" },
    { { "whiptail" }, "whiptail.tga" },
    { { "gold", "clover" }, "goldclover.tga" },
    { { "icethorn" }, "icethorn.tga" },
    { { "tiger", "lily" }, "tigerlily.tga" },
    { { "netherbloom" }, "netherbloom.tga" },
    { { "felweed" }, "felweed.tga" },
    { { "dreaming", "glory" }, "dreaming_glory.tga" },
    { { "terocone" }, "terocone.tga" },
    { { "ancient", "lichen" }, "ancient_lichen.tga" },
    { { "mana", "thistle" }, "mana_thistle.tga" },
    { { "nightmare", "vine" }, "nightmare_vine.tga" },
    { { "peacebloom" }, "peacebloom.tga" },
    { { "silverleaf" }, "silverleaf.tga" },
    { { "earthroot" }, "earthroot.tga" },
    { { "mageroyal" }, "mageroyal.tga" },
    { { "briarthorn" }, "briarthorn.tga" },
    { { "stranglekelp" }, "stranglekelp.tga" },
    { { "bruiseweed" }, "bruiseweed.tga" },
    { { "wild", "steelbloom" }, "wild_steelbloom.tga" },
    { { "grave", "moss" }, "grave_moss.tga" },
    { { "kingsblood" }, "kingsblood.tga" },
    { { "liferoot" }, "liferoot.tga" },
    { { "fadeleaf" }, "fadeleaf.tga" },
    { { "goldthorn" }, "goldthorn.tga" },
    { { "khadgar" }, "khadgars_whisker.tga" },
    { { "wintersbite" }, "wintersbite.tga" },
    { { "firebloom" }, "firebloom.tga" },
    { { "purple", "lotus" }, "purple_lotus.tga" },
    { { "arthas" }, "arthas_tears.tga" },
    { { "sungrass" }, "sungrass.tga" },
    { { "blindweed" }, "blindweed.tga" },
    { { "ghost", "mushroom" }, "ghost_mushroom.tga" },
    { { "gromsblood" }, "gromsblood.tga" },
    { { "dreamfoil" }, "dreamfoil.tga" },
    { { "plaguebloom" }, "plaguebloom.tga" },
    { { "icecap" }, "icecap.tga" },
    { { "black", "lotus" }, "black_lotus.tga" },
}

local function iconFromRules(text, folder, rules)
    text = normalizeIconText(text)
    if text == "" then
        return nil
    end

    for _, rule in ipairs(rules) do
        local terms = rule[1]
        local matched = true
        for _, term in ipairs(terms) do
            if not text:find(term, 1, true) then
                matched = false
                break
            end
        end
        if matched then
            return texturePath(folder, rule[2])
        end
    end
    return nil
end

local getNodeIdentityName

local function getNodeFallbackIcon(node)
    if type(node) ~= "table" then
        return nil
    end

    local name = getNodeIdentityName(node) or node.itemName or node.name or node.nodeName
    if node.kind == "mining" then
        return iconFromRules(name, "Mine", MINING_ICON_RULES)
    elseif node.kind == "herbalism" then
        return iconFromRules(name, "Herb", HERB_ICON_RULES)
    end
    return nil
end

function getNodeIdentityName(node)
    if type(node) ~= "table" then
        return nil
    end
    if isCraftingLootKind(node.kind) then
        return cleanNodeName(node.itemName or node.name or node.nodeName, nil)
    end
    return cleanNodeName(node.nodeName or node.name or node.itemName, nil)
end

local function getNodeGroupName(node)
    if type(node) ~= "table" then
        return ""
    end

    local sourceName = getNodeIdentityName(node)
    if sourceName then
        local groupName = getGatherGroupName(sourceName, node.kind)
        node.groupName = groupName ~= "" and groupName or nil
        return groupName
    end

    local groupName = cleanNodeName(node.groupName, nil)
    return groupName and normalizedGatherText(groupName) or ""
end

local function gatherGroupsMatch(first, second)
    first = tostring(first or "")
    second = tostring(second or "")
    if first == "" or second == "" then
        return false
    end
    if first == second then
        return true
    end
    if #first >= 4 and #second >= 4 and (first:find(second, 1, true) or second:find(first, 1, true)) then
        return true
    end
    for firstToken in first:gmatch("%S+") do
        if #firstToken >= 4 then
            for secondToken in second:gmatch("%S+") do
                if #secondToken >= 4 and (firstToken == secondToken or firstToken:find(secondToken, 1, true) or secondToken:find(firstToken, 1, true)) then
                    return true
                end
            end
        end
    end
    return false
end

local function isTradeGoodOrUnknown(itemInfo)
    local classID = tonumber(itemInfo and itemInfo.classID)
    return classID == nil or classID == TRADE_GOODS_CLASS_ID
end

local TRADE_GOOD_SUBCLASS_IDS = {
    leather = {},
}

local function markTradeGoodSubclass(kind, value)
    local subclassID = tonumber(value)
    if subclassID and TRADE_GOOD_SUBCLASS_IDS[kind] then
        TRADE_GOOD_SUBCLASS_IDS[kind][subclassID] = true
    end
end

markTradeGoodSubclass("leather", _G and _G.LE_ITEM_TRADEGOODS_LEATHER)
if Enum and Enum.ItemReagentSubclass then
    markTradeGoodSubclass("leather", Enum.ItemReagentSubclass.Leather)
end

local function textMatchesAnyWord(text, words)
    text = normalizedGatherText(text)
    if text == "" then
        return false
    end
    for _, word in ipairs(words or {}) do
        if text:find(word, 1, true) then
            return true
        end
    end
    return false
end

local function itemLooksLikeCookingIngredient(itemInfo)
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end

    return textMatchesAnyWord(itemInfo and itemInfo.itemName, COOKING_INGREDIENT_WORDS)
        or textMatchesAnyWord(itemInfo and itemInfo.itemSubType, COOKING_INGREDIENT_WORDS)
end

local function itemLooksLikeFish(itemInfo)
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end

    return textMatchesAnyWord(itemInfo and itemInfo.itemName, FISH_INGREDIENT_WORDS)
        or textMatchesAnyWord(itemInfo and itemInfo.itemSubType, FISH_INGREDIENT_WORDS)
end

local function itemLooksLikeLeatherMaterial(itemInfo)
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end

    local subClassID = tonumber(itemInfo and itemInfo.subClassID)
    if subClassID and TRADE_GOOD_SUBCLASS_IDS.leather[subClassID] then
        return true
    end

    return textMatchesAnyWord(itemInfo and itemInfo.itemName, LEATHER_MATERIAL_WORDS)
        or textMatchesAnyWord(itemInfo and itemInfo.itemSubType, LEATHER_MATERIAL_WORDS)
end

local function itemLooksLikeWoodMaterial(itemInfo)
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end

    return textMatchesAnyWord(itemInfo and itemInfo.itemName, WOOD_MATERIAL_WORDS)
        or textMatchesAnyWord(itemInfo and itemInfo.itemSubType, WOOD_MATERIAL_WORDS)
end

local function getCraftingLootKind(itemInfo)
    if itemLooksLikeFish(itemInfo) then
        return "fish"
    elseif itemLooksLikeLeatherMaterial(itemInfo) then
        return "leather"
    elseif itemLooksLikeWoodMaterial(itemInfo) then
        return "wood"
    elseif itemLooksLikeCookingIngredient(itemInfo) then
        return "cooking"
    end
    return nil
end

local function itemLooksLikeGatheredResource(itemInfo, kind, nodeName)
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end

    local itemName = normalizedGatherText(itemInfo and itemInfo.itemName)
    if itemName == "" then
        return false
    end

    if kind == "cooking" then
        return itemLooksLikeCookingIngredient(itemInfo)
    elseif kind == "leather" then
        return itemLooksLikeLeatherMaterial(itemInfo)
    elseif kind == "wood" then
        return itemLooksLikeWoodMaterial(itemInfo)
    elseif kind == "fish" then
        return itemLooksLikeFish(itemInfo)
    end

    local itemGroup = getGatherGroupName(itemName, kind)
    local nodeGroup = getGatherGroupName(nodeName, kind)
    if gatherGroupsMatch(itemGroup, nodeGroup) then
        return true
    end

    if kind == "mining" then
        return itemName:find("erz", 1, true) ~= nil
            or itemName:find("ore", 1, true) ~= nil
            or itemName:find("mineral", 1, true) ~= nil
    elseif kind == "herbalism" then
        return itemName:find("kraut", 1, true) ~= nil
            or itemName:find("herb", 1, true) ~= nil
            or itemName:find("blume", 1, true) ~= nil
            or itemName:find("flower", 1, true) ~= nil
            or itemName:find("lotus", 1, true) ~= nil
            or itemName:find("root", 1, true) ~= nil
            or itemName:find("wurz", 1, true) ~= nil
            or itemName:find("pollen", 1, true) ~= nil
    end

    return false
end

local function isAllowedLootItemForKind(itemInfo, kind, nodeName)
    if type(itemInfo) ~= "table" then
        return false
    end
    if not isTradeGoodOrUnknown(itemInfo) then
        return false
    end
    if kind == "cooking" then
        return itemLooksLikeCookingIngredient(itemInfo)
    elseif kind == "leather" then
        return itemLooksLikeLeatherMaterial(itemInfo)
    elseif kind == "wood" then
        return itemLooksLikeWoodMaterial(itemInfo)
    elseif kind == "fish" then
        return itemLooksLikeFish(itemInfo)
    end
    return itemLooksLikeGatheredResource(itemInfo, kind, nodeName)
end

local function scoreLootItemForNode(itemInfo, node)
    if type(itemInfo) ~= "table" or type(node) ~= "table" then
        return 0
    end
    if not isAllowedLootItemForKind(itemInfo, node.kind, node.nodeName or node.name) then
        return 0
    end

    local score = 0
    local itemName = normalizedGatherText(itemInfo.itemName)
    local nodeName = normalizedGatherText(node.nodeName or node.name)
    local itemKey = getMaterialMatchKey(itemName, node.kind)
    local nodeKey = getMaterialMatchKey(node.nodeName or node.name, node.kind)
    if itemKey ~= "" and nodeKey ~= "" and itemKey == nodeKey then
        score = score + 24
    end

    if score <= 0 and isCraftingLootKind(node.kind) and isAllowedLootItemForKind(itemInfo, node.kind, node.nodeName or node.name) then
        score = score + 8
    end
    if score > 0 and tonumber(itemInfo.classID) == 7 then
        score = score + 2
    end
    if score > 0 and itemInfo.itemIcon then
        score = score + 1
    end

    return score
end

local function isMinimapMouseOver()
    return _G.MinimapCluster and type(_G.MinimapCluster.IsMouseOver) == "function" and _G.MinimapCluster:IsMouseOver()
end

function Runtime:EnsureDB()
    if ns.EnsureDB then
        ns:EnsureDB()
    end

    local db = ns.db or _G.VaultloomNextDB
    if type(db) ~= "table" then
        return nil
    end

    db.gatheringNodes = type(db.gatheringNodes) == "table" and db.gatheringNodes or {}
    db.gatheringNodes.version = tonumber(db.gatheringNodes.version) or 1
    db.gatheringNodes.maps = type(db.gatheringNodes.maps) == "table" and db.gatheringNodes.maps or {}
    db.gatheringNodes.hiddenFilters = type(db.gatheringNodes.hiddenFilters) == "table" and db.gatheringNodes.hiddenFilters or {}
    db.gatheringNodes.characterHiddenFilters = type(db.gatheringNodes.characterHiddenFilters) == "table" and db.gatheringNodes.characterHiddenFilters or {}
    if db.gatheringNodes.displayEnabled == nil then
        db.gatheringNodes.displayEnabled = true
    end
    return db.gatheringNodes
end

function Runtime:GetFilterCharacterKey()
    if ns.GetCurrentCharacterInfo then
        local info = ns:GetCurrentCharacterInfo()
        if type(info) == "table" and type(info.key) == "string" and info.key ~= "" then
            return info.key
        end
    end

    return "current"
end

function Runtime:GetHiddenFilterStore(createIfMissing)
    local db = self:EnsureDB()
    if type(db) ~= "table" then
        return nil
    end

    db.characterHiddenFilters = type(db.characterHiddenFilters) == "table" and db.characterHiddenFilters or {}
    local characterKey = self:GetFilterCharacterKey()
    local store = db.characterHiddenFilters[characterKey]
    local shouldMigrateLegacy = db.hiddenFiltersMigrated ~= true
        and type(db.hiddenFilters) == "table"
        and next(db.hiddenFilters) ~= nil
    if type(store) ~= "table" and createIfMissing ~= true and not shouldMigrateLegacy then
        return nil
    end

    if type(store) ~= "table" then
        store = {}
        db.characterHiddenFilters[characterKey] = store
    end

    if shouldMigrateLegacy then
        for filterKey, hidden in pairs(db.hiddenFilters) do
            if hidden == true and type(filterKey) == "string" and filterKey ~= "" then
                store[filterKey] = true
            end
        end
        db.hiddenFilters = {}
        db.hiddenFiltersMigrated = true
    elseif db.hiddenFiltersMigrated ~= true and createIfMissing == true then
        db.hiddenFiltersMigrated = true
    end

    return store
end

function Runtime:IsDisplayEnabled()
    local db = self:EnsureDB()
    return type(db) == "table" and db.displayEnabled ~= false
end

function Runtime:SetDisplayEnabled(enabled)
    local db = self:EnsureDB()
    if type(db) ~= "table" then
        return false
    end

    db.displayEnabled = enabled == true
    self:RefreshButton()
    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    return db.displayEnabled
end

function Runtime:GetMapBucket(mapID, createIfMissing)
    local db = self:EnsureDB()
    if type(db) ~= "table" then
        return nil
    end

    local key = tostring(tonumber(mapID) or mapID or "")
    if key == "" then
        return nil
    end

    if createIfMissing and type(db.maps[key]) ~= "table" then
        db.maps[key] = {}
    end
    return db.maps[key]
end

local function findNearbyNode(bucket, kind, name, x, y, radius)
    if type(bucket) ~= "table" then
        return nil, nil
    end

    local groupName = getGatherGroupName(name, kind)
    radius = tonumber(radius) or NODE_MERGE_RADIUS
    local radius2 = radius * radius
    local bestKey, bestNode, bestDistance
    for key, node in pairs(bucket) do
        local nodeName = type(node) == "table" and (node.nodeName or node.name) or nil
        local nodeGroup = type(node) == "table" and getNodeGroupName(node) or ""
        local sameGroup = (groupName ~= "" and nodeGroup == groupName) or (groupName == "" and nodeName == name)
        if type(node) == "table" and node.kind == kind and sameGroup then
            local dx = (tonumber(node.x) or 0) - x
            local dy = (tonumber(node.y) or 0) - y
            local distance = (dx * dx) + (dy * dy)
            if distance <= radius2 and (not bestDistance or distance < bestDistance) then
                bestKey = key
                bestNode = node
                bestDistance = distance
            end
        end
    end

    return bestKey, bestNode
end

local function getStoredItemInfo(node)
    if type(node) ~= "table" then
        return nil
    end

    local itemID = tonumber(node.itemID)
    local itemName = cleanNodeName(node.itemName, nil)
    if not itemID and not itemName then
        return nil
    end

    local itemInfo = {
        itemID = itemID,
        itemName = itemName,
        itemIcon = node.itemIcon,
    }
    if itemID and (not itemInfo.itemName or not itemInfo.itemIcon) then
        itemInfo = enrichItemInfo(itemInfo) or itemInfo
    end
    return itemInfo
end

local function clearNodeItemInfo(node)
    if type(node) ~= "table" then
        return
    end

    node.itemID = nil
    node.itemName = nil
    node.itemLink = nil
    node.itemIcon = nil
    node.itemScore = nil
    node.classID = nil
    node.subClassID = nil
    node.name = cleanNodeName(node.nodeName or node.name, getKindLabel(node.kind))
    node.icon = nil
end

local function compactStoredNode(node)
    if type(node) ~= "table" then
        return
    end

    node.count = nil
    node.firstSeen = nil
    node.lastSeen = nil
    node.lastSeenRuntime = nil
    node.itemLink = nil
    node.itemScore = nil
    node.classID = nil
    node.subClassID = nil
    node.sourceSpellID = nil
    node.continentID = nil
    node.worldX = nil
    node.worldY = nil
    node.icon = nil
    node.key = nil
end

local function sanitizeStoredNode(node)
    if type(node) ~= "table" then
        return false
    end

    local kind = GATHER_KINDS[node.kind] and node.kind or nil
    if not kind then
        return false
    end
    node.kind = kind

    local mapID = tonumber(node.mapID)
    local x = tonumber(node.x)
    local y = tonumber(node.y)
    if not mapID or not x or not y then
        return false
    end
    node.mapID = mapID
    node.x = x
    node.y = y

    local itemInfo = getStoredItemInfo(node)
    if itemInfo then
        local craftingLootKind = getCraftingLootKind(itemInfo)
        if craftingLootKind then
            node.kind = craftingLootKind
            node.itemID = tonumber(itemInfo.itemID) or node.itemID
            node.itemName = cleanNodeName(itemInfo.itemName, nil) or node.itemName
            node.itemIcon = itemInfo.itemIcon or node.itemIcon
            node.name = node.itemName or node.name
        elseif not isAllowedLootItemForKind(itemInfo, node.kind, node.nodeName or node.name) then
            clearNodeItemInfo(node)
        elseif scoreLootItemForNode(itemInfo, node) < LOOT_ITEM_SCORE_MIN then
            clearNodeItemInfo(node)
        else
            node.itemID = tonumber(itemInfo.itemID) or node.itemID
            node.itemName = cleanNodeName(itemInfo.itemName, nil) or node.itemName
            node.itemIcon = itemInfo.itemIcon or node.itemIcon
            if node.itemName and node.itemName ~= "" then
                node.name = node.itemName
            end
        end
    end

    node.groupName = getGatherGroupName(getNodeIdentityName(node), node.kind)
    if node.groupName == "" then
        node.groupName = nil
    end
    node.name = cleanNodeName(node.name or node.nodeName, getKindLabel(node.kind))
    compactStoredNode(node)
    return true
end

local function getNodeDisplayIcon(node)
    if type(node) ~= "table" then
        return nil
    end

    return node.itemIcon or getNodeFallbackIcon(node) or getKindIcon(node.kind)
end

local function ensureNodeDisplayIcon(node)
    if type(node) ~= "table" then
        return
    end
    return getNodeDisplayIcon(node)
end

local function getNodeDisplayName(node)
    if type(node) ~= "table" then
        return ""
    end
    return cleanNodeName(stripItemQualityDecorations(node.itemName or node.name or node.nodeName), getKindLabel(node.kind)) or ""
end

local function sortTooltipNodes(nodes, focusedKey)
    local order = { mining = 1, herbalism = 2, leather = 3, wood = 4, fish = 5, cooking = 6, other = 7 }
    table.sort(nodes, function(a, b)
        local aFocused = focusedKey and a.key == focusedKey
        local bFocused = focusedKey and b.key == focusedKey
        if aFocused ~= bFocused then
            return aFocused
        end

        local ao = order[a.kind] or 99
        local bo = order[b.kind] or 99
        if ao ~= bo then
            return ao < bo
        end

        local an = lowerText(getNodeDisplayName(a))
        local bn = lowerText(getNodeDisplayName(b))
        if an ~= bn then
            return an < bn
        end

        if (tonumber(a.y) or 0) ~= (tonumber(b.y) or 0) then
            return (tonumber(a.y) or 0) < (tonumber(b.y) or 0)
        end
        return (tonumber(a.x) or 0) < (tonumber(b.x) or 0)
    end)
end

function Runtime:GetTooltipNodesNear(node)
    if type(node) ~= "table" then
        return {}
    end

    if node.vlCluster == true and type(node.members) == "table" and #node.members > 0 then
        local members = {}
        for _, member in ipairs(node.members) do
            if type(member) == "table" then
                ensureNodeDisplayIcon(member)
                members[#members + 1] = member
            end
        end
        sortTooltipNodes(members, nil)
        return members
    end

    local mapID = tonumber(node.mapID)
    local x = tonumber(node.x)
    local y = tonumber(node.y)
    local bucket = mapID and self:GetMapBucket(mapID, false) or nil
    if type(bucket) ~= "table" or not x or not y then
        return { node }
    end

    local radius = tonumber(TOOLTIP_STACK_RADIUS) or CRAFTING_LOOT_MERGE_RADIUS
    local radius2 = radius * radius
    local nodes = {}
    for key, candidate in pairs(bucket) do
        if type(candidate) == "table" and sanitizeStoredNode(candidate) and self:IsNodeVisible(candidate) then
            local candidateX = tonumber(candidate.x)
            local candidateY = tonumber(candidate.y)
            if candidateX and candidateY then
                local dx = candidateX - x
                local dy = candidateY - y
                if ((dx * dx) + (dy * dy)) <= radius2 then
                    ensureNodeDisplayIcon(candidate)
                    nodes[#nodes + 1] = candidate
                end
            end
        end
    end

    if #nodes == 0 then
        return { node }
    end

    sortTooltipNodes(nodes, node.key)

    return nodes
end

local function getNodeFilterKey(node)
    if type(node) ~= "table" then
        return nil
    end

    local groupName = getNodeGroupName(node)
    if groupName == "" then
        return nil
    end
    return string.format("group:%s:%08x", tostring(node.kind or "node"), simpleHash(lowerText(groupName)))
end

function Runtime:IsNodeVisible(node)
    local key = getNodeFilterKey(node)
    if not key then
        return true
    end

    local db = self:EnsureDB()
    local hidden = type(db) == "table" and self:GetHiddenFilterStore(false) or nil
    return not (hidden and hidden[key] == true)
end

function Runtime:GetNodesForMap(mapID, skipSort)
    local bucket = self:GetMapBucket(mapID, false)
    if type(bucket) ~= "table" then
        return {}
    end

    local nodes = {}
    for key, node in pairs(bucket) do
        if type(node) == "table" and sanitizeStoredNode(node) then
            ensureNodeDisplayIcon(node)
            if self:IsNodeVisible(node) then
                nodes[#nodes + 1] = node
            end
        end
    end

    if skipSort ~= true then
        table.sort(nodes, function(a, b)
            if (a.kind or "") ~= (b.kind or "") then
                return (a.kind or "") < (b.kind or "")
            end
            if (a.name or "") ~= (b.name or "") then
                return (a.name or "") < (b.name or "")
            end
            if (tonumber(a.y) or 0) ~= (tonumber(b.y) or 0) then
                return (tonumber(a.y) or 0) < (tonumber(b.y) or 0)
            end
            return (tonumber(a.x) or 0) < (tonumber(b.x) or 0)
        end)
    end

    return nodes
end

local function buildWorldMapCluster(members)
    if type(members) ~= "table" or #members <= 1 then
        return members and members[1] or nil
    end

    local representative = members[1]
    local xTotal, yTotal = 0, 0
    for _, node in ipairs(members) do
        xTotal = xTotal + (tonumber(node.x) or 0)
        yTotal = yTotal + (tonumber(node.y) or 0)
        if not representative.itemIcon and node.itemIcon then
            representative = node
        end
    end

    return {
        vlCluster = true,
        members = members,
        clusterCount = #members,
        mapID = tonumber(representative.mapID),
        x = xTotal / #members,
        y = yTotal / #members,
        kind = representative.kind,
        name = getNodeDisplayName(representative),
        itemIcon = getNodeDisplayIcon(representative),
    }
end

function Runtime:GetWorldMapNodesForMap(mapID)
    local nodes = self:GetNodesForMap(mapID, false)
    if #nodes <= 1 then
        return nodes
    end

    local radius = math.max(tonumber(WORLD_PIN_CLUSTER_RADIUS) or 0, tonumber(TOOLTIP_STACK_RADIUS) or 0)
    local radius2 = radius * radius
    local used = {}
    local displayNodes = {}

    for index, node in ipairs(nodes) do
        if not used[node] then
            used[node] = true
            local anchorX = tonumber(node.x)
            local anchorY = tonumber(node.y)
            local members = { node }

            if anchorX and anchorY then
                for nextIndex = index + 1, #nodes do
                    local candidate = nodes[nextIndex]
                    if not used[candidate] then
                        local candidateX = tonumber(candidate.x)
                        local candidateY = tonumber(candidate.y)
                        if candidateX and candidateY then
                            local dx = candidateX - anchorX
                            local dy = candidateY - anchorY
                            if ((dx * dx) + (dy * dy)) <= radius2 then
                                used[candidate] = true
                                members[#members + 1] = candidate
                                anchorX = ((anchorX * (#members - 1)) + candidateX) / #members
                                anchorY = ((anchorY * (#members - 1)) + candidateY) / #members
                            end
                        end
                    end
                end
            end

            if #members > 1 then
                displayNodes[#displayNodes + 1] = buildWorldMapCluster(members)
            else
                displayNodes[#displayNodes + 1] = node
            end
        end
    end

    return displayNodes
end

function Runtime:GetTotalNodeCount()
    local db = self:EnsureDB()
    if type(db) ~= "table" or type(db.maps) ~= "table" then
        return 0
    end

    local count = 0
    for _, bucket in pairs(db.maps) do
        if type(bucket) == "table" then
            for _, node in pairs(bucket) do
                if type(node) == "table" then
                    count = count + 1
                end
            end
        end
    end

    return count
end

local function mergeStoredNode(target, source)
    if type(target) ~= "table" or type(source) ~= "table" then
        return
    end

    if source.itemIcon and not target.itemIcon then
        target.itemID = source.itemID or target.itemID
        target.itemName = source.itemName or target.itemName
        target.itemIcon = source.itemIcon or target.itemIcon
        if target.itemName and target.itemName ~= "" then
            target.name = target.itemName
        end
    end
    compactStoredNode(target)
end

function Runtime:SanitizeDatabase()
    local db = self:EnsureDB()
    if type(db) ~= "table" or type(db.maps) ~= "table" then
        return false
    end

    local changed = false
    for _, bucket in pairs(db.maps) do
        if type(bucket) == "table" then
            for _, node in pairs(bucket) do
                if type(node) == "table" then
                    sanitizeStoredNode(node)
                    changed = true
                end
            end
        end
    end

    db.version = 6
    return changed
end

function Runtime:GetNodeCatalog()
    local db = self:EnsureDB()
    if type(db) ~= "table" or type(db.maps) ~= "table" then
        return {}
    end

    local byKey = {}
    local entries = {}
    for _, bucket in pairs(db.maps) do
        if type(bucket) == "table" then
            for key, node in pairs(bucket) do
                if type(node) == "table" and sanitizeStoredNode(node) then
                    ensureNodeDisplayIcon(node)
                    local key = getNodeFilterKey(node)
                    if key then
                        local entry = byKey[key]
                        if not entry then
                            entry = {
                                key = key,
                                kind = node.kind or "other",
                                name = getNodeDisplayName(node),
                                icon = getNodeDisplayIcon(node),
                                nodeCount = 0,
                            }
                            byKey[key] = entry
                            entries[#entries + 1] = entry
                        end
                        entry.nodeCount = entry.nodeCount + 1
                        entry.icon = entry.icon or getNodeDisplayIcon(node)
                    end
                end
            end
        end
    end

    local hidden = self:GetHiddenFilterStore(true) or {}
    for _, entry in ipairs(entries) do
        entry.visible = hidden[entry.key] ~= true
    end

    table.sort(entries, function(a, b)
        local order = { mining = 1, herbalism = 2, leather = 3, wood = 4, fish = 5, cooking = 6, other = 7 }
        local ao = order[a.kind] or 99
        local bo = order[b.kind] or 99
        if ao ~= bo then
            return ao < bo
        end
        return lowerText(a.name) < lowerText(b.name)
    end)

    return entries
end

function Runtime:SetNodeFilterVisible(filterKey, visible)
    if type(filterKey) ~= "string" or filterKey == "" then
        return false
    end

    local db = self:EnsureDB()
    if type(db) ~= "table" then
        return false
    end

    local hiddenFilters = self:GetHiddenFilterStore(true)
    if type(hiddenFilters) ~= "table" then
        return false
    end
    if visible == true then
        hiddenFilters[filterKey] = nil
    else
        hiddenFilters[filterKey] = true
    end

    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
    return true
end

function Runtime:RemoveNode(node)
    if type(node) ~= "table" then
        return false
    end

    local mapID = tonumber(node.mapID)
    local bucket = mapID and self:GetMapBucket(mapID, false) or nil
    if type(bucket) ~= "table" then
        return false
    end

    local key = node.key
    if type(key) ~= "string" or bucket[key] == nil then
        key = nil
        for candidateKey, candidate in pairs(bucket) do
            if candidate == node then
                key = candidateKey
                break
            end
        end
    end
    if not key then
        return false
    end

    local name = getNodeDisplayName(bucket[key])
    bucket[key] = nil
    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
    if self.optionsFrame and self.optionsFrame:IsShown() then
        self:RefreshOptionsFrame()
    end
    if ns.Print then
        ns:Print(string.format(L.FEATURE_GATHERING_NODES_DELETED_FORMAT or "Deleted node: %s", name ~= "" and name or getKindLabel(node.kind)))
    end
    return true
end

function Runtime:RemoveClusterNodes(cluster)
    if type(cluster) ~= "table" or cluster.vlCluster ~= true or type(cluster.members) ~= "table" then
        return self:RemoveNode(cluster)
    end

    local removed = 0
    local firstName
    for _, member in ipairs(cluster.members) do
        if type(member) == "table" then
            local mapID = tonumber(member.mapID)
            local bucket = mapID and self:GetMapBucket(mapID, false) or nil
            local key = member.key
            if type(bucket) == "table" and (type(key) ~= "string" or bucket[key] == nil) then
                key = nil
                for candidateKey, candidate in pairs(bucket) do
                    if candidate == member then
                        key = candidateKey
                        break
                    end
                end
            end
            if type(bucket) == "table" and type(key) == "string" and bucket[key] ~= nil then
                firstName = firstName or getNodeDisplayName(bucket[key])
                bucket[key] = nil
                removed = removed + 1
            end
        end
    end

    if removed <= 0 then
        return false
    end

    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
    if self.optionsFrame and self.optionsFrame:IsShown() then
        self:RefreshOptionsFrame()
    end
    if ns.Print then
        local label = firstName and removed == 1 and firstName
            or string.format("%d %s", removed, L.FEATURE_GATHERING_NODES_PREVIEW_LABEL or "map pins")
        ns:Print(string.format(L.FEATURE_GATHERING_NODES_DELETED_FORMAT or "Deleted node: %s", label))
    end
    return true
end

function Runtime:PropagateItemInfo(kind, nodeName, itemInfo, icon)
    -- Kept as a compatibility stub; each gathered node now updates only itself.
end

function Runtime:AddNode(kind, name, sourceSpellID, itemInfo)
    if self.enabled ~= true then
        return false
    end

    kind = GATHER_KINDS[kind] and kind or nil
    if not kind then
        return false
    end

    name = cleanNodeName(name, getKindLabel(kind))
    if not name then
        return false
    end

    local snapshot = getPlayerMapSnapshot()
    if not snapshot then
        return false
    end

    local bucket = self:GetMapBucket(snapshot.mapID, true)
    if type(bucket) ~= "table" then
        return false
    end

    itemInfo = enrichItemInfo(type(itemInfo) == "table" and itemInfo or nil)
    if itemInfo and not isAllowedLootItemForKind(itemInfo, kind, name) then
        itemInfo = nil
    end
    local itemIcon = itemInfo and (itemInfo.itemIcon or getItemIcon(itemInfo.itemID, itemInfo.itemLink)) or nil
    local itemName = itemInfo and cleanNodeName(itemInfo.itemName, nil) or nil
    local groupName = getGatherGroupName(name, kind)
    local keyName = groupName ~= "" and groupName or (itemName or name)

    local key, node = findNearbyNode(bucket, kind, keyName, snapshot.x, snapshot.y)
    if not key then
        key = buildNodeKey(kind, keyName, snapshot.x, snapshot.y)
    end

    node = type(node) == "table" and node or {}
    node.kind = kind
    node.nodeName = name
    node.name = itemName or node.itemName or node.name or name
    node.groupName = groupName ~= "" and groupName or node.groupName
    node.itemID = itemInfo and tonumber(itemInfo.itemID) or node.itemID
    node.itemName = itemName or node.itemName
    node.mapID = snapshot.mapID
    node.x = snapshot.x
    node.y = snapshot.y
    node.itemIcon = itemIcon or node.itemIcon
    node.sourceSpellID = tonumber(sourceSpellID)
    compactStoredNode(node)
    bucket[key] = node

    self.lastAddedKey = key
    self.recentNode = {
        mapID = snapshot.mapID,
        key = key,
        kind = kind,
        addedAt = getRuntimeSeconds(),
        resolved = itemInfo ~= nil,
    }
    self:ApplyBufferedLootToRecentNode(true)
    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
    return true
end

function Runtime:ApplyItemInfoToRecentNode(itemInfo, deferRefresh)
    local recent = self.recentNode
    if type(recent) ~= "table" then
        return false
    end
    if getRuntimeSeconds() - (tonumber(recent.addedAt) or 0) > 8 then
        return false
    end

    itemInfo = enrichItemInfo(itemInfo)
    if not itemInfo then
        return false
    end

    local bucket = self:GetMapBucket(recent.mapID, false)
    local node = type(bucket) == "table" and bucket[recent.key] or nil
    if type(node) ~= "table" then
        return false
    end

    local score = scoreLootItemForNode(itemInfo, node)
    if score < LOOT_ITEM_SCORE_MIN then
        return false
    end
    if recent.resolved == true and score <= (tonumber(recent.itemScore) or 0) then
        return false
    end

    local icon = itemInfo.itemIcon or getItemIcon(itemInfo.itemID, itemInfo.itemLink)
    node.itemID = itemInfo.itemID
    node.itemName = cleanNodeName(itemInfo.itemName, nil) or node.itemName
    node.itemIcon = icon or node.itemIcon
    node.groupName = getGatherGroupName(getNodeIdentityName(node), node.kind)
    if node.itemName and node.itemName ~= "" then
        node.name = node.itemName
    end
    compactStoredNode(node)
    recent.resolved = true
    recent.itemScore = score

    if deferRefresh ~= true then
        self:RefreshWorldMap()
        self:UpdateMinimapPins(true)
        self:RefreshButton()
    end
    return true
end

function Runtime:ApplyBufferedLootToRecentNode(deferRefresh)
    if isPlayerInInstance() then
        return false
    end

    local recent = self.recentNode
    if type(recent) ~= "table" then
        return false
    end

    local bucket = self:GetMapBucket(recent.mapID, false)
    local node = type(bucket) == "table" and bucket[recent.key] or nil
    if type(node) ~= "table" then
        return false
    end

    local nowSeconds = getRuntimeSeconds()
    local bestItem, bestScore
    for _, itemInfo in ipairs(self.recentLootItems or {}) do
        local age = nowSeconds - (tonumber(itemInfo.seenAt) or 0)
        if age >= 0 and age <= RECENT_LOOT_SECONDS then
            local score = scoreLootItemForNode(itemInfo, node)
            if score >= LOOT_ITEM_SCORE_MIN and (not bestScore or score > bestScore) then
                bestItem = itemInfo
                bestScore = score
            end
        end
    end

    if not bestItem then
        return false
    end

    return self:ApplyItemInfoToRecentNode(bestItem, deferRefresh)
end

function Runtime:IsRecentFishingLoot()
    local seenAt = tonumber(self.recentFishingCastAt)
    if not seenAt then
        return false
    end

    local age = getRuntimeSeconds() - seenAt
    return age >= 0 and age <= FISHING_LOOT_SECONDS
end

function Runtime:AddCraftingLootNode(itemInfo)
    if self.enabled ~= true then
        return false
    end
    if isPlayerInInstance() then
        return false
    end

    itemInfo = enrichItemInfo(itemInfo)
    local kind = getCraftingLootKind(itemInfo)
    if not kind and self:IsRecentFishingLoot() and isTradeGoodOrUnknown(itemInfo) then
        kind = "fish"
    end
    if not kind then
        return false
    end

    local name = cleanNodeName(itemInfo.itemName, nil)
    if not name then
        return false
    end

    local snapshot = getPlayerMapSnapshot()
    if not snapshot then
        return false
    end

    local runtimeNow = getRuntimeSeconds()
    local groupName = getGatherGroupName(name, kind)
    local dedupeKey = string.format(
        "%s:%s:%d:%d:%d",
        tostring(kind),
        tostring(tonumber(itemInfo.itemID) or groupName),
        tonumber(snapshot.mapID) or 0,
        roundCoord(snapshot.x),
        roundCoord(snapshot.y)
    )
    self.recentCraftingLootNodes = type(self.recentCraftingLootNodes) == "table" and self.recentCraftingLootNodes or {}
    if self.recentCraftingLootNodes[dedupeKey] and runtimeNow - self.recentCraftingLootNodes[dedupeKey] < 1.2 then
        return false
    end
    self.recentCraftingLootNodes[dedupeKey] = runtimeNow
    for key, seenAt in pairs(self.recentCraftingLootNodes) do
        if runtimeNow - (tonumber(seenAt) or 0) > 8 then
            self.recentCraftingLootNodes[key] = nil
        end
    end

    local bucket = self:GetMapBucket(snapshot.mapID, true)
    if type(bucket) ~= "table" then
        return false
    end

    local keyName = groupName ~= "" and groupName or name
    local key, node = findNearbyNode(bucket, kind, keyName, snapshot.x, snapshot.y, CRAFTING_LOOT_MERGE_RADIUS)
    if not key then
        key = buildNodeKey(kind, keyName, snapshot.x, snapshot.y)
    end

    local icon = itemInfo.itemIcon or getItemIcon(itemInfo.itemID, itemInfo.itemLink) or getKindIcon(kind)
    node = type(node) == "table" and node or {}
    node.kind = kind
    node.nodeName = name
    node.name = name
    node.groupName = keyName
    node.itemID = tonumber(itemInfo.itemID) or node.itemID
    node.itemName = name
    node.itemIcon = icon
    node.mapID = snapshot.mapID
    node.x = tonumber(node.x) or snapshot.x
    node.y = tonumber(node.y) or snapshot.y
    compactStoredNode(node)
    bucket[key] = node

    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
    if self.optionsFrame and self.optionsFrame:IsShown() then
        self:RefreshOptionsFrame()
    end
    return true
end

function Runtime:RecordLootItem(itemInfo)
    if isPlayerInInstance() then
        return false
    end

    itemInfo = enrichItemInfo(itemInfo)
    if not itemInfo then
        return false
    end

    itemInfo.seenAt = getRuntimeSeconds()
    self.recentLootItems = type(self.recentLootItems) == "table" and self.recentLootItems or {}
    table.insert(self.recentLootItems, 1, itemInfo)
    while #self.recentLootItems > RECENT_LOOT_MAX do
        table.remove(self.recentLootItems)
    end

    local applied = self:ApplyBufferedLootToRecentNode(false)
    if applied then
        return true
    end
    return self:AddCraftingLootNode(itemInfo)
end

function Runtime:ApplyLootItemToRecentNode(text)
    if isPlayerInInstance() then
        return false
    end

    local itemInfo = parseItemLinkFromText(text)
    if not itemInfo then
        return false
    end
    return self:RecordLootItem(itemInfo)
end

function Runtime:ScanLootWindow()
    if isPlayerInInstance() then
        return false
    end

    if type(GetNumLootItems) ~= "function" then
        return false
    end

    local okCount, count = pcall(GetNumLootItems)
    count = okCount and tonumber(count) or 0
    local applied = false
    for slotIndex = 1, count do
        local itemInfo = getLootSlotItemInfo(slotIndex)
        if itemInfo and self:RecordLootItem(itemInfo) then
            applied = true
        end
    end
    return applied
end

local function addNodeTooltipLine(node, compact)
    if type(node) ~= "table" then
        return
    end

    local color = getKindColor(node.kind)
    local name = getNodeDisplayName(node)
    if compact == true then
        GameTooltip:AddLine(name, color[1], color[2], color[3], true)
    else
        GameTooltip:AddLine(name, color[1], color[2], color[3], true)
    end
end

local function addNodeTooltipLines(node)
    if type(node) ~= "table" then
        return
    end

    local nodes = Runtime:GetTooltipNodesNear(node)
    if #nodes <= 1 then
        addNodeTooltipLine(node, false)
        return
    end

    for _, nearbyNode in ipairs(nodes) do
        addNodeTooltipLine(nearbyNode, true)
    end
end

local function setNodeWaypoint(node)
    if type(node) ~= "table" then
        return false
    end

    local mapID = tonumber(node.mapID)
    local x = tonumber(node.x)
    local y = tonumber(node.y)
    if not mapID or not x or not y then
        return false
    end

    if not C_Map
        or type(C_Map.SetUserWaypoint) ~= "function"
        or not UiMapPoint
        or type(UiMapPoint.CreateFromCoordinates) ~= "function"
    then
        if ns.Print then
            ns:Print(L.FEATURE_GATHERING_NODES_WAYPOINT_UNAVAILABLE or "Blizzard waypoint API is not available.")
        end
        return false
    end

    local okPoint, point = pcall(UiMapPoint.CreateFromCoordinates, mapID, x, y)
    if not okPoint or not point then
        return false
    end

    local okWaypoint = pcall(C_Map.SetUserWaypoint, point)
    if okWaypoint and C_SuperTrack and type(C_SuperTrack.SetSuperTrackedUserWaypoint) == "function" then
        pcall(C_SuperTrack.SetSuperTrackedUserWaypoint, true)
    end
    if okWaypoint and ns.Print then
        ns:Print(string.format(L.FEATURE_GATHERING_NODES_WAYPOINT_SET_FORMAT or "Waypoint set: %s", node.name or getKindLabel(node.kind)))
    end

    return okWaypoint == true
end

function ns:SetGatheringNodeWaypoint(node)
    return setNodeWaypoint(node)
end

local function applyIconMask(owner, texture)
    if not owner or not texture or owner.vlIconMask then
        return
    end
    if type(owner.CreateMaskTexture) ~= "function" or type(texture.AddMaskTexture) ~= "function" then
        return
    end

    local mask = owner:CreateMaskTexture(nil, "ARTWORK")
    mask:SetTexture(MASK_TEXTURE, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    mask:SetAllPoints(texture)
    texture:AddMaskTexture(mask)
    owner.vlIconMask = mask
end

local function ensurePinBorder(frame)
    if not frame or type(frame.CreateTexture) ~= "function" then
        return nil
    end
    if frame.vlGatheringPinBorder then
        return frame.vlGatheringPinBorder
    end

    local border = {}
    for _, key in ipairs({ "top", "bottom", "left", "right", "tl", "tr", "bl", "br" }) do
        local texture = frame:CreateTexture(nil, "OVERLAY", nil, 4)
        texture:SetTexture(WHITE_TEXTURE)
        if texture.SetSnapToPixelGrid then
            texture:SetSnapToPixelGrid(false)
        end
        if texture.SetTexelSnappingBias then
            texture:SetTexelSnappingBias(0)
        end
        border[key] = texture
    end

    frame.vlGatheringPinBorder = border
    return border
end

local function applyPinBorder(frame, color, alpha, inset, thickness, corner)
    local border = ensurePinBorder(frame)
    if not border then
        return
    end

    inset = tonumber(inset) or 0
    thickness = tonumber(thickness) or 1
    corner = tonumber(corner) or 2
    color = type(color) == "table" and color or COLORS.gold
    local r, g, b = color[1] or 1, color[2] or 1, color[3] or 1
    alpha = tonumber(alpha) or color[4] or 1

    for _, texture in pairs(border) do
        texture:ClearAllPoints()
        texture:SetColorTexture(r, g, b, alpha)
        texture:Show()
    end

    border.top:SetPoint("TOPLEFT", frame, "TOPLEFT", corner + inset, -inset)
    border.top:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -(corner + inset), -inset)
    border.top:SetHeight(thickness)

    border.bottom:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", corner + inset, inset)
    border.bottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -(corner + inset), inset)
    border.bottom:SetHeight(thickness)

    border.left:SetPoint("TOPLEFT", frame, "TOPLEFT", inset, -(corner + inset))
    border.left:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", inset, corner + inset)
    border.left:SetWidth(thickness)

    border.right:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -inset, -(corner + inset))
    border.right:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -inset, corner + inset)
    border.right:SetWidth(thickness)

    border.tl:SetPoint("TOPLEFT", frame, "TOPLEFT", inset + 1, -(inset + 1))
    border.tr:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -(inset + 1), -(inset + 1))
    border.bl:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", inset + 1, inset + 1)
    border.br:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -(inset + 1), inset + 1)
    border.tl:SetSize(thickness, thickness)
    border.tr:SetSize(thickness, thickness)
    border.bl:SetSize(thickness, thickness)
    border.br:SetSize(thickness, thickness)
end

local function hidePinBorder(frame)
    local border = frame and frame.vlGatheringPinBorder
    if type(border) ~= "table" then
        return
    end
    for _, texture in pairs(border) do
        texture:Hide()
    end
end

local function applyPinTextures(pin, node, size)
    if not pin or type(node) ~= "table" then
        return
    end

    local color = getKindColor(node.kind)
    size = tonumber(size) or WORLD_PIN_SIZE

    if pin.Icon then
        pin.Icon:SetTexture(getNodeDisplayIcon(node) or getKindIcon(node.kind))
        pin.Icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        pin.Icon:SetVertexColor(1, 1, 1, 1)
        pin.Icon:SetSize(math.max(8, size - 5), math.max(8, size - 5))
        pin.Icon:ClearAllPoints()
        pin.Icon:SetPoint("CENTER", 0, 0)
        applyIconMask(pin, pin.Icon)
        pin.Icon:Show()
    end

    if pin.Ring then
        pin.Ring:SetTexture(WORLD_PIN_RING_TEXTURE)
        pin.Ring:SetVertexColor(color[1], color[2], color[3], 0.98)
        pin.Ring:ClearAllPoints()
        pin.Ring:SetAllPoints(pin)
        pin.Ring:Show()
        hidePinBorder(pin)
    else
        applyPinBorder(pin, color, 0.96, 0, 1, 3)
    end

    if pin.Dot then
        pin.Dot:Hide()
    end
end

function WorldMapPinMixin:SetPassThroughButtons()
end

function WorldMapPinMixin:CheckMouseButtonPassthrough()
    return false
end

function WorldMapPinMixin:OnLoad()
    self:SetSize(WORLD_PIN_SIZE, WORLD_PIN_SIZE)
    if self.RegisterForClicks then
        self:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    end
    if self.UseFrameLevelType then
        self:UseFrameLevelType("PIN_FRAME_LEVEL_AREA_POI")
    end
    if self.SetScalingLimits then
        self:SetScalingLimits(1, 1.0, 1.25)
    end
end

function WorldMapPinMixin:OnAcquired(node)
    self.node = node
    self:SetSize(WORLD_PIN_SIZE, WORLD_PIN_SIZE)
    self:SetAlpha(0.94)
    applyPinTextures(self, node, WORLD_PIN_SIZE)
    if self.SetPosition then
        self:SetPosition(tonumber(node and node.x) or 0, tonumber(node and node.y) or 0)
    end
end

function WorldMapPinMixin:OnReleased()
    self.node = nil
end

function WorldMapPinMixin:OnMouseEnter()
    if type(self.node) ~= "table" then
        return
    end
    GameTooltip:SetOwner(self, "ANCHOR_CURSOR_RIGHT")
    addNodeTooltipLines(self.node)
    GameTooltip:Show()
end

function WorldMapPinMixin:OnMouseLeave()
    GameTooltip:Hide()
end

function WorldMapPinMixin:OnClick(button)
    if type(self.node) ~= "table" then
        return
    end
    if button == "LeftButton" then
        setNodeWaypoint(self.node)
    elseif button == "RightButton" then
        if self.node.vlCluster == true then
            Runtime:RemoveClusterNodes(self.node)
        else
            Runtime:RemoveNode(self.node)
        end
    end
end

if WorldMapDataProvider then
    function WorldMapDataProvider:RemoveAllData()
        local map = self.GetMap and self:GetMap() or nil
        if map and type(map.RemoveAllPinsByTemplate) == "function" then
            map:RemoveAllPinsByTemplate(WORLD_MAP_PIN_TEMPLATE)
        end
    end

    function WorldMapDataProvider:RefreshAllData()
        self:RemoveAllData()
        if Runtime.enabled ~= true or Runtime:IsDisplayEnabled() ~= true or isPlayerInInstance() then
            return
        end

        local map = self.GetMap and self:GetMap() or nil
        local mapID = map and map.GetMapID and map:GetMapID() or nil
        if not mapID or not map or type(map.AcquirePin) ~= "function" then
            return
        end

        for _, node in ipairs(Runtime:GetWorldMapNodesForMap(mapID)) do
            map:AcquirePin(WORLD_MAP_PIN_TEMPLATE, node)
        end
    end
end

function Runtime:EnsureWorldMapProvider()
    if not WorldMapDataProvider or not _G.WorldMapFrame then
        return false
    end

    local worldMap = _G.WorldMapFrame
    if self.providerAdded ~= true then
        if not (type(worldMap.dataProviders) == "table" and worldMap.dataProviders[WorldMapDataProvider]) then
            worldMap:AddDataProvider(WorldMapDataProvider)
        end
        self.providerAdded = true
    end
    self:HookWorldMap(worldMap)

    return true
end

function Runtime:HookWorldMap(worldMap)
    if self.worldMapHooked == true or not worldMap then
        return
    end

    self.worldMapHooked = true
    if worldMap.HookScript then
        worldMap:HookScript("OnShow", function()
            if Runtime.enabled == true then
                Runtime:EnsureWorldMapProvider()
                Runtime:RefreshWorldMap()
            end
        end)
    end
    if type(hooksecurefunc) == "function" then
        pcall(hooksecurefunc, worldMap, "OnMapChanged", function()
            if Runtime.enabled == true then
                Runtime:RefreshWorldMap()
            end
        end)
    end
end

function Runtime:RemoveWorldMapProvider()
    if WorldMapDataProvider and WorldMapDataProvider.RemoveAllData then
        WorldMapDataProvider:RemoveAllData()
    end

    local worldMap = _G.WorldMapFrame
    if worldMap
        and type(worldMap.dataProviders) == "table"
        and worldMap.dataProviders[WorldMapDataProvider]
        and type(worldMap.RemoveDataProvider) == "function"
    then
        worldMap:RemoveDataProvider(WorldMapDataProvider)
    end
    self.providerAdded = false
end

function Runtime:RefreshWorldMap()
    if self.enabled ~= true then
        return
    end
    self.worldMapDirty = true
    local worldMap = _G.WorldMapFrame
    if not worldMap or (type(worldMap.IsShown) == "function" and not worldMap:IsShown()) then
        return
    end
    if WorldMapDataProvider and WorldMapDataProvider.RefreshAllData then
        if self:EnsureWorldMapProvider() then
            WorldMapDataProvider:RefreshAllData()
            self.worldMapDirty = false
        end
    end
end

function Runtime:RecycleMinimapPins()
    for key, pin in pairs(self.minimapPins) do
        if pin then
            pin:Hide()
            self.minimapPool[#self.minimapPool + 1] = pin
        end
        self.minimapPins[key] = nil
    end
end

function Runtime:AcquireMinimapPin(key)
    local pin = self.minimapPins[key]
    if pin then
        return pin
    end

    pin = table.remove(self.minimapPool)
    if not pin then
        pin = CreateFrame("Button", nil, Minimap, BACKDROP_TEMPLATE)
        pin:SetSize(MINIMAP_PIN_SIZE, MINIMAP_PIN_SIZE)
        pin:SetFrameLevel((Minimap:GetFrameLevel() or 0) + 7)
        pin:SetFrameStrata(Minimap:GetFrameStrata() or "MEDIUM")
        pin:EnableMouse(true)
        pin:RegisterForClicks("LeftButtonUp", "RightButtonUp")

        pin.ring = pin:CreateTexture(nil, "OVERLAY")
        pin.ring:SetAllPoints(pin)
        pin.ring:SetTexture(PIN_RING_TEXTURE)
        pin.ring:Hide()

        pin:SetScript("OnEnter", function(selfPin)
            if type(selfPin.node) ~= "table" then
                return
            end
            GameTooltip:SetOwner(selfPin, "ANCHOR_LEFT")
            addNodeTooltipLines(selfPin.node)
            GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_MINIMAP_HINT or "Red circles mark saved spots; Blizzard tracking can still show live nodes through the center.", 0.76, 0.82, 0.72, true)
            GameTooltip:Show()
        end)
        pin:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)
        pin:SetScript("OnClick", function(selfPin, button)
            if type(selfPin.node) ~= "table" then
                return
            end
            if button == "LeftButton" then
                setNodeWaypoint(selfPin.node)
            elseif button == "RightButton" then
                Runtime:RemoveNode(selfPin.node)
            end
        end)
    end

    pin:SetParent(Minimap)
    pin:SetFrameLevel((Minimap:GetFrameLevel() or 0) + 7)
    pin:SetFrameStrata(Minimap:GetFrameStrata() or "MEDIUM")
    self.minimapPins[key] = pin
    return pin
end

local function updateNodeWorldPosition(node)
    if type(node) ~= "table" then
        return nil, nil, nil
    end

    local mapID = tonumber(node.mapID)
    local x = tonumber(node.x)
    local y = tonumber(node.y)
    if not mapID or not x or not y then
        return nil, nil, nil
    end

    return resolveWorldPosition(mapID, x, y)
end

local function getNodeMinimapDelta(snapshot, node)
    if type(snapshot) ~= "table" or type(node) ~= "table" then
        return nil, nil
    end

    local nodeX = tonumber(node.x)
    local nodeY = tonumber(node.y)
    if nodeX and nodeY and snapshot.mapWidth and snapshot.mapHeight then
        return (nodeX - snapshot.x) * snapshot.mapWidth, (nodeY - snapshot.y) * snapshot.mapHeight
    end

    local continentID, worldX, worldY = updateNodeWorldPosition(node)
    if continentID and worldX and worldY and continentID == snapshot.continentID and snapshot.worldX and snapshot.worldY then
        return snapshot.worldX - worldX, snapshot.worldY - worldY
    end

    return nil, nil
end

local function getMinimapDrawContext(reuse)
    local mapRadius = C_Minimap and type(C_Minimap.GetViewRadius) == "function" and C_Minimap.GetViewRadius() or 120
    mapRadius = math.max(60, tonumber(mapRadius) or 120)
    local rotateMinimap = C_CVar and type(C_CVar.GetCVar) == "function" and C_CVar.GetCVar("rotateMinimap") == "1"
    local facing = rotateMinimap and type(GetPlayerFacing) == "function" and GetPlayerFacing() or nil

    if rotateMinimap and not facing then
        return nil
    end

    local context = type(reuse) == "table" and reuse or {}
    context.mapRadius = mapRadius
    context.range = mapRadius * MINIMAP_RANGE_MULTIPLIER
    context.width = (Minimap:GetWidth() or 140) / 2
    context.height = (Minimap:GetHeight() or 140) / 2
    context.sinFacing = facing and math.sin(facing) or nil
    context.cosFacing = facing and math.cos(facing) or nil
    context.rotateMinimap = rotateMinimap
    return context
end

local function shouldShowNodeOnMinimap(node)
    return type(node) == "table" and MINIMAP_HIDDEN_KINDS[node.kind] ~= true
end

local function positionMinimapPin(pin, node, snapshot, context)
    if not pin or type(node) ~= "table" or type(snapshot) ~= "table" or type(context) ~= "table" then
        return false
    end

    local xDist, yDist = getNodeMinimapDelta(snapshot, node)
    if not xDist or not yDist then
        return false
    end

    local distance = math.sqrt((xDist * xDist) + (yDist * yDist))
    if distance > context.range then
        return false
    end

    if context.rotateMinimap and context.sinFacing and context.cosFacing then
        local dx, dy = xDist, yDist
        xDist = dx * context.cosFacing + dy * context.sinFacing
        yDist = -dx * context.sinFacing + dy * context.cosFacing
    end

    local diffX = xDist / context.mapRadius
    local diffY = yDist / context.mapRadius
    local distRatio = (distance / context.mapRadius) / 0.90
    local alpha = 1
    if distRatio > 1 then
        diffX = diffX / distRatio
        diffY = diffY / distRatio
        alpha = math.max(0.28, 1.85 - distRatio)
    end

    local color = MINIMAP_TRACK_CIRCLE_COLOR
    pin.node = node
    if pin.icon then
        pin.icon:Hide()
    end
    if pin.ring then
        pin.ring:SetVertexColor(color[1], color[2], color[3], distance <= context.mapRadius and 0.95 or 0.72)
        pin.ring:Show()
        hidePinBorder(pin)
    else
        applyPinBorder(pin, color, distance <= context.mapRadius and 0.95 or 0.72, 1, 2, 4)
    end
    if pin.dot then
        pin.dot:Hide()
    end
    pin:SetSize(MINIMAP_PIN_SIZE, MINIMAP_PIN_SIZE)
    pin:ClearAllPoints()
    pin:SetPoint("CENTER", Minimap, "CENTER", diffX * context.width, -diffY * context.height)
    pin:SetAlpha(alpha)
    pin:Show()
    return true
end

function Runtime:UpdateMinimapPins(force)
    if self.enabled ~= true or self:IsDisplayEnabled() ~= true or not Minimap or not Minimap:IsVisible() then
        self:RecycleMinimapPins()
        self:RefreshMinimapRuntime()
        return
    end

    self.minimapSnapshot = self.minimapSnapshot or {}
    self.minimapContext = self.minimapContext or {}
    local snapshot = getPlayerMapSnapshot(self.minimapSnapshot)
    local context = snapshot and getMinimapDrawContext(self.minimapContext) or nil
    if not snapshot or not context then
        self:RecycleMinimapPins()
        self:RefreshMinimapRuntime()
        return
    end

    local shown = self.minimapShownPins
    if type(shown) ~= "table" then
        shown = {}
        self.minimapShownPins = shown
    else
        for pin in pairs(shown) do
            shown[pin] = nil
        end
    end
    local maxDistanceSquared = context.range * context.range

    local bucket = self:GetMapBucket(snapshot.mapID, false)
    if type(bucket) == "table" then
        for key, node in pairs(bucket) do
            if type(node) == "table" and sanitizeStoredNode(node) and self:IsNodeVisible(node) and shouldShowNodeOnMinimap(node) then
                local xDist, yDist = getNodeMinimapDelta(snapshot, node)
                if xDist and yDist and ((xDist * xDist) + (yDist * yDist)) <= maxDistanceSquared then
                    local pin = self:AcquireMinimapPin(key)
                    if positionMinimapPin(pin, node, snapshot, context) then
                        shown[pin] = true
                    end
                end
            end
        end
    end

    for key, pin in pairs(self.minimapPins) do
        if not shown[pin] then
            pin:Hide()
            pin.node = nil
            self.minimapPool[#self.minimapPool + 1] = pin
            self.minimapPins[key] = nil
        end
    end
    self:RefreshMinimapRuntime()
end

function Runtime:UpdateActiveMinimapPinPositions()
    if self.enabled ~= true or self:IsDisplayEnabled() ~= true or not Minimap or not Minimap:IsVisible() then
        self:RecycleMinimapPins()
        self:RefreshMinimapRuntime()
        return
    end
    if not next(self.minimapPins) then
        self:RefreshMinimapRuntime()
        return
    end

    self.minimapSnapshot = self.minimapSnapshot or {}
    self.minimapContext = self.minimapContext or {}
    local snapshot = getPlayerMapSnapshot(self.minimapSnapshot)
    local context = snapshot and getMinimapDrawContext(self.minimapContext) or nil
    if not snapshot or not context then
        self:RecycleMinimapPins()
        self:RefreshMinimapRuntime()
        return
    end

    local removedAny = false
    for key, pin in pairs(self.minimapPins) do
        if not shouldShowNodeOnMinimap(pin.node) or not positionMinimapPin(pin, pin.node, snapshot, context) then
            pin:Hide()
            pin.node = nil
            self.minimapPool[#self.minimapPool + 1] = pin
            self.minimapPins[key] = nil
            removedAny = true
        end
    end
    if removedAny then
        self:RefreshMinimapRuntime()
    end
end

function Runtime:RefreshMinimapRuntime()
    local displayActive = self.enabled == true and self:IsDisplayEnabled() == true
    if not self.updateFrame then
        return
    end

    local needsFrame = displayActive
        and Minimap
        and Minimap:IsVisible()
    if needsFrame then
        self.updateFrame:Show()
    else
        self.updateFrame:Hide()
    end
end

function Runtime:EnsureUpdateFrame()
    if self.updateFrame then
        return
    end

    self.updateFrame = CreateFrame("Frame")
    self.updateFrame.fullElapsed = 0
    self.updateFrame.positionElapsed = 0
    self.updateFrame:SetScript("OnUpdate", function(frame, elapsed)
        if Runtime.enabled ~= true then
            frame:Hide()
            return
        end
        elapsed = tonumber(elapsed) or 0
        frame.fullElapsed = (tonumber(frame.fullElapsed) or 0) + elapsed
        frame.positionElapsed = (tonumber(frame.positionElapsed) or 0) + elapsed
        if frame.fullElapsed >= MINIMAP_FULL_UPDATE_INTERVAL then
            frame.fullElapsed = 0
            frame.positionElapsed = 0
            Runtime:UpdateMinimapPins(false)
        elseif frame.positionElapsed >= MINIMAP_POSITION_UPDATE_INTERVAL and next(Runtime.minimapPins) then
            frame.positionElapsed = 0
            Runtime:UpdateActiveMinimapPinPositions()
        end
    end)
    self.updateFrame:Hide()
end

function Runtime:ScanProfessions()
    buildSpellLookups()
    self.professions = {}
    self.professionSummary = ""

    if type(GetProfessions) ~= "function" or type(GetProfessionInfo) ~= "function" then
        return
    end

    local kindNames = {}
    for kind, definition in pairs(GATHER_KINDS) do
        kindNames[kind] = {}
        for _, spellID in ipairs(definition.spellIDs or {}) do
            local spellName = getSpellInfo(spellID)
            if type(spellName) == "string" and spellName ~= "" then
                kindNames[kind][#kindNames[kind] + 1] = spellName
            end
        end
    end

    local indices = { GetProfessions() }
    local labels = {}
    for slotIndex = 1, 6 do
        local index = indices[slotIndex]
        if index then
            local name, icon, skillLevel, maxSkillLevel, _, _, skillLineID, _, _, _, skillLineName = GetProfessionInfo(index)
            skillLineID = tonumber(skillLineID)
            for kind, definition in pairs(GATHER_KINDS) do
                local matches = skillLineID and definition.skillLines and definition.skillLines[skillLineID]
                if not matches then
                    for _, kindName in ipairs(kindNames[kind] or {}) do
                        if textContains(name, kindName) or textContains(skillLineName, kindName) then
                            matches = true
                            break
                        end
                    end
                end
                if matches and not self.professions[kind] then
                    self.professions[kind] = {
                        name = name or getKindLabel(kind),
                        icon = icon or getKindIcon(kind),
                        skillLevel = tonumber(skillLevel) or 0,
                        maxSkillLevel = tonumber(maxSkillLevel) or 0,
                    }
                    labels[#labels + 1] = name or getKindLabel(kind)
                end
            end
        end
    end

    self.professionSummary = table.concat(labels, ", ")
end

local function applyOptionsBackdrop(frame, alpha)
    if not frame or not frame.SetBackdrop then
        return
    end
    frame:SetBackdrop({
        bgFile = WHITE_TEXTURE,
        edgeFile = WHITE_TEXTURE,
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    frame:SetBackdropColor(0.025, 0.023, 0.026, alpha or 0.96)
    frame:SetBackdropBorderColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 0.42)
end

local function acquireOptionsLine(frame, index)
    frame.lines = frame.lines or {}
    local line = frame.lines[index]
    if line then
        return line
    end

    line = CreateFrame("Button", nil, frame.scrollChild, BACKDROP_TEMPLATE)
    line:SetSize(348, 25)
    line:RegisterForClicks("LeftButtonUp")
    applyOptionsBackdrop(line, 0.18)

    line.check = CreateFrame("CheckButton", nil, line, "UICheckButtonTemplate")
    line.check:SetSize(22, 22)
    line.check:SetPoint("LEFT", 2, 0)

    line.icon = line:CreateTexture(nil, "ARTWORK")
    line.icon:SetSize(18, 18)
    line.icon:SetPoint("LEFT", line.check, "RIGHT", 2, 0)
    line.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    line.label = line:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    line.label:SetPoint("LEFT", line.icon, "RIGHT", 7, 0)
    line.label:SetPoint("RIGHT", line, "RIGHT", -84, 0)
    line.label:SetJustifyH("LEFT")

    line.count = line:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    line.count:SetPoint("RIGHT", line, "RIGHT", -8, 0)
    line.count:SetJustifyH("RIGHT")

    line.check:SetScript("OnClick", function(selfCheck)
        local owner = selfCheck.ownerLine
        if owner and owner.filterKey then
            Runtime:SetNodeFilterVisible(owner.filterKey, selfCheck:GetChecked() == true)
            owner:SetAlpha(selfCheck:GetChecked() and 1 or 0.52)
        end
    end)
    line:SetScript("OnClick", function(selfLine)
        if selfLine.isHeader or not selfLine.check then
            return
        end
        selfLine.check:Click()
    end)

    line.check.ownerLine = line
    frame.lines[index] = line
    return line
end

local function getOptionsCategoryLabel(kind)
    if kind == "mining" or kind == "herbalism" or isCraftingLootKind(kind) then
        return getKindLabel(kind)
    end
    return L.FEATURE_GATHERING_NODES_OPTIONS_CATEGORY_OTHER or "Other"
end

function Runtime:EnsureOptionsFrame()
    if self.optionsFrame then
        return self.optionsFrame
    end

    local frame = CreateFrame("Frame", OPTIONS_FRAME_NAME, UIParent, BACKDROP_TEMPLATE)
    frame:SetSize(420, 430)
    frame:SetPoint("CENTER", UIParent, "CENTER", 0, 20)
    frame:SetFrameStrata("DIALOG")
    frame:SetFrameLevel(80)
    frame:SetClampedToScreen(true)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    applyOptionsBackdrop(frame, 0.98)

    if type(UISpecialFrames) == "table" then
        UISpecialFrames[#UISpecialFrames + 1] = OPTIONS_FRAME_NAME
    end

    frame.header = CreateFrame("Frame", nil, frame, BACKDROP_TEMPLATE)
    frame.header:SetPoint("TOPLEFT", 10, -10)
    frame.header:SetPoint("TOPRIGHT", -10, -10)
    frame.header:SetHeight(34)
    frame.header:EnableMouse(true)
    frame.header:RegisterForDrag("LeftButton")
    frame.header:SetScript("OnDragStart", function()
        frame:StartMoving()
    end)
    frame.header:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
    end)

    frame.title = frame.header:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    frame.title:SetPoint("LEFT", frame.header, "LEFT", 8, 0)
    frame.title:SetText(L.FEATURE_GATHERING_NODES_OPTIONS_TITLE or "Gathering Filters")

    frame.closeButton = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
    frame.closeButton:SetSize(24, 24)
    frame.closeButton:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
    frame.closeButton:SetScript("OnClick", function()
        frame:Hide()
    end)

    frame.scroll = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
    frame.scroll:SetPoint("TOPLEFT", frame, "TOPLEFT", 18, -54)
    frame.scroll:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -32, 18)
    frame.scrollChild = CreateFrame("Frame", nil, frame.scroll)
    frame.scrollChild:SetSize(350, 1)
    frame.scroll:SetScrollChild(frame.scrollChild)

    frame.empty = frame.scrollChild:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    frame.empty:SetPoint("TOPLEFT", frame.scrollChild, "TOPLEFT", 8, -8)
    frame.empty:SetWidth(330)
    frame.empty:SetJustifyH("LEFT")
    frame.empty:SetText(L.FEATURE_GATHERING_NODES_OPTIONS_EMPTY or "No saved gathering nodes yet.")

    frame:Hide()
    self.optionsFrame = frame
    return frame
end

function Runtime:RefreshOptionsFrame()
    local frame = self:EnsureOptionsFrame()
    if not frame then
        return
    end

    local entries = self:GetNodeCatalog()
    local lineIndex = 0
    local y = -2
    local currentKind

    frame.empty:SetShown(#entries == 0)
    for _, entry in ipairs(entries) do
        if entry.kind ~= currentKind then
            currentKind = entry.kind
            lineIndex = lineIndex + 1
            local header = acquireOptionsLine(frame, lineIndex)
            header.isHeader = true
            header.filterKey = nil
            header:ClearAllPoints()
            header:SetPoint("TOPLEFT", frame.scrollChild, "TOPLEFT", 0, y)
            header:SetHeight(22)
            header:SetAlpha(1)
            if header.SetBackdropColor then
                header:SetBackdropColor(0, 0, 0, 0)
                header:SetBackdropBorderColor(0, 0, 0, 0)
            end
            header.check:Hide()
            header.icon:Hide()
            header.count:Hide()
            header.label:ClearAllPoints()
            header.label:SetPoint("LEFT", header, "LEFT", 4, 0)
            header.label:SetPoint("RIGHT", header, "RIGHT", -6, 0)
            header.label:SetText(getOptionsCategoryLabel(currentKind))
            header.label:SetTextColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 1)
            header:Show()
            y = y - 23
        end

        lineIndex = lineIndex + 1
        local row = acquireOptionsLine(frame, lineIndex)
        row.isHeader = false
        row.filterKey = entry.key
        row:ClearAllPoints()
        row:SetPoint("TOPLEFT", frame.scrollChild, "TOPLEFT", 0, y)
        row:SetHeight(25)
        if row.SetBackdropColor then
            row:SetBackdropColor(0.05, 0.047, 0.052, 0.70)
            row:SetBackdropBorderColor(COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], 0.20)
        end
        row.check:Show()
        row.check:SetChecked(entry.visible == true)
        row.icon:Show()
        row.icon:SetTexture(entry.icon or getKindIcon(entry.kind))
        row.count:Show()
        row.label:ClearAllPoints()
        row.label:SetPoint("LEFT", row.icon, "RIGHT", 7, 0)
        row.label:SetPoint("RIGHT", row, "RIGHT", -84, 0)
        row.label:SetText(entry.name or "")
        row.label:SetTextColor(COLORS.text[1], COLORS.text[2], COLORS.text[3], entry.visible and 1 or 0.54)
        row.count:SetText(string.format(L.FEATURE_GATHERING_NODES_OPTIONS_COUNT_FORMAT or "%d nodes", tonumber(entry.nodeCount) or 0))
        row:SetAlpha(entry.visible and 1 or 0.52)
        row:Show()
        y = y - 27
    end

    for index = lineIndex + 1, #(frame.lines or {}) do
        frame.lines[index]:Hide()
    end
    frame.scrollChild:SetHeight(math.max(1, -y + 8))
end

function Runtime:ToggleOptionsFrame()
    local frame = self:EnsureOptionsFrame()
    if not frame then
        return
    end
    if frame:IsShown() then
        frame:Hide()
    else
        self:RefreshOptionsFrame()
        frame:Show()
    end
end

function Runtime:GetButtonIcon()
    return getKindIcon("mining")
end

function Runtime:EnsureButton()
    if self.button then
        return self.button
    end

    local button = CreateFrame("Button", BUTTON_NAME, UIParent, BACKDROP_TEMPLATE)
    button:SetSize(BUTTON_SIZE, BUTTON_SIZE)
    button:SetFrameStrata("MEDIUM")
    button:SetFrameLevel(25)
    button:SetClampedToScreen(true)
    button:SetMovable(true)
    button:EnableMouse(true)
    button:EnableMouseWheel(true)
    button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    button:RegisterForDrag("LeftButton")

    if ns.ApplyFeaturePosition then
        ns:ApplyFeaturePosition(button, BUTTON_POSITION_KEY, { point = "CENTER", relativePoint = "CENTER", x = 380, y = -48 })
    else
        button:SetPoint("CENTER", UIParent, "CENTER", 380, -48)
    end
    if ns.ApplyFeatureScale then
        ns:ApplyFeatureScale(button, BUTTON_SCALE_KEY, BUTTON_SCALE_MIN, BUTTON_SCALE_MAX, 1)
    end

    button.shadow = button:CreateTexture(nil, "BACKGROUND", nil, 1)
    button.shadow:SetPoint("TOPLEFT", 5, -5)
    button.shadow:SetPoint("BOTTOMRIGHT", -5, 5)
    button.shadow:SetTexture("Interface\\Buttons\\WHITE8X8")
    button.shadow:SetColorTexture(0, 0, 0, 0.56)
    if type(button.CreateMaskTexture) == "function" and type(button.shadow.AddMaskTexture) == "function" then
        button.shadowMask = button:CreateMaskTexture(nil, "ARTWORK")
        button.shadowMask:SetTexture(MASK_TEXTURE, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        button.shadowMask:SetAllPoints(button.shadow)
        button.shadow:AddMaskTexture(button.shadowMask)
    end

    button.icon = button:CreateTexture(nil, "ARTWORK")
    button.icon:SetPoint("TOPLEFT", 5, -5)
    button.icon:SetPoint("BOTTOMRIGHT", -5, 5)
    button.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    applyIconMask(button, button.icon)

    button.activeGlow = button:CreateTexture(nil, "BACKGROUND", nil, 2)
    button.activeGlow:SetPoint("TOPLEFT", -3, 3)
    button.activeGlow:SetPoint("BOTTOMRIGHT", 3, -3)
    button.activeGlow:SetTexture(RING_TEXTURE)
    button.activeGlow:SetBlendMode("ADD")

    button.highlight = button:CreateTexture(nil, "HIGHLIGHT")
    button.highlight:SetPoint("TOPLEFT", -1, 1)
    button.highlight:SetPoint("BOTTOMRIGHT", 1, -1)
    button.highlight:SetTexture(RING_TEXTURE)
    button.highlight:SetVertexColor(1, 0.92, 0.70, 0.70)
    button.highlight:SetAlpha(0)

    button.ring = button:CreateTexture(nil, "OVERLAY")
    button.ring:SetPoint("TOPLEFT", -1, 1)
    button.ring:SetPoint("BOTTOMRIGHT", 1, -1)
    button.ring:SetTexture(RING_TEXTURE)

    button:SetScript("OnClick", function(_, clickedButton)
        if clickedButton == "RightButton" then
            Runtime:ToggleOptionsFrame()
        else
            Runtime:SetDisplayEnabled(not Runtime:IsDisplayEnabled())
        end
    end)
    button:SetScript("OnDragStart", function(selfButton)
        if InCombatLockdown and InCombatLockdown() then
            return
        end
        selfButton:StartMoving()
    end)
    button:SetScript("OnDragStop", function(selfButton)
        selfButton:StopMovingOrSizing()
        if ns.StoreFeaturePosition then
            ns:StoreFeaturePosition(selfButton, BUTTON_POSITION_KEY)
        end
    end)
    button:SetScript("OnEnter", function(selfButton)
        if selfButton.highlight then
            selfButton.highlight:SetAlpha(0.62)
        end
        local active = Runtime:IsDisplayEnabled()
        GameTooltip:SetOwner(selfButton, "ANCHOR_RIGHT")
        GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_BUTTON_TITLE or "Gathering Nodes", COLORS.gold[1], COLORS.gold[2], COLORS.gold[3], true)
        GameTooltip:AddLine(active and (L.FEATURE_GATHERING_NODES_BUTTON_ON or "Pins on") or (L.FEATURE_GATHERING_NODES_BUTTON_OFF or "Pins off"), COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(string.format(L.FEATURE_GATHERING_NODES_BUTTON_COUNT_FORMAT or "%d saved nodes", Runtime:GetTotalNodeCount()), 0.76, 0.82, 0.72, true)
        if Runtime.professionSummary and Runtime.professionSummary ~= "" then
            GameTooltip:AddLine(Runtime.professionSummary, 0.68, 0.78, 0.92, true)
        end
        GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_BUTTON_CLICK or "Left click: show or hide map pins", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_BUTTON_FILTERS or "Right click: filters", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_BUTTON_DRAG or "Left drag: move", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:AddLine(L.FEATURE_GATHERING_NODES_BUTTON_WHEEL or "Shift + mouse wheel: size", COLORS.text[1], COLORS.text[2], COLORS.text[3], true)
        GameTooltip:Show()
    end)
    button:SetScript("OnLeave", function(selfButton)
        if selfButton.highlight then
            selfButton.highlight:SetAlpha(0)
        end
        GameTooltip:Hide()
    end)
    button:SetScript("OnMouseWheel", function(selfButton, delta)
        if type(IsShiftKeyDown) == "function" and not IsShiftKeyDown() then
            return
        end
        if not (ns.GetFeatureScale and ns.ClampNumber and ns.StoreFeatureScale) then
            return
        end

        local nextScale = ns:GetFeatureScale(BUTTON_SCALE_KEY, BUTTON_SCALE_MIN, BUTTON_SCALE_MAX, 1)
        if delta > 0 then
            nextScale = nextScale + BUTTON_SCALE_STEP
        elseif delta < 0 then
            nextScale = nextScale - BUTTON_SCALE_STEP
        end
        nextScale = ns:ClampNumber(nextScale, BUTTON_SCALE_MIN, BUTTON_SCALE_MAX)
        selfButton:SetScale(nextScale)
        ns:StoreFeatureScale(BUTTON_SCALE_KEY, nextScale, BUTTON_SCALE_MIN, BUTTON_SCALE_MAX)
    end)

    button:Hide()
    self.button = button
    return button
end

function Runtime:RefreshButton()
    if self.enabled ~= true then
        if self.button then
            self.button:Hide()
        end
        return
    end

    local button = self:EnsureButton()
    if ns.ApplyFeatureScale then
        ns:ApplyFeatureScale(button, BUTTON_SCALE_KEY, BUTTON_SCALE_MIN, BUTTON_SCALE_MAX, 1)
    end

    local active = self:IsDisplayEnabled()
    local color = active and COLORS.gold or COLORS.off
    button.icon:SetTexture(self:GetButtonIcon())
    if type(button.icon.SetDesaturated) == "function" then
        button.icon:SetDesaturated(not active)
    end
    button.icon:SetVertexColor(1, 1, 1, active and 1 or 0.56)
    button.ring:SetVertexColor(color[1], color[2], color[3], active and 0.96 or 0.72)
    button.activeGlow:SetVertexColor(color[1], color[2], color[3], active and 0.44 or 0.12)
    button:SetAlpha(active and 1 or 0.82)
    button:Show()
end

local function getValidPendingNodeName(kind, spellName, name)
    name = cleanNodeName(name, nil)
    if not name or name == spellName or name == getKindLabel(kind) then
        return nil
    end
    return name
end

function Runtime:CapturePendingTargetFromTooltip()
    local pending = self.pendingCast
    if type(pending) ~= "table" or pending.nodeName then
        return false
    end
    if isPlayerInInstance() then
        self.pendingCast = nil
        return false
    end
    if isMinimapMouseOver() then
        return false
    end

    local tooltipName = getValidPendingNodeName(pending.kind, pending.spellName, getTooltipNodeName())
    if not tooltipName then
        return false
    end

    pending.nodeName = tooltipName
    pending.nodeNameFromTooltip = true
    return true
end

function Runtime:CommitPendingCast()
    local pending = self.pendingCast
    if type(pending) ~= "table" then
        return
    end
    if isPlayerInInstance() then
        self.pendingCast = nil
        return
    end

    self:CapturePendingTargetFromTooltip()
    self.pendingCast = nil
    local name = getValidPendingNodeName(pending.kind, pending.spellName, pending.nodeName)
    if not name then
        return
    end

    self:AddNode(pending.kind, name, pending.spellID)
end

function Runtime:HandleSpellStarted(unit, target, castGUID, spellID)
    if unit ~= "player" then
        return
    end
    if isPlayerInInstance() then
        self.pendingCast = nil
        return
    end

    buildSpellLookups()
    spellID = tonumber(spellID)
    local spellName = getSpellInfo(spellID)
    if isFishingSpell(spellID, spellName) then
        self.recentFishingCastAt = getRuntimeSeconds()
    end
    local kind = SPELL_ID_TO_KIND[spellID]
    if not kind and type(spellName) == "string" then
        local okKind, lookupKind = pcall(function()
            return SPELL_NAME_TO_KIND[spellName]
        end)
        kind = okKind and lookupKind or nil
    end
    if not kind then
        self.pendingCast = nil
        return
    end

    self.pendingCast = {
        kind = kind,
        spellID = spellID,
        spellName = spellName,
        nodeName = getValidPendingNodeName(kind, spellName, target),
        castGUID = castGUID,
        startedAt = GetTime and GetTime() or 0,
    }
    self:CapturePendingTargetFromTooltip()
end

function Runtime:HandleSpellFinished(unit, castGUID, spellID)
    if unit ~= "player" or type(self.pendingCast) ~= "table" then
        return
    end
    if isPlayerInInstance() then
        self.pendingCast = nil
        return
    end

    spellID = tonumber(spellID)
    if self.pendingCast.castGUID and castGUID and self.pendingCast.castGUID ~= castGUID then
        return
    end
    if self.pendingCast.spellID and spellID and self.pendingCast.spellID ~= spellID then
        return
    end

    self:CapturePendingTargetFromTooltip()
    self:CommitPendingCast()
end

function Runtime:ClearPendingCast(unit, castGUID, spellID)
    if unit == nil or unit == "player" then
        self.pendingCast = nil
        if isFishingSpell(spellID) then
            self.recentFishingCastAt = nil
        end
    end
end

function Runtime:EnsureEventFrame()
    if self.eventFrame then
        return
    end

    self.eventFrame = CreateFrame("Frame")
    self.eventFrame:SetScript("OnEvent", function(_, event, ...)
        if Runtime.enabled ~= true then
            return
        end

        if event == "UNIT_SPELLCAST_SENT" then
            Runtime:HandleSpellStarted(...)
        elseif event == "UNIT_SPELLCAST_SUCCEEDED" or event == "UNIT_SPELLCAST_STOP" then
            Runtime:HandleSpellFinished(...)
        elseif event == "UNIT_SPELLCAST_FAILED" or event == "UNIT_SPELLCAST_INTERRUPTED" then
            Runtime:ClearPendingCast(...)
        elseif event == "CURSOR_CHANGED" then
            Runtime:CapturePendingTargetFromTooltip()
        elseif event == "UI_ERROR_MESSAGE" then
            Runtime:ApplyBufferedLootToRecentNode(false)
        elseif event == "CHAT_MSG_LOOT" then
            local text = ...
            Runtime:ApplyLootItemToRecentNode(text)
        elseif event == "LOOT_OPENED" or event == "LOOT_READY" or event == "LOOT_SLOT_CLEARED" then
            Runtime:ScanLootWindow()
        elseif event == "SKILL_LINES_CHANGED" then
            Runtime:ScanProfessions()
            Runtime:RefreshButton()
        elseif event == "PLAYER_ENTERING_WORLD" then
            Runtime:ScanProfessions()
            Runtime:RefreshButton()
            Runtime:EnsureWorldMapProvider()
            Runtime:RefreshWorldMap()
            Runtime:UpdateMinimapPins(true)
        elseif event == "ADDON_LOADED" then
            local loadedName = ...
            if loadedName ~= addonName and loadedName ~= "Blizzard_WorldMap" then
                return
            end
            Runtime:EnsureWorldMapProvider()
            Runtime:RefreshWorldMap()
        else
            Runtime:RefreshWorldMap()
            Runtime:UpdateMinimapPins(true)
        end
    end)
end

local function safeRegisterEvent(frame, eventName)
    if frame and type(eventName) == "string" and eventName ~= "" then
        pcall(frame.RegisterEvent, frame, eventName)
    end
end

local function safeRegisterUnitEvent(frame, eventName)
    if frame and type(eventName) == "string" and eventName ~= "" then
        if type(frame.RegisterUnitEvent) == "function" then
            pcall(frame.RegisterUnitEvent, frame, eventName, "player")
        else
            pcall(frame.RegisterEvent, frame, eventName)
        end
    end
end

function Runtime:RegisterEvents()
    self:EnsureEventFrame()
    if not self.eventFrame then
        return
    end

    self.eventFrame:UnregisterAllEvents()
    safeRegisterEvent(self.eventFrame, "ADDON_LOADED")
    safeRegisterEvent(self.eventFrame, "PLAYER_ENTERING_WORLD")
    safeRegisterEvent(self.eventFrame, "SKILL_LINES_CHANGED")
    safeRegisterEvent(self.eventFrame, "CURSOR_CHANGED")
    safeRegisterEvent(self.eventFrame, "UI_ERROR_MESSAGE")
    safeRegisterEvent(self.eventFrame, "CHAT_MSG_LOOT")
    safeRegisterEvent(self.eventFrame, "LOOT_OPENED")
    safeRegisterEvent(self.eventFrame, "LOOT_READY")
    safeRegisterEvent(self.eventFrame, "LOOT_SLOT_CLEARED")
    safeRegisterEvent(self.eventFrame, "ZONE_CHANGED_NEW_AREA")
    safeRegisterEvent(self.eventFrame, "MINIMAP_UPDATE_ZOOM")
    safeRegisterEvent(self.eventFrame, "CVAR_UPDATE")
    safeRegisterEvent(self.eventFrame, "UNIT_SPELLCAST_SENT")
    safeRegisterUnitEvent(self.eventFrame, "UNIT_SPELLCAST_SUCCEEDED")
    safeRegisterUnitEvent(self.eventFrame, "UNIT_SPELLCAST_STOP")
    safeRegisterUnitEvent(self.eventFrame, "UNIT_SPELLCAST_FAILED")
    safeRegisterUnitEvent(self.eventFrame, "UNIT_SPELLCAST_INTERRUPTED")
end

function Runtime:SetEnabled(enabled)
    enabled = enabled == true
    self.enabled = enabled
    self.pendingCast = nil

    if not enabled then
        if self.eventFrame then
            self.eventFrame:UnregisterAllEvents()
        end
        if self.updateFrame then
            self.updateFrame:Hide()
        end
        self:RecycleMinimapPins()
        self:RemoveWorldMapProvider()
        if self.optionsFrame then
            self.optionsFrame:Hide()
        end
        self:RefreshButton()
        return
    end

    self:EnsureDB()
    self:SanitizeDatabase()
    self:ScanProfessions()
    self:RegisterEvents()
    self:EnsureUpdateFrame()
    self:RefreshMinimapRuntime()
    self:EnsureWorldMapProvider()
    self:RefreshWorldMap()
    self:UpdateMinimapPins(true)
    self:RefreshButton()
end

ns:RegisterFeatureDefinition(FEATURE_ID, {
    id = FEATURE_ID,
    order = 47,
    category = "world_map",
    title = function() return L.FEATURE_GATHERING_NODES_TITLE or "Gathering Nodes" end,
    description = function() return L.FEATURE_GATHERING_NODES_DESCRIPTION or "Builds your own mining, herbalism, leather, wood, fish, and cooking ingredient database while you gather." end,
    note = function() return L.FEATURE_GATHERING_NODES_NOTE or "Saves a node when your character gathers from it, then shows small markers on the world map and minimap. The mini button only shows or hides pins; collection continues while the feature is enabled." end,
    previewLabel = function() return L.FEATURE_GATHERING_NODES_PREVIEW_LABEL or "Map Pins" end,
    previewNote = function() return L.FEATURE_GATHERING_NODES_PREVIEW_NOTE or "World map uses Blizzard item icons with a small clean frame; minimap uses hollow tracking circles." end,
    requiresReload = false,
    apply = function(isEnabled)
        Runtime:SetEnabled(isEnabled == true)
    end,
})
