GuildHelper = GuildHelper or {}

local addon = GuildHelper

addon.BISService = addon.BISService or {}

local DEFAULT_SEASON_ID = "current"
local DEFAULT_SPEC_ID = "Current Spec"
local PREFIX = "GH3BIS"
local CLASS_FILE_TO_ID = {
    WARRIOR = 1,
    PALADIN = 2,
    HUNTER = 3,
    ROGUE = 4,
    PRIEST = 5,
    DEATHKNIGHT = 6,
    SHAMAN = 7,
    MAGE = 8,
    WARLOCK = 9,
    MONK = 10,
    DRUID = 11,
    DEMONHUNTER = 12,
    EVOKER = 13,
}
local TRACK_ORDER = {
    { key = "explorer", label = "Explorer", rank = 1 },
    { key = "adventurer", label = "Adventurer", rank = 2 },
    { key = "veteran", label = "Veteran", rank = 3 },
    { key = "champion", label = "Champion", rank = 4 },
    { key = "hero", label = "Hero", rank = 5 },
    { key = "myth", label = "Myth", rank = 6 },
}
local INVENTORY_SLOT_IDS = {
    head = { 1 },
    neck = { 2 },
    shoulder = { 3 },
    chest = { 5 },
    waist = { 6 },
    legs = { 7 },
    feet = { 8 },
    wrist = { 9 },
    hands = { 10 },
    ring1 = { 11 },
    ring2 = { 12 },
    trinket1 = { 13 },
    trinket2 = { 14 },
    back = { 15 },
    mainhand = { 16 },
    offhand = { 17 },
}

local SLOT_ORDER = {
    { key = "head", label = "Head" },
    { key = "neck", label = "Neck" },
    { key = "shoulder", label = "Shoulder" },
    { key = "back", label = "Back" },
    { key = "chest", label = "Chest" },
    { key = "wrist", label = "Wrist" },
    { key = "hands", label = "Hands" },
    { key = "waist", label = "Waist" },
    { key = "legs", label = "Legs" },
    { key = "feet", label = "Feet" },
    { key = "ring1", label = "Ring 1" },
    { key = "ring2", label = "Ring 2" },
    { key = "trinket1", label = "Trinket 1" },
    { key = "trinket2", label = "Trinket 2" },
    { key = "mainhand", label = "Main Hand" },
    { key = "offhand", label = "Off Hand" },
}

local SOURCE_TYPE_OPTIONS = {
    { id = "all", label = "All Sources" },
    { id = "dungeon", label = "Dungeon" },
    { id = "raid", label = "Raid" },
    { id = "tier", label = "Tier" },
    { id = "pvp", label = "PvP" },
    { id = "crafted", label = "Crafted" },
    { id = "vault", label = "Vault" },
    { id = "other", label = "Other" },
}

local OWNERSHIP_OPTIONS = {
    { id = "all", label = "All States" },
    { id = "missing", label = "Missing" },
    { id = "owned", label = "Owned" },
    { id = "equipped", label = "Equipped" },
}

local COLLECTION_SORT_OPTIONS = {
    { id = "default", label = "Default Order" },
    { id = "collected-top", label = "Collected Top" },
    { id = "collected-bottom", label = "Collected Bottom" },
}

local PRIORITY_OPTIONS = {
    { id = "bis", label = "True BIS" },
    { id = "upgrade", label = "Upgrade" },
    { id = "optional", label = "Optional" },
}

local STAT_GOAL_OPTIONS = {
    { key = "crit", label = "Crit" },
    { key = "haste", label = "Haste" },
    { key = "mastery", label = "Mastery" },
    { key = "versatility", label = "Vers" },
    { key = "avoidance", label = "Avoid" },
    { key = "speed", label = "Speed" },
    { key = "dodge", label = "Dodge" },
    { key = "leech", label = "Leech" },
}

local STAT_GOAL_ALIASES = {
    avoid = "avoidance",
    avoidance = "avoidance",
    crit = "crit",
    critical = "crit",
    criticalstrike = "crit",
    dodge = "dodge",
    haste = "haste",
    leech = "leech",
    lifesteal = "leech",
    mastery = "mastery",
    speed = "speed",
    vers = "versatility",
    versatility = "versatility",
}

local INVENTORY_SCAN_THROTTLE_SECONDS = 2
local INVENTORY_SCAN_DEBOUNCE_SECONDS = 0.15
local INVENTORY_SCAN_STALE_SECONDS = 15
local JOURNAL_CACHE_SECONDS = 1800
local METADATA_AUTOFILL_VERSION = 3
local LOOT_ALERT_CACHE_SECONDS = 10
local LOOT_QUEUE_INTERVAL_SECONDS = 0.05
local SHARE_CACHE_SECONDS = 60
local DROP_REVIEW_CACHE_SECONDS = 120
local DROP_REVIEW_PRUNE_INTERVAL_SECONDS = 1
local DROP_REVIEW_POPUP_DELAY_SECONDS = 2
local DROP_REVIEW_REFRESH_DELAY_SECONDS = 0.05
local MAX_TRADE_ITEM_SLOTS = 7
local MAX_TRACK_INFO_CACHE_ENTRIES = 512
local LOOT_TRADABILITY_CACHE_SECONDS = 10
local MAX_LOOT_TRADABILITY_CACHE_ENTRIES = 256
local LOOT_HISTORY_MAX_ENTRIES = 120
local LOOT_HISTORY_KEEP_SECONDS = 7 * 24 * 60 * 60

local LOOT_HELPER_MODE_OPTIONS = {
    { id = "bis-only", label = "BIS Only" },
    { id = "all-tradeable", label = "All Tradeable Loot" },
}

local LOOT_HELPER_DETECTION_OPTIONS = {
    { id = "strict", label = "Strict Tradable Detection" },
    { id = "broad", label = "Broad Best-Effort Detection" },
}

local LOOT_REVIEW_FILTER_OPTIONS = {
    { id = "all", label = "All Rows" },
    { id = "need", label = "Your BIS" },
    { id = "offer", label = "Claims" },
    { id = "self-offer", label = "Self Offers" },
}

local LOOT_REVIEW_SORT_OPTIONS = {
    { id = "recent", label = "Newest" },
    { id = "owner", label = "Owner" },
    { id = "ilevel", label = "Item Level" },
}

local IMPORT_HEADER_ALIASES = {
    item = "itemName",
    itemname = "itemName",
    name = "itemName",
    notes = "notes",
    note = "notes",
    priority = "priority",
    prio = "priority",
    slot = "slot",
    slottype = "slot",
    source = "sourceName",
    sourcename = "sourceName",
    dungeon = "sourceName",
    raid = "sourceName",
    boss = "sourceTarget",
    sourcetarget = "sourceTarget",
    target = "sourceTarget",
    drop = "sourceTarget",
    sourcetype = "sourceType",
    type = "sourceType",
    category = "sourceType",
    itemid = "itemId",
    id = "itemId",
}

local SLOT_KEY_ALIASES = {
    back = "back",
    belt = "waist",
    boots = "feet",
    bracer = "wrist",
    bracers = "wrist",
    cape = "back",
    chest = "chest",
    cloak = "back",
    feet = "feet",
    finger = "ring1",
    finger1 = "ring1",
    finger2 = "ring2",
    glove = "hands",
    gloves = "hands",
    hands = "hands",
    head = "head",
    helm = "head",
    helmet = "head",
    legs = "legs",
    mainhand = "mainhand",
    mainweapon = "mainhand",
    neck = "neck",
    offhand = "offhand",
    offweapon = "offhand",
    ring = "ring1",
    ring1 = "ring1",
    ring2 = "ring2",
    shoulder = "shoulder",
    shoulders = "shoulder",
    trinket = "trinket1",
    trinket1 = "trinket1",
    trinket2 = "trinket2",
    waist = "waist",
    weapon = "mainhand",
    wrist = "wrist",
}

local JOURNAL_EQUIP_LOC_TO_SLOT_KEYS = {
    INVTYPE_2HWEAPON = { "mainhand" },
    INVTYPE_CHEST = { "chest" },
    INVTYPE_CLOAK = { "back" },
    INVTYPE_FEET = { "feet" },
    INVTYPE_FINGER = { "ring1", "ring2" },
    INVTYPE_HAND = { "hands" },
    INVTYPE_HOLDABLE = { "offhand" },
    INVTYPE_HEAD = { "head" },
    INVTYPE_LEGS = { "legs" },
    INVTYPE_NECK = { "neck" },
    INVTYPE_RANGED = { "mainhand" },
    INVTYPE_RANGEDRIGHT = { "mainhand" },
    INVTYPE_ROBE = { "chest" },
    INVTYPE_SHIELD = { "offhand" },
    INVTYPE_SHOULDER = { "shoulder" },
    INVTYPE_TRINKET = { "trinket1", "trinket2" },
    INVTYPE_WAIST = { "waist" },
    INVTYPE_WEAPON = { "mainhand", "offhand" },
    INVTYPE_WEAPONMAINHAND = { "mainhand" },
    INVTYPE_WEAPONOFFHAND = { "offhand" },
    INVTYPE_WRIST = { "wrist" },
}

local function trimString(value)
    local text = ""

    if type(value) == "string" then
        text = value
    elseif value ~= nil then
        local ok, converted = pcall(tostring, value)

        if ok and type(converted) == "string" then
            text = converted
        end
    end

    local ok, sanitized = pcall(function()
        text = string.gsub(text, "\r", "")
        text = string.gsub(text, "\194\160", " ")
        text = string.gsub(text, "\226\128\175", " ")
        text = string.gsub(text, "\226\128\137", " ")
        return string.match(text, "^%s*(.-)%s*$") or ""
    end)

    return ok and sanitized or ""
end

local function stripWoWMarkup(value)
    local text = trimString(value)
    text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "")
    text = string.gsub(text, "|r", "")
    text = string.gsub(text, "|T.-|t", "")
    text = string.gsub(text, "|H.-|h(.-)|h", "%1")
    return trimString(text)
end

local function lowerText(value)
    return string.lower(trimString(value))
end

local function canonicalToken(value)
    return (lowerText(value):gsub("[^a-z0-9]", ""))
end

local function normalizeItemName(value)
    return stripWoWMarkup(value)
end

local function canonicalItemName(value)
    return canonicalToken(normalizeItemName(value))
end

local function extractItemIdFromText(value)
    local text = trimString(value)

    if text == "" then
        return 0
    end

    if text:match("^%d+$") then
        return math.max(0, math.floor(tonumber(text) or 0))
    end

    for _, pattern in ipairs({
        "|Hitem:(%d+)",
        "item:(%d+)",
        "[?&]item=(%d+)",
        "/item=(%d+)",
        "/item/(%d+)",
    }) do
        local extracted = tonumber(text:match(pattern)) or 0

        if extracted > 0 then
            return extracted
        end
    end

    return 0
end

