--[[
    ITEM-CATALOGUE SORTING RULES - DEVELOPMENT TOOL ONLY

    This is intentionally the longest Lua file in the project. Dragonwilds'
    names and metadata contain real exceptions, so keeping the current game's
    decisions explicit is easier to audit than hiding them in clever code.

    Where to make the usual changes:
      ITEM_OVERRIDES             Move one known ITEM_ID to a specific heading.
      HIDDEN_IDS / HIDDEN_WORDS  Remove obsolete or development assets.
      *_ORDER tables             Change the order of catalogue headings.
      Vocabulary tables          Classify consistently named groups.
      LEARNABLE_TARGET_EXCEPTIONS
                                 Pair oddly named patterns/vestiges with gear.

    Classification precedence:
      1. Hide obsolete/development assets.
      2. Respect authoritative Quest, Rune, and Ammo tabs from items.json.
      3. Apply exact ITEM_ID overrides for genuine naming outliers.
      4. Use structured category/equipment metadata.
      5. Use small name/ITEM_ID vocabularies for mixed categories.

    Sorting happens only when a developer explicitly enables scanner.lua.
    None of these rules run during normal gameplay.
]]

local Sorting = {}

local PRIMARY_ORDER = {
    Food = 1,
    Resources = 2,
    Crafting = 3,
    Gear = 4,
    Magic = 5,
    Building = 6,
    Quest = 7,
    Mounts = 8,
    Misc = 9,
}

local FAMILY_ORDER = {
    -- Food
    Fish = 10,
    Meat = 20,
    Fruit = 30,
    Vegetables = 40,
    Eggs = 42,
    Flour = 44,
    Maple = 46,
    PreparedFood = 50,
    Tea = 60,
    Water = 70,
    Herbs = 80,

    -- Resources
    Seeds = 100,
    TreeSeeds = 110,
    Plants = 140,

    -- Crafting
    Ore = 200,
    Bars = 210,
    Salvage = 220,
    Wood = 230,
    Planks = 240,
    Fibres = 250,
    Stone = 260,
    Glass = 270,
    Fuel = 280,
    Leather = 290,
    Cloth = 300,
    Thread = 310,
    Bones = 320,
    MonsterParts = 330,
    Gems = 340,
    UpgradeMaterials = 350,
    Keys = 360,
    Components = 370,

    -- Gear
    Armour = 400,
    Weapons = 410,
    Tools = 420,
    Ammunition = 430,
    FishingBait = 440,
    Jewellery = 450,

    -- Magic
    RuneMaterials = 500,
    Runes = 510,
    Potions = 520,
    Tomes = 530,

    -- Building
    Plans = 600,
    Patterns = 610,
    Vestiges = 620,

    -- Quest / Misc
    Quest = 700,

    -- Mounts
    Mounts = 750,

    Packs = 800,
    Utility = 810,
    Currency = 820,
    Misc = 900,
}

local SUBGROUP_ORDER = {
    Fish = { Raw = 10, Cooked = 20, Dish = 30, Burnt = 40 },
    Meat = { Raw = 10, Cooked = 20, Dish = 30, Burnt = 40 },
    Fruit = { Raw = 10, Cooked = 20, Dish = 30, Burnt = 40 },
    Vegetables = { Raw = 10, Cooked = 20, Dish = 30, Burnt = 40 },
    Armour = { Head = 10, Body = 20, Legs = 30, Cape = 40, Shields = 50 },
    Ammunition = { Arrows = 10, Bolts = 20 },
}

local DETAIL_ORDER = {
    Ranged = 10,
    Melee = 20,
    Wizard = 30,
    Other = 40,
}

-- Only genuine semantic outliers belong here. Keys are lowercase ITEM_IDs.
local ITEM_OVERRIDES = {
    -- Raw plants versus similarly named food/crafting items.
    item_resources_cactus_pipe = { "Resources", "Plants" },
    item_resources_fibers_cactus = { "Crafting", "Fibres" },
    item_resources_flax = { "Crafting", "Fibres" },
    item_resources_weed_desert = { "Crafting", "Fibres" },
    item_swampweed = { "Crafting", "Fibres" },
    item_resources_mushroom = { "Food", "Vegetables", "Raw" },
    item_resources_steak_cactus = { "Food", "Vegetables", "Raw" },

    -- Quest items which happen to look like ordinary food.
    item_resources_fish_raw_unusualtrout_quest = { "Quest", "Quest" },

    -- Dish names whose main ingredient is not stated in the visible name.
    ["item_consumable_berrydrizzle_pan-seared"] = { "Food", "Fish", "Dish" },
    ["item_consumable_stir-fry"] = { "Food", "Fish", "Dish" },
    item_consumable_undead_delight = { "Food", "Fish", "Dish" },
    item_consumable_eggs_royale = { "Food", "Fish", "Dish" },
    item_consumable_herbbaked_trout = { "Food", "Fish", "Dish" },
    item_consumable_sauteed_sardines = { "Food", "Fish", "Dish" },
    item_consumable_pie_swabbie = { "Food", "Fish", "Dish" },
    item_consumable_roastdinner = { "Food", "Meat", "Dish" },
    item_consumable_steakandeggs = { "Food", "Meat", "Dish" },
    item_consumable_redberry_roast_rat = { "Food", "Meat", "Dish" },
    item_consumable_sausage_squeaking = { "Food", "Meat", "Dish" },
    item_consumable_fryup_cheeky = { "Food", "Meat", "Dish" },
    item_consumable_steak_cactus_cooked = { "Food", "Vegetables", "Cooked" },
    item_consumable_steak_cactus_burnt = { "Food", "Vegetables", "Burnt" },
    item_consumable_umbral_kebab = { "Food", "Vegetables", "Dish" },
    item_consumable_omelette_pungent = { "Food", "Vegetables", "Dish" },
    item_consumable_iconic_pie_wild = { "Food", "Vegetables", "Dish" },

    -- Ingredients and their directly processed food stay together.
    item_resources_egg = { "Food", "Eggs" },
    item_consumable_egg_burnt = { "Food", "Eggs" },
    item_consumable_egg_fried = { "Food", "Eggs" },

    item_resources_wheat = { "Food", "Flour" },
    item_resources_ground_wheat = { "Food", "Flour" },
    item_consumable_bread = { "Food", "Flour" },
    item_consumable_bread_burnt = { "Food", "Flour" },

    item_resources_maple_sap = { "Food", "Maple" },
    item_consumable_syrup_maple = { "Food", "Maple" },
    item_consumable_syrup_maple_burnt = { "Food", "Maple" },
    item_consumable_toastie_maple = { "Food", "Maple" },

    -- Swamp Tar is an adhesive precursor, not general-purpose fuel.
    item_resources_swamp_tar = { "Crafting", "Components" },
}

local HIDDEN_IDS = {
    -- Duplicate of ITEM_Consumable_Stew_Meat in this catalogue.
    item_consumable_v3_stew_meat = true,
}

local HIDDEN_WORDS = {
    "test",
    "deprecated",
    "depreciated", -- spelling used by one game asset/category
    "deleted",
    "inactive",
    "remove",
    "debug",
    "snake",       -- assets are not present in the current playable game
}

local FOOD_FISH = {
    "fish", "anchovies", "beltfish", "catfish", "eel", "herring",
    "krill", "lobster", "monkfish", "salmon", "sardine", "shrimp",
    "sardines", "sole", "trout", "bass",
}

local FOOD_MEAT = {
    "meat", "beef", "steak", "haunch", "flank", "fillet", "sausage", "rat",
}

local FOOD_FRUIT = {
    "fruit", "berry", "berries", "cadavaberry", "dwellberry", "redberry",
    "peach", "watermelon",
}

local FOOD_VEGETABLES = {
    "vegetable", "veggie", "cabbage", "carrot", "cactus", "mushroom",
    "onion", "potato", "pumpkin", "tomato", "veg ball", "vegan",
}

local FOOD_DISHES = {
    "broth", "cake", "crunchies", "delight", "dinner", "fryup", "kebab",
    "fish fry", "mixed grill", "omelette", "pie", "pizza", "royale", "sandwich",
    "skewer", "skewers", "soup", "stew", "stir fry", "stuffed", "toastie", "veg ball",
}

local MONSTER_PARTS = {
    "antler", "ashes", "blood", "ectoplasm", "fang", "feather", "fleece",
    "feathers", "fur", "heart", "hide", "horn", "ichor", "scale", "shell", "skin",
    "spine", "tooth", "visage", "wing",
}

local function lower(value)
    return type(value) == "string" and value:lower() or ""
end

local function normalize(value)
    value = lower(value):gsub("[^%w]+", " ")
    value = value:gsub("%s+", " ")
    value = value:gsub("^%s+", ""):gsub("%s+$", "")
    return value
end