local function resolveImportedItemId(row)
    local candidates = {
        type(row) == "table" and row.itemId or nil,
        type(row) == "table" and row.itemName or nil,
        type(row) == "table" and row.sourceName or nil,
        type(row) == "table" and row.sourceTarget or nil,
    }

    if type(row) == "table" and type(row.__rawValues) == "table" then
        for _, value in ipairs(row.__rawValues) do
            candidates[#candidates + 1] = value
        end
    end

    for _, value in ipairs(candidates) do
        local itemId = extractItemIdFromText(value)

        if itemId > 0 then
            return itemId
        end
    end

    return 0
end

local function resolveItemNameFromItemId(itemId)
    local resolvedItemId = math.max(0, math.floor(tonumber(itemId) or 0))

    if resolvedItemId <= 0 then
        return ""
    end

    if C_Item and C_Item.GetItemNameByID then
        local directName = normalizeItemName(C_Item.GetItemNameByID(resolvedItemId) or "")

        if directName ~= "" then
            return directName
        end
    end

    if not GetItemInfo then
        return ""
    end

    return normalizeItemName(GetItemInfo(resolvedItemId) or "")
end

local function resolveJournalItemName(itemId, lootInfo, itemLink)
    local candidates = {
        type(lootInfo) == "table" and lootInfo.name or nil,
        type(lootInfo) == "table" and lootInfo.itemName or nil,
        type(lootInfo) == "table" and lootInfo.itemLink or nil,
        type(lootInfo) == "table" and lootInfo.link or nil,
        itemLink,
        (tonumber(itemId) or 0) > 0 and resolveItemNameFromItemId(itemId) or nil,
    }

    for _, candidate in ipairs(candidates) do
        local resolved = normalizeItemName(candidate)

        if resolved ~= "" then
            return resolved
        end
    end

    if (tonumber(itemId) or 0) > 0 and C_Item and C_Item.RequestLoadItemDataByID then
        pcall(C_Item.RequestLoadItemDataByID, tonumber(itemId) or 0)
    end

    return ""
end

local function resolveItemFromClientCacheByName(itemName)
    local normalizedName = normalizeItemName(itemName)

    if normalizedName == "" or not GetItemInfo then
        return 0, ""
    end

    local resolvedName, itemLink = GetItemInfo(normalizedName)
    local resolvedItemId = extractItemIdFromText(itemLink)

    return math.max(0, math.floor(tonumber(resolvedItemId) or 0)), normalizeItemName(resolvedName)
end

local function getEntryAlertLookupMode(entry)
    if type(entry) ~= "table" then
        return "unresolved"
    end

    if (tonumber(entry.itemId) or 0) > 0 then
        return "id"
    end

    if canonicalItemName(entry.itemName) ~= "" then
        return "name"
    end

    return "unresolved"
end

local ITEM_NAME_TRAILING_QUALIFIER_PATTERNS = {
    "%s+[Rr]aid%s+[Ff]inder$",
    "%s+[Ll][Ff][Rr]$",
    "%s+[Nn]ormal$",
    "%s+[Hh]eroic$",
    "%s+[Mm]ythic$",
    "%s+[Ee]xplorer%s+%d+/%d+$",
    "%s+[Aa]dventurer%s+%d+/%d+$",
    "%s+[Vv]eteran%s+%d+/%d+$",
    "%s+[Cc]hampion%s+%d+/%d+$",
    "%s+[Hh]ero%s+%d+/%d+$",
    "%s+[Mm]yth%s+%d+/%d+$",
    "%s+[Ii][Ll][Vv][Ll]?%.?%s*%d+$",
    "%s+%d+/%d+$",
}

local function addCanonicalItemAlias(aliases, seenAliases, value)
    local canonicalName = canonicalItemName(value)

    if canonicalName ~= "" and not seenAliases[canonicalName] then
        seenAliases[canonicalName] = true
        aliases[#aliases + 1] = canonicalName
    end
end

local function trimImportedItemName(value)
    local text = normalizeItemName(value)

    if text == "" then
        return ""
    end

    local previousText = nil

    while text ~= previousText do
        previousText = text
        text = trimString((text:gsub("%b()", " ")))
        text = trimString((text:gsub("%b[]", " ")))
        text = trimString((text:gsub("%s+%-%s+[^%-]+$", "")))
        text = trimString((text:gsub("%s+|%s+.+$", "")))

        for _, pattern in ipairs(ITEM_NAME_TRAILING_QUALIFIER_PATTERNS) do
            text = trimString((text:gsub(pattern, "")))
        end
    end

    return text
end

local function buildCanonicalItemAliases(value)
    local aliases = {}
    local seenAliases = {}
    local normalizedName = normalizeItemName(value)

    if normalizedName == "" then
        return aliases
    end

    addCanonicalItemAlias(aliases, seenAliases, normalizedName)

    local trimmedName = trimImportedItemName(normalizedName)

    if trimmedName ~= "" and trimmedName ~= normalizedName then
        addCanonicalItemAlias(aliases, seenAliases, trimmedName)
    end

    local firstCommaValue = trimString((normalizedName:match("^([^,]+),") or ""))

    if firstCommaValue ~= "" then
        addCanonicalItemAlias(aliases, seenAliases, firstCommaValue)
    end

    return aliases
end

local function getTierFamilySignature(value)
    local normalizedName = normalizeItemName(value)

    if normalizedName == "" then
        return ""
    end

    local suffixSignature = trimString((normalizedName:match("(of the .+)$") or ""))

    if suffixSignature ~= "" then
        return lowerText(suffixSignature)
    end

    local possessivePrefix = trimString((normalizedName:match("^(.-)'s%s+") or ""))

    if possessivePrefix ~= "" then
        return lowerText(possessivePrefix)
    end

    return ""
end

local function getTrackBandKey(trackRank)
    local resolvedRank = tonumber(trackRank) or 0

    if resolvedRank >= 6 then
        return "mythic-bis"
    end

    if resolvedRank >= 5 then
        return "heroic-bis"
    end

    if resolvedRank >= 4 then
        return "champion-bis"
    end

    if resolvedRank > 0 then
        return "below-champion"
    end

    return nil
end

local function getTrackBandShortLabel(trackRank)
    local bandKey = getTrackBandKey(trackRank)

    if bandKey == "mythic-bis" then
        return "Mythic"
    end

    if bandKey == "heroic-bis" then
        return "Heroic"
    end

    if bandKey == "champion-bis" then
        return "Champion"
    end

    if bandKey == "below-champion" then
        return "Below Champion"
    end

    return ""
end

local function getTrackBandCompactLabel(trackRank)
    local bandKey = getTrackBandKey(trackRank)

    if bandKey == "mythic-bis" then
        return "Mythic"
    end

    if bandKey == "heroic-bis" then
        return "Heroic"
    end

    if bandKey == "champion-bis" then
        return "Champion"
    end

    if bandKey == "below-champion" then
        return "Below Champ"
    end

    return ""
end

local function getTrackFilterKey(trackRank)
    local resolvedRank = tonumber(trackRank) or 0

    if resolvedRank <= 1 then
        return "explorer"
    end

    if resolvedRank == 2 then
        return "adventurer"
    end

    if resolvedRank == 3 then
        return "veteran"
    end

    if resolvedRank == 4 then
        return "champion"
    end

    if resolvedRank == 5 then
        return "hero"
    end

    if resolvedRank >= 6 then
        return "myth"
    end

    return "unknown"
end

local function getJournalDifficultyLabel(sourceType, difficultyId)
    local normalizedSourceType = canonicalToken(sourceType)
    local resolvedSourceType = "other"

    if normalizedSourceType == "mplus" or normalizedSourceType == "mythicplus" or normalizedSourceType == "mythic" or normalizedSourceType == "dungeonloot" then
        resolvedSourceType = "dungeon"
    elseif normalizedSourceType == "raidloot" or normalizedSourceType == "raid" then
        resolvedSourceType = "raid"
    elseif normalizedSourceType == "dungeon" then
        resolvedSourceType = "dungeon"
    elseif normalizedSourceType == "tier" then
        resolvedSourceType = "tier"
    end

    local resolvedDifficultyId = tonumber(difficultyId) or 0

    if resolvedSourceType == "raid" then
        if resolvedDifficultyId == 14 then
            return "LFR"
        end

        if resolvedDifficultyId == 15 then
            return "Normal"
        end

        if resolvedDifficultyId == 16 then
            return "Heroic"
        end

        if resolvedDifficultyId == 17 then
            return "Mythic"
        end
    elseif resolvedSourceType == "dungeon" then
        if resolvedDifficultyId == 1 then
            return "Normal"
        end

        if resolvedDifficultyId == 2 then
            return "Heroic"
        end

        if resolvedDifficultyId == 23 then
            return "Mythic"
        end

        if resolvedDifficultyId == 8 then
            return "Mythic+"
        end
    elseif resolvedSourceType == "tier" then
        return "Tier"
    end

    return ""
end

local function roundStatValue(value)
    local numericValue = tonumber(value)

    if not numericValue then
        return nil
    end

    return math.floor((numericValue * 100) + 0.5) / 100
end

local function normalizeStatGoalKey(value)
    return STAT_GOAL_ALIASES[canonicalToken(value)]
end

local function normalizeStatTargetValue(value)
    local numericValue = roundStatValue(value)

    if not numericValue or numericValue < 0 then
        return 0
    end

    return numericValue
end

local function safeCallNumber(method, ...)
    if type(method) ~= "function" then
        return nil
    end

    local ok, result = pcall(method, ...)

    if not ok then
        return nil
    end

    -- Strip potential secret number value taint: WoW blocks arithmetic on numbers
    -- returned from protected APIs when execution is tainted by a non-Blizzard addon.
    -- Converting through pcall(tostring) produces a plain Lua string whose tonumber
    -- result is safe for all arithmetic operations.
    local strOk, str = pcall(tostring, result)
    return strOk and tonumber(str) or nil
end

local function getVersatilityPercent()
    local ratingId = rawget(_G, "CR_VERSATILITY_DAMAGE_DONE")

    if type(GetCombatRatingBonus) == "function" and ratingId then
        local ratingBonus = safeCallNumber(GetCombatRatingBonus, ratingId)

        if ratingBonus then
            return ratingBonus
        end
    end

    local method = rawget(_G, "GetCombatRatingBonus")

    if type(method) == "function" and ratingId then
        local ratingBonus = safeCallNumber(method, ratingId)

        if ratingBonus then
            return ratingBonus
        end
    end

    local versatilityMethod = rawget(_G, "GetVersatilityBonus")

    if type(versatilityMethod) == "function" then
        return safeCallNumber(versatilityMethod)
    end

    return nil
end

local function getCombatRatingValue(constantName)
    local ratingId = rawget(_G, constantName)

    if not ratingId or type(GetCombatRating) ~= "function" then
        return nil
    end

    return safeCallNumber(GetCombatRating, ratingId)
end

local function formatStatValueLabel(value)
    local numericValue = roundStatValue(value)

    if numericValue == nil then
        return "-"
    end

    if math.abs(numericValue - math.floor(numericValue + 0.5)) < 0.01 then
        return tostring(math.floor(numericValue + 0.5))
    end

    return string.format("%.2f", numericValue)
end

local function buildLiveStatSnapshot()
    return {
        avoidance = roundStatValue(getCombatRatingValue("CR_AVOIDANCE") or safeCallNumber(GetAvoidance)),
        crit = roundStatValue(getCombatRatingValue("CR_CRIT_MELEE") or getCombatRatingValue("CR_CRIT_RANGED") or getCombatRatingValue("CR_CRIT_SPELL") or safeCallNumber(GetCritChance)),
        dodge = roundStatValue(getCombatRatingValue("CR_DODGE") or safeCallNumber(GetDodgeChance)),
        haste = roundStatValue(getCombatRatingValue("CR_HASTE_MELEE") or getCombatRatingValue("CR_HASTE_RANGED") or getCombatRatingValue("CR_HASTE_SPELL") or safeCallNumber(GetHaste)),
        leech = roundStatValue(getCombatRatingValue("CR_LIFESTEAL") or safeCallNumber(GetLifesteal)),
        mastery = roundStatValue(getCombatRatingValue("CR_MASTERY") or safeCallNumber(GetMasteryEffect)),
        speed = roundStatValue(getCombatRatingValue("CR_SPEED") or safeCallNumber(GetSpeed)),
        versatility = roundStatValue(getCombatRatingValue("CR_VERSATILITY_DAMAGE_DONE") or getVersatilityPercent()),
    }
end

local function areStatSnapshotsEqual(left, right)
    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        local leftValue = roundStatValue(type(left) == "table" and left[option.key] or nil)
        local rightValue = roundStatValue(type(right) == "table" and right[option.key] or nil)

        if leftValue ~= rightValue then
            return false
        end
    end

    return true
end

local function deepCopy(value)
    if type(value) ~= "table" then
        return value
    end

    local copy = {}

    for key, nestedValue in pairs(value) do
        copy[key] = deepCopy(nestedValue)
    end

    return copy
end

local function normalizeSlotKey(value)
    local normalized = canonicalToken(value)

    if normalized == "" then
        return nil
    end

    if SLOT_KEY_ALIASES[normalized] then
        return SLOT_KEY_ALIASES[normalized]
    end

    for _, slot in ipairs(SLOT_ORDER) do
        if canonicalToken(slot.key) == normalized or canonicalToken(slot.label) == normalized then
            return slot.key
        end
    end

    return nil
end

local function normalizeSpecId(value)
    local normalized = trimString(value)
    return normalized ~= "" and normalized or DEFAULT_SPEC_ID
end

local function getToonFilterMember(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local member = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)

    if member then
        return normalizedToonId, member
    end

    if addon.RosterService.GetMyToonRecord then
        local myToonRecord = addon.RosterService:GetMyToonRecord(normalizedToonId)

        if type(myToonRecord) == "table" then
            return normalizedToonId, myToonRecord
        end
    end

    return normalizedToonId, nil
end

local function normalizeSourceType(value)
    local normalized = canonicalToken(value)

    if normalized == "" then
        return "other"
    end

    if normalized == "mplus" or normalized == "mythicplus" or normalized == "mythic" or normalized == "dungeonloot" then
        return "dungeon"
    end

    if normalized == "raidloot" then
        return "raid"
    end

    if normalized == "tier" or normalized == "tierset" or normalized == "set" or normalized == "catalyst" or normalized == "creationcatalyst" or normalized == "matrixcatalyst" or normalized == "token" or normalized == "raidtoken" then
        return "tier"
    end

    if normalized == "pvp" or normalized == "arena" or normalized == "battleground" or normalized == "bgs" or normalized == "conquest" or normalized == "honor" or normalized == "warmode" then
        return "pvp"
    end

    if normalized == "craft" or normalized == "craftedgear" or normalized == "profession" then
        return "crafted"
    end

    if normalized == "greatvault" then
        return "vault"
    end

    for _, option in ipairs(SOURCE_TYPE_OPTIONS) do
        if canonicalToken(option.id) == normalized then
            return option.id
        end
    end

    return "other"
end

local function normalizePriority(value)
    local normalized = canonicalToken(value)

    if normalized == "" then
        return "bis"
    end

    if normalized == "truebis" or normalized == "bestinslot" or normalized == "bestslot" then
        return "bis"
    end

    if normalized == "alt" or normalized == "alternative" or normalized == "situational" then
        return "optional"
    end

    for _, option in ipairs(PRIORITY_OPTIONS) do
        if canonicalToken(option.id) == normalized then
            return option.id
        end
    end

    return "bis"
end

local function buildEmptyEntry()
    return {
        detectedEquipped = false,
        detectedEquippedTrackLabel = "",
        detectedEquippedTrackRank = 0,
        detectedOwned = false,
        detectedOwnedTrackLabel = "",
        detectedOwnedTrackRank = 0,
        highestEquippedItemLevel = 0,
        highestOwnedItemLevel = 0,
        itemId = 0,
        itemName = "",
        lastAlertedItemLevel = 0,
        lastAlertedTrackRank = 0,
        notes = "",
        priority = "bis",
        sourceName = "",
        sourceTarget = "",
        sourceType = "other",
    }
end

local function buildEmptyStatTargets()
    local targets = {}

    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        targets[option.key] = 0
    end

    return targets
end

local function entryHasManualContent(entry)
    if type(entry) ~= "table" then
        return false
    end

    return (tonumber(entry.itemId) or 0) > 0
        or trimString(entry.itemName) ~= ""
        or trimString(entry.sourceName) ~= ""
        or trimString(entry.sourceTarget) ~= ""
        or trimString(entry.notes) ~= ""
end

local function buildEmptySlot(slotLabel)
    return {
        entries = {},
        selectedEntryIndex = 1,
        slotLabel = slotLabel,
    }
end

local function getSlotLabel(slotKey)
    for _, slot in ipairs(SLOT_ORDER) do
        if slot.key == slotKey then
            return slot.label
        end
    end

    return "Slot"
end

local function getJournalSlotKeysFromFilterType(filterType)
    local resolvedFilterType = tonumber(filterType) or 0

    if resolvedFilterType <= 0 or type(Enum) ~= "table" or type(Enum.ItemSlotFilterType) ~= "table" then
        return {}
    end

    for enumName, enumValue in pairs(Enum.ItemSlotFilterType) do
        if tonumber(enumValue) == resolvedFilterType then
            local normalizedSlotKey = normalizeSlotKey(enumName)

            if normalizedSlotKey == "ring1" then
                return { "ring1", "ring2" }
            end

            if normalizedSlotKey == "trinket1" then
                return { "trinket1", "trinket2" }
            end

            if normalizedSlotKey then
                return { normalizedSlotKey }
            end
        end
    end

    return {}
end

local function getJournalSlotKeys(itemId, itemLink, fallbackFilterType)
    if type(GetItemInfoInstant) ~= "function" then
        return getJournalSlotKeysFromFilterType(fallbackFilterType), ""
    end

    local query = (tonumber(itemId) or 0) > 0 and tonumber(itemId) or trimString(itemLink)

    if not query or query == "" then
        return getJournalSlotKeysFromFilterType(fallbackFilterType), ""
    end

    local equipLoc = select(4, GetItemInfoInstant(query)) or ""
    local slotKeys = {}

    for _, slotKey in ipairs(JOURNAL_EQUIP_LOC_TO_SLOT_KEYS[equipLoc] or {}) do
        slotKeys[#slotKeys + 1] = slotKey
    end

    if #slotKeys == 0 then
        for _, slotKey in ipairs(getJournalSlotKeysFromFilterType(fallbackFilterType)) do
            slotKeys[#slotKeys + 1] = slotKey
        end
    end

    return slotKeys, equipLoc
end

local function slotKeyAppearsInList(slotKey, slotKeys)
    local normalizedSlotKey = normalizeSlotKey(slotKey)

    if not normalizedSlotKey then
        return false
    end

    for _, candidate in ipairs(slotKeys or {}) do
        if normalizeSlotKey(candidate) == normalizedSlotKey then
            return true
        end
    end

    return false
end

local function getContainerNumSlotsCompat(bagId)
    if C_Container and C_Container.GetContainerNumSlots then
        return C_Container.GetContainerNumSlots(bagId)
    end

    if GetContainerNumSlots then
        return GetContainerNumSlots(bagId)
    end

    return 0
end

local function getContainerItemLinkCompat(bagId, slotIndex)
    if C_Container and C_Container.GetContainerItemLink then
        return C_Container.GetContainerItemLink(bagId, slotIndex)
    end

    if GetContainerItemLink then
        return GetContainerItemLink(bagId, slotIndex)
    end

    return nil
end

local function sendAddonMessage(prefix, payload, channel, target)
    if C_ChatInfo and C_ChatInfo.SendAddonMessage then
        local ok, result = pcall(C_ChatInfo.SendAddonMessage, prefix, payload, channel, target)

        if ok then
            return result
        end

        local parameterSets = {
            { prefix = prefix, message = payload, chatType = channel, target = target },
            { prefix = prefix, text = payload, chatType = channel, target = target },
            { prefix = prefix, message = payload, distribution = channel, target = target },
            { prefix = prefix, text = payload, distribution = channel, target = target },
        }

        for _, params in ipairs(parameterSets) do
            local tableOk, tableResult = pcall(C_ChatInfo.SendAddonMessage, params)

            if tableOk then
                return tableResult
            end
        end

        return nil
    end

    if SendAddonMessage then
        return SendAddonMessage(prefix, payload, channel, target)
    end

    return nil
end

local function isInInstanceGroup()
    return LE_PARTY_CATEGORY_INSTANCE and IsInGroup and IsInGroup(LE_PARTY_CATEGORY_INSTANCE) and true or false
end

local function getGroupAddonChannel()
    if isInInstanceGroup() then
        return "INSTANCE_CHAT"
    end

    if IsInRaid and IsInRaid() then
        return "RAID"
    end

    if IsInGroup and IsInGroup() then
        return "PARTY"
    end

    return nil
end

local function shortPlayerName(name)
    local resolvedName = tostring(name or "Unknown")
    return string.match(resolvedName, "([^%-]+)") or resolvedName
end

local function sanitizeInlineChatMarkup(messageText)
    local sanitized = trimString(messageText)

    sanitized = string.gsub(sanitized, "|A:.-|a", "")
    sanitized = string.gsub(sanitized, "|T.-|t", "")
    sanitized = stripWoWMarkup(sanitized)
    sanitized = string.gsub(sanitized, "|", "||")
    sanitized = string.gsub(sanitized, "%s+%]", "]")
    sanitized = string.gsub(sanitized, "%s%s+", " ")

    return trimString(sanitized)
end

local function sanitizeStoredItemLink(messageText)
    local sanitized = trimString(messageText)

    sanitized = string.gsub(sanitized, "|A:.-|a", "")
    sanitized = string.gsub(sanitized, "|T.-|t", "")

    return trimString(sanitized)
end

local function sanitizeStoredItemText(messageText)
    return stripWoWMarkup(messageText)
end

local function sanitizeChatMessageText(messageText)
    return sanitizeInlineChatMarkup(messageText)
end

local function sendWhisperMessage(messageText, target)
    local sanitizedMessageText = sanitizeChatMessageText(messageText)

    if sanitizedMessageText == "" or not SendChatMessage then
        return false
    end

    local whisperTarget = trimString(target)

    if trimString(whisperTarget) == "" then
        return false
    end

    SendChatMessage(sanitizedMessageText, "WHISPER", nil, whisperTarget)
    return true
end

local function sendPlayerAlertMessage(messageText, target)
    local sanitizedMessageText = sanitizeChatMessageText(messageText)
    local normalizedTarget = addon.Store and addon.Store.NormalizeFullName and addon.Store:NormalizeFullName(target or "", GetRealmName() or "Unknown") or trimString(target)
    local playerId = addon.Store and addon.Store.GetPlayerName and addon.Store:GetPlayerName() or ""

    if sanitizedMessageText == "" then
        return false
    end

    if normalizedTarget ~= "" and normalizedTarget == playerId then
        if addon.Print then
            addon:Print(sanitizedMessageText)
            return true
        end

        return false
    end

    return sendWhisperMessage(sanitizedMessageText, target)
end

local function sendGroupChatMessage(messageText)
    local sanitizedMessageText = sanitizeChatMessageText(messageText)

    if sanitizedMessageText == "" or not SendChatMessage then
        return false
    end

    local channel = getGroupAddonChannel()

    if not channel then
        return false
    end

    SendChatMessage(sanitizedMessageText, channel)
    return true
end

local function normalizePlayerId(name)
    return addon.Store:NormalizeFullName(name or "", GetRealmName() or "Unknown")
end

local function getUnitFullName(unit)
    if type(unit) ~= "string" or unit == "" or not UnitExists or not UnitExists(unit) then
        return ""
    end

    local unitName, unitRealm = UnitFullName and UnitFullName(unit) or nil, nil

    if UnitFullName then
        unitName, unitRealm = UnitFullName(unit)
    else
        unitName = UnitName and UnitName(unit) or ""
    end

    unitName = trimString(unitName)

    if unitName == "" then
        return ""
    end

    if trimString(unitRealm) ~= "" then
        return normalizePlayerId(unitName .. "-" .. unitRealm)
    end

    return normalizePlayerId(unitName)
end

local function findGroupUnitByPlayer(playerId)
    local normalizedPlayerId = normalizePlayerId(playerId)

    if normalizedPlayerId == "" then
        return nil
    end

    if getUnitFullName("player") == normalizedPlayerId then
        return "player"
    end

    if IsInRaid and IsInRaid() and GetNumGroupMembers then
        for index = 1, GetNumGroupMembers() do
            local unit = "raid" .. index

            if getUnitFullName(unit) == normalizedPlayerId then
                return unit
            end
        end
    elseif IsInGroup and IsInGroup() and GetNumSubgroupMembers then
        for index = 1, GetNumSubgroupMembers() do
            local unit = "party" .. index

            if getUnitFullName(unit) == normalizedPlayerId then
                return unit
            end
        end
    end

    if getUnitFullName("target") == normalizedPlayerId then
        return "target"
    end

    return nil
end

local function resolveDropReviewTradeTargetId(row, playerId)
    if type(row) ~= "table" then
        return ""
    end

    local currentPlayerId = normalizePlayerId(playerId)
    local ownerId = trimString(row.ownerId)
    local targetId = trimString(row.targetId)

    if trimString(row.kind) == "self-offer" then
        return targetId
    end

    if targetId ~= "" and targetId ~= currentPlayerId then
        return targetId
    end

    if ownerId ~= "" and ownerId ~= currentPlayerId then
        return ownerId
    end

    if targetId ~= "" then
        return targetId
    end

    return ownerId
end

local function parseMessage(message)
    local command, payload = string.match(message or "", "^([^:]+):?(.*)$")
    return command, payload
end

local function decodeJson(payload)
    if trimString(payload) == "" then
        return nil
    end

    local ok, decoded = pcall(addon.Json.Decode, addon.Json, payload)
    return ok and decoded or nil
end

local function encodeJson(payload)
    local ok, encoded = pcall(addon.Json.Encode, addon.Json, payload)
    return ok and encoded or nil
end

local function getItemDetails(itemLink)
    if not itemLink or itemLink == "" then
        return nil, nil, 0
    end

    local itemName = GetItemInfo and GetItemInfo(itemLink) or nil
    local detailedItemLevel = GetDetailedItemLevelInfo and select(1, GetDetailedItemLevelInfo(itemLink)) or 0
    local itemLevel = tonumber(detailedItemLevel) or 0
    local itemId = GetItemInfoInstant and select(1, GetItemInfoInstant(itemLink)) or 0
    return tonumber(itemId) or 0, normalizeItemName(itemName), tonumber(itemLevel) or 0
end

local function getItemEquipLocation(itemLinkOrItemId)
    if type(GetItemInfoInstant) ~= "function" or not itemLinkOrItemId or itemLinkOrItemId == "" then
        return ""
    end

    return trimString(select(4, GetItemInfoInstant(itemLinkOrItemId)) or "")
end

local function getItemClassId(itemLinkOrItemId)
    if type(GetItemInfoInstant) ~= "function" or not itemLinkOrItemId or itemLinkOrItemId == "" then
        return 0
    end

    return tonumber(select(6, GetItemInfoInstant(itemLinkOrItemId)) or 0) or 0
end

local function isGearItemClass(itemLinkOrItemId)
    local classId = getItemClassId(itemLinkOrItemId)
    return classId == 2 or classId == 4
end

local function isEquippableLootItem(itemLinkOrItemId)
    if not isGearItemClass(itemLinkOrItemId) then
        return false
    end

    if type(IsEquippableItem) == "function" and itemLinkOrItemId and itemLinkOrItemId ~= "" then
        local ok, equippable = pcall(IsEquippableItem, itemLinkOrItemId)

        if ok and equippable then
            return true
        end
    end

    local equipLoc = getItemEquipLocation(itemLinkOrItemId)
    return equipLoc ~= "" and equipLoc ~= "INVTYPE_NON_EQUIP"
end

local function isMailboxLootContext()
    if MailFrame and MailFrame.IsShown and MailFrame:IsShown() then
        return true
    end

    if InboxFrame and InboxFrame.IsShown and InboxFrame:IsShown() then
        return true
    end

    if OpenMailFrame and OpenMailFrame.IsShown and OpenMailFrame:IsShown() then
        return true
    end

    return false
end

local function getTooltipTextLines(itemLink)
    local lines = {}

    if trimString(itemLink) == "" then
        return lines
    end

    addon.BISService._trackScanTooltip = addon.BISService._trackScanTooltip or CreateFrame("GameTooltip", "GH3BISTrackScanTooltip", UIParent, "GameTooltipTemplate")
    local tooltip = addon.BISService._trackScanTooltip

    if not tooltip then
        return lines
    end

    tooltip:SetOwner(UIParent or WorldFrame, "ANCHOR_NONE")
    tooltip:SetHyperlink(itemLink)

    for index = 2, 20 do
        local lineRegion = _G["GH3BISTrackScanTooltipTextLeft" .. index]
        local lineText = trimString(lineRegion and lineRegion:GetText() or "")

        if lineText ~= "" then
            lines[#lines + 1] = lineText
        end
    end

    tooltip:Hide()
    return lines
end

local function classifyLootTradability(itemLink)
    local result = {
        isEquippable = isEquippableLootItem(itemLink),
        isLikelyTradable = false,
        hasExplicitTradeWindow = false,
        hasBindOnEquip = false,
        hasBindOnPickup = false,
        hasSoulbound = false,
        reason = "",
    }

    if not result.isEquippable then
        result.reason = "not-equippable"
        return result
    end

    local bindOnEquipText = lowerText(ITEM_BIND_ON_EQUIP or "binds when equipped")
    local bindOnPickupText = lowerText(ITEM_BIND_ON_PICKUP or "binds when picked up")
    local soulboundText = lowerText(ITEM_SOULBOUND or "soulbound")

    for _, lineText in ipairs(getTooltipTextLines(itemLink)) do
        local lowered = lowerText(lineText)

        if lowered == bindOnEquipText or string.find(lowered, "binds when equipped", 1, true) then
            result.hasBindOnEquip = true
        end

        if lowered == bindOnPickupText or string.find(lowered, "binds when picked up", 1, true) then
            result.hasBindOnPickup = true
        end

        if lowered == soulboundText or string.find(lowered, "soulbound", 1, true) then
            result.hasSoulbound = true
        end

        if string.find(lowered, "you may trade this item", 1, true)
            or (string.find(lowered, "trade this item", 1, true) and string.find(lowered, "eligible to loot", 1, true)) then
            result.hasExplicitTradeWindow = true
        end
    end

    if result.hasExplicitTradeWindow then
        result.isLikelyTradable = true
        result.reason = "temporary-trade"
        return result
    end

    if result.hasBindOnEquip then
        result.isLikelyTradable = true
        result.reason = "bind-on-equip"
        return result
    end

    if result.hasBindOnPickup then
        result.reason = "bind-on-pickup"
        return result
    end

    if result.hasSoulbound then
        result.reason = "soulbound"
        return result
    end

    result.isLikelyTradable = true
    result.reason = "unknown"
    return result
end

local function pruneTrackInfoCache(cache)
    local entryCount = 0
    local oldestKey = nil
    local oldestAccessAt = math.huge

    for cacheKey, cacheEntry in pairs(cache or {}) do
        if type(cacheEntry) == "table" then
            entryCount = entryCount + 1

            local accessedAt = tonumber(cacheEntry.accessedAt) or 0

            if accessedAt < oldestAccessAt then
                oldestAccessAt = accessedAt
                oldestKey = cacheKey
            end
        end
    end

    if entryCount < MAX_TRACK_INFO_CACHE_ENTRIES or oldestKey == nil then
        return
    end

    cache[oldestKey] = nil
end

local function pruneLootTradabilityCache(cache, now)
    local staleKeys = nil
    local entryCount = 0
    local oldestKey = nil
    local oldestCachedAt = math.huge

    for cacheKey, cacheEntry in pairs(cache or {}) do
        local cachedAt = tonumber(type(cacheEntry) == "table" and cacheEntry.cachedAt) or 0

        if type(cacheEntry) ~= "table"
            or type(cacheEntry.result) ~= "table"
            or (now - cachedAt) > LOOT_TRADABILITY_CACHE_SECONDS then
            staleKeys = staleKeys or {}
            staleKeys[#staleKeys + 1] = cacheKey
        else
            entryCount = entryCount + 1

            if cachedAt < oldestCachedAt then
                oldestCachedAt = cachedAt
                oldestKey = cacheKey
            end
        end
    end

    if staleKeys then
        for _, cacheKey in ipairs(staleKeys) do
            cache[cacheKey] = nil
        end
    end

    if entryCount < MAX_LOOT_TRADABILITY_CACHE_ENTRIES or oldestKey == nil then
        return
    end

    cache[oldestKey] = nil
end

local function getCachedLootTradability(service, itemLink)
    local resolvedItemLink = trimString(itemLink)

    if resolvedItemLink == "" then
        return classifyLootTradability(resolvedItemLink)
    end

    local lootState = service:GetRuntimeState().loot
    local cache = lootState.tradabilityCache or {}
    local now = time()

    lootState.tradabilityCache = cache

    local cached = cache[resolvedItemLink]

    if type(cached) == "table"
        and type(cached.result) == "table"
        and (now - (tonumber(cached.cachedAt) or 0)) <= LOOT_TRADABILITY_CACHE_SECONDS then
        cached.cachedAt = now
        return cached.result
    end

    local result = classifyLootTradability(resolvedItemLink)

    pruneLootTradabilityCache(cache, now)
    cache[resolvedItemLink] = {
        cachedAt = now,
        result = result,
    }
    return result
end

local function isDropReviewRowValid(row)
    if type(row) ~= "table" then
        return false
    end

    local kind = trimString(row.kind)

    if kind ~= "need" and kind ~= "offer" and kind ~= "self-offer" then
        return true
    end

    local itemLink = trimString(row.itemLink)
    local itemId = tonumber(row.itemId) or 0
    local itemName = trimString(row.itemName)

    if itemLink == "" and itemId <= 0 and itemName == "" then
        return false
    end

    if row.isPreview then
        return true
    end

    if itemLink ~= "" then
        return isEquippableLootItem(itemLink)
    end

    if itemId > 0 then
        return isEquippableLootItem(itemId)
    end

    return false
end

local function collectTradeItemLinks(getLinkFunction)
    local links = {}

    if type(getLinkFunction) ~= "function" then
        return links
    end

    for slotIndex = 1, MAX_TRADE_ITEM_SLOTS do
        local itemLink = trimString(getLinkFunction(slotIndex))

        if itemLink ~= "" then
            links[#links + 1] = itemLink
        end
    end

    return links
end

local function summarizeTradeLinks(itemLinks)
    local labels = {}

    for _, itemLink in ipairs(itemLinks or {}) do
        local itemText = trimString(itemLink)

        if itemText ~= "" then
            labels[#labels + 1] = itemText
        end
    end

    if #labels <= 0 then
        return "that item"
    end

    if #labels == 1 then
        return labels[1]
    end

    if #labels == 2 then
        return labels[1] .. " and " .. labels[2]
    end

    return string.format("%s, %s, and %d more item(s)", labels[1], labels[2], #labels - 2)
end

local function resolveTradeTargetId()
    if UnitExists and UnitExists("target") and UnitIsPlayer and UnitIsPlayer("target") then
        local targetId = getUnitFullName("target")

        if trimString(targetId) ~= "" then
            return targetId
        end
    end

    local recipientText = _G and _G.TradeFrameRecipientNameText

    if recipientText and recipientText.GetText then
        local recipientName = stripWoWMarkup(recipientText:GetText())

        if trimString(recipientName) ~= "" then
            return normalizePlayerId(recipientName)
        end
    end

    return ""
end

local function extractItemLinksFromMessage(message)
    local itemLinks = {}
    local messageText = trimString(message)

    for itemLink in string.gmatch(messageText, "(|Hitem:.-|h%[.-%]|h)") do
        itemLinks[#itemLinks + 1] = itemLink
    end

    return itemLinks
end

local function normalizeLootMessageText(message)
    return lowerText(stripWoWMarkup(message))
end

local function isSelfLootMessage(normalizedMessage)
    return string.find(normalizedMessage, "you receive", 1, true) ~= nil
        or string.find(normalizedMessage, "you create", 1, true) ~= nil
end

local function collectGroupLootCandidatesCached(runtimeLootState)
    if type(runtimeLootState) == "table"
        and type(runtimeLootState.groupLootCandidates) == "table"
        and #runtimeLootState.groupLootCandidates > 0 then
        return runtimeLootState.groupLootCandidates
    end

    local candidates = {}
    local seen = {}

    local function addCandidate(name, realm)
        local shortName = trimString(name)

        if shortName == "" then
            return
        end

        local fullName = addon.Store:NormalizeFullName(shortName, realm or GetRealmName() or "Unknown")

        if seen[fullName] then
            return
        end

        seen[fullName] = true
        candidates[#candidates + 1] = {
            fullLower = lowerText(fullName),
            fullName = fullName,
            shortLower = lowerText(shortName),
            shortName = shortName,
        }
    end

    do
        local playerName
        local playerRealm = GetRealmName and GetRealmName() or nil

        if UnitFullName then
            playerName, playerRealm = UnitFullName("player")
        else
            playerName = UnitName("player")
        end

        addCandidate(playerName, playerRealm)
    end

    if IsInRaid and IsInRaid() then
        for index = 1, (GetNumGroupMembers and GetNumGroupMembers() or 0) do
            local unitId = "raid" .. tostring(index)
            local memberName
            local memberRealm

            if UnitFullName then
                memberName, memberRealm = UnitFullName(unitId)
            else
                memberName = UnitName(unitId)
            end

            addCandidate(memberName, memberRealm)
        end
    elseif IsInGroup and IsInGroup() then
        for index = 1, (GetNumSubgroupMembers and GetNumSubgroupMembers() or 0) do
            local unitId = "party" .. tostring(index)
            local memberName
            local memberRealm

            if UnitFullName then
                memberName, memberRealm = UnitFullName(unitId)
            else
                memberName = UnitName(unitId)
            end

            addCandidate(memberName, memberRealm)
        end
    end

    table.sort(candidates, function(left, right)
        return #left.shortName > #right.shortName
    end)

    if type(runtimeLootState) == "table" then
        runtimeLootState.groupLootCandidates = candidates
        runtimeLootState.groupLootCandidatesBuiltAt = time()
    end

    return candidates
end

local function resolveLootRecipient(normalizedMessage, candidates)
    local loweredMessage = trimString(normalizedMessage)

    if loweredMessage == "" then
        return nil, nil, false
    end

    if isSelfLootMessage(loweredMessage) then
        local playerId = addon.Store:GetPlayerName()
        local shortName = trimString((playerId or ""):match("^[^-]+") or playerId)
        return playerId, shortName, true
    end

    for _, candidate in ipairs(candidates or {}) do
        if candidate.fullLower ~= "" and string.find(loweredMessage, candidate.fullLower, 1, true) then
            return candidate.fullName, candidate.shortName, candidate.fullName == addon.Store:GetPlayerName()
        end

        if candidate.shortLower ~= "" and string.find(loweredMessage, candidate.shortLower, 1, true) then
            return candidate.fullName, candidate.shortName, candidate.fullName == addon.Store:GetPlayerName()
        end
    end

    return nil, nil, false
end

local function getTrackInfoFromItemLink(itemLink)
    if not itemLink or itemLink == "" then
        return 0, ""
    end

    addon.BISService._trackScanTooltip = addon.BISService._trackScanTooltip or CreateFrame("GameTooltip", "GH3BISTrackScanTooltip", UIParent, "GameTooltipTemplate")
    local tooltip = addon.BISService._trackScanTooltip

    if not tooltip then
        return 0, ""
    end

    tooltip:SetOwner(UIParent or WorldFrame, "ANCHOR_NONE")
    tooltip:SetHyperlink(itemLink)

    local bestRank = 0
    local bestLabel = ""

    local function tryTrackLine(lineText)
        local lowered = lowerText(lineText)

        if lowered == "" then
            return false
        end

        local hasUpgradeMarker = string.find(lowered, "upgrade level", 1, true)
            or string.find(lowered, "upgrade:", 1, true)
            or string.find(lowered, "%d+/%d+")

        if not hasUpgradeMarker then
            return false
        end

        for _, track in ipairs(TRACK_ORDER) do
            if string.find(lowered, track.key, 1, true) then
                bestRank = track.rank
                bestLabel = trimString(lineText)
                return true
            end
        end

        return false
    end

    for index = 2, 20 do
        local lineRegion = _G["GH3BISTrackScanTooltipTextLeft" .. index]
        local lineText = lineRegion and lineRegion:GetText() or nil

        if tryTrackLine(lineText) then
            break
        end
    end

    tooltip:Hide()
    return bestRank, bestLabel
end

local function entryMatchesItem(entry, itemId, itemName)
    if type(entry) ~= "table" then
        return false
    end

    local trackedItemId = tonumber(entry.itemId) or 0
    local trackedName = canonicalItemName(entry.itemName)
    local liveName = canonicalItemName(itemName)

    if trackedItemId > 0 and trackedItemId == (tonumber(itemId) or 0) then
        return true
    end

    return trackedName ~= "" and trackedName == liveName
end

local function csvEscape(value)
    local text = tostring(value or "")

    if string.find(text, '"', 1, true) or string.find(text, ',', 1, true) or string.find(text, '\n', 1, true) then
        text = '"' .. string.gsub(text, '"', '""') .. '"'
    end

    return text
end

local function buildCsv(headers, rows)
    local lines = { table.concat(headers, ",") }

    for _, row in ipairs(rows or {}) do
        local values = {}

        for _, header in ipairs(headers) do
            values[#values + 1] = csvEscape(row[header])
        end

        lines[#lines + 1] = table.concat(values, ",")
    end

    return table.concat(lines, "\n")
end

local function detectDelimitedTextSeparator(text)
    local sample = trimString(tostring(text or ""))

    if sample == "" then
        return ","
    end

    for line in string.gmatch(sample .. "\n", "([^\n]*)\n") do
        local trimmed = trimString(line)

        if trimmed ~= "" then
            if string.find(line, "\t", 1, true) then
                return "\t"
            end

            if string.find(line, "  ") then
                return "__MULTISPACE__"
            end

            return ","
        end
    end

    return ","
end

local function parseDelimitedLine(line, separator)
    if separator == "__MULTISPACE__" then
        local values = {}

        local text = tostring(line or "")
        local startIndex = 1

        while startIndex <= #text do
            local separatorStart, separatorEnd = string.find(text, "%s%s+", startIndex)

            if not separatorStart then
                local trailingValue = trimString(string.sub(text, startIndex))

                if trailingValue ~= "" or #values > 0 then
                    values[#values + 1] = trailingValue
                end
                break
            end

            values[#values + 1] = trimString(string.sub(text, startIndex, separatorStart - 1))
            startIndex = separatorEnd + 1
        end

        if #values == 0 then
            values[1] = trimString(text)
        end

        return values
    end

    if separator == "\t" and not string.find(tostring(line or ""), "\t", 1, true) and string.find(tostring(line or ""), "%s%s+") then
        return parseDelimitedLine(line, "__MULTISPACE__")
    end

    local values = {}
    local index = 1
    local buffer = ""
    local inQuotes = false
    local resolvedSeparator = separator or ","

    while index <= #line do
        local character = string.sub(line, index, index)

        if inQuotes then
            if character == '"' then
                if string.sub(line, index + 1, index + 1) == '"' then
                    buffer = buffer .. '"'
                    index = index + 1
                else
                    inQuotes = false
                end
            else
                buffer = buffer .. character
            end
        else
            if character == '"' then
                inQuotes = true
            elseif character == resolvedSeparator then
                values[#values + 1] = buffer
                buffer = ""
            else
                buffer = buffer .. character
            end
        end

        index = index + 1
    end

    values[#values + 1] = buffer
    return values
end

local function realignImportValues(headers, values, line)
    local resolvedValues = values or {}
    local headerCount = #(headers or {})

    if headerCount <= 0 or #resolvedValues >= headerCount then
        return resolvedValues
    end

    local normalizedHeaders = {}

    for index, header in ipairs(headers or {}) do
        normalizedHeaders[index] = IMPORT_HEADER_ALIASES[canonicalToken(header)] or trimString(header)
    end

    if headerCount == 3 and normalizedHeaders[1] == "slot" and normalizedHeaders[2] == "itemName" and normalizedHeaders[3] == "itemId" then
        local slotValue, itemNameValue, itemIdValue = tostring(line or ""):match("^%s*(%S+)%s+(.+)%s+(%d+)%s*$")

        if slotValue and itemNameValue and itemIdValue then
            return {
                trimString(slotValue),
                trimString(itemNameValue),
                trimString(itemIdValue),
            }
        end
    end

    return resolvedValues
end

local function parseDelimitedText(text)
    local rows = {}
    local headers
    local separator = detectDelimitedTextSeparator(text)
    local lineNumber = 0

    for line in string.gmatch((text or "") .. "\n", "([^\n]*)\n") do
        lineNumber = lineNumber + 1
        local trimmed = trimString(line)

        if trimmed ~= "" then
            local values = parseDelimitedLine(line, separator)

            if not headers then
                headers = values
            else
                values = realignImportValues(headers, values, line)
                local row = {}

                for index, header in ipairs(headers) do
                    row[trimString(header)] = trimString(values[index])
                end

                row.__rawValues = values
                row.__rowNumber = lineNumber

                rows[#rows + 1] = row
            end
        end
    end

    return headers or {}, rows
end

local function normalizeImportHeaders(headers)
    local headerMap = {}
    local seenHeaders = {}

    for _, header in ipairs(headers or {}) do
        local alias = IMPORT_HEADER_ALIASES[canonicalToken(header)] or trimString(header)
        headerMap[#headerMap + 1] = {
            original = trimString(header),
            normalized = alias,
        }
        seenHeaders[canonicalToken(alias)] = true
    end

    return headerMap, seenHeaders
end

local function remapImportRows(headerMap, rows)
    local normalizedRows = {}

    for _, row in ipairs(rows or {}) do
        local normalizedRow = {}

        for _, headerInfo in ipairs(headerMap or {}) do
            normalizedRow[headerInfo.normalized] = trimString(row[headerInfo.original])
        end

        normalizedRow.__rawValues = row.__rawValues
        normalizedRow.__rowNumber = row.__rowNumber

        normalizedRows[#normalizedRows + 1] = normalizedRow
    end

    return normalizedRows
end

local function applyCollapsedBisColumnFallback(row)
    if type(row) ~= "table" then
        return row
    end

    local rawValues = row.__rawValues

    if type(rawValues) ~= "table" or #rawValues < 2 then
        return row
    end

    if trimString(row.priority) ~= "" then
        return row
    end

    if trimString(row.itemId) ~= "" then
        return row
    end

    if trimString(row.sourceName) ~= "" or trimString(row.sourceTarget) ~= "" or trimString(row.sourceType) ~= "" then
        return row
    end

    if #rawValues == 3 then
        row.sourceName = trimString(rawValues[3])
        return row
    end

    if #rawValues == 4 then
        row.sourceName = trimString(rawValues[3])
        row.priority = trimString(rawValues[4])
        row.sourceType = trimString(row.sourceType)
        row.sourceTarget = ""
        return row
    end

    if #rawValues == 5 and canonicalToken(rawValues[5]) ~= "" then
        local thirdValue = trimString(rawValues[3])
        local fourthValue = trimString(rawValues[4])
        local fifthValue = trimString(rawValues[5])

        if normalizeSourceType(thirdValue) ~= "other" then
            row.sourceType = thirdValue
            row.sourceName = fourthValue
            row.priority = fifthValue
        else
            row.sourceName = thirdValue
            row.sourceTarget = fourthValue
            row.priority = fifthValue
            row.sourceType = ""
        end
    end

    return row
end

local function buildTemplateRows(simpleMode)
    local templateExamples = {
        head = { itemName = "Circuit Breaker Crown" },
        neck = { itemName = "Strapped Rescue-Keg" },
        shoulder = { itemName = "Feverflare Spaulders" },
        back = { itemName = "Test Pilot's Go-Pack" },
        chest = { itemName = "Junkreaver's Breastplate" },
        wrist = { itemName = "Flameforged Armguard" },
        hands = { itemName = "Gauntlets of Absolute Authority" },
        waist = { itemName = "Coin-Operated Girdle" },
        legs = { itemName = "Legguards of the Steamjack" },
        feet = { itemName = "Rik's Walkin' Boots" },
        ring1 = { itemName = "Cyrce's Circlet" },
        ring2 = { itemName = "Miniature Roulette Wheel" },
        trinket1 = { itemName = "Signet of the Priory" },
        trinket2 = { itemName = "Improvised Seaforium Pacemaker" },
        mainhand = { itemName = "Electro-Arm Bludgeoner" },
        offhand = { itemName = "Titan of Industry" },
    }

    local rows = {}

    for _, slot in ipairs(SLOT_ORDER) do
        local example = templateExamples[slot.key] or {}
        local row = {
            slot = slot.key,
            itemName = example.itemName or string.format("Example %s Item", slot.label),
        }

        if not simpleMode then
            row.sourceType = ""
            row.sourceName = ""
            row.sourceTarget = ""
            row.priority = "bis"
            row.notes = ""
        end

        rows[#rows + 1] = row
    end

    return rows
end

local function buildImportDuplicateKey(slotKey, entry)
    return table.concat({
        trimString(slotKey),
        lowerText(entry and entry.itemName),
        lowerText(entry and entry.sourceType),
        lowerText(entry and entry.sourceName),
        lowerText(entry and entry.sourceTarget),
        lowerText(entry and entry.priority),
        lowerText(entry and entry.notes),
    }, "|")
end

local function isCurrentPlayerToon(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    return normalizedToonId == addon.Store:GetPlayerName()
end

local function clearDetectionState(entry)
    if type(entry) ~= "table" then
        return false
    end

    local changed = false

    local function resetBoolean(fieldName)
        if entry[fieldName] ~= false then
            entry[fieldName] = false
            changed = true
        end
    end

    local function resetNumber(fieldName)
        if (tonumber(entry[fieldName]) or 0) ~= 0 then
            entry[fieldName] = 0
            changed = true
        end
    end

    local function resetString(fieldName)
        if trimString(entry[fieldName]) ~= "" then
            entry[fieldName] = ""
            changed = true
        end
    end

    resetBoolean("detectedOwned")
    resetBoolean("detectedEquipped")
    resetNumber("highestOwnedItemLevel")
    resetNumber("highestEquippedItemLevel")
    resetNumber("detectedOwnedTrackRank")
    resetNumber("detectedEquippedTrackRank")
    resetString("detectedOwnedTrackLabel")
    resetString("detectedEquippedTrackLabel")
    return changed
end

local function getLogicalSlotKeys(slotKey)
    if slotKey == "ring1" or slotKey == "ring2" then
        return { "ring1", "ring2" }
    end

    if slotKey == "trinket1" or slotKey == "trinket2" then
        return { "trinket1", "trinket2" }
    end

    return { slotKey }
end

function addon.BISService:GetStore()
    local store = addon.Store:GetBis()
    store.filters = store.filters or {}
    store.lootHelper = store.lootHelper or {}
    store.records = store.records or {}
    store.seasonId = trimString(store.seasonId) ~= "" and trimString(store.seasonId) or DEFAULT_SEASON_ID
    store.selectedSlotKey = normalizeSlotKey(store.selectedSlotKey) or SLOT_ORDER[1].key
    return store
end

function addon.BISService:GetLootHelperSettings()
    local settings = self:GetStore().lootHelper or {}
    local mode = trimString(settings.mode)
    local detectionMode = trimString(settings.detectionMode)
    local reviewFilter = trimString(settings.reviewFilter)
    local reviewSort = trimString(settings.reviewSort)

    settings.mode = mode == "all-tradeable" and "all-tradeable" or "bis-only"
    settings.detectionMode = detectionMode == "broad" and "broad" or "strict"
    settings.autoOpenPopup = settings.autoOpenPopup ~= false
    settings.playPopupSound = settings.playPopupSound ~= false
    settings.whisperTradeCompletion = settings.whisperTradeCompletion ~= false
    settings.compactPopup = settings.compactPopup and true or false
    settings.pinPopupPosition = settings.pinPopupPosition and true or false
    settings.keepRowsUntilDismissed = settings.keepRowsUntilDismissed and true or false
    settings.popupOffsetX = tonumber(settings.popupOffsetX) or 0
    settings.popupOffsetY = tonumber(settings.popupOffsetY) or 0
    settings.compactPopupOffsetX = tonumber(settings.compactPopupOffsetX)
    settings.compactPopupOffsetY = tonumber(settings.compactPopupOffsetY)
    settings.popupPositionSaved = settings.popupPositionSaved and true or false
    settings.compactPopupPositionSaved = settings.compactPopupPositionSaved and true or false
    settings.reviewFilter = (reviewFilter == "need" or reviewFilter == "offer" or reviewFilter == "self-offer") and reviewFilter or "all"
    settings.reviewSort = (reviewSort == "owner" or reviewSort == "ilevel") and reviewSort or "recent"
    settings.history = type(settings.history) == "table" and settings.history or {}

    if settings.compactPopupOffsetX == nil then
        settings.compactPopupOffsetX = settings.popupOffsetX
    end

    if settings.compactPopupOffsetY == nil then
        settings.compactPopupOffsetY = settings.popupOffsetY
    end

    self:GetStore().lootHelper = settings
    return settings
end

function addon.BISService:GetLootHelperModeOptions()
    return LOOT_HELPER_MODE_OPTIONS
end

function addon.BISService:GetLootHelperDetectionOptions()
    return LOOT_HELPER_DETECTION_OPTIONS
end

function addon.BISService:GetLootReviewFilterOptions()
    return LOOT_REVIEW_FILTER_OPTIONS
end

function addon.BISService:GetLootReviewSortOptions()
    return LOOT_REVIEW_SORT_OPTIONS
end

function addon.BISService:UpdateLootHelperSettings(payload)
    if type(payload) ~= "table" then
        return false
    end

    local settings = self:GetLootHelperSettings()

    if payload.mode ~= nil then
        settings.mode = trimString(payload.mode) == "all-tradeable" and "all-tradeable" or "bis-only"
    end

    if payload.detectionMode ~= nil then
        settings.detectionMode = trimString(payload.detectionMode) == "broad" and "broad" or "strict"
    end

    if payload.autoOpenPopup ~= nil then
        settings.autoOpenPopup = payload.autoOpenPopup and true or false
    end

    if payload.playPopupSound ~= nil then
        settings.playPopupSound = payload.playPopupSound and true or false
    end

    if payload.whisperTradeCompletion ~= nil then
        settings.whisperTradeCompletion = payload.whisperTradeCompletion and true or false
    end

    if payload.compactPopup ~= nil then
        settings.compactPopup = payload.compactPopup and true or false
    end

    if payload.pinPopupPosition ~= nil then
        settings.pinPopupPosition = payload.pinPopupPosition and true or false
    end

    if payload.keepRowsUntilDismissed ~= nil then
        settings.keepRowsUntilDismissed = payload.keepRowsUntilDismissed and true or false
    end

    if payload.popupOffsetX ~= nil then
        settings.popupOffsetX = tonumber(payload.popupOffsetX) or 0
    end

    if payload.popupOffsetY ~= nil then
        settings.popupOffsetY = tonumber(payload.popupOffsetY) or 0
    end

    if payload.compactPopupOffsetX ~= nil then
        settings.compactPopupOffsetX = tonumber(payload.compactPopupOffsetX) or 0
    end

    if payload.compactPopupOffsetY ~= nil then
        settings.compactPopupOffsetY = tonumber(payload.compactPopupOffsetY) or 0
    end

    if payload.popupPositionSaved ~= nil then
        settings.popupPositionSaved = payload.popupPositionSaved and true or false
    end

    if payload.compactPopupPositionSaved ~= nil then
        settings.compactPopupPositionSaved = payload.compactPopupPositionSaved and true or false
    end

    if payload.reviewFilter ~= nil then
        local reviewFilter = trimString(payload.reviewFilter)
        settings.reviewFilter = (reviewFilter == "need" or reviewFilter == "offer" or reviewFilter == "self-offer") and reviewFilter or "all"
    end

    if payload.reviewSort ~= nil then
        local reviewSort = trimString(payload.reviewSort)
        settings.reviewSort = (reviewSort == "owner" or reviewSort == "ilevel") and reviewSort or "recent"
    end

    self:GetStore().lootHelper = settings
    return true
end

function addon.BISService:PruneLootHistory()
    local settings = self:GetLootHelperSettings()
    local history = settings.history or {}
    local kept = {}
    local now = time()

    table.sort(history, function(left, right)
        return (tonumber(left.at) or 0) > (tonumber(right.at) or 0)
    end)

    for _, entry in ipairs(history) do
        if type(entry) == "table" and (now - (tonumber(entry.at) or 0)) <= LOOT_HISTORY_KEEP_SECONDS then
            kept[#kept + 1] = entry

            if #kept >= LOOT_HISTORY_MAX_ENTRIES then
                break
            end
        end
    end

    settings.history = kept
    self:GetStore().lootHelper = settings
    return kept
end

function addon.BISService:RecordLootHistory(row, outcome, extraText)
    if type(row) ~= "table" then
        return false
    end

    local settings = self:GetLootHelperSettings()
    local history = self:PruneLootHistory()
    history[#history + 1] = {
        at = time(),
        kind = trimString(row.kind),
        ownerId = trimString(row.ownerId),
        targetId = trimString(row.targetId),
        itemId = tonumber(row.itemId) or 0,
        itemLink = sanitizeStoredItemLink(row.itemLink),
        itemName = sanitizeStoredItemText(row.itemName),
        itemLevel = tonumber(row.itemLevel) or 0,
        slotLabel = trimString(row.slotLabel),
        lootSpecId = trimString(row.lootSpecId),
        metaText = trimString(extraText) ~= "" and trimString(extraText) or trimString(row.metaText),
        outcome = trimString(outcome),
    }

    settings.history = history
    self:GetStore().lootHelper = settings
    self:PruneLootHistory()
    return true
end

function addon.BISService:ListLootHistory()
    return self:PruneLootHistory()
end

function addon.BISService:GetLootHistoryPolicyText()
    return string.format("Keeps the newest %d history rows for up to %d days.", LOOT_HISTORY_MAX_ENTRIES, math.floor(LOOT_HISTORY_KEEP_SECONDS / 86400))
end

function addon.BISService:GetRuntimeState()
    self._runtimeState = self._runtimeState or {
        inventory = {
            dirty = true,
            lastPlayerId = nil,
            lastScanAt = 0,
            lastSpecRevision = 0,
            pendingScanGeneration = 0,
            trackInfoCache = {},
        },
        journal = {
            builtAt = 0,
            filterKey = "",
            itemIdIndex = nil,
            index = nil,
            lootRows = nil,
            scope = "",
        },
        loot = {
            groupLootCandidates = {},
            groupLootCandidatesBuiltAt = 0,
            matchIndexBySpec = {},
            pendingMessages = {},
            pendingMessageStart = 1,
            pendingMessageEnd = 0,
            processorFrame = nil,
            processorElapsed = 0,
            recentAlerts = {},
            tradabilityCache = {},
        },
        share = {
            recentClaims = {},
            recentOffers = {},
            recentGroupOffers = {},
            recentRolls = {},
        },
        dropReview = {
            lastPrunedAt = 0,
            nextId = 1,
            popupToken = 0,
            rows = {},
        },
        trade = {
            active = false,
            playerAccepted = false,
            targetAccepted = false,
            playerItems = {},
            targetItems = {},
            targetId = "",
        },
        vault = {
            lastFingerprint = "",
        },
    }

    self._runtimeState.inventory = self._runtimeState.inventory or {
        dirty = true,
        lastPlayerId = nil,
        lastScanAt = 0,
        lastSpecRevision = 0,
        pendingScanGeneration = 0,
        trackInfoCache = {},
    }
    self._runtimeState.inventory.pendingScanGeneration = math.max(0, math.floor(tonumber(self._runtimeState.inventory.pendingScanGeneration) or 0))
    self._runtimeState.inventory.trackInfoCache = self._runtimeState.inventory.trackInfoCache or {}
    self._runtimeState.journal = self._runtimeState.journal or { builtAt = 0, filterKey = "", itemIdIndex = nil, index = nil, lootRows = nil, scope = "" }
    self._runtimeState.journal.itemIdIndex = self._runtimeState.journal.itemIdIndex or {}
    self._runtimeState.journal.lootRows = self._runtimeState.journal.lootRows or {}
    self._runtimeState.journal.filterKey = trimString(self._runtimeState.journal.filterKey)
    self._runtimeState.journal.scope = trimString(self._runtimeState.journal.scope)
    self._runtimeState.loot = self._runtimeState.loot or { groupLootCandidates = {}, groupLootCandidatesBuiltAt = 0, matchIndexBySpec = {}, pendingMessages = {}, pendingMessageStart = 1, pendingMessageEnd = 0, processorFrame = nil, processorElapsed = 0, recentAlerts = {}, tradabilityCache = {} }
    self._runtimeState.loot.groupLootCandidates = self._runtimeState.loot.groupLootCandidates or {}
    self._runtimeState.loot.matchIndexBySpec = self._runtimeState.loot.matchIndexBySpec or {}
    self._runtimeState.loot.pendingMessages = self._runtimeState.loot.pendingMessages or {}
    self._runtimeState.loot.pendingMessageStart = math.max(1, math.floor(tonumber(self._runtimeState.loot.pendingMessageStart) or 1))
    self._runtimeState.loot.pendingMessageEnd = math.max(0, math.floor(tonumber(self._runtimeState.loot.pendingMessageEnd) or 0))
    self._runtimeState.loot.processorElapsed = tonumber(self._runtimeState.loot.processorElapsed) or 0
    self._runtimeState.loot.tradabilityCache = self._runtimeState.loot.tradabilityCache or {}
    self._runtimeState.share = self._runtimeState.share or { recentClaims = {}, recentOffers = {}, recentGroupOffers = {}, recentRolls = {} }
    self._runtimeState.dropReview = self._runtimeState.dropReview or { lastPrunedAt = 0, nextId = 1, popupToken = 0, rows = {} }
    self._runtimeState.dropReview.lastPrunedAt = tonumber(self._runtimeState.dropReview.lastPrunedAt) or 0
    self._runtimeState.dropReview.nextId = math.max(1, math.floor(tonumber(self._runtimeState.dropReview.nextId) or 1))
    self._runtimeState.dropReview.popupToken = math.max(0, math.floor(tonumber(self._runtimeState.dropReview.popupToken) or 0))
    self._runtimeState.dropReview.rows = self._runtimeState.dropReview.rows or {}
    self._runtimeState.trade = self._runtimeState.trade or { active = false, playerAccepted = false, targetAccepted = false, playerItems = {}, targetItems = {}, targetId = "" }
    self._runtimeState.trade.playerItems = self._runtimeState.trade.playerItems or {}
    self._runtimeState.trade.targetItems = self._runtimeState.trade.targetItems or {}
    self._runtimeState.trade.targetId = trimString(self._runtimeState.trade.targetId)
    self._runtimeState.vault = self._runtimeState.vault or { lastFingerprint = "" }
    return self._runtimeState
end

function addon.BISService:ScheduleDropReviewPopup(delaySeconds, options)
    local resolvedOptions = type(options) == "table" and options or {}

    if not resolvedOptions.force and self:GetLootHelperSettings().autoOpenPopup == false then
        return false
    end

    local reviewState = self:GetRuntimeState().dropReview
    local resolvedDelay = math.max(0, tonumber(delaySeconds) or 0)
    reviewState.popupToken = reviewState.popupToken + 1

    local currentToken = reviewState.popupToken
    local function showPopup()
        local latestState = self:GetRuntimeState().dropReview

        if currentToken ~= latestState.popupToken then
            return
        end

        if #self:ListActiveDropReviewRows() <= 0 then
            return
        end

        if addon.MainFrame and addon.MainFrame.ShowBISDropReviewDialog then
            addon.MainFrame:ShowBISDropReviewDialog()
        end
    end

    if resolvedDelay <= 0 or not (C_Timer and C_Timer.After) then
        showPopup()
        return true
    end

    C_Timer.After(resolvedDelay, showPopup)
    return true
end

function addon.BISService:CaptureTradeStateSnapshot()
    local tradeState = self:GetRuntimeState().trade
    tradeState.targetId = resolveTradeTargetId()
    tradeState.playerItems = collectTradeItemLinks(GetTradePlayerItemLink)
    tradeState.targetItems = collectTradeItemLinks(GetTradeTargetItemLink)
    return tradeState
end

function addon.BISService:ResetTradeState()
    local tradeState = self:GetRuntimeState().trade
    tradeState.active = false
    tradeState.playerAccepted = false
    tradeState.targetAccepted = false
    tradeState.playerItems = {}
    tradeState.targetItems = {}
    tradeState.targetId = ""
end

function addon.BISService:HandleTradeShow()
    local tradeState = self:GetRuntimeState().trade
    tradeState.active = true
    tradeState.playerAccepted = false
    tradeState.targetAccepted = false
    tradeState.playerItems = {}
    tradeState.targetItems = {}
    tradeState.targetId = resolveTradeTargetId()
    self:CaptureTradeStateSnapshot()
    return true
end

function addon.BISService:HandleTradeItemsChanged()
    local tradeState = self:GetRuntimeState().trade

    if not tradeState.active then
        return false
    end

    self:CaptureTradeStateSnapshot()
    return true
end

function addon.BISService:HandleTradeAcceptUpdate(playerAccepted, targetAccepted)
    local tradeState = self:GetRuntimeState().trade

    if not tradeState.active then
        return false
    end

    tradeState.playerAccepted = playerAccepted and true or false
    tradeState.targetAccepted = targetAccepted and true or false
    self:CaptureTradeStateSnapshot()
    return true
end

function addon.BISService:HandleTradeClosed()
    local settings = self:GetLootHelperSettings()
    local tradeState = self:GetRuntimeState().trade
    local targetId = trimString(tradeState.targetId)
    local playerName = shortPlayerName(addon.Store:GetPlayerName())
    local targetName = shortPlayerName(targetId)
    local playerItems = tradeState.playerItems or {}
    local targetItems = tradeState.targetItems or {}
    local completed = tradeState.active and tradeState.playerAccepted and tradeState.targetAccepted

    if completed and settings.whisperTradeCompletion ~= false and targetId ~= "" and #playerItems + #targetItems > 0 then
        local messageText = nil

        if #playerItems > 0 and #targetItems > 0 then
            messageText = string.format("GH3 Trade: %s traded %s to %s, and %s traded %s to %s successfully by GuildHelperV3.",
                playerName,
                summarizeTradeLinks(playerItems),
                targetName,
                targetName,
                summarizeTradeLinks(targetItems),
                playerName)
        elseif #playerItems > 0 then
            messageText = string.format("GH3 Trade: %s traded %s to %s successfully by GuildHelperV3.",
                playerName,
                summarizeTradeLinks(playerItems),
                targetName)
        else
            messageText = string.format("GH3 Trade: %s traded %s to %s successfully by GuildHelperV3.",
                targetName,
                summarizeTradeLinks(targetItems),
                playerName)
        end

        sendWhisperMessage(messageText, targetId)
    end

    if completed then
        if #playerItems > 0 then
            self:RecordLootHistory({
                kind = "trade-complete",
                ownerId = addon.Store:GetPlayerName(),
                targetId = targetId,
                itemLink = summarizeTradeLinks(playerItems),
                itemName = summarizeTradeLinks(playerItems),
            }, "trade-complete", "Trade window completed successfully.")
        end

        if #targetItems > 0 then
            self:RecordLootHistory({
                kind = "trade-complete",
                ownerId = targetId,
                targetId = addon.Store:GetPlayerName(),
                itemLink = summarizeTradeLinks(targetItems),
                itemName = summarizeTradeLinks(targetItems),
            }, "trade-complete", "Received items through a completed trade.")
        end
    end

    self:ResetTradeState()
    return completed
end

function addon.BISService:BuildDropReviewKey(kind, ownerId, targetId, itemId, itemName)
    return table.concat({
        lowerText(kind),
        lowerText(ownerId),
        lowerText(targetId),
        tostring(tonumber(itemId) or 0),
        canonicalItemName(itemName),
    }, "|")
end

local function buildDropReviewCandidateMaps(candidates)
    local fullNameSet = {}
    local shortNameBuckets = {}

    for _, candidate in ipairs(candidates or {}) do
        local fullName = normalizePlayerId(candidate and candidate.fullName)

        if fullName ~= "" then
            local shortKey = lowerText(shortPlayerName(fullName))
            local bucket = shortNameBuckets[shortKey]

            fullNameSet[fullName] = true

            if not bucket then
                bucket = { count = 0, values = {} }
                shortNameBuckets[shortKey] = bucket
            end

            if not bucket.values[fullName] then
                bucket.values[fullName] = true
                bucket.count = bucket.count + 1
                bucket.singleValue = fullName
            end
        end
    end

    return fullNameSet, shortNameBuckets
end

local function remapDropReviewPlayerId(playerId, currentPlayerId, fullNameSet, shortNameBuckets)
    local normalizedPlayerId = normalizePlayerId(playerId)

    if normalizedPlayerId == "" or normalizedPlayerId == currentPlayerId or fullNameSet[normalizedPlayerId] then
        return normalizedPlayerId, false
    end

    local bucket = shortNameBuckets[lowerText(shortPlayerName(normalizedPlayerId))]

    if bucket and bucket.count == 1 and trimString(bucket.singleValue) ~= "" then
        return bucket.singleValue, bucket.singleValue ~= normalizedPlayerId
    end

    return normalizedPlayerId, false
end

local function mergeDropReviewMaps(targetMap, sourceMap)
    targetMap = type(targetMap) == "table" and targetMap or {}

    for key, value in pairs(type(sourceMap) == "table" and sourceMap or {}) do
        targetMap[key] = value
    end

    return targetMap
end

local function mergeDropReviewRows(rows, playerId, candidates)
    local fullNameSet, shortNameBuckets = buildDropReviewCandidateMaps(candidates)
    local mergedRows = {}
    local mergedByKey = {}
    local changed = false

    table.sort(rows, function(left, right)
        return (tonumber(left and left.createdAt) or 0) > (tonumber(right and right.createdAt) or 0)
    end)

    for _, row in ipairs(rows or {}) do
        if type(row) == "table" then
            local normalizedOwnerId = normalizePlayerId(row.ownerId)
            local normalizedTargetId = normalizePlayerId(row.targetId)
            local remappedOwnerId, ownerChanged = remapDropReviewPlayerId(normalizedOwnerId, playerId, fullNameSet, shortNameBuckets)
            local remappedTargetId, targetChanged = remapDropReviewPlayerId(normalizedTargetId, playerId, fullNameSet, shortNameBuckets)
            local canonicalRowKey = table.concat({
                lowerText(row.kind),
                lowerText(remappedOwnerId),
                lowerText(remappedTargetId),
                tostring(tonumber(row.itemId) or 0),
                canonicalItemName(row.itemName),
            }, "|")

            row.ownerId = remappedOwnerId
            row.targetId = remappedTargetId

            if trimString(row.rowKey) ~= canonicalRowKey then
                row.rowKey = canonicalRowKey
                changed = true
            end

            if ownerChanged or targetChanged then
                changed = true
            end

            local existing = mergedByKey[canonicalRowKey]

            if existing then
                existing.createdAt = math.max(tonumber(existing.createdAt) or 0, tonumber(row.createdAt) or 0)
                existing.expiresAt = math.max(tonumber(existing.expiresAt) or 0, tonumber(row.expiresAt) or 0)
                existing.keepUntilDismissed = existing.keepUntilDismissed or row.keepUntilDismissed
                existing.offerKey = trimString(existing.offerKey) ~= "" and trimString(existing.offerKey) or trimString(row.offerKey)
                existing.itemId = (tonumber(existing.itemId) or 0) > 0 and (tonumber(existing.itemId) or 0) or (tonumber(row.itemId) or 0)
                existing.itemLink = trimString(existing.itemLink) ~= "" and trimString(existing.itemLink) or trimString(row.itemLink)
                existing.itemName = trimString(existing.itemName) ~= "" and trimString(existing.itemName) or trimString(row.itemName)
                existing.itemLevel = math.max(tonumber(existing.itemLevel) or 0, tonumber(row.itemLevel) or 0)
                existing.slotLabel = trimString(existing.slotLabel) ~= "" and trimString(existing.slotLabel) or trimString(row.slotLabel)
                existing.lootSpecId = trimString(existing.lootSpecId) ~= "" and trimString(existing.lootSpecId) or trimString(row.lootSpecId)

                if #trimString(row.metaText) > #trimString(existing.metaText) then
                    existing.metaText = trimString(row.metaText)
                end

                existing.canWhisper = existing.canWhisper or row.canWhisper
                existing.canOffer = existing.canOffer or row.canOffer
                existing.canTrade = existing.canTrade or row.canTrade
                existing.canClaim = existing.canClaim or row.canClaim
                existing.canRoll = existing.canRoll or row.canRoll
                existing.leftGroup = existing.leftGroup and row.leftGroup
                existing.isPreview = existing.isPreview or row.isPreview
                existing.claimantIds = mergeDropReviewMaps(existing.claimantIds, row.claimantIds)
                existing.rolls = mergeDropReviewMaps(existing.rolls, row.rolls)
                changed = true
            else
                mergedByKey[canonicalRowKey] = row
                mergedRows[#mergedRows + 1] = row
            end
        end
    end

    return changed, mergedRows
end

function addon.BISService:PruneDropReviewRows()
    local reviewState = self:GetRuntimeState().dropReview
    local keptRows = {}
    local now = time()

    for _, row in ipairs(reviewState.rows or {}) do
        if type(row) == "table" and isDropReviewRowValid(row) and (row.keepUntilDismissed or (tonumber(row.expiresAt) or 0) > now) then
            keptRows[#keptRows + 1] = row
        end
    end

    local _, mergedRows = mergeDropReviewRows(keptRows, addon.Store:GetPlayerName(), collectGroupLootCandidatesCached(self:GetRuntimeState().loot))

    reviewState.rows = mergedRows
    reviewState.lastPrunedAt = now
    return mergedRows
end

function addon.BISService:ListActiveDropReviewRows()
    local rows = self:PruneDropReviewRows()
    table.sort(rows, function(left, right)
        return (tonumber(left.createdAt) or 0) > (tonumber(right.createdAt) or 0)
    end)
    return rows
end

function addon.BISService:ListOfferableDropReviewRows()
    local rows = {}
    local playerId = addon.Store:GetPlayerName()

    for _, row in ipairs(self:ListActiveDropReviewRows()) do
        if type(row) == "table" and row.canOffer and trimString(row.ownerId) == playerId then
            rows[#rows + 1] = row
        end
    end

    return rows
end

function addon.BISService:DismissDropReviewRow(rowId)
    local reviewState = self:GetRuntimeState().dropReview
    local keptRows = {}
    local removed = false

    for _, row in ipairs(reviewState.rows or {}) do
        if type(row) == "table" and tostring(row.id) == tostring(rowId) then
            removed = true
        else
            keptRows[#keptRows + 1] = row
        end
    end

    reviewState.rows = keptRows
    return removed
end

function addon.BISService:ClearDropReviewRows()
    local reviewState = self:GetRuntimeState().dropReview
    reviewState.rows = {}
    reviewState.popupToken = math.max(0, math.floor(tonumber(reviewState.popupToken) or 0)) + 1
    return true
end

function addon.BISService:QueueLootHelperPreviewRows()
    local playerId = addon.Store:GetPlayerName()
    local realmName = GetRealmName() or "Unknown"
    local ownerA = addon.Store:NormalizeFullName("Lootfriend", realmName)
    local ownerB = addon.Store:NormalizeFullName("Rolltank", realmName)
    local ownerC = addon.Store:NormalizeFullName("HealsforDays", realmName)
    local claimantA = addon.Store:NormalizeFullName("Magebuddy", realmName)
    local claimantB = addon.Store:NormalizeFullName("Roguepal", realmName)

    if trimString(playerId) == "" then
        return false
    end

    self:ClearDropReviewRows()

    self:QueueDropReviewRow({
        kind = "need",
        ownerId = ownerA,
        targetId = playerId,
        offerKey = self:BuildLootOfferKey(ownerA, 221122, "Tested Storm Signet"),
        itemId = 221122,
        itemLink = "|cffa335ee|Hitem:221122::::::::80:::::|h[Tested Storm Signet]|h|r",
        itemName = "Tested Storm Signet",
        itemLevel = 639,
        slotLabel = "Ring",
        lootSpecId = "Retribution",
        isPreview = true,
        metaText = "Preview row | Your BIS from a GH3 peer offer",
        canWhisper = true,
        canTrade = true,
        canRoll = true,
        claimantId = playerId,
        keepUntilDismissed = true,
    })

    self:QueueDropReviewRow({
        kind = "offer",
        ownerId = playerId,
        targetId = claimantA,
        offerKey = self:BuildLootOfferKey(playerId, 221233, "Raid-Test Gilded Blade"),
        itemId = 221233,
        itemLink = "|cffa335ee|Hitem:221233::::::::80:::::|h[Raid-Test Gilded Blade]|h|r",
        itemName = "Raid-Test Gilded Blade",
        itemLevel = 645,
        slotLabel = "One-Hand",
        lootSpecId = "Protection",
        isPreview = true,
        metaText = "Preview owner row | Claimed by multiple players",
        canOffer = true,
        canTrade = true,
        claimantId = claimantA,
        keepUntilDismissed = true,
    })
    self:ApplyDropReviewOfferState(self:BuildLootOfferKey(playerId, 221233, "Raid-Test Gilded Blade"), { claimantId = claimantB })
    self:ApplyDropReviewOfferState(self:BuildLootOfferKey(playerId, 221233, "Raid-Test Gilded Blade"), { rollerId = claimantA, rollValue = 42 })
    self:ApplyDropReviewOfferState(self:BuildLootOfferKey(playerId, 221233, "Raid-Test Gilded Blade"), { rollerId = claimantB, rollValue = 88 })

    self:QueueDropReviewRow({
        kind = "offer",
        ownerId = ownerB,
        targetId = "",
        offerKey = self:BuildLootOfferKey(ownerB, 221344, "Dungeon-Test Bulwark"),
        itemId = 221344,
        itemLink = "|cffa335ee|Hitem:221344::::::::80:::::|h[Dungeon-Test Bulwark]|h|r",
        itemName = "Dungeon-Test Bulwark",
        itemLevel = 632,
        slotLabel = "Shield",
        lootSpecId = "Protection",
        isPreview = true,
        metaText = "Preview manual offer | Any GH3 user can claim or roll",
        canWhisper = true,
        canTrade = true,
        canClaim = true,
        canRoll = true,
        keepUntilDismissed = true,
    })

    self:QueueDropReviewRow({
        kind = "self-offer",
        ownerId = playerId,
        targetId = "",
        offerKey = self:BuildLootOfferKey(playerId, 221455, "Solo-Test Relic"),
        itemId = 221455,
        itemLink = "|cffa335ee|Hitem:221455::::::::80:::::|h[Solo-Test Relic]|h|r",
        itemName = "Solo-Test Relic",
        itemLevel = 626,
        slotLabel = "Trinket",
        isPreview = true,
        metaText = "Preview self-offer row | T opens the trade target dropdown",
        canOffer = true,
        canTrade = true,
        keepUntilDismissed = true,
    })

    self:QueueDropReviewRow({
        kind = "need",
        ownerId = ownerC,
        targetId = playerId,
        offerKey = self:BuildLootOfferKey(ownerC, 221566, "Compact-Test Cloak"),
        itemId = 221566,
        itemLink = "|cffa335ee|Hitem:221566::::::::80:::::|h[Compact-Test Cloak]|h|r",
        itemName = "Compact-Test Cloak",
        itemLevel = 628,
        slotLabel = "Back",
        lootSpecId = "Holy",
        isPreview = true,
        metaText = "Preview compact row | Good for testing pin and minimize",
        canWhisper = true,
        canTrade = true,
        canRoll = true,
        keepUntilDismissed = true,
    })

    return true
end

function addon.BISService:SetDropReviewRowTarget(rowId, targetId)
    local resolvedTargetId = normalizePlayerId(targetId)

    if trimString(resolvedTargetId) == "" then
        return false
    end

    for _, row in ipairs(self:GetRuntimeState().dropReview.rows or {}) do
        if type(row) == "table" and tostring(row.id) == tostring(rowId) then
            row.targetId = resolvedTargetId
            return true
        end
    end

    return false
end

function addon.BISService:ListCurrentGroupTradeTargets()
    local lootState = self:GetRuntimeState().loot
    local candidates = collectGroupLootCandidatesCached(lootState)
    local targets = {}
    local playerId = addon.Store:GetPlayerName()

    for _, candidate in ipairs(candidates or {}) do
        if candidate and candidate.fullName and candidate.fullName ~= playerId then
            targets[#targets + 1] = candidate.fullName
        end
    end

    table.sort(targets, function(left, right)
        return lowerText(tostring(left or "")) < lowerText(tostring(right or ""))
    end)

    return targets
end

function addon.BISService:StartTradeWithPlayer(targetId)
    local tradeTargetId = normalizePlayerId(targetId)

    if trimString(tradeTargetId) == "" then
        addon:Print("GH3 BIS: select a player to trade with.")
        return false
    end

    local tradeUnit = findGroupUnitByPlayer(tradeTargetId)

    if not tradeUnit then
        addon:Print(string.format("GH3 BIS: Could not find %s in your current group for trading.", shortPlayerName(tradeTargetId)))
        return false
    end

    if CheckInteractDistance and not CheckInteractDistance(tradeUnit, 2) then
        addon:Print(string.format("GH3 BIS: %s is not in trade range yet.", shortPlayerName(tradeTargetId)))
        return false
    end

    if InitiateTrade then
        InitiateTrade(tradeUnit)
    end

    return true
end

function addon.BISService:HandleGroupRosterUpdate()
    local lootState = self:GetRuntimeState().loot
    lootState.groupLootCandidatesBuiltAt = 0
    lootState.groupLootCandidates = {}

    local activeRows = self:PruneDropReviewRows()

    if #activeRows <= 0 then
        return false
    end

    local candidates = collectGroupLootCandidatesCached(lootState)
    local candidateSet = {}
    local playerId = addon.Store:GetPlayerName()
    local changedAny = false

    for _, candidate in ipairs(candidates or {}) do
        candidateSet[candidate.fullName] = true
    end

    for _, row in ipairs(activeRows) do
        local ownerId = trimString(row.ownerId)
        local targetId = trimString(row.targetId)
        local otherPartyMissing = false

        if ownerId ~= "" and ownerId ~= playerId and not candidateSet[ownerId] then
            otherPartyMissing = true
        elseif targetId ~= "" and targetId ~= playerId and not candidateSet[targetId] then
            otherPartyMissing = true
        end

        if otherPartyMissing then
            if not row.leftGroup then
                row.leftGroup = true
                self:RecordLootHistory(row, "left-group", "Related player left group. Row kept so you can still whisper them later.")
                changedAny = true
            end
        elseif row.leftGroup then
            row.leftGroup = false
            changedAny = true
        end
    end

    return changedAny
end

function addon.BISService:QueueDropReviewRow(payload)
    if type(payload) ~= "table" then
        return false
    end

    local reviewState = self:GetRuntimeState().dropReview
    local now = time()
    local rows = reviewState.rows or {}

    if (now - (tonumber(reviewState.lastPrunedAt) or 0)) >= DROP_REVIEW_PRUNE_INTERVAL_SECONDS then
        rows = self:PruneDropReviewRows()
    end

    local sanitizedItemLink = sanitizeStoredItemLink(payload.itemLink)
    local sanitizedItemName = sanitizeStoredItemText(payload.itemName)
    local itemText = trimString(sanitizedItemLink) ~= "" and sanitizedItemLink or trimString(sanitizedItemName)
    local rowKey = trimString(payload.rowKey)
    local defaultOfferKey = self:BuildLootOfferKey(payload.ownerId or "", payload.itemId, payload.itemName)
    local resolvedOfferKey = trimString(payload.offerKey) ~= "" and trimString(payload.offerKey) or defaultOfferKey
    local keepUntilDismissed = payload.keepUntilDismissed ~= nil and (payload.keepUntilDismissed and true or false) or self:GetLootHelperSettings().keepRowsUntilDismissed

    if rowKey == "" then
        rowKey = self:BuildDropReviewKey(payload.kind or "", payload.ownerId or "", payload.targetId or "", payload.itemId, payload.itemName)
    end

    if trimString(rowKey) == "" or trimString(itemText) == "" then
        return false
    end

    for _, row in ipairs(rows) do
        if row.rowKey == rowKey then
            row.createdAt = now
            row.keepUntilDismissed = payload.keepUntilDismissed ~= nil and (payload.keepUntilDismissed and true or false) or (row.keepUntilDismissed and true or false)
            row.expiresAt = row.keepUntilDismissed and 0 or (now + DROP_REVIEW_CACHE_SECONDS)
            row.kind = trimString(payload.kind) ~= "" and trimString(payload.kind) or trimString(row.kind)
            row.ownerId = trimString(payload.ownerId) ~= "" and normalizePlayerId(payload.ownerId) or trimString(row.ownerId)
            row.targetId = trimString(payload.targetId) ~= "" and normalizePlayerId(payload.targetId) or trimString(row.targetId)
            row.offerKey = resolvedOfferKey ~= "" and resolvedOfferKey or trimString(row.offerKey)
            row.itemLink = trimString(sanitizedItemLink) ~= "" and trimString(sanitizedItemLink) or trimString(row.itemLink)
            row.itemName = trimString(sanitizedItemName) ~= "" and trimString(sanitizedItemName) or trimString(row.itemName)
            row.itemLevel = math.max(tonumber(payload.itemLevel) or 0, tonumber(row.itemLevel) or 0)
            row.slotLabel = trimString(payload.slotLabel) ~= "" and trimString(payload.slotLabel) or trimString(row.slotLabel)
            row.lootSpecId = trimString(payload.lootSpecId) ~= "" and trimString(payload.lootSpecId) or trimString(row.lootSpecId)
            row.metaText = trimString(payload.metaText) ~= "" and trimString(payload.metaText) or trimString(row.metaText)
            row.canWhisper = payload.canWhisper ~= nil and (payload.canWhisper and true or false) or (row.canWhisper and true or false)
            row.canOffer = payload.canOffer ~= nil and (payload.canOffer and true or false) or (row.canOffer and true or false)
            row.canTrade = payload.canTrade ~= nil and (payload.canTrade and true or false) or (row.canTrade and true or false)
            row.canClaim = payload.canClaim ~= nil and (payload.canClaim and true or false) or (row.canClaim and true or false)
            row.canRoll = payload.canRoll ~= nil and (payload.canRoll and true or false) or (row.canRoll and true or false)
            row.leftGroup = payload.leftGroup ~= nil and (payload.leftGroup and true or false) or (row.leftGroup and true or false)
            row.isPreview = payload.isPreview ~= nil and (payload.isPreview and true or false) or (row.isPreview and true or false)
            row.claimantIds = type(row.claimantIds) == "table" and row.claimantIds or {}
            row.rolls = type(row.rolls) == "table" and row.rolls or {}

            if trimString(payload.claimantId) ~= "" then
                row.claimantIds[normalizePlayerId(payload.claimantId)] = true
            end

            if trimString(payload.rollerId) ~= "" and (tonumber(payload.rollValue) or 0) > 0 then
                row.rolls[normalizePlayerId(payload.rollerId)] = math.max(1, math.min(100, math.floor(tonumber(payload.rollValue) or 0)))
            end
            reviewState.rows = rows

            if addon.MainFrame and addon.MainFrame.bisDropReviewFrame and addon.MainFrame.bisDropReviewFrame:IsShown() then
                self:ScheduleDropReviewPopup(DROP_REVIEW_REFRESH_DELAY_SECONDS, { force = true })
            else
                self:ScheduleDropReviewPopup(DROP_REVIEW_POPUP_DELAY_SECONDS)
            end

            return true
        end
    end

    rows[#rows + 1] = {
        id = reviewState.nextId,
        rowKey = rowKey,
        kind = trimString(payload.kind),
        ownerId = normalizePlayerId(payload.ownerId),
        targetId = normalizePlayerId(payload.targetId),
        offerKey = resolvedOfferKey,
        itemId = tonumber(payload.itemId) or 0,
        itemLink = trimString(sanitizedItemLink),
        itemName = trimString(sanitizedItemName),
        itemLevel = tonumber(payload.itemLevel) or 0,
        lootSpecId = trimString(payload.lootSpecId),
        slotLabel = trimString(payload.slotLabel),
        metaText = trimString(payload.metaText),
        canWhisper = payload.canWhisper and true or false,
        canOffer = payload.canOffer and true or false,
        canTrade = payload.canTrade and true or false,
        canClaim = payload.canClaim and true or false,
        canRoll = payload.canRoll and true or false,
        leftGroup = payload.leftGroup and true or false,
        isPreview = payload.isPreview and true or false,
        keepUntilDismissed = keepUntilDismissed,
        claimantIds = {},
        rolls = {},
        createdAt = now,
        expiresAt = keepUntilDismissed and 0 or (now + DROP_REVIEW_CACHE_SECONDS),
    }

    if trimString(payload.claimantId) ~= "" then
        rows[#rows].claimantIds[normalizePlayerId(payload.claimantId)] = true
    end

    if trimString(payload.rollerId) ~= "" and (tonumber(payload.rollValue) or 0) > 0 then
        rows[#rows].rolls[normalizePlayerId(payload.rollerId)] = math.max(1, math.min(100, math.floor(tonumber(payload.rollValue) or 0)))
    end

    reviewState.nextId = reviewState.nextId + 1
    reviewState.rows = rows

    if addon.MainFrame and addon.MainFrame.bisDropReviewFrame and addon.MainFrame.bisDropReviewFrame:IsShown() then
        self:ScheduleDropReviewPopup(DROP_REVIEW_REFRESH_DELAY_SECONDS, { force = true })
    else
        self:ScheduleDropReviewPopup(DROP_REVIEW_POPUP_DELAY_SECONDS)
    end

    return true
end

function addon.BISService:HandleDropReviewAction(rowId, actionKey)
    local action = string.upper(trimString(actionKey))
    local selectedRow = nil
    local playerId = addon.Store:GetPlayerName()

    for _, row in ipairs(self:ListActiveDropReviewRows()) do
        if tostring(row.id) == tostring(rowId) then
            selectedRow = row
            break
        end
    end

    if not selectedRow then
        return false
    end

    local itemText = trimString(selectedRow.itemLink) ~= "" and selectedRow.itemLink or (trimString(selectedRow.itemName) ~= "" and selectedRow.itemName or "that item")
    local slotText = trimString(selectedRow.slotLabel)
    local specText = trimString(selectedRow.lootSpecId)
    local offerKey = trimString(selectedRow.offerKey) ~= "" and trimString(selectedRow.offerKey) or self:BuildLootOfferKey(selectedRow.ownerId, selectedRow.itemId, selectedRow.itemName)

    if action == "W" then
        if not selectedRow.canWhisper or trimString(selectedRow.ownerId) == "" then
            return false
        end

        local whisperText = string.format("GH3 BIS: If you do not need %s, could you trade it to me%s%s?",
            itemText,
            slotText ~= "" and (" for my " .. slotText) or "",
            specText ~= "" and (" (" .. specText .. ")") or "")

        if sendWhisperMessage(whisperText, selectedRow.ownerId) then
            self:RecordLootHistory(selectedRow, "whispered", "Sent GH3 request whisper.")
            return true
        end

        return false
    end

    if action == "O" then
        if not selectedRow.canOffer then
            return false
        end

        local offerText = string.format("GH3 BIS: %s is tradable from me. Whisper me if you need it%s%s.",
            itemText,
            slotText ~= "" and (" for " .. slotText) or "",
            specText ~= "" and (" (" .. specText .. ")") or "")

        local postedToGroup = sendGroupChatMessage(offerText)
        local sharedWithAddonPeers = self:BroadcastManualGroupOffer(selectedRow)

        if postedToGroup or sharedWithAddonPeers then
            self:RecordLootHistory(selectedRow, "offered", sharedWithAddonPeers and "Posted GH3 offer to the group and broadcast it to GH3 users." or "Posted GH3 offer to the current group.")
            return true
        end

        return false
    end

    if action == "C" then
        if not selectedRow.canClaim or trimString(selectedRow.ownerId) == "" or selectedRow.ownerId == playerId then
            return false
        end

        local claimPayload = {
            itemId = tonumber(selectedRow.itemId) or 0,
            itemLink = trimString(selectedRow.itemLink),
            itemName = trimString(selectedRow.itemName),
            claimant = playerId,
            lootSpecId = self:GetLootSpecId(playerId),
            slotLabel = slotText,
        }
        local sentClaim = self:SendPeerMessage("CLAIM", claimPayload, selectedRow.ownerId)
        local sentWhisper = sendWhisperMessage(string.format("GH3 BIS: I am interested in %s%s%s.",
            itemText,
            slotText ~= "" and (" for " .. slotText) or "",
            specText ~= "" and (" (" .. specText .. ")") or ""), selectedRow.ownerId)

        self:ApplyDropReviewOfferState(offerKey, { claimantId = playerId })

        if sentClaim or sentWhisper then
            self:RecordLootHistory(selectedRow, "claimed", "Sent GH3 claim for the item.")
            self:DismissDropReviewRow(selectedRow.id)
            return true
        end

        return false
    end

    if action == "R" then
        if not selectedRow.canRoll or trimString(selectedRow.ownerId) == "" or selectedRow.ownerId == playerId then
            return false
        end

        local rollValue = math.max(1, math.min(100, math.floor(((math.random and math.random(1, 100)) or 1))))
        local postedRoll = sendGroupChatMessage(string.format("GH3 Loot Roll: %s rolled %d for %s.", shortPlayerName(playerId), rollValue, itemText))
        local sharedRoll = self:BroadcastLootRoll(selectedRow, rollValue)

        self:ApplyDropReviewOfferState(offerKey, {
            claimantId = playerId,
            rollerId = playerId,
            rollValue = rollValue,
        })

        if addon.MainFrame and addon.MainFrame.bisDropReviewFrame and addon.MainFrame.bisDropReviewFrame:IsShown() then
            addon.MainFrame:ShowBISDropReviewDialog()
        end

        if postedRoll or sharedRoll then
            self:RecordLootHistory(selectedRow, "rolled", string.format("Rolled %d for the item.", rollValue))
            return true
        end

        return false
    end

    if action == "D" then
        self:RecordLootHistory(selectedRow, "dismissed", "Row dismissed manually.")
        return self:DismissDropReviewRow(selectedRow.id)
    end

    if action == "T" then
        if not selectedRow.canTrade then
            return false
        end

        if selectedRow.leftGroup then
            addon:Print("GH3 BIS: trade is unavailable until that player is back in your current group.")
            return false
        end

        local tradeTargetId = resolveDropReviewTradeTargetId(selectedRow, playerId)

        if trimString(selectedRow.kind) == "self-offer" and trimString(tradeTargetId) == "" then
            addon:Print("GH3 Loot Helper: select a target first for this self-offer row.")
            return false
        end

        if trimString(tradeTargetId) == "" then
            addon:Print("GH3 BIS: this row does not have a valid trade target yet.")
            return false
        end

        if not self:StartTradeWithPlayer(tradeTargetId) then
            return false
        end

        self:RecordLootHistory(selectedRow, "trade-started", "Attempted to open trade from the drop review row.")
        self:DismissDropReviewRow(selectedRow.id)
        return true
    end

    return false
end

function addon.BISService:GetPrefix()
    return PREFIX
end

function addon.BISService:SendGroupMessage(command, payload)
    if (addon.IsSuspended and addon:IsSuspended()) or trimString(command) == "" then
        return false
    end

    local channel = getGroupAddonChannel()
    local encodedPayload = encodeJson(payload or {})

    if not channel or not encodedPayload then
        return false
    end

    return sendAddonMessage(self:GetPrefix(), command .. ":" .. encodedPayload, channel)
end

function addon.BISService:SendPeerMessage(command, payload, target)
    if (addon.IsSuspended and addon:IsSuspended()) or trimString(command) == "" or trimString(target) == "" then
        return false
    end

    local encodedPayload = encodeJson(payload or {})

    if not encodedPayload then
        return false
    end

    return sendAddonMessage(self:GetPrefix(), command .. ":" .. encodedPayload, "WHISPER", target)
end

function addon.BISService:MarkInventoryScanDirty(toonId)
    if toonId and not isCurrentPlayerToon(toonId) then
        return false
    end

    self:GetRuntimeState().inventory.dirty = true
    return true
end

function addon.BISService:GetCachedTrackInfo(itemLink)
    if not itemLink or itemLink == "" then
        return 0, ""
    end

    local inventoryState = self:GetRuntimeState().inventory
    local cached = inventoryState.trackInfoCache[itemLink]
    local now = time()

    if type(cached) == "table" then
        cached.accessedAt = now
        return cached.rank, cached.label
    end

    pruneTrackInfoCache(inventoryState.trackInfoCache)

    local rank, label = getTrackInfoFromItemLink(itemLink)
    inventoryState.trackInfoCache[itemLink] = {
        rank = rank,
        label = label,
        accessedAt = now,
    }
    return rank, label
end

local function resolveLootFilterIds(toonId, specId)
    local _, member = getToonFilterMember(toonId)
    local classFile = member and trimString(member.classFile) or ""
    local classId = classFile ~= "" and CLASS_FILE_TO_ID[classFile] or 0
    local requestedSpecId = normalizeSpecId(specId)
    local resolvedSpecId = tonumber(requestedSpecId) or 0

    if requestedSpecId == DEFAULT_SPEC_ID then
        requestedSpecId = normalizeSpecId(member and member.spec or nil)
        resolvedSpecId = tonumber(requestedSpecId) or 0
    end

    local normalizedSpecName = requestedSpecId ~= DEFAULT_SPEC_ID and lowerText(requestedSpecId) or ""

    if classId > 0 and resolvedSpecId <= 0 and normalizedSpecName ~= "" and GetNumSpecializationsForClassID and GetSpecializationInfoForClassID then
        local specCount = tonumber(GetNumSpecializationsForClassID(classId)) or 0

        for index = 1, specCount do
            local candidateSpecId, candidateSpecName = GetSpecializationInfoForClassID(classId, index)

            if lowerText(candidateSpecName) == normalizedSpecName or tostring(candidateSpecId) == requestedSpecId then
                resolvedSpecId = tonumber(candidateSpecId) or 0
                break
            end
        end
    end

    return classId, resolvedSpecId
end

function addon.BISService:BuildEncounterJournalIndex(options)
    local runtimeState = self:GetRuntimeState()
    local journalState = runtimeState.journal
    local resolvedOptions = type(options) == "table" and options or {}
    local currentSeasonOnly = resolvedOptions.currentSeasonOnly and true or false
    local requestedScope = currentSeasonOnly and "current" or "all"
    local filterClassId, filterSpecId = resolveLootFilterIds(resolvedOptions.toonId, resolvedOptions.specId)
    local requestedFilterKey = string.format("%s|%d|%d", requestedScope, tonumber(filterClassId) or 0, tonumber(filterSpecId) or 0)
    local now = time()

    if journalState.index and (now - (journalState.builtAt or 0)) < JOURNAL_CACHE_SECONDS then
        if journalState.filterKey == requestedFilterKey and (journalState.scope == "all" or journalState.scope == requestedScope) then
        return journalState.index
        end
    end

    journalState.lastBuildStatus = "starting"
    journalState.lastMissingApis = ""
    journalState.lastTierCount = 0
    journalState.lastCurrentTier = 0
    journalState.lastLoadAttempted = false
    journalState.lastLoadResult = ""
    journalState.lastRequestedFilterClassId = tonumber(filterClassId) or 0
    journalState.lastRequestedFilterSpecId = tonumber(filterSpecId) or 0
    journalState.lastAppliedFilterClassId = 0
    journalState.lastAppliedFilterSpecId = 0

    local isJournalLoaded = (C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("Blizzard_EncounterJournal"))
        or (IsAddOnLoaded and IsAddOnLoaded("Blizzard_EncounterJournal"))

    if not isJournalLoaded then
        journalState.lastLoadAttempted = true

        if EncounterJournal_LoadUI then
            local ok = pcall(EncounterJournal_LoadUI)
            journalState.lastLoadResult = ok and "EncounterJournal_LoadUI" or "EncounterJournal_LoadUI failed"
        end

        if not ((C_AddOns and C_AddOns.IsAddOnLoaded and C_AddOns.IsAddOnLoaded("Blizzard_EncounterJournal"))
            or (IsAddOnLoaded and IsAddOnLoaded("Blizzard_EncounterJournal"))) then
            if UIParentLoadAddOn then
                local ok = pcall(UIParentLoadAddOn, "Blizzard_EncounterJournal")
                journalState.lastLoadResult = ok and "UIParentLoadAddOn" or "UIParentLoadAddOn failed"
            end
        end

        if C_AddOns and C_AddOns.LoadAddOn then
            local ok = pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal")
            if ok then
                journalState.lastLoadResult = "C_AddOns.LoadAddOn"
            end
        elseif LoadAddOn then
            local ok = pcall(LoadAddOn, "Blizzard_EncounterJournal")
            if ok then
                journalState.lastLoadResult = "LoadAddOn"
            end
        end
    end

    local journalApi = {
        GetCurrentTier = EJ_GetCurrentTier or (C_EncounterJournal and C_EncounterJournal.GetCurrentTier),
        GetDifficulty = EJ_GetDifficulty or (C_EncounterJournal and C_EncounterJournal.GetDifficulty),
        GetEncounterInfo = EJ_GetEncounterInfo or (C_EncounterJournal and C_EncounterJournal.GetEncounterInfo),
        GetEncounterInfoByIndex = EJ_GetEncounterInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetEncounterInfoByIndex),
        GetInstanceByIndex = EJ_GetInstanceByIndex or (C_EncounterJournal and C_EncounterJournal.GetInstanceByIndex),
        GetLootFilter = EJ_GetLootFilter or (C_EncounterJournal and C_EncounterJournal.GetLootFilter),
        GetLootInfoByIndex = EJ_GetLootInfoByIndex or (C_EncounterJournal and C_EncounterJournal.GetLootInfoByIndex),
        GetNumLoot = EJ_GetNumLoot or (C_EncounterJournal and C_EncounterJournal.GetNumLoot),
        GetNumTiers = EJ_GetNumTiers or (C_EncounterJournal and C_EncounterJournal.GetNumTiers),
        SelectEncounter = EJ_SelectEncounter or (C_EncounterJournal and C_EncounterJournal.SelectEncounter),
        SelectInstance = EJ_SelectInstance or (C_EncounterJournal and C_EncounterJournal.SelectInstance),
        SetDifficulty = EJ_SetDifficulty or (C_EncounterJournal and C_EncounterJournal.SetDifficulty),
        SetLootFilter = EJ_SetLootFilter or (C_EncounterJournal and C_EncounterJournal.SetLootFilter),
        SelectTier = EJ_SelectTier or (C_EncounterJournal and C_EncounterJournal.SelectTier),
    }

    local missingApis = {}

    for key, value in pairs(journalApi) do
        if type(value) ~= "function" then
            missingApis[#missingApis + 1] = key
        end
    end

    if #missingApis > 0 then
        journalState.lastBuildStatus = "missing-api"
        journalState.lastMissingApis = table.concat(missingApis, ",")
        journalState.index = {}
        journalState.itemIdIndex = {}
        journalState.lootRows = {}
        journalState.builtAt = now
        return nil
    end

    local tierCount = tonumber(journalApi.GetNumTiers()) or 0
    local currentTier = journalApi.GetCurrentTier and tonumber(journalApi.GetCurrentTier()) or 0
    journalState.lastTierCount = tierCount
    journalState.lastCurrentTier = currentTier

    if tierCount <= 0 and currentTier <= 0 then
        journalState.lastBuildStatus = "no-tiers"
        journalState.index = {}
        journalState.itemIdIndex = {}
        journalState.lootRows = {}
        journalState.builtAt = now
        return nil
    end

    local index = {}
    local itemIdIndex = {}
    local lootRows = {}
    local seenLootRows = {}

    local function getLootRow(lootIndex)
        local lootInfo = journalApi.GetLootInfoByIndex(lootIndex)

        if type(lootInfo) == "table" then
            local itemId = tonumber(lootInfo.itemID or lootInfo.itemId or lootInfo.id) or 0
            local encounterId = tonumber(lootInfo.encounterID or lootInfo.encounterId) or 0
            local itemLink = trimString(lootInfo.link or lootInfo.itemLink)
            local itemName = resolveJournalItemName(itemId, lootInfo, itemLink)
            return itemId, encounterId, itemName, itemLink, lootInfo
        end

        local itemId, encounterId, itemName = journalApi.GetLootInfoByIndex(lootIndex)
        return tonumber(itemId) or 0, tonumber(encounterId) or 0, normalizeItemName(itemName), "", nil
    end

    local function addLootRow(itemId, encounterId, itemName, itemLink, instanceName, sourceType, fallbackTarget, lootInfo, tierIndex, difficultyId)
        if (tonumber(itemId) or 0) <= 0 and trimString(itemName) == "" then
            return
        end

        local bossName = trimString(fallbackTarget)

        if (tonumber(encounterId) or 0) > 0 and journalApi.GetEncounterInfo then
            bossName = trimString(select(1, journalApi.GetEncounterInfo(encounterId)))
        end

        local slotKeys, equipLoc = getJournalSlotKeys(itemId, itemLink, lootInfo and (lootInfo.filterType or lootInfo.invType or lootInfo.slotFilter))
        local trackRank, trackLabel = getTrackInfoFromItemLink(itemLink)
        local isCurrentSeason = tierIndex and currentTier and tierIndex == currentTier or false

        if sourceType == "tier" and currentSeasonOnly then
            isCurrentSeason = true
        end

        local sourceRecord = {
            currentSeason = isCurrentSeason,
            difficultyId = tonumber(difficultyId) or 0,
            difficultyLabel = getJournalDifficultyLabel(sourceType, difficultyId),
            equipLoc = equipLoc,
            itemId = tonumber(itemId) or 0,
            itemName = trimString(itemName),
            itemLink = trimString(itemLink),
            slotKeys = slotKeys,
            sourceName = trimString(instanceName),
            sourceTarget = bossName,
            sourceType = sourceType,
            specId = tonumber(lootInfo and (lootInfo.specID or lootInfo.specId)) or 0,
            specName = trimString(lootInfo and (lootInfo.specName or lootInfo.spec) or ""),
            tierIndex = tonumber(tierIndex) or 0,
            trackKey = getTrackFilterKey(trackRank),
            trackLabel = trimString(trackLabel),
            trackRank = tonumber(trackRank) or 0,
        }

        local lootKey = table.concat({
            tostring(sourceRecord.itemId),
            tostring(sourceRecord.difficultyId),
            lowerText(sourceRecord.itemName),
            lowerText(sourceRecord.sourceType),
            lowerText(sourceRecord.sourceName),
            lowerText(sourceRecord.sourceTarget),
            lowerText(sourceRecord.trackKey),
        }, "|")

        if not seenLootRows[lootKey] then
            seenLootRows[lootKey] = true
            lootRows[#lootRows + 1] = sourceRecord
        end

        if sourceRecord.itemId > 0 and not itemIdIndex[sourceRecord.itemId] then
            itemIdIndex[sourceRecord.itemId] = sourceRecord
        end

        local aliasValues = {
            itemName,
            itemLink,
            (tonumber(itemId) or 0) > 0 and (GetItemInfo and GetItemInfo(itemId) or "") or "",
        }

        for _, aliasValue in ipairs(aliasValues) do
            for _, canonicalName in ipairs(buildCanonicalItemAliases(aliasValue)) do
                if canonicalName ~= "" and not index[canonicalName] then
                    index[canonicalName] = sourceRecord
                end
            end
        end
    end

    local originalDifficulty = journalApi.GetDifficulty and tonumber(journalApi.GetDifficulty()) or 0
    local originalFilterClassId, originalFilterSpecId = 0, 0
    local raidDifficulties = { 14, 15, 16, 17 }
    local dungeonDifficulties = { 1, 2, 23, 8 }
    local tierIndices = {}

    local function addTierSetRows()
        if not (C_LootJournal and type(C_LootJournal.GetItemSets) == "function" and type(C_LootJournal.GetItemSetItems) == "function") then
            return
        end

        local itemSets = C_LootJournal.GetItemSets(filterClassId > 0 and filterClassId or nil, filterSpecId > 0 and filterSpecId or nil) or {}

        for _, itemSetInfo in ipairs(itemSets) do
            local setName = trimString(itemSetInfo and itemSetInfo.name)
            local setItems = C_LootJournal.GetItemSetItems(itemSetInfo and itemSetInfo.setID) or {}

            for _, setItemInfo in ipairs(setItems) do
                local itemId = tonumber(setItemInfo and (setItemInfo.itemID or setItemInfo.itemId)) or 0

                if itemId > 0 then
                    local itemName = resolveItemNameFromItemId(itemId)
                    local itemLink = select(2, GetItemInfo(itemId)) or ""
                    addLootRow(itemId, 0, itemName, itemLink, setName ~= "" and setName or "Tier Set", "tier", "Catalyst / Raid", {
                        specID = filterSpecId,
                        specName = specId,
                    }, currentTier > 0 and currentTier or 0, 0)
                end
            end
        end
    end

    if journalApi.GetLootFilter then
        originalFilterClassId, originalFilterSpecId = journalApi.GetLootFilter()
        originalFilterClassId = tonumber(originalFilterClassId) or 0
        originalFilterSpecId = tonumber(originalFilterSpecId) or 0
    end

    local function applyRequestedLootFilter()
        if not (journalApi.SetLootFilter and filterClassId > 0) then
            return
        end

        pcall(journalApi.SetLootFilter, filterClassId, filterSpecId)

        if journalApi.GetLootFilter then
            local appliedClassId, appliedSpecId = journalApi.GetLootFilter()
            journalState.lastAppliedFilterClassId = tonumber(appliedClassId) or 0
            journalState.lastAppliedFilterSpecId = tonumber(appliedSpecId) or 0
        else
            journalState.lastAppliedFilterClassId = tonumber(filterClassId) or 0
            journalState.lastAppliedFilterSpecId = tonumber(filterSpecId) or 0
        end
    end

    applyRequestedLootFilter()

    if currentSeasonOnly and currentTier and currentTier > 0 then
        tierIndices[1] = currentTier
    elseif tierCount > 0 then
        for tierIndex = 1, tierCount do
            tierIndices[#tierIndices + 1] = tierIndex
        end
    else
        tierIndices[1] = false
    end

    for _, tierIndex in ipairs(tierIndices) do
        if tierIndex then
            local selectOk = pcall(journalApi.SelectTier, tierIndex)

            if not selectOk then
                journalState.lastBuildStatus = "invalid-tier"
                journalState.lastInvalidTier = tierIndex
                break
            end

            applyRequestedLootFilter()
        end

        for _, isRaid in ipairs({ true, false }) do
            local difficulties = isRaid and raidDifficulties or dungeonDifficulties

            for _, difficultyId in ipairs(difficulties) do
                if journalApi.SetDifficulty then
                    journalApi.SetDifficulty(difficultyId)
                end

                applyRequestedLootFilter()

            for instanceIndex = 1, 128 do
                local instanceId, instanceName = journalApi.GetInstanceByIndex(instanceIndex, isRaid)

                if not instanceId then
                    break
                end

                journalApi.SelectInstance(instanceId)
                applyRequestedLootFilter()

                for lootIndex = 1, (tonumber(journalApi.GetNumLoot()) or 0) do
                    local itemId, encounterId, itemName, itemLink, lootInfo = getLootRow(lootIndex)
                    addLootRow(itemId, encounterId, itemName, itemLink, instanceName, isRaid and "raid" or "dungeon", "", lootInfo, tierIndex, difficultyId)
                end

                if journalApi.GetEncounterInfoByIndex and journalApi.SelectEncounter then
                    for encounterIndex = 1, 64 do
                        local encounterName, _, encounterId = journalApi.GetEncounterInfoByIndex(encounterIndex, instanceId)

                        if not encounterId then
                            break
                        end

                        journalApi.SelectEncounter(encounterId)
                        applyRequestedLootFilter()

                        for lootIndex = 1, (tonumber(journalApi.GetNumLoot()) or 0) do
                            local itemId, lootEncounterId, itemName, itemLink, lootInfo = getLootRow(lootIndex)
                            addLootRow(itemId, lootEncounterId or encounterId, itemName, itemLink, instanceName, isRaid and "raid" or "dungeon", encounterName, lootInfo, tierIndex, difficultyId)
                        end
                    end
                end
            end
            end
        end
    end

    addTierSetRows()

    if journalApi.SetDifficulty and originalDifficulty and originalDifficulty > 0 then
        journalApi.SetDifficulty(originalDifficulty)
    end

    if journalApi.SetLootFilter then
        pcall(journalApi.SetLootFilter, originalFilterClassId, originalFilterSpecId)
    end

    if tierCount > 0 and currentTier and currentTier > 0 and currentTier <= tierCount then
        pcall(journalApi.SelectTier, currentTier)
    end

    journalState.lastBuildStatus = "ok"
    journalState.builtAt = now
    journalState.filterKey = requestedFilterKey
    journalState.index = index
    journalState.itemIdIndex = itemIdIndex
    journalState.lootRows = lootRows
    journalState.scope = requestedScope
    return index
end

function addon.BISService:TryAutofillEntryFromJournal(entry, options)
    if type(entry) ~= "table" then
        return false
    end

    local resolvedOptions = type(options) == "table" and options or {}
    local forceSourceAutofill = resolvedOptions.forceSourceAutofill and true or false
    local journalState = self:GetRuntimeState().journal or {}
    local journalIndex = nil
    local itemId = tonumber(entry.itemId) or 0
    local match = nil

    if resolvedOptions.allowJournalBuild == false then
        journalIndex = journalState.index
    else
        journalIndex = self:BuildEncounterJournalIndex(resolvedOptions)
    end

    if type(journalIndex) ~= "table" then
        return false
    end

    if itemId > 0 and type(journalState.itemIdIndex) == "table" then
        match = journalState.itemIdIndex[itemId]
    end

    if not match then
        local aliasValues = {
            entry.itemName,
            itemId > 0 and (GetItemInfo and GetItemInfo(itemId) or "") or "",
        }

        for _, aliasValue in ipairs(aliasValues) do
            for _, canonicalName in ipairs(buildCanonicalItemAliases(aliasValue)) do
                match = journalIndex and journalIndex[canonicalName] or nil

                if match then
                    break
                end
            end

            if match then
                break
            end
        end
    end

    if not match then
        return false
    end

    local changed = false

    if (tonumber(entry.itemId) or 0) <= 0 and (tonumber(match.itemId) or 0) > 0 then
        entry.itemId = tonumber(match.itemId) or 0
        changed = true
    end

    if trimString(entry.itemName) == "" then
        local resolvedItemName = resolveItemNameFromItemId(entry.itemId)
        local matchItemName = trimString(match.itemName)
        local nextItemName = resolvedItemName ~= "" and resolvedItemName or matchItemName

        if nextItemName ~= "" then
            entry.itemName = nextItemName
            changed = true
        end
    end

    if trimString(match.sourceName) ~= "" then
        if forceSourceAutofill then
            if trimString(entry.sourceName) ~= trimString(match.sourceName) then
                entry.sourceName = trimString(match.sourceName)
                changed = true
            end
        elseif trimString(entry.sourceName) == "" then
            entry.sourceName = trimString(match.sourceName)
            changed = true
        end
    end

    if trimString(match.sourceTarget) ~= "" then
        if forceSourceAutofill then
            if trimString(entry.sourceTarget) ~= trimString(match.sourceTarget) then
                entry.sourceTarget = trimString(match.sourceTarget)
                changed = true
            end
        elseif trimString(entry.sourceTarget) == "" then
            entry.sourceTarget = trimString(match.sourceTarget)
            changed = true
        end
    end

    if trimString(match.sourceType) ~= "" then
        local resolvedSourceType = normalizeSourceType(match.sourceType)

        if forceSourceAutofill then
            if normalizeSourceType(entry.sourceType) ~= resolvedSourceType then
                entry.sourceType = resolvedSourceType
                changed = true
            end
        elseif (trimString(entry.sourceType) == "" or entry.sourceType == "other") then
            entry.sourceType = resolvedSourceType
            changed = true
        end
    end

    return changed
end

function addon.BISService:TryAutofillEntryFallback(toonId, specId, slotKey, entry)
    if type(entry) ~= "table" then
        return false
    end

    local tierSlots = {
        head = true,
        shoulder = true,
        chest = true,
        hands = true,
        legs = true,
    }

    if not tierSlots[normalizeSlotKey(slotKey)] then
        return false
    end

    local tierFamilySignature = getTierFamilySignature(entry.itemName)

    if tierFamilySignature == "" then
        return false
    end

    local specRecord = self:GetSpecRecord(toonId, specId)

    if not specRecord then
        return false
    end

    local familyCount = 0

    for candidateSlotKey, slotRecord in pairs(specRecord.slots or {}) do
        if tierSlots[candidateSlotKey] then
            for _, candidateEntry in ipairs(slotRecord.entries or {}) do
                local candidateSignature = getTierFamilySignature(candidateEntry.itemName)

                if candidateSignature ~= "" and candidateSignature == tierFamilySignature then
                    familyCount = familyCount + 1
                    break
                end
            end
        end
    end

    if familyCount < 2 then
        return false
    end

    local changed = false

    if trimString(entry.sourceName) == "" then
        entry.sourceName = "Tier Set"
        changed = true
    end

    if trimString(entry.sourceTarget) == "" then
        entry.sourceTarget = ""
    end

    if trimString(entry.sourceType) == "" or entry.sourceType == "other" then
        entry.sourceType = "tier"
        changed = true
    end

    return changed
end

function addon.BISService:TryAutofillEntryFromClientCache(entry)
    if type(entry) ~= "table" then
        return false
    end

    local changed = false
    local currentItemId = math.max(0, math.floor(tonumber(entry.itemId) or 0))

    if currentItemId <= 0 and trimString(entry.itemName) ~= "" then
        local resolvedItemId, resolvedItemName = resolveItemFromClientCacheByName(entry.itemName)

        if resolvedItemId > 0 then
            entry.itemId = resolvedItemId
            currentItemId = resolvedItemId
            changed = true
        end

        if trimString(entry.itemName) == "" and resolvedItemName ~= "" then
            entry.itemName = resolvedItemName
            changed = true
        end
    end

    if currentItemId > 0 and trimString(entry.itemName) == "" then
        local resolvedItemName = resolveItemNameFromItemId(currentItemId)

        if resolvedItemName ~= "" then
            entry.itemName = resolvedItemName
            changed = true
        end
    end

    return changed
end

function addon.BISService:TryAutofillEntryMetadata(toonId, specId, slotKey, entry, options)
    local changed = false

    if self:TryAutofillEntryFromClientCache(entry) then
        changed = true
    end

    if self:TryAutofillEntryFromJournal(entry, options) then
        changed = true
    end

    if self:TryAutofillEntryFallback(toonId, specId, slotKey, entry) then
        changed = true
    end

    return changed
end

function addon.BISService:EnsureCurrentInventoryScan(options)
    local resolvedOptions = type(options) == "table" and options or {}
    local playerId = addon.Store:GetPlayerName()
    local toonRecord = self:GetToonRecord(playerId)
    local specRecord = self:GetSpecRecord(playerId)

    if not playerId or not toonRecord or not specRecord then
        return false
    end

    local inventoryState = self:GetRuntimeState().inventory
    local now = time()
    local specRevision = tonumber(specRecord.revision) or 0
    local isStale = (now - (tonumber(inventoryState.lastScanAt) or 0)) >= INVENTORY_SCAN_STALE_SECONDS
    local revisionChanged = inventoryState.lastPlayerId ~= playerId or (tonumber(inventoryState.lastSpecRevision) or 0) ~= specRevision
    local shouldScan = resolvedOptions.force or inventoryState.dirty or revisionChanged or isStale

    if not shouldScan then
        return false
    end

    if not resolvedOptions.force and (now - (tonumber(inventoryState.lastScanAt) or 0)) < INVENTORY_SCAN_THROTTLE_SECONDS then
        inventoryState.dirty = true
        return false
    end

    return self:ScanCurrentToonInventory(resolvedOptions.announce and true or false)
end

function addon.BISService:BackfillJournalDataForSpec(toonId, specId, options)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId, specId)

    if not toonRecord or not specRecord then
        return false
    end

    local runtimeState = self:GetRuntimeState()
    runtimeState.journal.backfillRevisionBySpec = runtimeState.journal.backfillRevisionBySpec or {}

    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(toonId))
    local cacheKey = normalizedToonId .. "|" .. resolvedSpecId
    local currentRevision = tonumber(specRecord.revision) or 0
    local currentBackfillStamp = string.format("%d:%d", currentRevision, METADATA_AUTOFILL_VERSION)

    if tostring(runtimeState.journal.backfillRevisionBySpec[cacheKey] or "") == currentBackfillStamp then
        return false
    end

    local changed = false

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecordForSpec(normalizedToonId, resolvedSpecId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            if self:TryAutofillEntryMetadata(normalizedToonId, resolvedSpecId, slot.key, entry, options) then
                changed = true
            end
        end
    end

    if changed then
        self:TouchRecord(toonRecord, specRecord)
        currentRevision = tonumber(specRecord.revision) or currentRevision
        self:MarkInventoryScanDirty(toonId)
        currentBackfillStamp = string.format("%d:%d", currentRevision, METADATA_AUTOFILL_VERSION)
    end

    runtimeState.journal.backfillRevisionBySpec[cacheKey] = currentBackfillStamp
    return changed
end

function addon.BISService:GetSourceTypeOptions()
    return SOURCE_TYPE_OPTIONS
end

function addon.BISService:GetOwnershipOptions()
    return OWNERSHIP_OPTIONS
end

function addon.BISService:GetCollectionSortOptions()
    return COLLECTION_SORT_OPTIONS
end

function addon.BISService:GetPriorityOptions()
    return PRIORITY_OPTIONS
end

function addon.BISService:GetSlotOptions()
    return SLOT_ORDER
end

function addon.BISService:ListJournalLootOptions(toonId, specId, slotKey, filters)
    local normalizedSlotKey = normalizeSlotKey(slotKey)
    local resolvedFilters = type(filters) == "table" and filters or { sourceType = filters }
    self:BuildEncounterJournalIndex({ currentSeasonOnly = resolvedFilters.currentSeasonOnly and true or false, toonId = toonId, specId = specId })
    local normalizedSourceType = trimString(resolvedFilters.sourceType) == "all" and "all" or normalizeSourceType(resolvedFilters.sourceType)
    local currentSeasonOnly = resolvedFilters.currentSeasonOnly and true or false
    local trackFilter = canonicalToken(resolvedFilters.trackFilter)
    local rows = {}
    local lootRows = self:GetRuntimeState().journal and self:GetRuntimeState().journal.lootRows or {}

    for _, row in ipairs(lootRows or {}) do
        local include = true

        if normalizedSlotKey and not slotKeyAppearsInList(normalizedSlotKey, row.slotKeys) then
            include = false
        end

        if include and normalizedSourceType ~= "all" and row.sourceType ~= normalizedSourceType then
            include = false
        end

        if include and currentSeasonOnly and not row.currentSeason then
            include = false
        end

        if include and trackFilter ~= "" and trackFilter ~= "all" and canonicalToken(row.trackKey) ~= trackFilter then
            include = false
        end

        if include then
            rows[#rows + 1] = {
                currentSeason = row.currentSeason and true or false,
                difficultyLabel = trimString(row.difficultyLabel),
                itemId = tonumber(row.itemId) or 0,
                itemName = trimString(row.itemName),
                key = table.concat({ tostring(row.itemId or 0), tostring(row.difficultyId or 0), lowerText(row.sourceName), lowerText(row.sourceTarget), lowerText(row.trackKey) }, "|"),
                slotKeys = row.slotKeys or {},
                sourceName = trimString(row.sourceName),
                sourceTarget = trimString(row.sourceTarget),
                sourceType = trimString(row.sourceType),
                sourceTypeLabel = self:GetSourceTypeLabel(row.sourceType),
                summaryLabel = string.format("%s | %s | %s | %s", trimString(row.itemName), trimString(row.trackLabel) ~= "" and trimString(row.trackLabel) or "Track ?", trimString(row.sourceName), trimString(row.sourceTarget) ~= "" and trimString(row.sourceTarget) or (trimString(row.difficultyLabel) ~= "" and trimString(row.difficultyLabel) or self:GetSourceTypeLabel(row.sourceType))),
                trackKey = trimString(row.trackKey),
                trackLabel = trimString(row.trackLabel),
            }
        end
    end

    table.sort(rows, function(left, right)
        if left.currentSeason ~= right.currentSeason then
            return left.currentSeason
        end

        if lowerText(left.itemName) == lowerText(right.itemName) then
            if lowerText(left.trackKey) == lowerText(right.trackKey) then
                if lowerText(left.sourceName) == lowerText(right.sourceName) then
                    return lowerText(left.sourceTarget) < lowerText(right.sourceTarget)
                end

                return lowerText(left.sourceName) < lowerText(right.sourceName)
            end

            return lowerText(left.trackKey) < lowerText(right.trackKey)
        end

        return lowerText(left.itemName) < lowerText(right.itemName)
    end)

    return rows
end

function addon.BISService:BuildGearEntryPreview(toonId, specId, slotKey, payload)
    local entry = buildEmptyEntry()
    local resolvedPayload = type(payload) == "table" and payload or {}
    local previewOptions = type(resolvedPayload.__options) == "table" and resolvedPayload.__options or {}
    local forceSourceAutofill = previewOptions.forceSourceAutofill and true or false
    local previewSourceType = forceSourceAutofill and "" or (trimString(resolvedPayload.sourceType) == "all" and "" or resolvedPayload.sourceType)
    local itemId = resolveImportedItemId({
        __rawValues = { resolvedPayload.itemId, resolvedPayload.itemName, resolvedPayload.sourceName, resolvedPayload.sourceTarget },
        itemId = resolvedPayload.itemId,
        itemName = resolvedPayload.itemName,
        sourceName = resolvedPayload.sourceName,
        sourceTarget = resolvedPayload.sourceTarget,
    })

    entry.itemId = math.max(0, math.floor(tonumber(itemId) or 0))
    entry.itemName = normalizeItemName(resolvedPayload.itemName)
    entry.notes = trimString(resolvedPayload.notes)
    entry.priority = normalizePriority(resolvedPayload.priority)
    entry.sourceName = forceSourceAutofill and "" or trimString(resolvedPayload.sourceName)
    entry.sourceTarget = forceSourceAutofill and "" or trimString(resolvedPayload.sourceTarget)
    entry.sourceType = normalizeSourceType(previewSourceType)

    if entry.itemId > 0 and trimString(entry.itemName) == "" then
        entry.itemName = resolveItemNameFromItemId(entry.itemId)
    end

    if previewOptions.currentSeasonOnly then
        self:BuildEncounterJournalIndex({ currentSeasonOnly = true, toonId = toonId, specId = specId })
    end

    if forceSourceAutofill then
        self:TryAutofillEntryFromClientCache(entry)
        self:TryAutofillEntryFromJournal(entry, {
            allowJournalBuild = not previewOptions.currentSeasonOnly,
            currentSeasonOnly = previewOptions.currentSeasonOnly and true or false,
            forceSourceAutofill = true,
        })
    else
        self:TryAutofillEntryMetadata(toonId, specId, slotKey, entry, {
            allowJournalBuild = not previewOptions.currentSeasonOnly,
            currentSeasonOnly = previewOptions.currentSeasonOnly and true or false,
        })
    end

    return {
        itemId = tonumber(entry.itemId) or 0,
        itemName = trimString(entry.itemName),
        notes = trimString(entry.notes),
        priority = trimString(entry.priority),
        priorityLabel = self:GetPriorityLabel(entry.priority),
        sourceName = trimString(entry.sourceName),
        sourceTarget = trimString(entry.sourceTarget),
        sourceType = trimString(entry.sourceType),
        sourceTypeLabel = self:GetSourceTypeLabel(entry.sourceType),
    }
end

function addon.BISService:ListGearEditorRows(toonId, specId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(toonId))
    local rows = {}

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecordForSpec(normalizedToonId, resolvedSpecId, slot.key)

        if slotRecord and #(slotRecord.entries or {}) > 0 then
            for index, entry in ipairs(slotRecord.entries or {}) do
                rows[#rows + 1] = {
                    entryIndex = index,
                    itemId = (tonumber(entry.itemId) or 0) > 0 and tostring(tonumber(entry.itemId) or 0) or "",
                    itemName = trimString(entry.itemName),
                    notes = trimString(entry.notes),
                    priority = trimString(entry.priority),
                    slotKey = slot.key,
                    slotLabel = slot.label,
                    sourceName = trimString(entry.sourceName),
                    sourceTarget = trimString(entry.sourceTarget),
                    sourceType = trimString(entry.sourceType),
                }
            end
        else
            rows[#rows + 1] = {
                entryIndex = 1,
                itemId = "",
                itemName = "",
                notes = "",
                priority = "bis",
                slotKey = slot.key,
                slotLabel = slot.label,
                sourceName = "",
                sourceTarget = "",
                sourceType = "other",
            }
        end
    end

    return rows
end

function addon.BISService:SaveGearEditorRows(toonId, specId, rows)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId, specId)
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(toonId))
    local savedCount = 0

    if not toonRecord or not specRecord then
        return false, 0
    end

    self:BuildEncounterJournalIndex({ currentSeasonOnly = true, toonId = toonId, specId = resolvedSpecId })

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = buildEmptySlot(slot.label)
    end

    for _, row in ipairs(rows or {}) do
        local slotKey = normalizeSlotKey(type(row) == "table" and row.slotKey or nil)

        if slotKey and specRecord.slots[slotKey] then
            local entry = buildEmptyEntry()

            entry.itemId = math.max(0, math.floor(tonumber(row.itemId) or 0))
            entry.itemName = normalizeItemName(row.itemName)
            entry.notes = trimString(row.notes)
            entry.priority = normalizePriority(row.priority)
            entry.sourceName = trimString(row.sourceName)
            entry.sourceTarget = trimString(row.sourceTarget)
            entry.sourceType = normalizeSourceType(trimString(row.sourceType) == "all" and "" or row.sourceType)

            local previewPayload = {
                itemId = row.itemId,
                itemName = row.itemName,
                notes = row.notes,
                priority = row.priority,
                sourceName = row.sourceName,
                sourceTarget = row.sourceTarget,
                sourceType = row.sourceType,
                __options = {
                    currentSeasonOnly = true,
                },
            }
            local preview = self:BuildGearEntryPreview(toonId, resolvedSpecId, slotKey, previewPayload)

            if (tonumber(preview.itemId) or 0) > 0 then
                entry.itemId = tonumber(preview.itemId) or 0
            end

            if trimString(preview.itemName) ~= "" then
                entry.itemName = trimString(preview.itemName)
            end

            if trimString(preview.notes) ~= "" then
                entry.notes = trimString(preview.notes)
            end

            if trimString(preview.priority) ~= "" then
                entry.priority = normalizePriority(preview.priority)
            end

            if trimString(preview.sourceName) ~= "" then
                entry.sourceName = trimString(preview.sourceName)
            end

            if trimString(preview.sourceTarget) ~= "" then
                entry.sourceTarget = trimString(preview.sourceTarget)
            end

            if trimString(preview.sourceType) ~= "" and trimString(preview.sourceType) ~= "all" then
                entry.sourceType = normalizeSourceType(preview.sourceType)
            end

            if entryHasManualContent(entry) then
                specRecord.slots[slotKey].entries[#specRecord.slots[slotKey].entries + 1] = entry
                savedCount = savedCount + 1
            end
        end
    end

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = self:EnsureSlotRecord(specRecord.slots[slot.key], slot.label)
    end

    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(toonId)
    self:GetRuntimeState().loot.matchIndexBySpec = {}

    if isCurrentPlayerToon(toonId) then
        self:EnsureCurrentInventoryScan({ force = true })
    end

    return true, savedCount
end

function addon.BISService:GetSelectedToonId()
    local store = self:GetStore()

    if store.selectedToonId and addon.RosterService:IsMyToon(store.selectedToonId) then
        return store.selectedToonId
    end

    local currentPlayerId = addon.Store:GetPlayerName()

    if currentPlayerId and addon.RosterService:IsMyToon(currentPlayerId) then
        store.selectedToonId = currentPlayerId
        return currentPlayerId
    end

    store.selectedToonId = addon.RosterService:GetFirstMyToonId()
    return store.selectedToonId
end

function addon.BISService:SetSelectedToonId(toonId)
    local store = self:GetStore()
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")

    if normalizedToonId ~= "" then
        store.selectedToonId = normalizedToonId
    end

    return store.selectedToonId
end

function addon.BISService:GetToonRecord(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")

    if normalizedToonId == "" then
        return nil
    end

    local store = self:GetStore()

    if type(store.records[normalizedToonId]) ~= "table" then
        store.records[normalizedToonId] = {
            lootSpecId = nil,
            seasonId = store.seasonId,
            selectedSpecId = nil,
            specs = {},
            updatedAt = 0,
            revision = 0,
        }
    end

    local record = store.records[normalizedToonId]
    record.specs = record.specs or {}
    record.seasonId = trimString(record.seasonId) ~= "" and trimString(record.seasonId) or store.seasonId
    return record
end

function addon.BISService:GetCurrentSpecId(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local member = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)
    return normalizeSpecId(member and member.spec or nil)
end

function addon.BISService:GetSelectedSpecId(toonId)
    local record = self:GetToonRecord(toonId)

    if not record then
        return DEFAULT_SPEC_ID
    end

    record.selectedSpecId = normalizeSpecId(record.selectedSpecId or self:GetCurrentSpecId(toonId))
    self:GetSpecRecord(toonId, record.selectedSpecId)
    return record.selectedSpecId
end

function addon.BISService:SetSelectedSpecId(toonId, specId)
    local record = self:GetToonRecord(toonId)

    if not record then
        return nil
    end

    record.selectedSpecId = normalizeSpecId(specId)
    self:GetSpecRecord(toonId, record.selectedSpecId)
    self:MarkInventoryScanDirty(toonId)
    return record.selectedSpecId
end

function addon.BISService:GetLootSpecId(toonId)
    local record = self:GetToonRecord(toonId)

    if not record then
        return DEFAULT_SPEC_ID
    end

    record.lootSpecId = normalizeSpecId(record.lootSpecId or self:GetSelectedSpecId(toonId))
    return record.lootSpecId
end

function addon.BISService:SetLootSpecId(toonId, specId)
    local record = self:GetToonRecord(toonId)

    if not record then
        return nil
    end

    record.lootSpecId = normalizeSpecId(specId)
    return record.lootSpecId
end

function addon.BISService:EnsureSlotRecord(slotRecord, slotLabel)
    local resolved = type(slotRecord) == "table" and slotRecord or buildEmptySlot(slotLabel)
    resolved.slotLabel = resolved.slotLabel or slotLabel

    if type(resolved.entries) ~= "table" then
        local migratedEntry = buildEmptyEntry()

        if type(slotRecord) == "table" then
            for key, value in pairs(slotRecord) do
                if migratedEntry[key] ~= nil then
                    migratedEntry[key] = value
                end
            end
        end

        resolved.entries = entryHasManualContent(migratedEntry) and { migratedEntry } or {}
    end

    for index, entry in ipairs(resolved.entries) do
        for key, defaultValue in pairs(buildEmptyEntry()) do
            if entry[key] == nil then
                entry[key] = defaultValue
            end
        end

        entry.itemId = tonumber(entry.itemId) or 0
        entry.priority = normalizePriority(entry.priority)
        entry.sourceType = normalizeSourceType(entry.sourceType)
        entry.itemName = normalizeItemName(entry.itemName)
        entry.sourceName = trimString(entry.sourceName)
        entry.sourceTarget = trimString(entry.sourceTarget)
        entry.notes = trimString(entry.notes)
        entry.highestOwnedItemLevel = tonumber(entry.highestOwnedItemLevel) or 0
        entry.highestEquippedItemLevel = tonumber(entry.highestEquippedItemLevel) or 0
        entry.lastAlertedItemLevel = tonumber(entry.lastAlertedItemLevel) or 0
        entry.lastAlertedTrackRank = tonumber(entry.lastAlertedTrackRank) or 0
        entry.detectedOwnedTrackRank = tonumber(entry.detectedOwnedTrackRank) or 0
        entry.detectedEquippedTrackRank = tonumber(entry.detectedEquippedTrackRank) or 0
        entry.detectedOwnedTrackLabel = trimString(entry.detectedOwnedTrackLabel)
        entry.detectedEquippedTrackLabel = trimString(entry.detectedEquippedTrackLabel)
        entry.detectedOwned = entry.detectedOwned and true or false
        entry.detectedEquipped = entry.detectedEquipped and true or false
        resolved.entries[index] = entry
    end

    resolved.selectedEntryIndex = math.max(1, math.min(tonumber(resolved.selectedEntryIndex) or 1, math.max(#resolved.entries, 1)))
    return resolved
end

function addon.BISService:GetSpecRecord(toonId, specId)
    local toonRecord = self:GetToonRecord(toonId)
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(toonId))

    if not toonRecord then
        return nil
    end

    if type(toonRecord.specs[resolvedSpecId]) ~= "table" then
        toonRecord.specs[resolvedSpecId] = {
            liveStatsSnapshot = buildEmptyStatTargets(),
            liveStatsUpdatedAt = 0,
            slots = {},
            statTargets = buildEmptyStatTargets(),
            updatedAt = 0,
            revision = 0,
        }
    end

    local specRecord = toonRecord.specs[resolvedSpecId]
    specRecord.liveStatsSnapshot = specRecord.liveStatsSnapshot or buildEmptyStatTargets()
    specRecord.liveStatsUpdatedAt = tonumber(specRecord.liveStatsUpdatedAt) or 0
    specRecord.slots = specRecord.slots or {}
    specRecord.statTargets = specRecord.statTargets or buildEmptyStatTargets()

    for statKey, defaultValue in pairs(buildEmptyStatTargets()) do
        specRecord.liveStatsSnapshot[statKey] = roundStatValue(specRecord.liveStatsSnapshot[statKey]) or defaultValue

        if specRecord.statTargets[statKey] == nil then
            specRecord.statTargets[statKey] = defaultValue
        else
            specRecord.statTargets[statKey] = normalizeStatTargetValue(specRecord.statTargets[statKey])
        end
    end

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = self:EnsureSlotRecord(specRecord.slots[slot.key], slot.label)
    end

    return specRecord
end

function addon.BISService:GetSlotRecord(toonId, slotKey)
    local specRecord = self:GetSpecRecord(toonId)
    local normalizedSlotKey = normalizeSlotKey(slotKey)

    if not specRecord or not normalizedSlotKey then
        return nil
    end

    specRecord.slots[normalizedSlotKey] = self:EnsureSlotRecord(specRecord.slots[normalizedSlotKey], getSlotLabel(normalizedSlotKey))
    return specRecord.slots[normalizedSlotKey]
end

function addon.BISService:GetSlotRecordForSpec(toonId, specId, slotKey)
    local specRecord = self:GetSpecRecord(toonId, specId)
    local normalizedSlotKey = normalizeSlotKey(slotKey)

    if not specRecord or not normalizedSlotKey then
        return nil
    end

    specRecord.slots[normalizedSlotKey] = self:EnsureSlotRecord(specRecord.slots[normalizedSlotKey], getSlotLabel(normalizedSlotKey))
    return specRecord.slots[normalizedSlotKey]
end

function addon.BISService:GetSelectedSlotKey()
    local store = self:GetStore()
    store.selectedSlotKey = normalizeSlotKey(store.selectedSlotKey) or SLOT_ORDER[1].key
    return store.selectedSlotKey
end

function addon.BISService:SetSelectedSlotKey(slotKey)
    local store = self:GetStore()
    store.selectedSlotKey = normalizeSlotKey(slotKey) or SLOT_ORDER[1].key
    return store.selectedSlotKey
end

function addon.BISService:GetSelectedEntryIndex(toonId, slotKey)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not slotRecord then
        return 1
    end

    slotRecord.selectedEntryIndex = math.max(1, math.min(tonumber(slotRecord.selectedEntryIndex) or 1, math.max(#slotRecord.entries, 1)))
    return slotRecord.selectedEntryIndex
end

function addon.BISService:SetSelectedEntryIndex(toonId, slotKey, entryIndex)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not slotRecord then
        return 1
    end

    slotRecord.selectedEntryIndex = math.max(1, math.min(tonumber(entryIndex) or 1, math.max(#slotRecord.entries, 1)))
    return slotRecord.selectedEntryIndex
end

function addon.BISService:GetSourceFilter()
    local filters = self:GetStore().filters
    filters.sourceType = lowerText(filters.sourceType)

    for _, option in ipairs(SOURCE_TYPE_OPTIONS) do
        if option.id == filters.sourceType then
            return option.id
        end
    end

    filters.sourceType = "all"
    return filters.sourceType
end

function addon.BISService:SetSourceFilter(value)
    self:GetStore().filters.sourceType = lowerText(value)
    return self:GetSourceFilter()
end

function addon.BISService:GetOwnershipFilter()
    local filters = self:GetStore().filters
    filters.ownership = lowerText(filters.ownership)

    for _, option in ipairs(OWNERSHIP_OPTIONS) do
        if option.id == filters.ownership then
            return option.id
        end
    end

    filters.ownership = "all"
    return filters.ownership
end

function addon.BISService:GetCollectionSort()
    local filters = self:GetStore().filters
    filters.collectionSort = lowerText(filters.collectionSort)

    for _, option in ipairs(COLLECTION_SORT_OPTIONS) do
        if option.id == filters.collectionSort then
            return option.id
        end
    end

    filters.collectionSort = "default"
    return filters.collectionSort
end

function addon.BISService:SetOwnershipFilter(value)
    self:GetStore().filters.ownership = lowerText(value)
    return self:GetOwnershipFilter()
end

function addon.BISService:SetCollectionSort(value)
    self:GetStore().filters.collectionSort = lowerText(value)
    return self:GetCollectionSort()
end

function addon.BISService:GetSourceFilterLabel()
    local current = self:GetSourceFilter()

    for _, option in ipairs(SOURCE_TYPE_OPTIONS) do
        if option.id == current then
            return option.label
        end
    end

    return "All Sources"
end

function addon.BISService:GetOwnershipFilterLabel()
    local current = self:GetOwnershipFilter()

    for _, option in ipairs(OWNERSHIP_OPTIONS) do
        if option.id == current then
            return option.label
        end
    end

    return "All States"
end

function addon.BISService:GetCollectionSortLabel()
    local current = self:GetCollectionSort()

    for _, option in ipairs(COLLECTION_SORT_OPTIONS) do
        if option.id == current then
            return option.label
        end
    end

    return "Default Order"
end

function addon.BISService:GetSourceTypeLabel(sourceType)
    for _, option in ipairs(SOURCE_TYPE_OPTIONS) do
        if option.id == lowerText(sourceType) then
            return option.label
        end
    end

    return "Other"
end

function addon.BISService:GetPriorityLabel(priority)
    for _, option in ipairs(PRIORITY_OPTIONS) do
        if option.id == lowerText(priority) then
            return option.label
        end
    end

    return "True BIS"
end

function addon.BISService:GetPriorityCompactLabel(priority)
    local normalizedPriority = lowerText(priority)

    if normalizedPriority == "upgrade" then
        return "Upg"
    end

    if normalizedPriority == "optional" then
        return "Opt"
    end

    return "BIS"
end

function addon.BISService:TouchRecord(toonRecord, specRecord)
    local now = time()

    if type(specRecord) == "table" then
        specRecord.updatedAt = now
        specRecord.revision = (specRecord.revision or 0) + 1
    end

    if type(toonRecord) == "table" then
        toonRecord.updatedAt = now
        toonRecord.revision = (toonRecord.revision or 0) + 1
    end
end

function addon.BISService:AddSlotEntry(toonId, slotKey)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not toonRecord or not specRecord or not slotRecord then
        return false
    end

    slotRecord.entries[#slotRecord.entries + 1] = buildEmptyEntry()
    slotRecord.selectedEntryIndex = #slotRecord.entries
    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(toonId)
    return true
end

function addon.BISService:RemoveSelectedEntry(toonId, slotKey)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not toonRecord or not specRecord or not slotRecord then
        return false
    end

    local selectedIndex = self:GetSelectedEntryIndex(toonId, slotKey)

    if #slotRecord.entries <= 1 then
        slotRecord.entries = {}
        slotRecord.selectedEntryIndex = 1
    else
        table.remove(slotRecord.entries, selectedIndex)
        slotRecord.selectedEntryIndex = math.max(1, math.min(selectedIndex, #slotRecord.entries))
    end

    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(toonId)
    return true
end

function addon.BISService:ClearSpecEntries(toonId, specId)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId, specId)

    if not toonRecord or not specRecord then
        return false
    end

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = buildEmptySlot(slot.label)
    end

    self:SetSelectedSlotKey(SLOT_ORDER[1].key)
    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(toonId)

    if isCurrentPlayerToon(toonId) then
        self:EnsureCurrentInventoryScan({ force = true })
    end

    return true
end

function addon.BISService:ResetAllEntries()
    local store = self:GetStore()
    local runtimeState = self:GetRuntimeState()

    store.records = {}
    store.selectedSlotKey = SLOT_ORDER[1].key

    runtimeState.inventory.dirty = true
    runtimeState.inventory.lastPlayerId = nil
    runtimeState.inventory.lastScanAt = 0
    runtimeState.inventory.lastSpecRevision = 0
    runtimeState.inventory.trackInfoCache = {}
    runtimeState.journal.backfillRevisionBySpec = {}
    runtimeState.loot.recentAlerts = {}
    runtimeState.loot.matchIndexBySpec = {}
    runtimeState.loot.tradabilityCache = {}
    runtimeState.share.recentClaims = {}
    runtimeState.share.recentOffers = {}
    runtimeState.vault.lastFingerprint = ""

    return true
end

function addon.BISService:UpdateEntry(toonId, slotKey, entryIndex, payload)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not toonRecord or not specRecord or not slotRecord or type(payload) ~= "table" then
        return false
    end

    entryIndex = math.max(1, tonumber(entryIndex) or 1)

    while #slotRecord.entries < entryIndex do
        slotRecord.entries[#slotRecord.entries + 1] = buildEmptyEntry()
    end

    local entry = slotRecord.entries[entryIndex]
    local changed = false

    local function applyString(fieldName)
        if payload[fieldName] ~= nil then
            local value = fieldName == "itemName" and normalizeItemName(payload[fieldName]) or trimString(payload[fieldName])

            if entry[fieldName] ~= value then
                entry[fieldName] = value
                changed = true
            end
        end
    end

    applyString("itemName")
    applyString("sourceName")
    applyString("sourceTarget")
    applyString("notes")

    if payload.itemId ~= nil then
        local itemId = math.max(0, math.floor(tonumber(payload.itemId) or 0))

        if (tonumber(entry.itemId) or 0) ~= itemId then
            entry.itemId = itemId
            changed = true
        end
    end

    if payload.sourceType ~= nil then
        local sourceType = normalizeSourceType(payload.sourceType)

        if entry.sourceType ~= sourceType then
            entry.sourceType = sourceType
            changed = true
        end
    end

    if payload.priority ~= nil then
        local priority = normalizePriority(payload.priority)

        if entry.priority ~= priority then
            entry.priority = priority
            changed = true
        end
    end

    if self:TryAutofillEntryMetadata(toonId, nil, slotKey, entry) then
        changed = true
    end

    if changed then
        slotRecord.selectedEntryIndex = entryIndex
        self:TouchRecord(toonRecord, specRecord)
        self:MarkInventoryScanDirty(toonId)
        self:GetRuntimeState().loot.matchIndexBySpec = {}
    end

    return changed
end

function addon.BISService:UpdateSelectedEntry(toonId, slotKey, payload)
    return self:UpdateEntry(toonId, slotKey, self:GetSelectedEntryIndex(toonId, slotKey), payload)
end

function addon.BISService:UpdateStatTargets(toonId, specId, payload)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId, specId)
    local changed = false

    if not toonRecord or not specRecord or type(payload) ~= "table" then
        return false
    end

    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        if payload[option.key] ~= nil then
            local targetValue = normalizeStatTargetValue(payload[option.key])

            if normalizeStatTargetValue(specRecord.statTargets[option.key]) ~= targetValue then
                specRecord.statTargets[option.key] = targetValue
                changed = true
            end
        end
    end

    if changed then
        self:TouchRecord(toonRecord, specRecord)
    end

    return changed
end

function addon.BISService:GetLiveStatSnapshot(toonId, specId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")

    if normalizedToonId == "" or normalizedToonId ~= addon.Store:GetPlayerName() then
        return nil
    end

    local toonRecord = self:GetToonRecord(normalizedToonId)
    local specRecord = self:GetSpecRecord(normalizedToonId, specId)
    local liveSnapshot = buildLiveStatSnapshot()

    if toonRecord and specRecord and not areStatSnapshotsEqual(specRecord.liveStatsSnapshot, liveSnapshot) then
        specRecord.liveStatsSnapshot = deepCopy(liveSnapshot)
        specRecord.liveStatsUpdatedAt = time()
        self:TouchRecord(toonRecord, specRecord)
    elseif specRecord and (tonumber(specRecord.liveStatsUpdatedAt) or 0) <= 0 then
        specRecord.liveStatsUpdatedAt = time()
        if toonRecord then
            specRecord.liveStatsSnapshot = deepCopy(liveSnapshot)
            self:TouchRecord(toonRecord, specRecord)
        end
    end

    return liveSnapshot
end

function addon.BISService:GetStatGoalSummary(toonId, specId)
    local specRecord = self:GetSpecRecord(toonId, specId)
    local liveStats = self:GetLiveStatSnapshot(toonId, specId)
    local savedStats = specRecord and (tonumber(specRecord.liveStatsUpdatedAt) or 0) > 0 and specRecord.liveStatsSnapshot or nil
    local rows = {}

    if not specRecord then
        return {
            liveAvailable = false,
            snapshotAvailable = false,
            rows = rows,
        }
    end

    local currentStats = liveStats or savedStats

    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        local currentValue = currentStats and currentStats[option.key] or nil
        local targetValue = normalizeStatTargetValue(specRecord.statTargets[option.key])
        local deltaLabel = "No goal"
        local deltaValue = nil

        if targetValue > 0 and currentValue ~= nil then
            local difference = roundStatValue(targetValue - currentValue) or 0
            deltaValue = difference

            if math.abs(difference) < 0.05 then
                deltaLabel = "0"
            elseif difference > 0 then
                deltaLabel = "+" .. formatStatValueLabel(difference)
            else
                deltaLabel = "-" .. formatStatValueLabel(math.abs(difference))
            end
        elseif targetValue > 0 then
            deltaLabel = "-"
        elseif currentValue ~= nil then
            deltaLabel = "-"
        end

        rows[#rows + 1] = {
            currentLabel = formatStatValueLabel(currentValue),
            currentValue = currentValue,
            deltaLabel = deltaLabel,
            deltaValue = deltaValue,
            key = option.key,
            label = option.label,
            targetLabel = targetValue > 0 and formatStatValueLabel(targetValue) or "-",
            targetValue = targetValue,
        }
    end

    return {
        liveAvailable = liveStats ~= nil,
        snapshotAvailable = savedStats ~= nil,
        snapshotUpdatedAt = tonumber(specRecord.liveStatsUpdatedAt) or 0,
        rows = rows,
    }
end

function addon.BISService:BuildStatGoalTemplate(toonId, specId)
    local summary = self:GetStatGoalSummary(toonId, specId)
    local rows = {}

    for _, row in ipairs(summary.rows or {}) do
        rows[#rows + 1] = {
            currentValue = row.currentValue ~= nil and formatStatValueLabel(row.currentValue) or "",
            stat = row.key,
            targetValue = row.targetValue > 0 and formatStatValueLabel(row.targetValue) or "",
        }
    end

    return buildCsv({ "stat", "targetValue", "currentValue" }, rows)
end

function addon.BISService:ImportStatGoalTemplate(toonId, specId, text)
    local updates = {}
    local recognizedCount = 0
    local skippedRows = {}
    local separator = detectDelimitedTextSeparator(text)
    local lineNumber = 0

    for line in string.gmatch((text or "") .. "\n", "([^\n]*)\n") do
        lineNumber = lineNumber + 1

        if trimString(line) ~= "" then
            local values = parseDelimitedLine(line, separator)
            local statKey = normalizeStatGoalKey(values[1])
            local firstToken = canonicalToken(values[1])

            if not statKey and #values == 1 then
                local left, right = tostring(line):match("^%s*([^:=,\t]+)%s*[:=,\t]%s*(.-)%s*$")

                if left then
                    statKey = normalizeStatGoalKey(left)
                    values[2] = right
                    firstToken = canonicalToken(left)
                end
            end

            if statKey then
                updates[statKey] = normalizeStatTargetValue(values[2])
                recognizedCount = recognizedCount + 1
            elseif firstToken ~= "" and firstToken ~= "stat" then
                skippedRows[#skippedRows + 1] = tostring(lineNumber)
            end
        end
    end

    if recognizedCount <= 0 then
        return false, 0, "No BIS stat goals were found. Use rows like crit,383 or haste,741."
    end

    local changed = self:UpdateStatTargets(toonId, specId, updates)
    local message = changed and string.format("Updated %d BIS stat goal%s.", recognizedCount, recognizedCount == 1 and "" or "s") or "BIS stat goals already match the saved values."

    if #skippedRows > 0 then
        message = message .. " Skipped rows: " .. table.concat(skippedRows, ", ") .. "."
    end

    return true, recognizedCount, message
end

function addon.BISService:BuildCsvTemplate()
    local rows = buildTemplateRows(false)

    local fullExamplesBySlot = {
        head = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Vexie Fullthrottle", priority = "bis", notes = "Main raid helm target" },
        neck = { sourceType = "dungeon", sourceName = "Cinderbrew Meadery", sourceTarget = "+10 or higher", priority = "bis", notes = "High-value neck option" },
        shoulder = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Cauldron of Carnage", priority = "bis", notes = "Tier-adjacent shoulder slot" },
        back = { sourceType = "dungeon", sourceName = "Operation: Floodgate", sourceTarget = "+8 or higher", priority = "bis", notes = "Fast farm cape" },
        chest = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Chrome King Gallywix", priority = "bis", notes = "Main chest target" },
        wrist = { sourceType = "dungeon", sourceName = "Darkflame Cleft", sourceTarget = "+7 or higher", priority = "upgrade", notes = "Good fallback if raid wrists do not drop" },
        hands = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Rik Reverb", priority = "bis", notes = "Best glove target" },
        waist = { sourceType = "dungeon", sourceName = "Priory of the Sacred Flame", sourceTarget = "+9 or higher", priority = "bis", notes = "Main belt choice" },
        legs = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Sprocketmonger Lockenstock", priority = "bis", notes = "Primary leg slot target" },
        feet = { sourceType = "dungeon", sourceName = "The Rookery", sourceTarget = "+10 or higher", priority = "upgrade", notes = "Good boots while waiting on raid drop" },
        ring1 = { sourceType = "other", sourceName = "Siren Isle", sourceTarget = "Weekly or vendor track", priority = "bis", notes = "Keep one strong ring option in Ring 1" },
        ring2 = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "Mug'Zee", priority = "bis", notes = "Second ring example" },
        trinket1 = { sourceType = "dungeon", sourceName = "Priory of the Sacred Flame", sourceTarget = "+10 or higher", priority = "bis", notes = "Common trinket target" },
        trinket2 = { sourceType = "dungeon", sourceName = "Operation: Floodgate", sourceTarget = "+10 or higher", priority = "upgrade", notes = "Alternative trinket slot" },
        mainhand = { sourceType = "raid", sourceName = "Liberation of Undermine", sourceTarget = "One-Armed Bandit", priority = "bis", notes = "Weapon example" },
        offhand = { sourceType = "dungeon", sourceName = "Theater of Pain", sourceTarget = "+8 or higher", priority = "bis", notes = "Shield or off-hand example" },
    }

    for _, row in ipairs(rows) do
        local example = fullExamplesBySlot[row.slot] or {}
        row.sourceType = example.sourceType or ""
        row.itemId = ""
        row.sourceName = example.sourceName or ""
        row.sourceTarget = example.sourceTarget or ""
        row.priority = example.priority or "bis"
        row.notes = example.notes or ""
    end

    rows[#rows + 1] = {
        slot = "ring1",
        itemName = "85-Year Tenure Ring",
        sourceType = "raid",
        sourceName = "Liberation of Undermine",
        sourceTarget = "Chrome King Gallywix",
        priority = "upgrade",
        notes = "Second Ring 1 example to show duplicate slot options",
    }
    rows[#rows + 1] = {
        slot = "trinket1",
        itemName = "House of Cards",
        sourceType = "other",
        sourceName = "Darkmoon Deck",
        sourceTarget = "Craft or auction house",
        priority = "upgrade",
        notes = "Extra trinket option for the same slot",
    }
    rows[#rows + 1] = {
        slot = "mainhand",
        itemName = "Gallytech Turbo-Torch",
        sourceType = "raid",
        sourceName = "Liberation of Undermine",
        sourceTarget = "Chrome King Gallywix",
        priority = "upgrade",
        notes = "Second main hand example to show multiple entries per slot",
    }

    return buildCsv({ "slot", "itemId", "itemName", "sourceType", "sourceName", "sourceTarget", "priority", "notes" }, rows)
end

function addon.BISService:BuildSimpleCsvTemplate()
    return buildCsv({ "slot", "itemName" }, buildTemplateRows(true))
end

local BIS_SHARE_SCHEMA = "gh3-bis-share"
local BIS_SHARE_VERSION = 1

local function buildBisShareEntry(entry)
    return {
        equipped = entry.detectedEquipped and true or false,
        equippedTrackLabel = trimString(entry.detectedEquippedTrackLabel),
        equippedTrackRank = tonumber(entry.detectedEquippedTrackRank) or 0,
        highestEquippedItemLevel = tonumber(entry.highestEquippedItemLevel) or 0,
        highestOwnedItemLevel = tonumber(entry.highestOwnedItemLevel) or 0,
        itemId = tonumber(entry.itemId) or 0,
        itemName = trimString(entry.itemName),
        lastAlertedItemLevel = tonumber(entry.lastAlertedItemLevel) or 0,
        lastAlertedTrackRank = tonumber(entry.lastAlertedTrackRank) or 0,
        notes = trimString(entry.notes),
        owned = entry.detectedOwned and true or false,
        ownedTrackLabel = trimString(entry.detectedOwnedTrackLabel),
        ownedTrackRank = tonumber(entry.detectedOwnedTrackRank) or 0,
        priority = normalizePriority(entry.priority),
        sourceName = trimString(entry.sourceName),
        sourceTarget = trimString(entry.sourceTarget),
        sourceType = normalizeSourceType(entry.sourceType),
    }
end

local function isBisSharePayload(payload)
    if type(payload) ~= "table" then
        return false
    end

    local schemaToken = canonicalToken(payload.schema or payload.kind or payload.type)

    if schemaToken == "gh3bisshare" or schemaToken == "bisshare" then
        return true
    end

    return type(payload.items) == "table" and type(payload.spec) == "table"
end

function addon.BISService:BuildShareExport(toonId, specId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(normalizedToonId))
    local toonRecord = self:GetToonRecord(normalizedToonId)
    local specRecord = self:GetSpecRecord(normalizedToonId, resolvedSpecId)
    local member = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)
    local lootSpecId = self:GetLootSpecId(normalizedToonId)
    local fullName = member and trimString(member.fullName) or normalizedToonId
    local toonName, realmName = tostring(fullName):match("^([^%-]+)%-?(.*)$")
    local items = {}

    self:BackfillJournalDataForSpec(normalizedToonId, resolvedSpecId, { allowJournalBuild = false })

    if isCurrentPlayerToon(normalizedToonId) then
        self:EnsureCurrentInventoryScan()
    end

    local summary = self:GetSummary(normalizedToonId)
    local statGoalSummary = self:GetStatGoalSummary(normalizedToonId, resolvedSpecId)

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecordForSpec(normalizedToonId, resolvedSpecId, slot.key)
        local shareEntries = {}

        for _, entry in ipairs(slotRecord.entries or {}) do
            if entryHasManualContent(entry) then
                shareEntries[#shareEntries + 1] = buildBisShareEntry(entry)
            end
        end

        items[#items + 1] = {
            entries = shareEntries,
            slot = slot.key,
            slotLabel = slot.label,
        }
    end

    local payload = {
        exportedAt = time(),
        items = items,
        schema = BIS_SHARE_SCHEMA,
        source = {
            addon = "GH3",
            seasonId = toonRecord and trimString(toonRecord.seasonId) or trimString(self:GetStore().seasonId),
        },
        spec = {
            loot = lootSpecId,
            selected = resolvedSpecId,
        },
        stats = {
            liveAvailable = statGoalSummary.liveAvailable and true or false,
            rows = deepCopy(statGoalSummary.rows or {}),
            snapshot = deepCopy(specRecord and specRecord.liveStatsSnapshot or buildEmptyStatTargets()),
            snapshotAvailable = statGoalSummary.snapshotAvailable and true or false,
            snapshotUpdatedAt = tonumber(specRecord and specRecord.liveStatsUpdatedAt or 0) or 0,
            targets = deepCopy(specRecord and specRecord.statTargets or buildEmptyStatTargets()),
        },
        summary = {
            collectedCount = tonumber(summary and summary.collectedCount or 0) or 0,
            metaLine = trimString(summary and summary.metaLine),
            missingCount = tonumber(summary and summary.missingCount or 0) or 0,
            totalSlots = tonumber(summary and summary.totalSlots or #SLOT_ORDER) or #SLOT_ORDER,
            vaultTargetCount = tonumber(summary and summary.vaultTargetCount or 0) or 0,
        },
        toon = {
            classFile = member and trimString(member.classFile) or "",
            className = member and trimString(member.className) or "",
            fullName = fullName,
            itemLevel = tonumber(member and member.itemLevel or 0) or 0,
            name = trimString(toonName),
            realm = trimString(realmName) ~= "" and trimString(realmName) or trimString(member and member.realm or GetRealmName() or ""),
        },
        version = BIS_SHARE_VERSION,
    }

    return encodeJson(payload) or ""
end

function addon.BISService:BuildCsvExport(toonId)
    local specRecord = self:GetSpecRecord(toonId)
    local rows = {}

    if not specRecord then
        return self:BuildCsvTemplate()
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecord(toonId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            if entryHasManualContent(entry) then
                rows[#rows + 1] = {
                    slot = slot.key,
                    itemId = tonumber(entry.itemId) or 0,
                    itemName = entry.itemName,
                    notes = entry.notes,
                    priority = entry.priority,
                    sourceName = entry.sourceName,
                    sourceTarget = entry.sourceTarget,
                    sourceType = entry.sourceType,
                }
            end
        end
    end

    return buildCsv({ "slot", "itemId", "itemName", "sourceType", "sourceName", "sourceTarget", "priority", "notes" }, rows)
end

function addon.BISService:ImportSharePayload(toonId, payload)
    local decodedPayload = type(payload) == "table" and payload or decodeJson(payload)

    if not isBisSharePayload(decodedPayload) then
        return false, 0, "No GH3 BIS share JSON payload was found."
    end

    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local toonRecord = self:GetToonRecord(normalizedToonId)
    local targetMember = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)
    local sourceToon = type(decodedPayload.toon) == "table" and decodedPayload.toon or {}
    local sourceClassFile = trimString(sourceToon.classFile)
    local targetClassFile = targetMember and trimString(targetMember.classFile) or ""
    local specPayload = type(decodedPayload.spec) == "table" and decodedPayload.spec or {}
    local importedSpecId = normalizeSpecId(specPayload.selected or decodedPayload.selectedSpecId or decodedPayload.specId)
    local importedLootSpecId = normalizeSpecId(specPayload.loot or decodedPayload.lootSpecId or importedSpecId)
    local statsPayload = type(decodedPayload.stats) == "table" and decodedPayload.stats or {}
    local statTargetsPayload = type(statsPayload.targets) == "table" and statsPayload.targets or (type(decodedPayload.statTargets) == "table" and decodedPayload.statTargets or {})
    local itemGroups = type(decodedPayload.items) == "table" and decodedPayload.items or (type(decodedPayload.slots) == "table" and decodedPayload.slots or {})
    local parsedSlots = {}
    local duplicateCount = 0
    local importedCount = 0
    local importedStatGoalCount = 0
    local invalidCount = 0
    local duplicateRows = {}
    local invalidRows = {}
    local seenRows = {}

    if not toonRecord then
        return false, 0, "No BIS target record is available for this toon."
    end

    if sourceClassFile ~= "" and targetClassFile ~= "" and lowerText(sourceClassFile) ~= lowerText(targetClassFile) then
        local sourceClassLabel = trimString(sourceToon.className) ~= "" and trimString(sourceToon.className) or sourceClassFile
        local targetClassLabel = targetMember and (trimString(targetMember.className) ~= "" and trimString(targetMember.className) or targetClassFile) or targetClassFile
        return false, 0, string.format("This BIS share is for %s, but %s is %s.", sourceClassLabel, targetMember and targetMember.fullName or normalizedToonId, targetClassLabel)
    end

    for _, slot in ipairs(SLOT_ORDER) do
        parsedSlots[slot.key] = {}
    end

    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        if normalizeStatTargetValue(statTargetsPayload[option.key]) > 0 then
            importedStatGoalCount = importedStatGoalCount + 1
        end
    end

    for groupIndex, slotPayload in ipairs(itemGroups) do
        local slotKey = normalizeSlotKey(type(slotPayload) == "table" and (slotPayload.slot or slotPayload.slotKey or slotPayload.key) or nil)
        local entries = type(slotPayload) == "table" and slotPayload.entries or nil

        if type(entries) ~= "table" and type(slotPayload) == "table" then
            entries = { slotPayload }
        end

        if slotKey and parsedSlots[slotKey] and type(entries) == "table" then
            for entryIndex, entryPayload in ipairs(entries) do
                local sourcePayload = type(entryPayload.source) == "table" and entryPayload.source or {}
                local entry = buildEmptyEntry()

                entry.itemId = math.max(0, math.floor(tonumber(entryPayload.itemId) or resolveImportedItemId(entryPayload) or 0))
                entry.itemName = normalizeItemName(entryPayload.itemName or entryPayload.name)

                if entry.itemName == "" and entry.itemId > 0 then
                    entry.itemName = resolveItemNameFromItemId(entry.itemId)
                end

                entry.sourceType = normalizeSourceType(entryPayload.sourceType or sourcePayload.type)
                entry.sourceName = trimString(entryPayload.sourceName or sourcePayload.name)
                entry.sourceTarget = trimString(entryPayload.sourceTarget or entryPayload.sourceBoss or sourcePayload.target or sourcePayload.boss)
                entry.priority = normalizePriority(entryPayload.priority)
                entry.notes = trimString(entryPayload.notes)
                self:TryAutofillEntryMetadata(normalizedToonId, importedSpecId, slotKey, entry, {
                    allowJournalBuild = false,
                })

                if entryHasManualContent(entry) then
                    local duplicateKey = buildImportDuplicateKey(slotKey, entry)

                    if seenRows[duplicateKey] then
                        duplicateCount = duplicateCount + 1
                        duplicateRows[#duplicateRows + 1] = string.format("Slot %s entry %d | %s", slotKey, entryIndex, trimString(entry.itemName) ~= "" and entry.itemName or (entry.itemId > 0 and ("itemId " .. tostring(entry.itemId)) or "duplicate entry"))
                    else
                        seenRows[duplicateKey] = true
                        parsedSlots[slotKey][#parsedSlots[slotKey] + 1] = entry
                        importedCount = importedCount + 1
                    end
                else
                    invalidCount = invalidCount + 1
                    invalidRows[#invalidRows + 1] = string.format("Slot %s entry %d | empty or unusable after normalization.", slotKey, entryIndex)
                end
            end
        elseif type(slotPayload) == "table" and trimString(slotPayload.slot or slotPayload.slotKey or slotPayload.key) ~= "" then
            invalidCount = invalidCount + 1
            invalidRows[#invalidRows + 1] = string.format("Group %d | invalid slot '%s'.", groupIndex, trimString(slotPayload.slot or slotPayload.slotKey or slotPayload.key))
        end
    end

    if importedCount <= 0 and importedStatGoalCount <= 0 then
        return false, 0, "The BIS share payload did not contain any BIS items or stat goals."
    end

    toonRecord.selectedSpecId = importedSpecId
    toonRecord.lootSpecId = importedLootSpecId

    local specRecord = self:GetSpecRecord(normalizedToonId, importedSpecId)

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = buildEmptySlot(slot.label)
    end

    for _, option in ipairs(STAT_GOAL_OPTIONS) do
        specRecord.statTargets[option.key] = normalizeStatTargetValue(statTargetsPayload[option.key])
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotEntries = parsedSlots[slot.key] or {}

        for _, entry in ipairs(slotEntries) do
            specRecord.slots[slot.key].entries[#specRecord.slots[slot.key].entries + 1] = entry
        end
    end

    self:SetSelectedSlotKey(SLOT_ORDER[1].key)
    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(normalizedToonId)
    self:GetRuntimeState().loot.matchIndexBySpec = {}

    if isCurrentPlayerToon(normalizedToonId) then
        self:EnsureCurrentInventoryScan({ force = true })
    end

    local sourceLabel = trimString(sourceToon.fullName) ~= "" and trimString(sourceToon.fullName) or "shared BIS payload"
    local message = string.format(
        "Imported BIS share for %s. Restored %d item%s and %d stat goal%s from %s.",
        importedSpecId,
        importedCount,
        importedCount == 1 and "" or "s",
        importedStatGoalCount,
        importedStatGoalCount == 1 and "" or "s",
        sourceLabel
    )
    local reportLines = {
        "BIS Share Import Report",
        string.format("sourceToon=%s", sourceLabel),
        string.format("targetToon=%s", normalizedToonId),
        string.format("spec=%s", importedSpecId),
        string.format("lootSpec=%s", importedLootSpecId),
        string.format("importedItems=%d", importedCount),
        string.format("importedStatGoals=%d", importedStatGoalCount),
        string.format("duplicateItems=%d", duplicateCount),
        string.format("invalidItems=%d", invalidCount),
    }

    if duplicateCount > 0 then
        message = message .. string.format(" Skipped %d duplicate item rows.", duplicateCount)
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Duplicate Items"

        for _, line in ipairs(duplicateRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if invalidCount > 0 then
        message = message .. string.format(" Skipped %d invalid item rows.", invalidCount)
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Invalid Items"

        for _, line in ipairs(invalidRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    return true, importedCount, message, table.concat(reportLines, "\n")
end

function addon.BISService:ImportText(toonId, text)
    local trimmedText = trimString(text)
    local decodedPayload = decodeJson(trimmedText)

    if trimmedText == "" then
        return false, 0, "Paste GH3 BIS share JSON or BIS CSV rows to import."
    end

    if type(decodedPayload) == "table" or trimmedText:match("^[%{%[]") then
        return self:ImportSharePayload(toonId, decodedPayload or trimmedText)
    end

    return self:ImportCsv(toonId, text)
end

function addon.BISService:ImportCsv(toonId, text)
    local toonRecord = self:GetToonRecord(toonId)
    local specRecord = self:GetSpecRecord(toonId)
    local headers, rows = parseDelimitedText(text)
    local headerMap, seenHeaders = normalizeImportHeaders(headers)
    rows = remapImportRows(headerMap, rows)

    if not seenHeaders.slot or (not seenHeaders.itemname and not seenHeaders.itemid) then
        return false, 0, "BIS import requires slot plus either itemName or itemId columns."
    end

    if not toonRecord or not specRecord then
        return false, 0, "No BIS target record is available for this toon."
    end

    for _, slot in ipairs(SLOT_ORDER) do
        specRecord.slots[slot.key] = buildEmptySlot(slot.label)
    end

    local importedCount = 0
    local idBackedCount = 0
    local metadataResolvedCount = 0
    local suppliedIdCount = 0
    local journalResolvedCount = 0
    local clientCacheResolvedCount = 0
    local duplicateCount = 0
    local invalidSlotCount = 0
    local skippedEmptyCount = 0
    local seenRows = {}
    local idBackedRows = {}
    local suppliedIdRows = {}
    local journalResolvedRows = {}
    local clientCacheResolvedRows = {}
    local unresolvedRows = {}
    local nameOnlyRows = {}
    local invalidRows = {}
    local duplicateRows = {}

    for _, row in ipairs(rows) do
        row = applyCollapsedBisColumnFallback(row)
        local slotKey = normalizeSlotKey(row.slot)
        local rowNumber = tonumber(row.__rowNumber) or 0
        local suppliedItemId = math.max(0, math.floor(tonumber(row.itemId) or resolveImportedItemId(row) or 0))

        if slotKey and specRecord.slots[slotKey] then
            local entry = buildEmptyEntry()
            entry.itemId = suppliedItemId
            entry.itemName = normalizeItemName(row.itemName)

            if entry.itemName == "" and entry.itemId > 0 then
                entry.itemName = resolveItemNameFromItemId(entry.itemId)
            end

            entry.sourceType = normalizeSourceType(row.sourceType)
            entry.sourceName = trimString(row.sourceName)
            entry.sourceTarget = trimString(row.sourceTarget)
            entry.priority = normalizePriority(row.priority)
            entry.notes = trimString(row.notes)
            self:TryAutofillEntryMetadata(toonId, nil, slotKey, entry)

            if entryHasManualContent(entry) then
                local duplicateKey = buildImportDuplicateKey(slotKey, entry)

                if not seenRows[duplicateKey] then
                    seenRows[duplicateKey] = true
                    specRecord.slots[slotKey].entries[#specRecord.slots[slotKey].entries + 1] = entry
                    importedCount = importedCount + 1

                    local alertLookupMode = getEntryAlertLookupMode(entry)
                    local metadataResolved = (trimString(entry.sourceName) ~= "" or trimString(entry.sourceTarget) ~= "" or normalizeSourceType(entry.sourceType) ~= "other")

                    if alertLookupMode == "id" then
                        idBackedCount = idBackedCount + 1
                        local resolutionSource = "client-cache"

                        if suppliedItemId > 0 then
                            resolutionSource = "supplied-id"
                            suppliedIdCount = suppliedIdCount + 1
                            suppliedIdRows[#suppliedIdRows + 1] = string.format("Row %d | %s | %s | supplied itemId %d", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or "Untitled", suppliedItemId)
                        elseif metadataResolved then
                            resolutionSource = "journal"
                            journalResolvedCount = journalResolvedCount + 1
                            journalResolvedRows[#journalResolvedRows + 1] = string.format("Row %d | %s | %s | stored itemId %d", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or "Untitled", tonumber(entry.itemId) or 0)
                        else
                            clientCacheResolvedCount = clientCacheResolvedCount + 1
                            clientCacheResolvedRows[#clientCacheResolvedRows + 1] = string.format("Row %d | %s | %s | stored itemId %d", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or "Untitled", tonumber(entry.itemId) or 0)
                        end

                        idBackedRows[#idBackedRows + 1] = string.format("Row %d | %s | %s | resolution %s | supplied itemId %d | stored itemId %d", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or "Untitled", resolutionSource, suppliedItemId, tonumber(entry.itemId) or 0)
                    end

                    if metadataResolved then
                        metadataResolvedCount = metadataResolvedCount + 1
                    end

                    if alertLookupMode == "unresolved" then
                        unresolvedRows[#unresolvedRows + 1] = string.format("Row %d | %s | no usable item name or itemId was resolved.", rowNumber, slotKey)
                    elseif alertLookupMode == "name" then
                        nameOnlyRows[#nameOnlyRows + 1] = string.format("Row %d | %s | %s | supplied itemId %d | stored itemId %d | name-only tracking. Alerts depend on the imported item name matching the live drop name.", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or "Untitled", suppliedItemId, tonumber(entry.itemId) or 0)
                    end
                else
                    duplicateCount = duplicateCount + 1
                    duplicateRows[#duplicateRows + 1] = string.format("Row %d | %s | %s", rowNumber, slotKey, trimString(entry.itemName) ~= "" and entry.itemName or (entry.itemId > 0 and ("itemId " .. tostring(entry.itemId)) or "duplicate entry"))
                end
            else
                skippedEmptyCount = skippedEmptyCount + 1
                invalidRows[#invalidRows + 1] = string.format("Row %d | %s | empty or unusable after normalization.", rowNumber, slotKey)
            end
        elseif trimString(row.slot) ~= "" or trimString(row.itemName) ~= "" or resolveImportedItemId(row) > 0 then
            invalidSlotCount = invalidSlotCount + 1
            invalidRows[#invalidRows + 1] = string.format("Row %d | invalid slot '%s'.", rowNumber, trimString(row.slot))
        end
    end

    self:TouchRecord(toonRecord, specRecord)
    self:MarkInventoryScanDirty(toonId)
    self:GetRuntimeState().loot.matchIndexBySpec = {}
    self:EnsureCurrentInventoryScan({ force = true })

    local message = string.format("Imported %d BIS target rows.", importedCount)
    local reportLines = {
        "BIS Import Report",
        string.format("importedRows=%d", importedCount),
        string.format("idBackedRows=%d", idBackedCount),
        string.format("metadataResolvedRows=%d", metadataResolvedCount),
        string.format("suppliedIdRows=%d", suppliedIdCount),
        string.format("journalResolvedRows=%d", journalResolvedCount),
        string.format("clientCacheResolvedRows=%d", clientCacheResolvedCount),
        string.format("duplicateRows=%d", duplicateCount),
        string.format("invalidSlotRows=%d", invalidSlotCount),
        string.format("emptyOrUnusableRows=%d", skippedEmptyCount),
        string.format("nameOnlyRows=%d", #nameOnlyRows),
        string.format("unresolvedRows=%d", #unresolvedRows),
    }

    if duplicateCount > 0 then
        message = message .. string.format(" Skipped %d duplicate rows.", duplicateCount)
    end

    if invalidSlotCount > 0 or skippedEmptyCount > 0 then
        message = message .. string.format(" Skipped %d invalid rows.", invalidSlotCount + skippedEmptyCount)
    end

    if #nameOnlyRows > 0 then
        message = message .. string.format(" %d rows are name-only matches.", #nameOnlyRows)
    end

    if #unresolvedRows > 0 then
        message = message .. string.format(" %d rows could not resolve an item name or ID.", #unresolvedRows)
    end

    if #duplicateRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Duplicate Rows"

        for _, line in ipairs(duplicateRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #idBackedRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "ID-Backed Rows"

        for _, line in ipairs(idBackedRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #suppliedIdRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Supplied ID Rows"

        for _, line in ipairs(suppliedIdRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #journalResolvedRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Journal Resolved Rows"

        for _, line in ipairs(journalResolvedRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #clientCacheResolvedRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Client Cache Resolved Rows"

        for _, line in ipairs(clientCacheResolvedRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #invalidRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Invalid Or Skipped Rows"

        for _, line in ipairs(invalidRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #nameOnlyRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Name-Only Rows"

        for _, line in ipairs(nameOnlyRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    if #unresolvedRows > 0 then
        reportLines[#reportLines + 1] = ""
        reportLines[#reportLines + 1] = "Unresolved Rows"

        for _, line in ipairs(unresolvedRows) do
            reportLines[#reportLines + 1] = line
        end
    end

    local reportText = table.concat(reportLines, "\n")

    return true, importedCount, message, reportText
end

function addon.BISService:GetOwnedStateLabel(entry)
    if type(entry) ~= "table" then
        return "Missing"
    end

    local priority = normalizePriority(entry.priority)

    if entry.detectedEquipped then
        local trackLabel = getTrackBandShortLabel(entry.detectedEquippedTrackRank)
        local itemLevel = tonumber(entry.highestEquippedItemLevel) or 0

        if priority == "optional" then
            return itemLevel > 0 and string.format("Optional Equipped %d", itemLevel) or "Optional Equipped"
        end

        if trackLabel ~= "" then
            return itemLevel > 0 and string.format("Equipped %s %d", trackLabel, itemLevel) or ("Equipped " .. trackLabel)
        end

        return itemLevel > 0 and string.format("Equipped %d", itemLevel) or "Equipped"
    end

    if entry.detectedOwned then
        local trackLabel = getTrackBandShortLabel(entry.detectedOwnedTrackRank)
        local itemLevel = tonumber(entry.highestOwnedItemLevel) or 0

        if priority == "optional" then
            return itemLevel > 0 and string.format("Optional Owned %d", itemLevel) or "Optional Owned"
        end

        if trackLabel ~= "" then
            return itemLevel > 0 and string.format("Owned %s %d", trackLabel, itemLevel) or ("Owned " .. trackLabel)
        end

        return itemLevel > 0 and string.format("Owned %d", itemLevel) or "Owned"
    end

    if priority == "optional" then
        return "Optional"
    end

    return "Missing"
end

function addon.BISService:GetEntryVisualState(entry)
    if type(entry) ~= "table" then
        return "missing"
    end

    local priority = normalizePriority(entry.priority)

    if priority == "optional" then
        return "optional"
    end

    if entry.detectedEquipped then
        return getTrackBandKey(entry.detectedEquippedTrackRank) or "below-champion"
    end

    if entry.detectedOwned then
        return getTrackBandKey(entry.detectedOwnedTrackRank) or "below-champion"
    end

    return "missing"
end

function addon.BISService:GetEntryCompactStateLabel(entry)
    if type(entry) ~= "table" then
        return "Missing"
    end

    local priority = normalizePriority(entry.priority)

    if priority == "optional" then
        if entry.detectedEquipped then
            return "Optional Eq"
        end

        if entry.detectedOwned then
            return "Optional"
        end

        return "Optional"
    end

    if entry.detectedEquipped then
        local trackLabel = getTrackBandCompactLabel(entry.detectedEquippedTrackRank)
        return trackLabel ~= "" and trackLabel or "Equipped"
    end

    if entry.detectedOwned then
        local trackLabel = getTrackBandCompactLabel(entry.detectedOwnedTrackRank)
        return trackLabel ~= "" and ("Owned " .. trackLabel) or "Owned"
    end

    return "Missing"
end

function addon.BISService:GetEntryLookupStatus(entry)
    if type(entry) ~= "table" then
        return "needs-review"
    end

    if (tonumber(entry.itemId) or 0) > 0 then
        if trimString(entry.sourceName) ~= "" or trimString(entry.sourceTarget) ~= "" or normalizeSourceType(entry.sourceType) ~= "other" then
            return "source-resolved"
        end

        return "id-backed"
    end

    if canonicalItemName(entry.itemName) ~= "" then
        return "name-only"
    end

    return "needs-review"
end

function addon.BISService:GetEntryLookupLabel(entry)
    local status = self:GetEntryLookupStatus(entry)

    if status == "source-resolved" then
        return "ID+Src"
    end

    if status == "id-backed" then
        return "ID"
    end

    if status == "name-only" then
        return "Name"
    end

    return "Review"
end

function addon.BISService:GetEntryLookupDescription(entry)
    local status = self:GetEntryLookupStatus(entry)

    if status == "source-resolved" then
        return "ID-backed with source metadata. This is the strongest lookup path."
    end

    if status == "id-backed" then
        return "ID-backed. Drop matching is reliable even if source metadata is incomplete."
    end

    if status == "name-only" then
        return "Name-only. GH3 can still track it, but exact itemId-backed rows are more reliable."
    end

    return "Needs review. Add a correct item name or itemId so GH3 can track it reliably."
end

function addon.BISService:ScanCurrentToonInventory(announce)
    local playerId = addon.Store:GetPlayerName()
    local toonRecord = self:GetToonRecord(playerId)
    local specRecord = self:GetSpecRecord(playerId)

    if not toonRecord or not specRecord then
        return false
    end

    local inventoryState = self:GetRuntimeState().inventory
    local changed = false
    local trackedByItemId = {}
    local trackedByName = {}

    local function indexEntry(slotKey, entry)
        local itemId = tonumber(entry.itemId) or 0
        local itemName = canonicalItemName(entry.itemName)

        if itemId > 0 then
            trackedByItemId[itemId] = trackedByItemId[itemId] or {}
            trackedByItemId[itemId][#trackedByItemId[itemId] + 1] = { entry = entry, slotKey = slotKey }
        end

        if itemName ~= "" then
            trackedByName[itemName] = trackedByName[itemName] or {}
            trackedByName[itemName][#trackedByName[itemName] + 1] = { entry = entry, slotKey = slotKey }
        end
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecord(playerId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            if clearDetectionState(entry) then
                changed = true
            end

            indexEntry(slot.key, entry)
        end
    end

    local function applyDetection(entryRefs, itemLevel, trackRank, trackLabel, equipped)
        for _, entryRef in ipairs(entryRefs or {}) do
            local entry = entryRef.entry

            if entry then
                if not entry.detectedOwned then
                    entry.detectedOwned = true
                    changed = true
                end

                if (tonumber(entry.highestOwnedItemLevel) or 0) < itemLevel then
                    entry.highestOwnedItemLevel = itemLevel
                    changed = true
                end

                if (tonumber(entry.detectedOwnedTrackRank) or 0) < (tonumber(trackRank) or 0) then
                    entry.detectedOwnedTrackRank = tonumber(trackRank) or 0
                    entry.detectedOwnedTrackLabel = trimString(trackLabel)
                    changed = true
                elseif (tonumber(entry.detectedOwnedTrackRank) or 0) == (tonumber(trackRank) or 0) and trimString(entry.detectedOwnedTrackLabel) ~= trimString(trackLabel) and trimString(trackLabel) ~= "" then
                    entry.detectedOwnedTrackLabel = trimString(trackLabel)
                    changed = true
                end

                if equipped then
                    if not entry.detectedEquipped then
                        entry.detectedEquipped = true
                        changed = true
                    end

                    if (tonumber(entry.highestEquippedItemLevel) or 0) < itemLevel then
                        entry.highestEquippedItemLevel = itemLevel
                        changed = true
                    end

                    if (tonumber(entry.detectedEquippedTrackRank) or 0) < (tonumber(trackRank) or 0) then
                        entry.detectedEquippedTrackRank = tonumber(trackRank) or 0
                        entry.detectedEquippedTrackLabel = trimString(trackLabel)
                        changed = true
                    elseif (tonumber(entry.detectedEquippedTrackRank) or 0) == (tonumber(trackRank) or 0) and trimString(entry.detectedEquippedTrackLabel) ~= trimString(trackLabel) and trimString(trackLabel) ~= "" then
                        entry.detectedEquippedTrackLabel = trimString(trackLabel)
                        changed = true
                    end
                end
            end
        end
    end

    local function getMatchingEntries(itemId, itemName, candidateSlotKeys)
        local matches = {}
        local seenEntries = {}
        local keyedMatches = (tonumber(itemId) or 0) > 0 and trackedByItemId[tonumber(itemId) or 0] or nil
        local normalizedItemName = canonicalItemName(itemName)
        local namedMatches = normalizedItemName ~= "" and trackedByName[normalizedItemName] or nil
        local allowedSlots = {}

        for _, slotKey in ipairs(candidateSlotKeys or {}) do
            allowedSlots[slotKey] = true
        end

        local function appendMatches(source)
            for _, entryRef in ipairs(source or {}) do
                if (not next(allowedSlots)) or allowedSlots[entryRef.slotKey] then
                    if not seenEntries[entryRef.entry] then
                        seenEntries[entryRef.entry] = true
                        matches[#matches + 1] = entryRef
                    end
                end
            end
        end

        appendMatches(keyedMatches)
        appendMatches(namedMatches)
        return matches
    end

    for slotKey, inventorySlots in pairs(INVENTORY_SLOT_IDS) do
        for _, inventorySlotId in ipairs(inventorySlots) do
            local itemLink = GetInventoryItemLink and GetInventoryItemLink("player", inventorySlotId) or nil
            local itemId, itemName, itemLevel = getItemDetails(itemLink)
            local trackRank, trackLabel = self:GetCachedTrackInfo(itemLink)

            if itemLink and ((itemId or 0) > 0 or itemName ~= "") then
                applyDetection(getMatchingEntries(itemId, itemName, getLogicalSlotKeys(slotKey)), itemLevel, trackRank, trackLabel, true)
            end
        end
    end

    local maxBagId = NUM_BAG_SLOTS or 4

    for bagId = 0, maxBagId do
        for slotIndex = 1, (getContainerNumSlotsCompat(bagId) or 0) do
            local itemLink = getContainerItemLinkCompat(bagId, slotIndex)
            local itemId, itemName, itemLevel = getItemDetails(itemLink)
            local trackRank, trackLabel = self:GetCachedTrackInfo(itemLink)

            if itemLink and ((itemId or 0) > 0 or itemName ~= "") then
                applyDetection(getMatchingEntries(itemId, itemName), itemLevel, trackRank, trackLabel, false)
            end
        end
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecord(playerId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            local currentOwnedLevel = tonumber(entry.highestOwnedItemLevel) or 0
            local currentOwnedTrackRank = tonumber(entry.detectedOwnedTrackRank) or 0
            local lastAlertedLevel = tonumber(entry.lastAlertedItemLevel) or 0
            local lastAlertedTrackRank = tonumber(entry.lastAlertedTrackRank) or 0

            if currentOwnedLevel > 0 and not announce and lastAlertedLevel <= 0 then
                entry.lastAlertedItemLevel = currentOwnedLevel
                entry.lastAlertedTrackRank = currentOwnedTrackRank
                changed = true
            elseif announce and currentOwnedLevel > 0 and (currentOwnedTrackRank > lastAlertedTrackRank or (currentOwnedTrackRank == lastAlertedTrackRank and currentOwnedLevel > lastAlertedLevel)) then
                local alertLabel = trimString(entry.itemName) ~= "" and entry.itemName or (entry.itemId > 0 and ("item " .. tostring(entry.itemId)) or slot.label)
                local trackText = trimString(entry.detectedOwnedTrackLabel)

                if lastAlertedLevel <= 0 then
                    addon:Print(string.format("Tracked BIS item found: %s (%s %s iLvl %d).", alertLabel, slot.label, trackText ~= "" and (trackText .. " ") or "", currentOwnedLevel))
                else
                    addon:Print(string.format("Tracked BIS item improved: %s (%s %s iLvl %d).", alertLabel, slot.label, trackText ~= "" and (trackText .. " ") or "", currentOwnedLevel))
                end

                entry.lastAlertedItemLevel = currentOwnedLevel
                entry.lastAlertedTrackRank = currentOwnedTrackRank
                changed = true
            end
        end
    end

    if changed then
        self:TouchRecord(toonRecord, specRecord)
    end

    inventoryState.dirty = false
    inventoryState.lastPlayerId = playerId
    inventoryState.lastScanAt = time()
    inventoryState.lastSpecRevision = tonumber(specRecord.revision) or 0

    return changed
end

function addon.BISService:HandleInventoryChanged()
    self:MarkInventoryScanDirty()

    local inventoryState = self:GetRuntimeState().inventory

    if InCombatLockdown and InCombatLockdown() then
        return false
    end

    if C_Timer and C_Timer.After then
        inventoryState.pendingScanGeneration = math.max(0, math.floor(tonumber(inventoryState.pendingScanGeneration) or 0)) + 1
        local expectedGeneration = inventoryState.pendingScanGeneration

        C_Timer.After(INVENTORY_SCAN_DEBOUNCE_SECONDS, function()
            local perfScopeName = "bis:debounced-inventory-scan"
            local perfStartedAt = addon.BeginPerfScope and addon:BeginPerfScope(perfScopeName) or nil
            local liveInventoryState = self:GetRuntimeState().inventory

            if tonumber(liveInventoryState.pendingScanGeneration) ~= expectedGeneration then
                if perfStartedAt ~= nil and addon.EndPerfScope then
                    addon:EndPerfScope(perfScopeName, perfStartedAt)
                end
                return
            end

            liveInventoryState.pendingScanGeneration = 0

            local changed = self:EnsureCurrentInventoryScan({ announce = true }) and true or false
            local bisViewActive = addon.MainFrame
                and addon.MainFrame.frame
                and addon.MainFrame.frame:IsShown()
                and addon.Router
                and addon.Router.GetCurrentView
                and addon.Router:GetCurrentView() == "bis"

            if changed and bisViewActive and addon.MainFrame.RefreshCurrentView then
                addon.MainFrame:RefreshCurrentView(true)
            elseif changed and addon.MainFrame and addon.MainFrame.MarkBackgroundRefreshPending then
                addon.MainFrame:MarkBackgroundRefreshPending()
            end

            if perfStartedAt ~= nil and addon.EndPerfScope then
                addon:EndPerfScope(perfScopeName, perfStartedAt)
            end
        end)

        return false
    end

    return self:EnsureCurrentInventoryScan({ announce = true })
end

function addon.BISService:BuildLootMatchIndex(toonId, specId)
    local resolvedSpecId = normalizeSpecId(specId or self:GetSelectedSpecId(toonId))
    local specRecord = self:GetSpecRecord(toonId, resolvedSpecId)
    local lootState = self:GetRuntimeState().loot
    local cacheKey = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown") .. "|" .. resolvedSpecId
    local revision = tonumber(specRecord and specRecord.revision) or 0
    local cached = lootState.matchIndexBySpec[cacheKey]

    if cached and cached.revision == revision then
        return cached
    end

    local index = {
        byItemId = {},
        byName = {},
        revision = revision,
    }

    if not specRecord then
        lootState.matchIndexBySpec[cacheKey] = index
        return index
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecordForSpec(toonId, resolvedSpecId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            local sourceType = normalizeSourceType(entry.sourceType)
            local trackedItemId = tonumber(entry.itemId) or 0
            local trackedName = canonicalItemName(entry.itemName)

            if sourceType == "dungeon" or sourceType == "raid" then
                local matchRow = {
                    entry = entry,
                    slotKey = slot.key,
                    slotLabel = slot.label,
                }

                if trackedItemId > 0 then
                    index.byItemId[trackedItemId] = index.byItemId[trackedItemId] or {}
                    index.byItemId[trackedItemId][#index.byItemId[trackedItemId] + 1] = matchRow
                end

                if trackedName ~= "" then
                    index.byName[trackedName] = index.byName[trackedName] or {}
                    index.byName[trackedName][#index.byName[trackedName] + 1] = matchRow
                end
            end
        end
    end

    lootState.matchIndexBySpec[cacheKey] = index
    return index
end

function addon.BISService:ListLootAlertMatches(toonId, itemId, itemName, specId, options)
    local resolvedOptions = type(options) == "table" and options or {}
    local lootIndex = self:BuildLootMatchIndex(toonId, specId)
    local matches = {}

    if not lootIndex then
        return matches
    end

    local seenMatches = {}
    local function addMatches(candidateRows)
        for _, match in ipairs(candidateRows or {}) do
            local entry = match.entry
            local matchKey = tostring(match.slotKey) .. "|" .. tostring(entry)

            if not seenMatches[matchKey] then
                seenMatches[matchKey] = true

                if not (resolvedOptions.onlyMissing and entry and entry.detectedOwned) then
                    matches[#matches + 1] = match
                end
            end
        end
    end

    local trackedItemId = tonumber(itemId) or 0
    local trackedName = canonicalItemName(itemName)

    if trackedItemId > 0 then
        addMatches(lootIndex.byItemId[trackedItemId])
    end

    if trackedName ~= "" then
        addMatches(lootIndex.byName[trackedName])
    end

    return matches
end

function addon.BISService:ShouldSuppressLootAlert(recipientId, itemId, itemName)
    local lootState = self:GetRuntimeState().loot
    local now = time()
    local cacheKey = table.concat({
        lowerText(recipientId),
        tostring(tonumber(itemId) or 0),
        canonicalItemName(itemName),
    }, "|")

    for key, value in pairs(lootState.recentAlerts or {}) do
        if (now - (tonumber(value) or 0)) > LOOT_ALERT_CACHE_SECONDS then
            lootState.recentAlerts[key] = nil
        end
    end

    if lootState.recentAlerts[cacheKey] and (now - (tonumber(lootState.recentAlerts[cacheKey]) or 0)) <= LOOT_ALERT_CACHE_SECONDS then
        return true
    end

    lootState.recentAlerts[cacheKey] = now
    return false
end

function addon.BISService:ShouldSuppressOwnedSelfLootAlert(matches)
    local isUpgradeInteractionActive = false

    if ItemUpgradeFrame and ItemUpgradeFrame.IsShown and ItemUpgradeFrame:IsShown() then
        isUpgradeInteractionActive = true
    elseif C_ItemInteraction and type(C_ItemInteraction.GetItemInteractionType) == "function" then
        local interactionType = C_ItemInteraction.GetItemInteractionType()
        local interactionEnum = Enum and Enum.UIItemInteractionType or nil

        if type(interactionEnum) == "table" then
            if interactionEnum.ItemUpgrade and interactionType == interactionEnum.ItemUpgrade then
                isUpgradeInteractionActive = true
            elseif interactionEnum.Upgrade and interactionType == interactionEnum.Upgrade then
                isUpgradeInteractionActive = true
            end
        end
    end

    if not isUpgradeInteractionActive then
        return false
    end

    if type(matches) ~= "table" then
        return false
    end

    for _, match in ipairs(matches) do
        local entry = type(match) == "table" and match.entry or nil

        if type(entry) == "table" and (entry.detectedEquipped or entry.detectedOwned) and (tonumber(entry.lastAlertedItemLevel) or 0) > 0 then
            return true
        end
    end

    return false
end

function addon.BISService:ProcessLootMessage(message)
    local perfScopeName = "bis:process-loot-message"
    local perfStartedAt = addon.BeginPerfScope and addon:BeginPerfScope(perfScopeName) or nil
    local function finishPerf(result)
        if perfStartedAt ~= nil and addon.EndPerfScope then
            addon:EndPerfScope(perfScopeName, perfStartedAt)
        end

        return result
    end

    local playerId = addon.Store:GetPlayerName()
    local lootHelperSettings = self:GetLootHelperSettings()
    local lootSpecId = self:GetLootSpecId(playerId)

    if not playerId or trimString(message) == "" then
        return finishPerf(false)
    end

    local lootState = self:GetRuntimeState().loot
    local normalizedMessage = normalizeLootMessageText(message)
    local itemLinks = extractItemLinksFromMessage(message)
    local handled = false
    local inRestrictedInstance = addon.IsInRestrictedInstance and addon:IsInRestrictedInstance() or false
    local shouldReviewAllTradable = lootHelperSettings.mode == "all-tradeable"
    local selfLootMessage = isSelfLootMessage(normalizedMessage)
    local recipientId = nil
    local recipientLabel = nil
    local recipientIsPlayer = false

    if #itemLinks <= 0 then
        return finishPerf(false)
    end

    if not selfLootMessage and not shouldReviewAllTradable and not inRestrictedInstance then
        return finishPerf(false)
    end

    if selfLootMessage then
        recipientId = playerId
        recipientLabel = trimString((playerId or ""):match("^[^-]+") or playerId)
        recipientIsPlayer = true
    else
        recipientId, recipientLabel, recipientIsPlayer = resolveLootRecipient(normalizedMessage, collectGroupLootCandidatesCached(lootState))
    end

    for _, itemLink in ipairs(itemLinks) do
        local itemId, itemName, itemLevel = getItemDetails(itemLink)
        local matches = self:ListLootAlertMatches(playerId, itemId, itemName, lootSpecId)
        local tradability = nil

        if #matches > 0 and not self:ShouldSuppressLootAlert(recipientId or "", itemId, itemName) then
            local firstMatch = matches[1]
            local entry = firstMatch.entry or {}
            local alertLabel = trimString(itemName) ~= "" and itemName or (trimString(entry.itemName) ~= "" and entry.itemName or "Tracked item")
            local slotLabel = trimString(firstMatch.slotLabel) ~= "" and trimString(firstMatch.slotLabel) or "tracked"
            local lootSpecLabel = trimString(lootSpecId) ~= "" and trimString(lootSpecId) or "loot spec"
            local sourceTypeLabel = self:GetSourceTypeLabel(entry.sourceType)
            local sourceName = trimString(entry.sourceName)
            local sourceTarget = trimString(entry.sourceTarget)
            local sourceSummary = sourceTypeLabel

            if sourceName ~= "" and sourceTarget ~= "" then
                sourceSummary = string.format("%s: %s / %s", sourceTypeLabel, sourceName, sourceTarget)
            elseif sourceName ~= "" then
                sourceSummary = string.format("%s: %s", sourceTypeLabel, sourceName)
            end

            if recipientIsPlayer or not recipientId then
                if not self:ShouldSuppressOwnedSelfLootAlert(matches) then
                    sendPlayerAlertMessage(string.format("GH3 BIS: %s matches your %s %s list. %s.", alertLabel, lootSpecLabel, slotLabel, sourceSummary), playerId)
                    handled = true
                end
            else
                sendPlayerAlertMessage(string.format("GH3 BIS: %s was looted by %s for your %s %s list. %s. If it is tradable, GH3 will whisper them for you.", alertLabel, recipientLabel or recipientId, lootSpecLabel, slotLabel, sourceSummary), playerId)
                self:QueueDropReviewRow({
                    kind = "need",
                    ownerId = recipientId,
                    targetId = playerId,
                    itemId = itemId,
                    itemLink = itemLink,
                    itemName = itemName,
                    itemLevel = itemLevel,
                    slotLabel = slotLabel,
                    lootSpecId = lootSpecId,
                    metaText = string.format("Your %s %s BIS | %s", lootSpecLabel, slotLabel, sourceSummary),
                    canWhisper = true,
                    canTrade = true,
                })
                handled = true
            end
        elseif shouldReviewAllTradable then
            tradability = getCachedLootTradability(self, itemLink)
            local allowBroadReview = lootHelperSettings.detectionMode == "broad" and tradability.isEquippable
            local allowStrictReview = tradability.isEquippable and tradability.isLikelyTradable
            local suppressMailboxSelfReview = recipientIsPlayer and isMailboxLootContext()

            if not suppressMailboxSelfReview and (allowStrictReview or allowBroadReview) then
            if recipientIsPlayer then
                self:QueueDropReviewRow({
                    kind = "self-offer",
                    ownerId = playerId,
                    targetId = "",
                    itemId = itemId,
                    itemLink = itemLink,
                    itemName = itemName,
                    itemLevel = itemLevel,
                    slotLabel = "Tradable Loot",
                    metaText = tradability.reason == "temporary-trade" and "Self-looted gear | Temporary trade window detected"
                        or tradability.reason == "bind-on-equip" and "Self-looted gear | Bind on Equip"
                        or lootHelperSettings.detectionMode == "broad" and "Self-looted gear | Broad best-effort review"
                        or "Self-looted gear | Quick offer row",
                    canOffer = true,
                })
                handled = true
            elseif trimString(recipientId) ~= "" then
                self:QueueDropReviewRow({
                    kind = "offer",
                    ownerId = recipientId,
                    targetId = "",
                    itemId = itemId,
                    itemLink = itemLink,
                    itemName = itemName,
                    itemLevel = itemLevel,
                    slotLabel = "Tradable Loot",
                    metaText = tradability.reason == "bind-on-equip"
                        and string.format("Group-looted gear from %s | Bind on Equip", shortPlayerName(recipientId))
                        or tradability.reason == "temporary-trade"
                        and string.format("Group-looted gear from %s | Trade window detected", shortPlayerName(recipientId))
                        or lootHelperSettings.detectionMode == "broad"
                        and string.format("Group-looted gear from %s | Broad best-effort review", shortPlayerName(recipientId))
                        or string.format("Group-looted gear from %s | Best-effort tradable review", shortPlayerName(recipientId)),
                    canWhisper = true,
                    canTrade = true,
                })
                handled = true
            end
            end
        end

        if recipientIsPlayer and itemLink and itemLink ~= "" then
            if shouldReviewAllTradable then
                tradability = tradability or getCachedLootTradability(self, itemLink)

                if tradability.isEquippable then
                    self:BroadcastLootOffer(itemLink, tradability)
                end
            elseif inRestrictedInstance and isEquippableLootItem(itemLink) then
                self:BroadcastLootOffer(itemLink)
            end
        end
    end

    return finishPerf(handled)
end

local function resetPendingLootQueue(lootState)
    if type(lootState) ~= "table" then
        return
    end

    lootState.pendingMessages = {}
    lootState.pendingMessageStart = 1
    lootState.pendingMessageEnd = 0
end

function addon.BISService:EnsureLootMessagePump()
    local lootState = self:GetRuntimeState().loot

    if lootState.processorFrame then
        return lootState.processorFrame
    end

    local frame = CreateFrame("Frame")
    lootState.processorFrame = frame
    frame:Hide()
    frame:SetScript("OnUpdate", function(_, elapsed)
        local runtimeLootState = self:GetRuntimeState().loot

        runtimeLootState.processorElapsed = (tonumber(runtimeLootState.processorElapsed) or 0) + (tonumber(elapsed) or 0)

        if runtimeLootState.processorElapsed < LOOT_QUEUE_INTERVAL_SECONDS then
            return
        end

        runtimeLootState.processorElapsed = 0

        local nextIndex = math.max(1, math.floor(tonumber(runtimeLootState.pendingMessageStart) or 1))
        local lastIndex = math.max(0, math.floor(tonumber(runtimeLootState.pendingMessageEnd) or 0))

        if nextIndex > lastIndex then
            resetPendingLootQueue(runtimeLootState)
            frame:Hide()
            return
        end

        local message = runtimeLootState.pendingMessages[nextIndex]
        runtimeLootState.pendingMessages[nextIndex] = nil
        runtimeLootState.pendingMessageStart = nextIndex + 1

        if trimString(message) ~= "" and not (addon.IsSuspended and addon:IsSuspended()) then
            self:ProcessLootMessage(message)
        end

        if runtimeLootState.pendingMessageStart > runtimeLootState.pendingMessageEnd then
            resetPendingLootQueue(runtimeLootState)
            frame:Hide()
        end
    end)

    return frame
end

function addon.BISService:HandleLootMessage(message)
    local resolvedMessage = trimString(message)

    if resolvedMessage == "" then
        return false
    end

    if not string.find(resolvedMessage, "|Hitem:", 1, true) then
        return false
    end

    local lootHelperSettings = self:GetLootHelperSettings()
    local inRestrictedInstance = addon.IsInRestrictedInstance and addon:IsInRestrictedInstance() or false

    if lootHelperSettings.mode ~= "all-tradeable" and not inRestrictedInstance then
        local normalizedMessage = normalizeLootMessageText(resolvedMessage)

        if not isSelfLootMessage(normalizedMessage) then
            return false
        end
    end

    local lootState = self:GetRuntimeState().loot
    local nextIndex = math.max(0, math.floor(tonumber(lootState.pendingMessageEnd) or 0)) + 1

    lootState.pendingMessages[nextIndex] = resolvedMessage
    lootState.pendingMessageEnd = nextIndex

    if nextIndex < lootState.pendingMessageStart then
        lootState.pendingMessageStart = nextIndex
    end

    self:EnsureLootMessagePump():Show()
    return true
end

function addon.BISService:BuildLootOfferKey(sourcePlayer, itemId, itemName)
    return table.concat({
        lowerText(sourcePlayer),
        tostring(tonumber(itemId) or 0),
        canonicalItemName(itemName),
    }, "|")
end

function addon.BISService:ApplyDropReviewOfferState(offerKey, payload)
    local resolvedOfferKey = trimString(offerKey)

    if resolvedOfferKey == "" or type(payload) ~= "table" then
        return false
    end

    local changed = false
    local claimantId = normalizePlayerId(payload.claimantId)
    local rollerId = normalizePlayerId(payload.rollerId)
    local rollValue = math.max(0, math.floor(tonumber(payload.rollValue) or 0))

    for _, row in ipairs(self:GetRuntimeState().dropReview.rows or {}) do
        if type(row) == "table" and trimString(row.offerKey) == resolvedOfferKey then
            if claimantId ~= "" then
                row.claimantIds = type(row.claimantIds) == "table" and row.claimantIds or {}

                if not row.claimantIds[claimantId] then
                    row.claimantIds[claimantId] = true
                    changed = true
                end
            end

            if rollerId ~= "" and rollValue > 0 then
                row.rolls = type(row.rolls) == "table" and row.rolls or {}

                if tonumber(row.rolls[rollerId]) ~= rollValue then
                    row.rolls[rollerId] = rollValue
                    changed = true
                end

                row.claimantIds = type(row.claimantIds) == "table" and row.claimantIds or {}

                if not row.claimantIds[rollerId] then
                    row.claimantIds[rollerId] = true
                    changed = true
                end
            end
        end
    end

    return changed
end

function addon.BISService:BroadcastLootRoll(row, rollValue)
    if type(row) ~= "table" then
        return false
    end

    local channel = getGroupAddonChannel()
    local playerId = addon.Store:GetPlayerName()
    local itemId = tonumber(row.itemId) or 0
    local itemLink = trimString(row.itemLink)
    local itemName = trimString(row.itemName)
    local ownerId = normalizePlayerId(row.ownerId)
    local resolvedRoll = math.max(1, math.min(100, math.floor(tonumber(rollValue) or 0)))

    if not channel or trimString(playerId) == "" or ownerId == "" or (itemId <= 0 and itemLink == "" and canonicalItemName(itemName) == "") then
        return false
    end

    return self:SendGroupMessage("ROLL", {
        itemId = itemId,
        itemLink = itemLink,
        itemName = itemName,
        ownerId = ownerId,
        roller = playerId,
        roll = resolvedRoll,
        sentAt = time(),
    }) and true or false
end

function addon.BISService:BroadcastLootOffer(itemLink, tradability)
    local channel = getGroupAddonChannel()
    local playerId = addon.Store:GetPlayerName()
    local itemId, itemName = getItemDetails(itemLink)
    local resolvedIsEquippable = type(tradability) == "table"
        and tradability.isEquippable
        or isEquippableLootItem(itemLink)

    if not channel or trimString(playerId) == "" or ((tonumber(itemId) or 0) <= 0 and canonicalItemName(itemName) == "") then
        return false
    end

    if not resolvedIsEquippable then
        return false
    end

    local offerPayload = {
        itemId = tonumber(itemId) or 0,
        itemLink = itemLink,
        itemName = trimString(itemName),
        looter = playerId,
        sentAt = time(),
    }

    return self:SendGroupMessage("OFFER", offerPayload) and true or false
end

function addon.BISService:BroadcastManualGroupOffer(row)
    if type(row) ~= "table" then
        return false
    end

    local channel = getGroupAddonChannel()
    local playerId = addon.Store:GetPlayerName()
    local itemId = tonumber(row.itemId) or 0
    local itemLink = trimString(row.itemLink)
    local itemName = trimString(row.itemName)

    if not channel or trimString(playerId) == "" or (itemId <= 0 and itemLink == "" and canonicalItemName(itemName) == "") then
        return false
    end

    local offerPayload = {
        itemId = itemId,
        itemLink = itemLink,
        itemName = itemName,
        looter = playerId,
        lootSpecId = trimString(row.lootSpecId),
        slotLabel = trimString(row.slotLabel),
        metaText = trimString(row.metaText),
        sentAt = time(),
    }

    return self:SendGroupMessage("GROUP_OFFER", offerPayload) and true or false
end

function addon.BISService:ShouldSuppressShareKey(cacheName, key)
    local shareState = self:GetRuntimeState().share
    local cache = shareState[cacheName] or {}
    local now = time()

    shareState[cacheName] = cache

    for existingKey, existingTime in pairs(cache) do
        if (now - (tonumber(existingTime) or 0)) > SHARE_CACHE_SECONDS then
            cache[existingKey] = nil
        end
    end

    if cache[key] and (now - (tonumber(cache[key]) or 0)) <= SHARE_CACHE_SECONDS then
        return true
    end

    cache[key] = now
    return false
end

function addon.BISService:HandleLootOffer(payload, sender)
    if type(payload) ~= "table" then
        return false
    end

    local playerId = addon.Store:GetPlayerName()
    local looterId = addon.Store:NormalizeFullName(payload.looter or sender, GetRealmName() or "Unknown")
    local itemId = tonumber(payload.itemId) or 0
    local itemName = normalizeItemName(payload.itemName or payload.itemLink or "")

    if trimString(looterId) == "" or looterId == playerId then
        return false
    end

    local offerKey = self:BuildLootOfferKey(looterId, itemId, itemName)
    local settings = self:GetLootHelperSettings()
    local inRestrictedInstance = addon.IsInRestrictedInstance and addon:IsInRestrictedInstance() or false

    if self:ShouldSuppressShareKey("recentOffers", offerKey .. "|seen") then
        return false
    end

    if settings.mode ~= "all-tradeable" and not inRestrictedInstance then
        return false
    end

    local lootSpecId = self:GetLootSpecId(playerId)
    local matches = self:ListLootAlertMatches(playerId, itemId, itemName, lootSpecId, { onlyMissing = true })

    if #matches <= 0 then
        if settings.mode ~= "all-tradeable" then
            return false
        end

        local tradability = getCachedLootTradability(self, trimString(payload.itemLink))
        local allowBroadReview = settings.detectionMode == "broad" and tradability.isEquippable
        local allowStrictReview = tradability.isEquippable and tradability.isLikelyTradable

        if not (allowStrictReview or allowBroadReview) then
            return false
        end

        self:QueueDropReviewRow({
            kind = "offer",
            ownerId = looterId,
            targetId = "",
            offerKey = offerKey,
            itemId = itemId,
            itemLink = trimString(payload.itemLink),
            itemName = trimString(itemName),
            itemLevel = select(3, getItemDetails(payload.itemLink)),
            slotLabel = trimString(payload.slotLabel) ~= "" and trimString(payload.slotLabel) or "Tradable Loot",
            lootSpecId = trimString(payload.lootSpecId),
            metaText = tradability.reason == "bind-on-equip"
                and string.format("GH3 peer Bind on Equip loot from %s", shortPlayerName(looterId))
                or tradability.reason == "temporary-trade"
                and string.format("GH3 peer timed-trade loot from %s", shortPlayerName(looterId))
                or settings.detectionMode == "broad"
                and string.format("GH3 peer best-effort loot from %s", shortPlayerName(looterId))
                or string.format("GH3 peer tradable loot from %s", shortPlayerName(looterId)),
            canWhisper = true,
            canTrade = true,
            canClaim = true,
            canRoll = true,
        })
        return true
    end

    local firstMatch = matches[1]
    local claimKey = offerKey .. "|" .. playerId

    if self:ShouldSuppressShareKey("recentClaims", claimKey) then
        return false
    end

    local claimPayload = {
        itemId = itemId,
        itemLink = trimString(payload.itemLink),
        itemName = trimString(itemName),
        claimant = playerId,
        lootSpecId = lootSpecId,
        slotLabel = firstMatch.slotLabel,
    }

    self:SendPeerMessage("CLAIM", claimPayload, looterId)

    self:QueueDropReviewRow({
        kind = "need",
        ownerId = looterId,
        targetId = playerId,
        offerKey = offerKey,
        itemId = itemId,
        itemLink = trimString(payload.itemLink),
        itemName = trimString(itemName),
        itemLevel = select(3, getItemDetails(payload.itemLink)),
        slotLabel = firstMatch.slotLabel,
        lootSpecId = lootSpecId,
        metaText = string.format("Offered by %s | Your %s BIS", shortPlayerName(looterId), firstMatch.slotLabel),
        canWhisper = true,
        canTrade = true,
        canRoll = true,
        claimantId = playerId,
    })

    local whisperText = string.format("GH3 BIS: %s is on my tracked %s list for %s%s.",
        trimString(payload.itemLink) ~= "" and payload.itemLink or (trimString(itemName) ~= "" and itemName or "that item"),
        trimString(lootSpecId) ~= "" and lootSpecId or "loot spec",
        firstMatch.slotLabel,
        trimString(firstMatch.entry and firstMatch.entry.sourceName or "") ~= "" and (" from " .. trimString(firstMatch.entry.sourceName)) or "")

    sendWhisperMessage(whisperText, looterId)
    return true
end

function addon.BISService:HandleLootClaim(payload, sender)
    if type(payload) ~= "table" then
        return false
    end

    local claimantId = addon.Store:NormalizeFullName(payload.claimant or sender, GetRealmName() or "Unknown")
    local playerId = addon.Store:GetPlayerName()

    if trimString(claimantId) == "" or claimantId == playerId then
        return false
    end

    local offerKey = self:BuildLootOfferKey(playerId, payload.itemId, payload.itemName)
    local claimKey = offerKey .. "|" .. claimantId

    if self:ShouldSuppressShareKey("recentClaims", claimKey .. "|notify") then
        return false
    end

    local itemText = trimString(payload.itemLink) ~= "" and payload.itemLink or (trimString(payload.itemName) ~= "" and payload.itemName or "that item")
    local lootSpecId = trimString(payload.lootSpecId)
    local slotLabel = trimString(payload.slotLabel)

    sendWhisperMessage(string.format("GH3 BIS: %s wants %s%s%s.",
        shortPlayerName(claimantId),
        itemText,
        lootSpecId ~= "" and (" for " .. lootSpecId) or "",
        slotLabel ~= "" and (" (" .. slotLabel .. ")") or ""), playerId)
    self:QueueDropReviewRow({
        kind = "offer",
        ownerId = playerId,
        targetId = claimantId,
        offerKey = offerKey,
        itemId = tonumber(payload.itemId) or 0,
        itemLink = trimString(payload.itemLink),
        itemName = trimString(payload.itemName),
        itemLevel = select(3, getItemDetails(payload.itemLink)),
        slotLabel = slotLabel,
        lootSpecId = lootSpecId,
        metaText = string.format("Claimed by %s%s%s",
            shortPlayerName(claimantId),
            lootSpecId ~= "" and (" for " .. lootSpecId) or "",
            slotLabel ~= "" and (" | " .. slotLabel) or ""),
        canOffer = true,
        canTrade = true,
        claimantId = claimantId,
    })
    self:ApplyDropReviewOfferState(offerKey, { claimantId = claimantId })
    return true
end

function addon.BISService:HandleGroupOffer(payload, sender)
    if type(payload) ~= "table" then
        return false
    end

    local playerId = addon.Store:GetPlayerName()
    local looterId = addon.Store:NormalizeFullName(payload.looter or sender, GetRealmName() or "Unknown")
    local itemId = tonumber(payload.itemId) or 0
    local itemLink = trimString(payload.itemLink)
    local itemName = normalizeItemName(payload.itemName or itemLink or "")

    if not isEquippableLootItem(itemLink ~= "" and itemLink or itemId) then
        return false
    end

    if trimString(looterId) == "" or looterId == playerId then
        return false
    end

    local offerKey = self:BuildLootOfferKey(looterId, itemId, itemName)

    if self:ShouldSuppressShareKey("recentGroupOffers", offerKey) then
        return false
    end

    local lootSpecId = self:GetLootSpecId(playerId)
    local matches = self:ListLootAlertMatches(playerId, itemId, itemName, lootSpecId, { onlyMissing = true })
    local slotLabel = trimString(payload.slotLabel)
    local matchedSlotLabel = #matches > 0 and trimString(matches[1].slotLabel) or ""
    local settings = self:GetLootHelperSettings()

    if #matches <= 0 and settings.mode ~= "all-tradeable" then
        return false
    end

    self:QueueDropReviewRow({
        kind = #matches > 0 and "need" or "offer",
        ownerId = looterId,
        targetId = #matches > 0 and playerId or "",
        offerKey = offerKey,
        itemId = itemId,
        itemLink = itemLink,
        itemName = itemName,
        itemLevel = select(3, getItemDetails(itemLink)),
        slotLabel = matchedSlotLabel ~= "" and matchedSlotLabel or slotLabel,
        lootSpecId = #matches > 0 and lootSpecId or trimString(payload.lootSpecId),
        metaText = #matches > 0
            and string.format("Manual group offer from %s%s", shortPlayerName(looterId), matchedSlotLabel ~= "" and (" | Your " .. matchedSlotLabel .. " BIS") or "")
            or string.format("Manual group offer from %s", shortPlayerName(looterId)),
        canWhisper = true,
        canTrade = true,
        canClaim = true,
        canRoll = true,
    })

    return true
end

function addon.BISService:HandleLootRoll(payload, sender)
    if type(payload) ~= "table" then
        return false
    end

    local ownerId = addon.Store:NormalizeFullName(payload.ownerId or "", GetRealmName() or "Unknown")
    local rollerId = addon.Store:NormalizeFullName(payload.roller or sender, GetRealmName() or "Unknown")
    local itemId = tonumber(payload.itemId) or 0
    local itemLink = trimString(payload.itemLink)
    local itemName = normalizeItemName(payload.itemName or itemLink or "")
    local rollValue = math.max(1, math.min(100, math.floor(tonumber(payload.roll) or 0)))

    if trimString(ownerId) == "" or trimString(rollerId) == "" or rollValue <= 0 then
        return false
    end

    local offerKey = self:BuildLootOfferKey(ownerId, itemId, itemName)
    local rollKey = table.concat({ offerKey, lowerText(rollerId), tostring(rollValue) }, "|")

    if self:ShouldSuppressShareKey("recentRolls", rollKey) then
        return false
    end

    local updated = self:ApplyDropReviewOfferState(offerKey, {
        claimantId = rollerId,
        rollerId = rollerId,
        rollValue = rollValue,
    })

    if updated then
        if addon.MainFrame and addon.MainFrame.bisDropReviewFrame and addon.MainFrame.bisDropReviewFrame:IsShown() then
            addon.MainFrame:ShowBISDropReviewDialog()
        else
            self:ScheduleDropReviewPopup(0)
        end
    end

    return updated
end

function addon.BISService:HandleIncoming(prefix, message, _, sender)
    if (addon.IsSuspended and addon:IsSuspended()) or prefix ~= self:GetPrefix() or not sender or sender == addon.Store:GetPlayerName() then
        return false
    end

    local command, payloadText = parseMessage(message)
    local payload = decodeJson(payloadText)
    local normalizedCommand = string.upper(command or "")

    if normalizedCommand == "OFFER" then
        return self:HandleLootOffer(payload, sender)
    end

    if normalizedCommand == "CLAIM" then
        return self:HandleLootClaim(payload, sender)
    end

    if normalizedCommand == "GROUP_OFFER" then
        return self:HandleGroupOffer(payload, sender)
    end

    if normalizedCommand == "ROLL" then
        return self:HandleLootRoll(payload, sender)
    end

    return false
end

function addon.BISService:ListVaultAlertMatches(toonId, itemId, itemName)
    local specId = self:GetLootSpecId(toonId)
    local specRecord = self:GetSpecRecord(toonId, specId)
    local matches = {}

    if not specRecord then
        return matches
    end

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecordForSpec(toonId, specId, slot.key)

        for _, entry in ipairs(slotRecord.entries or {}) do
            if normalizeSourceType(entry.sourceType) == "vault" and not entry.detectedOwned and entryMatchesItem(entry, itemId, itemName) then
                matches[#matches + 1] = {
                    entry = entry,
                    slotLabel = slot.label,
                }
            end
        end
    end

    return matches
end

function addon.BISService:HandleWeeklyRewardsUpdate()
    local weeklyRewards = C_WeeklyRewards

    if not (weeklyRewards and weeklyRewards.GetActivities and weeklyRewards.HasAvailableRewards) then
        return false
    end

    if not weeklyRewards.HasAvailableRewards() then
        return false
    end

    local playerId = addon.Store:GetPlayerName()
    local activities = weeklyRewards.GetActivities() or {}
    local matchedRows = {}
    local fingerprintParts = {}

    for _, activity in ipairs(activities) do
        local rewards = type(activity) == "table" and activity.rewards or nil

        for _, reward in ipairs(rewards or {}) do
            if tonumber(reward.type) == 1 then
                local hyperlink = nil
                local upgradeHyperlink = nil

                if weeklyRewards.GetExampleRewardItemHyperlinks then
                    hyperlink, upgradeHyperlink = weeklyRewards.GetExampleRewardItemHyperlinks(tonumber(reward.id) or 0)

                    if trimString(hyperlink) == "" then
                        hyperlink, upgradeHyperlink = weeklyRewards.GetExampleRewardItemHyperlinks(tonumber(activity.id) or 0)
                    end
                end

                local rewardLink = trimString(hyperlink) ~= "" and hyperlink or upgradeHyperlink
                local rewardItemId, rewardItemName = getItemDetails(rewardLink)

                if ((tonumber(rewardItemId) or 0) <= 0 and trimString(rewardItemName) == "") and GetItemInfo then
                    rewardItemId = tonumber(reward.id) or 0
                    rewardItemName = normalizeItemName(select(1, GetItemInfo(rewardItemId)))
                end

                if (tonumber(rewardItemId) or 0) > 0 or trimString(rewardItemName) ~= "" then
                    fingerprintParts[#fingerprintParts + 1] = tostring(tonumber(rewardItemId) or 0) .. ":" .. canonicalItemName(rewardItemName)

                    local matches = self:ListVaultAlertMatches(playerId, rewardItemId, rewardItemName)

                    for _, match in ipairs(matches) do
                        matchedRows[#matchedRows + 1] = {
                            itemLink = rewardLink,
                            itemName = rewardItemName,
                            slotLabel = match.slotLabel,
                        }
                    end
                end
            end
        end
    end

    table.sort(fingerprintParts)
    local fingerprint = table.concat(fingerprintParts, "|")
    local vaultState = self:GetRuntimeState().vault

    if fingerprint == "" or vaultState.lastFingerprint == fingerprint then
        return false
    end

    vaultState.lastFingerprint = fingerprint

    for _, match in ipairs(matchedRows) do
        local itemText = trimString(match.itemLink) ~= "" and match.itemLink or match.itemName
        addon:Print(string.format("Great Vault BIS offer detected: %s matches your tracked %s target.", itemText, match.slotLabel))
    end

    return #matchedRows > 0
end

function addon.BISService:ListSpecOptions(toonId)
    local toonRecord = self:GetToonRecord(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")
    local member = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)
    local options = {}
    local seen = {}

    local function addOption(value)
        local normalized = normalizeSpecId(value)

        if seen[normalized] then
            return
        end

        seen[normalized] = true
        options[#options + 1] = { id = normalized, label = normalized }
    end

    addOption(self:GetCurrentSpecId(toonId))
    addOption(toonRecord and toonRecord.selectedSpecId or nil)
    addOption(toonRecord and toonRecord.lootSpecId or nil)

    local classId = member and member.classFile and CLASS_FILE_TO_ID[member.classFile] or nil

    if classId and GetNumSpecializationsForClassID and GetSpecializationInfoForClassID then
        local specCount = tonumber(GetNumSpecializationsForClassID(classId)) or 0

        for index = 1, specCount do
            local _, specName = GetSpecializationInfoForClassID(classId, index)
            addOption(specName)
        end
    end

    for specId in pairs(toonRecord and toonRecord.specs or {}) do
        addOption(specId)
    end

    table.sort(options, function(left, right)
        return lowerText(left.label) < lowerText(right.label)
    end)

    return options
end

function addon.BISService:GetSummary(toonId)
    local normalizedToonId = addon.Store:NormalizeFullName(toonId, GetRealmName() or "Unknown")

    if normalizedToonId == addon.Store:GetPlayerName() then
        addon.RosterService:RefreshLivePlayerMember()
    end

    local member = addon.RosterService:GetMyToonMember(normalizedToonId) or addon.RosterService:GetMember(normalizedToonId)
    local selectedSpecId = self:GetSelectedSpecId(normalizedToonId)
    local lootSpecId = self:GetLootSpecId(normalizedToonId)
    local collectedCount = 0
    local missingCount = 0
    local vaultTargetCount = 0

    for _, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecord(normalizedToonId, slot.key)
        local slotOwned = false
        local slotVaultMissing = false

        for _, entry in ipairs(slotRecord.entries or {}) do
            if entry.detectedOwned then
                slotOwned = true
            end

            if not entry.detectedOwned and entry.sourceType == "vault" then
                slotVaultMissing = true
            end
        end

        if slotOwned then
            collectedCount = collectedCount + 1
        else
            missingCount = missingCount + 1
        end

        if slotVaultMissing then
            vaultTargetCount = vaultTargetCount + 1
        end
    end

    local metaParts = {}

    if member and member.className then
        metaParts[#metaParts + 1] = member.className
    end

    if member and member.spec then
        metaParts[#metaParts + 1] = member.spec
    end

    metaParts[#metaParts + 1] = string.format("ilvl %d", tonumber(member and member.itemLevel or 0) or 0)
    metaParts[#metaParts + 1] = "Current Season"

    return {
        collectedCount = collectedCount,
        fullName = member and member.fullName or normalizedToonId,
        lootSpecLabel = lootSpecId,
        metaLine = table.concat(metaParts, " | "),
        missingCount = missingCount,
        specLabel = selectedSpecId,
        totalSlots = #SLOT_ORDER,
        vaultTargetCount = vaultTargetCount,
    }
end

function addon.BISService:ListToons()
    local myToons = addon.RosterService:ListMyToons()
    local records = {}
    local selectedToonId = self:GetSelectedToonId()

    for _, member in ipairs(myToons) do
        local summary = self:GetSummary(member.id)

        if summary then
            records[#records + 1] = {
                collectedCount = summary.collectedCount,
                fullName = summary.fullName,
                id = member.id,
                totalSlots = summary.totalSlots,
            }
        end
    end

    table.sort(records, function(left, right)
        if left.id == selectedToonId then
            return true
        end

        if right.id == selectedToonId then
            return false
        end

        return lowerText(left.fullName) < lowerText(right.fullName)
    end)

    return records
end

function addon.BISService:ListSlotEntryRows(toonId, slotKey)
    local slotRecord = self:GetSlotRecord(toonId, slotKey)
    local rows = {}

    if not slotRecord then
        return rows
    end

    for index, entry in ipairs(slotRecord.entries or {}) do
        rows[#rows + 1] = {
            entryIndex = index,
            itemName = trimString(entry.itemName) ~= "" and entry.itemName or "Untitled",
            lookupLabel = self:GetEntryLookupLabel(entry),
            lookupDescription = self:GetEntryLookupDescription(entry),
            priorityLabel = self:GetPriorityCompactLabel(entry.priority),
            rowVisualState = self:GetEntryVisualState(entry),
            sourceTypeLabel = self:GetSourceTypeLabel(entry.sourceType),
            stateLabel = self:GetEntryCompactStateLabel(entry),
        }
    end

    return rows
end

function addon.BISService:GetSelectedEntryDetails(toonId)
    local slotKey = self:GetSelectedSlotKey()
    local slotRecord = self:GetSlotRecord(toonId, slotKey)

    if not slotRecord then
        return nil
    end

    local selectedIndex = self:GetSelectedEntryIndex(toonId, slotKey)
    local entry = slotRecord.entries[selectedIndex]

    if not entry then
        return {
            entryIndex = 1,
            itemId = 0,
            itemName = "",
            notes = "",
            ownedStateLabel = "Missing",
            priority = "bis",
            priorityLabel = self:GetPriorityLabel("bis"),
            sourceName = "",
            sourceTarget = "",
            sourceType = "other",
            sourceTypeLabel = self:GetSourceTypeLabel("other"),
            slotKey = slotKey,
            slotLabel = slotRecord.slotLabel,
            summaryLine = "Add one or more tracked targets for this slot.",
        }
    end

    return {
        entryIndex = selectedIndex,
        itemId = tonumber(entry.itemId) or 0,
        itemName = entry.itemName,
        notes = entry.notes,
        ownedStateLabel = self:GetOwnedStateLabel(entry),
        lookupDescription = self:GetEntryLookupDescription(entry),
        lookupLabel = self:GetEntryLookupLabel(entry),
        priority = entry.priority,
        priorityLabel = self:GetPriorityLabel(entry.priority),
        sourceName = entry.sourceName,
        sourceTarget = entry.sourceTarget,
        sourceType = entry.sourceType,
        sourceTypeLabel = self:GetSourceTypeLabel(entry.sourceType),
        slotKey = slotKey,
        slotLabel = slotRecord.slotLabel,
        summaryLine = trimString(entry.itemName) ~= ""
            and string.format("%s | %s | %s", entry.itemName, self:GetSourceTypeLabel(entry.sourceType), self:GetOwnedStateLabel(entry))
            or "Add one or more tracked targets for this slot.",
    }
end

function addon.BISService:BuildSelectedEntryJournalDebugReport(toonId)
    local detail = self:GetSelectedEntryDetails(toonId)
    local runtimeState = self:GetRuntimeState()
    local journalIndex = self:BuildEncounterJournalIndex()
    local canonicalName = canonicalItemName(detail and detail.itemName or "")
    local canonicalAliases = buildCanonicalItemAliases(detail and detail.itemName or "")
    local match = nil
    local journalKeyCount = 0

    for _ in pairs(journalIndex or {}) do
        journalKeyCount = journalKeyCount + 1
    end

    local resolvedItemName = ""
    local clientCacheItemId = 0
    local clientCacheItemName = ""

    if detail and (tonumber(detail.itemId) or 0) > 0 and GetItemInfo then
        resolvedItemName = normalizeItemName(GetItemInfo(detail.itemId) or "")
    end

    if detail and trimString(detail.itemName) ~= "" then
        clientCacheItemId, clientCacheItemName = resolveItemFromClientCacheByName(detail.itemName)
    end

    if detail then
        local itemId = tonumber(detail.itemId) or 0
        local journalState = runtimeState and runtimeState.journal or nil

        if itemId > 0 and journalState and type(journalState.itemIdIndex) == "table" then
            match = journalState.itemIdIndex[itemId]
        end

        if not match then
            for _, alias in ipairs(canonicalAliases) do
                match = journalIndex and journalIndex[alias] or nil

                if match then
                    break
                end
            end
        end
    end

    local lines = {
        "BIS Journal Debug",
        string.format("toonId=%s", tostring(toonId or "")),
        string.format("slotKey=%s", tostring(detail and detail.slotKey or "")),
        string.format("entryIndex=%s", tostring(detail and detail.entryIndex or "")),
        string.format("itemName=%s", tostring(detail and detail.itemName or "")),
        string.format("canonicalItemName=%s", tostring(canonicalName or "")),
        string.format("canonicalAliases=%s", table.concat(canonicalAliases, ",")),
        string.format("itemId=%s", tostring(detail and detail.itemId or 0)),
        string.format("resolvedItemNameByItemId=%s", resolvedItemName),
        string.format("itemIdExtractedFromItemName=%s", tostring(detail and extractItemIdFromText(detail.itemName) or 0)),
        string.format("clientCacheItemIdByName=%s", tostring(clientCacheItemId or 0)),
        string.format("clientCacheItemNameByName=%s", tostring(clientCacheItemName or "")),
        string.format("currentSourceType=%s", tostring(detail and detail.sourceType or "")),
        string.format("currentSourceName=%s", tostring(detail and detail.sourceName or "")),
        string.format("currentSourceTarget=%s", tostring(detail and detail.sourceTarget or "")),
        string.format("alertLookupMode=%s", getEntryAlertLookupMode(detail)),
        string.format("canAlertByItemId=%s", ((detail and (tonumber(detail.itemId) or 0) > 0) and "true" or "false")),
        string.format("canAlertByName=%s", ((detail and canonicalItemName(detail.itemName) ~= "") and "true" or "false")),
        string.format("journalBuiltAt=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.builtAt or 0)),
        string.format("journalBuildStatus=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastBuildStatus or "")),
        string.format("journalMissingApis=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastMissingApis or "")),
        string.format("journalTierCount=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastTierCount or 0)),
        string.format("journalCurrentTier=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastCurrentTier or 0)),
        string.format("journalLoadAttempted=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastLoadAttempted or false)),
        string.format("journalLoadResult=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastLoadResult or "")),
        string.format("journalRequestedFilterClassId=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastRequestedFilterClassId or 0)),
        string.format("journalRequestedFilterSpecId=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastRequestedFilterSpecId or 0)),
        string.format("journalAppliedFilterClassId=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastAppliedFilterClassId or 0)),
        string.format("journalAppliedFilterSpecId=%s", tostring(runtimeState and runtimeState.journal and runtimeState.journal.lastAppliedFilterSpecId or 0)),
        string.format("journalKeyCount=%s", tostring(journalKeyCount)),
        string.format("journalMatchFound=%s", match and "true" or "false"),
    }

    if match then
        lines[#lines + 1] = string.format("journal.itemId=%s", tostring(match.itemId or 0))
        lines[#lines + 1] = string.format("journal.itemName=%s", tostring(match.itemName or ""))
        lines[#lines + 1] = string.format("journal.sourceType=%s", tostring(match.sourceType or ""))
        lines[#lines + 1] = string.format("journal.sourceName=%s", tostring(match.sourceName or ""))
        lines[#lines + 1] = string.format("journal.sourceTarget=%s", tostring(match.sourceTarget or ""))
    end

    return table.concat(lines, "\n")
end

function addon.BISService:ListGearRows(toonId)
    local rows = {}
    local sourceFilter = self:GetSourceFilter()
    local ownershipFilter = self:GetOwnershipFilter()
    local collectionSort = self:GetCollectionSort()

    for slotIndex, slot in ipairs(SLOT_ORDER) do
        local slotRecord = self:GetSlotRecord(toonId, slot.key)
        local matchedEntries = {}

        for _, entry in ipairs(slotRecord.entries or {}) do
            local matchesSource = sourceFilter == "all" or entry.sourceType == sourceFilter
            local matchesOwnership = ownershipFilter == "all"
                or (ownershipFilter == "missing" and not entry.detectedOwned)
                or (ownershipFilter == "owned" and entry.detectedOwned)
                or (ownershipFilter == "equipped" and entry.detectedEquipped)

            if matchesSource and matchesOwnership then
                matchedEntries[#matchedEntries + 1] = entry
            end
        end

        if #matchedEntries > 0 or (sourceFilter == "all" and ownershipFilter ~= "owned" and ownershipFilter ~= "equipped") then
            local primaryEntry = matchedEntries[1] or slotRecord.entries[1]
            local ownedCount = 0
            local equippedCount = 0
            local hasOptionalTracked = false

            for entryIndex, entry in ipairs(slotRecord.entries or {}) do
                if entry.detectedOwned then
                    ownedCount = ownedCount + 1
                end

                if normalizePriority(entry.priority) == "optional" then
                    hasOptionalTracked = true
                end

                if entry.detectedEquipped then
                    equippedCount = equippedCount + 1
                end
            end

            local stateLabel = #slotRecord.entries == 0 and "No Targets"
                or (primaryEntry and self:GetEntryCompactStateLabel(primaryEntry))
                or (ownedCount > 0 and string.format("%d/%d Owned", ownedCount, #slotRecord.entries))
                or "Missing"

            local sourceTypeLabel = "-"

            if #matchedEntries > 1 then
                sourceTypeLabel = "Multi"
            elseif primaryEntry then
                sourceTypeLabel = self:GetSourceTypeLabel(primaryEntry.sourceType)
            end

            local rowVisualState = #slotRecord.entries == 0 and "empty"
                or (primaryEntry and self:GetEntryVisualState(primaryEntry))
                or "missing"

            if rowVisualState == "missing" and hasOptionalTracked then
                rowVisualState = "optional"
            end

            local instanceLabel = "-"
            local bossLabel = "-"
            local notesLabel = ""

            if #matchedEntries > 1 then
                instanceLabel = "Multiple"
                bossLabel = string.format("%d targets", #matchedEntries)
            elseif #slotRecord.entries > 1 and not primaryEntry then
                instanceLabel = "Multiple"
                bossLabel = string.format("%d targets", #slotRecord.entries)
            elseif primaryEntry then
                instanceLabel = trimString(primaryEntry.sourceName) ~= "" and trimString(primaryEntry.sourceName) or "-"
                bossLabel = trimString(primaryEntry.sourceTarget) ~= "" and trimString(primaryEntry.sourceTarget) or "-"
                notesLabel = trimString(primaryEntry.notes)
            end

            rows[#rows + 1] = {
                bossLabel = bossLabel,
                collectionRank = equippedCount > 0 and 2 or (ownedCount > 0 and 1 or 0),
                itemName = primaryEntry and (trimString(primaryEntry.itemName) ~= "" and primaryEntry.itemName or "-") or "-",
                instanceLabel = instanceLabel,
                notes = notesLabel,
                rowVisualState = rowVisualState,
                slotOrderIndex = slotIndex,
                slotKey = slot.key,
                slotLabel = slot.label,
                sourceTypeLabel = sourceTypeLabel,
                stateLabel = stateLabel,
                targetCount = #slotRecord.entries,
            }
        end
    end

    if collectionSort ~= "default" then
        table.sort(rows, function(left, right)
            local leftRank = tonumber(left and left.collectionRank) or 0
            local rightRank = tonumber(right and right.collectionRank) or 0

            if leftRank ~= rightRank then
                if collectionSort == "collected-top" then
                    return leftRank > rightRank
                end

                return leftRank < rightRank
            end

            return (tonumber(left and left.slotOrderIndex) or 0) < (tonumber(right and right.slotOrderIndex) or 0)
        end)
    end

    return rows
end