local function padded(value)
    local text = normalize(value)
    return text == "" and " " or (" " .. text .. " ")
end

local function has(text, phrase)
    phrase = normalize(phrase)
    return phrase ~= "" and text:find(" " .. phrase .. " ", 1, true) ~= nil
end

local function has_any(text, phrases)
    for _, phrase in ipairs(phrases) do
        if has(text, phrase) then
            return true
        end
    end
    return false
end

local function starts_with(value, prefix)
    return value:sub(1, #prefix) == prefix
end

local function category_suffix(category)
    return category:match("^[^/]+/(.+)$") or ""
end

local ARMOUR_SLOTS = {
    head = "Head",
    body = "Body",
    legs = "Legs",
    cape = "Cape",
}

local WEAPON_TYPES = {
    abyssalwhip = "AbyssalWhip",
    bow = "Bow",
    crossbow = "Crossbow",
    crystalbow = "Bow",
    dagger = "Dagger",
    fireoil = "FireOil",
    granitemaul = "Hammer",
    greataxe = "GreatAxe",
    greatsword = "GreatSword",
    hammer = "Hammer",
    mace = "Mace",
    scimitar = "Scimitar",
    staff = "Staff",
    staffoflight = "Staff",
    sword = "Sword",
}

local function result(primary, family, subgroup, detail)
    return {
        primary = primary,
        family = family,
        subgroup = subgroup or "",
        detail = detail or "",
    }
end

local function override_result(values)
    return result(values[1], values[2], values[3], values[4])
end

local function context(item)
    local id = lower(item.id)
    local name = lower(item.name)
    local category = lower(item.category)
    local path = lower(item.sourcePath)

    return {
        id = id,
        name = name,
        category = category,
        equipment = lower(item.equipment),
        tab = lower(item._tab),
        subject = padded(id .. " " .. name),
        full = padded(id .. " " .. name .. " " .. category .. " " .. path),
    }
end

local function is_hidden(ctx)
    return HIDDEN_IDS[ctx.id] == true
        or starts_with(ctx.name, "armour_t4_cape_")
        or has_any(ctx.full, HIDDEN_WORDS)
end

local function is_pack(ctx)
    if starts_with(ctx.category, "tools/") then
        return true
    end

    return has(ctx.subject, "reward pack")
        or has(ctx.subject, "refund pack")
        or has(ctx.subject, "plan pack")
        or has(ctx.subject, "bird nest")
        or has(ctx.subject, "scarecrow scavenging")
        or has(ctx.subject, "finished farming tutorial pack")
        or starts_with(ctx.id, "item_consumable_pack_")
        or ctx.id:find("rewardpack", 1, true) ~= nil
end

local function classify_building(ctx)
    local relevant = ctx.category == "plans"
        or ctx.category == "vestiges"
        or starts_with(ctx.id, "da_consumable_plan_")
        or starts_with(ctx.id, "da_consumable_pattern_")
        or starts_with(ctx.id, "da_consumable_vestige_")

    if not relevant then
        return nil
    end

    if ctx.id:find("_vestige_", 1, true) then
        return result("Building", "Vestiges")
    elseif ctx.id:find("_pattern_", 1, true) then
        return result("Building", "Patterns")
    end

    return result("Building", "Plans")
end

local TOOL_CATEGORIES = {
    ["materials/pickaxe"] = "Pickaxes",
    ["materials/hatchet"] = "LoggingAxes",
    ["materials/fishingrod"] = "FishingRods",
    ["materials/fishingnet"] = "FishingNets",
    ["materials/coarsenet"] = "FishingNets",
    ["materials/linennet"] = "FishingNets",
    ["materials/mysticnet"] = "FishingNets",
    ["materials/woolnet"] = "FishingNets",
    ["materials/shovel"] = "Spades",
    ["materials/torch"] = "Torches",
    ["materials/tools"] = "Tools",
    ["materials/bucket"] = "Buckets",
}

local WEAPON_CATEGORIES = {
    ["materials/dagger"] = "Dagger",
    ["materials/greatsword"] = "GreatSword",
    ["materials/mace"] = "Mace",
    ["materials/scarabstaff"] = "Staff",
}

-- The current armour IDs consistently reuse the set name across Head/Body/Legs.
-- Keeping this explicit makes the novice-facing combat-role split predictable.
local ARMOUR_SET_ROLES = {
    -- Melee sets
    adamant = "Melee",
    black = "Melee",
    bronze = "Melee",
    iron = "Melee",
    mithril = "Melee",
    obsidian = "Melee",
    obsidianhelmet = "Melee",
    paladin = "Melee",
    reinforced = "Melee",
    skeleton = "Melee",
    steel = "Melee",
    white = "Melee",

    -- Ranged sets
    blackranger = "Ranged",
    bluedragonhide = "Ranged",
    greendragonhide = "Ranged",
    hardleather = "Ranged",
    kalphitecarapace = "Ranged",
    leather = "Ranged",
    ranger = "Ranged",
    reddragon = "Ranged",
    skeletonranger = "Ranged",
    studdedleather = "Ranged",
    wildarcher = "Ranged",

    -- Wizard sets
    darkmage = "Wizard",
    dragonkinmage = "Wizard",
    infinity = "Wizard",
    linen = "Wizard",
    lunargaroumage = "Wizard",
    mystic = "Wizard",
    necromancer = "Wizard",
    splitbark = "Wizard",
    wizard = "Wizard",
    zamorak = "Wizard",
}

local function armour_set_suffix(id)
    return id:match("^item_armour_t%d+_head_(.+)$")
        or id:match("^item_armour_t%d+_body_(.+)$")
        or id:match("^item_armour_t%d+_legs_(.+)$")
        or id:match("^item_armour_head_(.+)$")
        or id:match("^item_armour_body_(.+)$")
        or id:match("^item_armour_legs_(.+)$")
end

local function armour_role(ctx, slot)
    if slot == "Shields" then
        return "Melee"
    end

    if slot == "Cape" then
        if ctx.id:find("avas_", 1, true)
            or ctx.id:find("skillcape_ranged", 1, true) then
            return "Ranged"
        elseif ctx.id:find("skillcape_magic", 1, true)
            or ctx.id:find("skillcape_runecrafting", 1, true) then
            return "Wizard"
        elseif ctx.id:find("skillcape_attack", 1, true)
            or ctx.id == "item_cape_fire" then
            return "Melee"
        end
        return "Other"
    end

    local set = armour_set_suffix(ctx.id)
    return (set and ARMOUR_SET_ROLES[set]) or "Other"
end

local function inferred_weapon_type(ctx)
    local types = {
        "abyssal whip", "crossbow", "great axe", "great sword", "greataxe",
        "greatsword", "scimitar", "dagger", "hammer", "longbow", "mace",
        "staff", "sword", "bow", "club", "whip",
    }

    for _, weapon in ipairs(types) do
        if has(ctx.subject, weapon) then
            local compact = weapon:gsub(" ", "")
            return WEAPON_TYPES[compact]
                or (compact:sub(1, 1):upper() .. compact:sub(2))
        end
    end

    return "Other"
end

local function classify_gear(ctx)
    if starts_with(ctx.category, "armour/") then
        local slot = category_suffix(ctx.category)
        slot = ARMOUR_SLOTS[slot] or slot
        return result("Gear", "Armour", slot, armour_role(ctx, slot))
    end

    if ctx.equipment == "head" or ctx.equipment == "body"
        or ctx.equipment == "legs" or ctx.equipment == "cape" then
        local slot = ctx.equipment:sub(1, 1):upper() .. ctx.equipment:sub(2)
        return result("Gear", "Armour", slot, armour_role(ctx, slot))
    end

    if ctx.category == "materials/shield" or starts_with(ctx.id, "item_shield_") then
        return result("Gear", "Armour", "Shields", armour_role(ctx, "Shields"))
    end

    if starts_with(ctx.category, "weapons/") then
        local weapon = category_suffix(ctx.category)
        if weapon == "main" or weapon == "" then
            weapon = inferred_weapon_type(ctx)
        else
            weapon = WEAPON_TYPES[weapon] or weapon
        end
        return result("Gear", "Weapons", weapon)
    end

    if WEAPON_CATEGORIES[ctx.category] then
        return result("Gear", "Weapons", WEAPON_CATEGORIES[ctx.category])
    end

    if starts_with(ctx.id, "item_masterworks_")
        or starts_with(ctx.id, "item_dagger_")
        or starts_with(ctx.id, "item_club_") then
        return result("Gear", "Weapons", inferred_weapon_type(ctx))
    end

    if ctx.tab == "ammo" or ctx.category == "materials/ammo"
        or starts_with(ctx.id, "item_ammo_") then
        local subgroup = has(ctx.subject, "bolt") and "Bolts" or "Arrows"
        return result("Gear", "Ammunition", subgroup)
    end

    if ctx.category == "materials/baits"
        or starts_with(ctx.id, "item_fishbait_")
        or starts_with(ctx.id, "item_fishingbait_") then
        return result("Gear", "FishingBait")
    end

    if ctx.category == "materials/jewellery" or ctx.category == "materials/jewelry"
        or ctx.equipment == "jewellery" or starts_with(ctx.id, "item_trinket_") then
        return result("Gear", "Jewellery")
    end

    local tool = TOOL_CATEGORIES[ctx.category]
    if tool then
        if starts_with(ctx.id, "item_wateringcan_") then
            tool = "WateringCans"
        end
        return result("Gear", "Tools", tool)
    end

    local tool_prefixes = {
        { "item_pickaxe_", "Pickaxes" },
        { "item_logging_axe_", "LoggingAxes" },
        { "item_fishingrod_", "FishingRods" },
        { "item_fishingnet_", "FishingNets" },
        { "item_wateringcan_", "WateringCans" },
        { "item_spade_", "Spades" },
        { "item_secateurs_", "Secateurs" },
        { "item_torch", "Torches" },
        { "item_bucket_", "Buckets" },
    }

    for _, values in ipairs(tool_prefixes) do
        if starts_with(ctx.id, values[1]) then
            return result("Gear", "Tools", values[2])
        end
    end

    return nil
end

local function classify_magic(ctx)
    if starts_with(ctx.id, "item_consumable_tome_")
        or ctx.category == "consumables/tomes"
        or ctx.category == "consumables/quest" then
        return result("Magic", "Tomes")
    end

    if starts_with(ctx.id, "item_consumable_potion_")
        or ctx.id:find("potion", 1, true) ~= nil
        or has(ctx.subject, "potion") then
        return result("Magic", "Potions")
    end

    if starts_with(ctx.id, "item_rune_") then
        if has(ctx.subject, "essence") then
            return result("Magic", "RuneMaterials")
        end
        return result("Magic", "Runes")
    end

    if ctx.category == "materials/magic" or ctx.category == "materials/animaorb"
        or has(ctx.subject, "wild anima") or has(ctx.subject, "cursed essence") then
        return result("Magic", "RuneMaterials")
    end

    return nil
end

local function food_subgroup(ctx, is_raw)
    if has(ctx.subject, "burnt") then
        return "Burnt"
    elseif is_raw then
        return "Raw"
    elseif has_any(ctx.subject, FOOD_DISHES) then
        return "Dish"
    end
    return "Cooked"
end

local function classify_food(ctx)
    if ctx.category == "materials/seeds" or ctx.category == "materials/treeplanting"
        or has(ctx.subject, "seed") then
        return nil
    end

    if ctx.category == "materials/herb" then
        return result("Food", "Herbs")
    end

    if has(ctx.subject, "tea") or has(ctx.subject, "infusion") then
        return result("Food", "Tea")
    end

    if has(ctx.subject, "water") then
        return result("Food", "Water")
    end

    local raw = starts_with(ctx.id, "item_resources_")
        and not starts_with(ctx.id, "item_consumable_")

    if has_any(ctx.subject, FOOD_FISH) then
        return result("Food", "Fish", food_subgroup(ctx, raw))
    end

    if has_any(ctx.subject, FOOD_MEAT) then
        return result("Food", "Meat", food_subgroup(ctx, raw))
    end

    if has_any(ctx.subject, FOOD_VEGETABLES) then
        return result("Food", "Vegetables", food_subgroup(ctx, raw))
    end

    if has_any(ctx.subject, FOOD_FRUIT) then
        local plain_fruit = starts_with(ctx.id, "item_consumable_fruit_")
            and not has_any(ctx.subject, { "burnt", "dried" })
        return result("Food", "Fruit", food_subgroup(ctx, raw or plain_fruit))
    end

    if starts_with(ctx.category, "consumables/") then
        return result("Food", "PreparedFood")
    end

    return nil
end

local function classify_resources(ctx)
    if ctx.category == "materials/treeplanting" or has(ctx.subject, "tree seed") then
        return result("Resources", "TreeSeeds")
    end

    if ctx.category == "materials/seeds" or has(ctx.subject, "seed") then
        return result("Resources", "Seeds")
    end

    if ctx.id == "item_resources_egg" then
        return result("Resources", "Eggs")
    end

    if has(ctx.subject, "flour") or ctx.id == "item_resources_ground_wheat" then
        return result("Resources", "Flour")
    end

    if ctx.category == "materials/plant" then
        if has(ctx.subject, "log") or has(ctx.subject, "wood") then
            return nil
        end
        return result("Resources", "Plants")
    end

    return nil
end

local function classify_crafting(ctx)
    if has_any(ctx.subject, {
        "fibre", "fibres", "fiber", "fibers", "fibrous", "flax", "desert weed", "swamp weed"
    }) then
        return result("Crafting", "Fibres")
    end

    if has(ctx.subject, "ore") then
        return result("Crafting", "Ore")
    end

    if has_any(ctx.subject, { "bar", "ingot" }) then
        return result("Crafting", "Bars")
    end

    if has(ctx.subject, "salvage")
        or (ctx.category == "materials/metal" and has_any(ctx.subject, { "scrap", "scraps" })) then
        return result("Crafting", "Salvage")
    end

    if has(ctx.subject, "plank") then
        return result("Crafting", "Planks")
    end

    if has_any(ctx.subject, { "log", "wood", "bark", "timber" }) then
        return result("Crafting", "Wood")
    end

    if has(ctx.subject, "leather") then
        return result("Crafting", "Leather")
    end

    if has_any(ctx.subject, { "cloth", "fabric", "weave" }) then
        return result("Crafting", "Cloth")
    end

    if has_any(ctx.subject, { "thread", "rope", "string", "twine" }) then
        return result("Crafting", "Thread")
    end

    if ctx.category == "materials/components" then
        return result("Crafting", "Components")
    end

    if has_any(ctx.subject, { "bone", "bones", "bonemeal" }) then
        return result("Crafting", "Bones")
    end

    if ctx.category == "materials/gem" or has_any(ctx.subject, {
        "diamond", "dragonstone", "emerald", "jade", "opal", "sapphire", "topaz"
    }) then
        return result("Crafting", "Gems")
    end

    if ctx.category == "materials/glass" or has(ctx.subject, "glass") then
        return result("Crafting", "Glass")
    end

    if has_any(ctx.subject, { "charcoal", "coal", "fuel pellet", "tar" }) then
        return result("Crafting", "Fuel")
    end

    if has_any(ctx.subject, MONSTER_PARTS) then
        return result("Crafting", "MonsterParts")
    end

    if has_any(ctx.subject, { "adhesive", "device", "limbs", "oil", "powder", "shrapnel", "wax" }) then
        return result("Crafting", "Components")
    end

    if has_any(ctx.subject, { "stone", "granite", "limestone", "sandstone", "clay", "gypsum", "obsidian" })
        or ctx.category == "materials/mineral" then
        return result("Crafting", "Stone")
    end

    if has_any(ctx.subject, { "upgrade core", "upgrade shard", "ascension core", "ascension shard" }) then
        return result("Crafting", "UpgradeMaterials")
    end

    if ctx.category == "materials/key" or has(ctx.subject, " key")
        or ctx.id:find("_key_", 1, true) ~= nil then
        return result("Crafting", "Keys")
    end

    if starts_with(ctx.category, "materials/") or ctx.category == "materials" then
        return result("Crafting", "Components")
    end

    return nil
end

local function classify_mounts(ctx)
    -- Mount unlock consumables. The current FModel supplements use
    -- Consumables/Mounts, while the ID check also catches verified mount
    -- assets if an upstream catalogue uses a less-specific category.
    if ctx.category == "consumables/mounts"
        or starts_with(ctx.id, "da_consumabe_mount_")
        or starts_with(ctx.id, "da_consumable_mount_") then
        return result("Mounts", "Mounts")
    end

    return nil
end


local function classify_misc(ctx)
    if has_any(ctx.subject, { "coin", "currency", "chit", "soul fragment", "infernal fragment" }) then
        return result("Misc", "Currency")
    end

    if starts_with(ctx.id, "item_consumable_") then
        return result("Misc", "Utility")
    end

    return result("Misc", "Misc")
end

local function classify_currency(ctx)
    if ctx.category == "materials/currency"
        or has_any(ctx.subject, { "coin", "currency", "chit", "soul fragment", "infernal fragment" }) then
        return result("Misc", "Currency")
    end
    return nil
end

local function classify_utility(ctx)
    if ctx.category == "materials/misc" and starts_with(ctx.id, "item_consumable_") then
        return result("Misc", "Utility")
    end
    return nil
end

function Sorting.classify(item)
    local ctx = context(item)

    if is_hidden(ctx) then
        return nil
    end

    -- These tabs are authoritative and must not be reinterpreted by names.
    if ctx.tab == "quest" then
        return result("Quest", "Quest")
    elseif ctx.tab == "rune" then
        return result("Magic", "Runes")
    elseif ctx.tab == "ammo" then
        local subgroup = ctx.category == "bolts" and "Bolts" or "Arrows"
        return result("Gear", "Ammunition", subgroup)
    end

    local override = ITEM_OVERRIDES[ctx.id]
    if override then
        return override_result(override)
    end

    local classified = classify_building(ctx)
        or classify_gear(ctx)

    if classified then
        return classified
    end

    if is_pack(ctx) then
        return result("Misc", "Packs")
    end

    return classify_mounts(ctx)
        or classify_magic(ctx)
        or classify_food(ctx)
        or classify_resources(ctx)
        or classify_currency(ctx)
        or classify_utility(ctx)
        or classify_crafting(ctx)
        or classify_misc(ctx)
end

-- A small exception list covers the genuine spelling/name mismatches between a
-- learnable asset and the gear it unlocks. The regular ID forms are resolved
-- below, directly against the current items.json catalogue.
local LEARNABLE_TARGET_EXCEPTIONS = {
    da_consumable_vestige_weapon_sword_chieftains = "item_sword_chieftans",
    da_consumable_vestige_weapon_club_swingslash = "item_club_swingslash",
    da_consumable_vestige_weapon_staff_grievingmoon = "item_staff_garou",
    da_consumable_vestige_weapon_longbow_adventurers = "item_longbow_wood",
    da_consumable_vestige_weapon_longbow_hunterstag = "item_longbow_garou",
    da_consumable_vestige_armour_head_sack = "item_armour_head_bagofnoggin",
    da_consumable_vestige_armour_t7_legs_kalpitecarapace = "item_armour_t7_legs_kalphitecarapace",
    da_consumable_vestige_armour_t7_body_kalpitecarapace = "item_armour_t7_body_kalphitecarapace",
    da_consumable_vestige_armour_t7_head_kalpitecarapace = "item_armour_t7_head_kalphitecarapace",
    da_consumable_vestige_armour_t7_head_obsidian = "item_armour_t7_head_obsidianhelmet",
    da_consumable_vestige_trinket_symbol_unholy = "item_trinket_unholy_symbol",
    da_consumable_vestige_weapon_staff_pharaohsceptre = "item_staff_pharaohssceptre",
    da_consumable_vestige_weapon_staff_scarab = "item_scarabstaff",
    da_consumable_vestige_weapon_club_skullsplitter = "item_mace_skullsplitter",
    da_consumable_vestige_tool_shovel_undead = "item_spade_t5_undead",
    da_consumable_vestige_armour_head_sack_spectral = "item_armour_head_spectralbagofnoggin",
    da_consumable_vestige_weapon_shield_hoplite = "item_shield_skeleton",
    da_consumable_vestige_cape_shadowscale = "item_cape_skeleton",
}

local function existing_target(by_id, id)
    return id and by_id[id] and id or nil
end

local function learnable_target_id(id, by_id)
    local exception = LEARNABLE_TARGET_EXCEPTIONS[id]
    if exception then
        return existing_target(by_id, exception)
    end

    local suffix = id:match("^da_consumable_pattern_armor_cape_(.+)$")
    if suffix then
        return existing_target(by_id, "item_cape_" .. suffix)
    end

    suffix = id:match("^da_consumable_vestige_cape_(.+)$")
    if suffix then
        return existing_target(by_id, "item_cape_" .. suffix)
    end

    suffix = id:match("^da_consumable_vestige_armour_cape_menaphite_(.+)$")
    if suffix then
        return existing_target(by_id, "item_cape_menaphitecape_" .. suffix)
    end

    suffix = id:match("^da_consumable_vestige_armour_cape_(.+)$")
    if suffix then
        return existing_target(by_id, "item_cape_" .. suffix)
    end

    local armour_slots = { head = true, body = true, legs = true }
    local slot, set = id:match("^da_consumable_vestige_armour_t7_([^_]+)_(.+)$")
    if slot and armour_slots[slot] then
        return existing_target(by_id, "item_armour_t7_" .. slot .. "_" .. set)
    end

    slot, set = id:match("^da_consumable_vestige_armour_([^_]+)_(.+)$")
    if slot and armour_slots[slot] then
        local pattern = "^item_armour_t%d+_" .. slot .. "_" .. set .. "$"
        for candidate in pairs(by_id) do
            if candidate:match(pattern) then
                return candidate
            end
        end
        return existing_target(by_id, "item_armour_" .. slot .. "_" .. set)
    end

    suffix = id:match("^da_consumable_vestige_trinket_(.+)$")
    if suffix then
        return existing_target(by_id, "item_trinket_iconic_" .. suffix)
    end

    suffix = id:match("^da_consumable_vestige_tool_torch_(.+)$")
    if suffix then
        return existing_target(by_id, "item_torch_" .. suffix)
    end

    local weapon_type
    weapon_type, suffix = id:match("^da_consumable_vestige_weapon_([^_]+)_(.+)$")
    if weapon_type then
        local target_types = {
            greataxe = "greataxe",
            greatsword = "greatsword",
            swords = "sword",
            sword = "sword",
            club = "club",
            staff = "staff",
            dagger = "dagger",
            crossbow = "crossbow",
            longbow = "longbow",
            hammer = "hammer",
            scimitar = "scimitar",
            shield = "shield",
        }
        local target_type = target_types[weapon_type]
        if target_type then
            return existing_target(by_id, "item_" .. target_type .. "_" .. suffix)
        end
    end

    return nil
end

local function order_value(order, value)
    return order[value] or 999
end

local function subgroup_value(family, subgroup)
    local order = SUBGROUP_ORDER[family]
    return order and (order[subgroup] or 999) or 999
end

local function compare_items(a, b)
    local ca = a._class
    local cb = b._class

    local av = order_value(PRIMARY_ORDER, ca.primary)
    local bv = order_value(PRIMARY_ORDER, cb.primary)
    if av ~= bv then
        return av < bv
    end

    av = order_value(FAMILY_ORDER, ca.family)
    bv = order_value(FAMILY_ORDER, cb.family)
    if av ~= bv then
        return av < bv
    end

    av = subgroup_value(ca.family, ca.subgroup)
    bv = subgroup_value(cb.family, cb.subgroup)
    if av ~= bv then
        return av < bv
    end

    if ca.subgroup ~= cb.subgroup then
        return ca.subgroup < cb.subgroup
    end

    av = order_value(DETAIL_ORDER, ca.detail)
    bv = order_value(DETAIL_ORDER, cb.detail)
    if av ~= bv then
        return av < bv
    end

    if ca.detail ~= cb.detail then
        return ca.detail < cb.detail
    end

    local ap = tonumber(a._sort_power)
    local bp = tonumber(b._sort_power)
    if ap ~= bp then
        if ap == nil then return false end
        if bp == nil then return true end
        return ap < bp
    end

    local an = a._sort_name or lower(a.name)
    local bn = b._sort_name or lower(b.name)
    if an ~= bn then
        return an < bn
    end

    local ak = a._pair_key or lower(a.id)
    local bk = b._pair_key or lower(b.id)
    if ak ~= bk then
        return ak < bk
    end

    local ao = a._pair_order or 0
    local bo = b._pair_order or 0
    if ao ~= bo then
        return ao < bo
    end

    an = lower(a.name)
    bn = lower(b.name)
    if an ~= bn then
        return an < bn
    end

    return lower(a.id) < lower(b.id)
end

function Sorting.is_power_level(item)
    return type(item) == "table" and type(item.powerLevel) == "number"
end

function Sorting.sort_sections(sections)
    local visible = {}
    local by_id = {}

    for _, section in ipairs(sections) do
        for _, item in ipairs(section.items) do
            item._tab = section.key
            item._class = Sorting.classify(item)
            if item._class then
                visible[#visible + 1] = item
                by_id[lower(item.id)] = item
            end
        end
    end

    for _, item in ipairs(visible) do
        local id = lower(item.id)
        local target_id = learnable_target_id(id, by_id)
        local target = target_id and by_id[target_id] or nil

        if target and target._class then
            item._class = target._class
            item._pair_key = target_id
            item._pair_order = id:find("_pattern_", 1, true) and 1 or 2
            item._sort_power = target.powerLevel
            item._sort_name = lower(target.name or target.id)

            target._pair_key = target_id
            target._pair_order = 0
            target._sort_power = target.powerLevel
            target._sort_name = lower(target.name or target.id)
        else
            item._pair_key = id
            item._pair_order = 0
            item._sort_power = item.powerLevel
            item._sort_name = lower(item.name or item.id)
        end
    end

    table.sort(visible, compare_items)
    return visible
end

return Sorting
