EbonholdHub.Scoring = {}

local CLASS_BITS = {
    WARRIOR = 1, PALADIN = 2, HUNTER = 4, ROGUE = 8, PRIEST = 16,
    DEATHKNIGHT = 32, SHAMAN = 64, MAGE = 128, WARLOCK = 256, DRUID = 1024,
}

-- Normalize family tokens produced by ProjectEbonhold to the 6 canonical keys
-- used in settings.familyBonus.
local FAMILY_MAP = {
    Tank = "Tank", Survivability = "Survivability", Healer = "Healer",
    Caster = "Caster", ["Caster DPS"] = "Caster",
    Melee  = "Melee",  ["Melee DPS"]  = "Melee",
    Ranged = "Ranged", ["Ranged DPS"] = "Ranged",
    None   = "No family",
}

local function NormFamily(f) return FAMILY_MAP[f] end

-- Strip quality / class prefixes so "Death Knight - Ember Spark - Rare" → "Ember Spark".
local QUALITY_SUFFIX_PATTERNS = {
    " %- Common$", " %- Uncommon$", " %- Rare$", " %- Epic$", " %- Legendary$",
}
local CLASS_PREFIX_PATTERNS = {
    "^Warrior %- ", "^Paladin %- ", "^Hunter %- ", "^Rogue %- ", "^Priest %- ",
    "^Death Knight %- ", "^Shaman %- ", "^Mage %- ", "^Warlock %- ", "^Druid %- ",
}

-- Permanent memoization cache — echo names are fixed for a given input string.
local normalizeCache = {}

function EbonholdHub.Scoring.NormalizeEchoBaseName(name)
    if not name or name == "" then return nil end
    local cached = normalizeCache[name]
    if cached ~= nil then return cached end
    local result = name
    for _, pattern in ipairs(QUALITY_SUFFIX_PATTERNS) do
        local stripped = result:match("^(.+)" .. pattern)
        if stripped then result = stripped; break end
    end
    for _, pattern in ipairs(CLASS_PREFIX_PATTERNS) do
        result = result:gsub(pattern, "")
    end
    normalizeCache[name] = result
    return result
end

-- Echo names owned in the current roguelite run (synced from grantedPerks).
local runPickedNames = {}

local function CountStackEntries(list)
    if type(list) ~= "table" then return 0 end
    if list.spellId then
        return math.max(1, tonumber(list.stack) or 1)
    end
    local count = 0
    if list[1] ~= nil then
        for _, entry in ipairs(list) do
            if type(entry) == "table" then
                count = count + math.max(1, tonumber(entry.stack) or 1)
            end
        end
        return count
    end
    for _, entry in pairs(list) do
        if type(entry) == "table" and entry.spellId then
            count = count + math.max(1, tonumber(entry.stack) or 1)
        end
    end
    return count
end

local function NameMatchesGrantedKey(key, baseName)
    return type(key) == "string"
        and EbonholdHub.Scoring.NormalizeEchoBaseName(key) == baseName
end

local function GetEchoGroupId(spellId)
    local data = spellId and ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[spellId]
    return data and data.groupId
end

local function EntryMatchesGrantedEcho(entry, spellId, baseName, groupId, quality)
    if type(entry) ~= "table" then return false end

    local sid = entry.spellId or entry.id
    if spellId and sid and sid == spellId then return true end

    local entryQuality = entry.quality
    if entryQuality == nil and sid then
        local db = ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[sid]
        entryQuality = db and db.quality
    end
    if quality ~= nil and entryQuality ~= nil and entryQuality ~= quality then
        return false
    end

    local entryBase
    if sid then
        local data = ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[sid]
        if data and data.comment and data.comment ~= "" then
            entryBase = EbonholdHub.Scoring.NormalizeEchoBaseName(data.comment)
        end
        if not entryBase then
            entryBase = EbonholdHub.Scoring.NormalizeEchoBaseName(GetSpellInfo(sid))
        end
        if groupId and data and data.groupId == groupId and baseName and entryBase == baseName then
            return true
        end
    end

    local entryName = entry.name or entry.comment
    if not entryName and sid then entryName = GetSpellInfo(sid) end
    local norm = EbonholdHub.Scoring.NormalizeEchoBaseName(entryName) or entryName
    return baseName and norm == baseName
end

local function CountStacksForQuality(value, spellId, baseName, groupId, quality)
    if type(value) ~= "table" then return 0 end
    local count = 0
    if value[1] ~= nil then
        for _, entry in ipairs(value) do
            if EntryMatchesGrantedEcho(entry, spellId, baseName, groupId, quality) then
                count = count + math.max(1, tonumber(entry.stack) or 1)
            end
        end
    elseif EntryMatchesGrantedEcho(value, spellId, baseName, groupId, quality) then
        count = count + math.max(1, tonumber(value.stack) or 1)
    end
    return count
end

local function CountOwnedStacks(spellId, baseName, quality)
    baseName = EbonholdHub.Scoring.NormalizeEchoBaseName(baseName) or baseName
    if not baseName or baseName == "" then return 0 end

    -- Use only the live run cache (Echoes panel). PerkService.GetGrantedPerks can
    -- include broader/account data and cause false Repeat labels.
    local gp = ProjectEbonhold and ProjectEbonhold.Perks and ProjectEbonhold.Perks.grantedPerks
    if not gp then return 0 end

    local groupId = GetEchoGroupId(spellId)
    local count = 0

    for key, value in pairs(gp) do
        if type(key) == "string" and NameMatchesGrantedKey(key, baseName) then
            count = count + CountStacksForQuality(value, spellId, baseName, groupId, quality)
        elseif type(value) == "table" then
            count = count + CountStacksForQuality(value, spellId, baseName, groupId, quality)
        end
    end
    return count
end

-- How many copies of this echo the player owns. Pass quality to restrict to one rank,
-- or nil to count all ranks (used for maxPicks enforcement — a rank upgrade still counts).
function EbonholdHub.Scoring.GetGrantedCount(baseName, spellId, quality)
    return CountOwnedStacks(spellId, baseName, quality)
end

function EbonholdHub.Scoring.IsEchoOwnedInRun(spellId, baseName, quality)
    return CountOwnedStacks(spellId, baseName, quality) > 0
end

function EbonholdHub.Scoring.SyncRunEchoesFromGranted()
    local gp = ProjectEbonhold and ProjectEbonhold.Perks and ProjectEbonhold.Perks.grantedPerks
    if not gp then return end

    local newPickedNames = {}
    for key, value in pairs(gp) do
        if type(key) == "string" then
            local baseName = EbonholdHub.Scoring.NormalizeEchoBaseName(key) or key
            if baseName and baseName ~= "" then
                newPickedNames[baseName] = CountStackEntries(value)
            end
        end
    end

    -- Monotonic guard: never replace a populated cache with a smaller/empty one.
    -- grantedPerks can temporarily be empty during zone transitions or server lag,
    -- which would cause Pick# to falsely reset to 0 mid-run.
    local newCount, oldCount = 0, 0
    for _ in pairs(newPickedNames) do newCount = newCount + 1 end
    for _ in pairs(runPickedNames) do oldCount = oldCount + 1 end
    if newCount >= oldCount then
        runPickedNames = newPickedNames
    end
end

local function ApplyModifier(score, baseWeight, value, multiplicative)
    if multiplicative then
        if value == 0 then return score end
        return score + baseWeight * (value - 1)
    else
        return score + value
    end
end

local function ApplyFamilyBonuses(s, base, entry, fb, fm, wl)
    local hasWhitelist = false
    for _ in pairs(wl) do hasWhitelist = true; break end

    if entry.families and #entry.families > 0 then
        for i = 1, #entry.families do
            local key = NormFamily(entry.families[i])
            if key and (not hasWhitelist or wl[key]) then
                s = ApplyModifier(s, base, fb[key] or 0, fm[key])
            end
        end
    else
        if not hasWhitelist or wl["No family"] then
            s = ApplyModifier(s, base, fb["No family"] or 0, fm["No family"])
        end
    end
    return s
end

function EbonholdHub.Scoring.ScorePerQuality(entry, weight, settings, quality)
    local qb = settings.qualityBonus or {}
    local qm = settings.qualityBonusMode or {}
    local fb = settings.familyBonus  or {}
    local fm = settings.familyBonusMode or {}
    local wl = settings.banishFamilyWhitelist or {}
    local base = weight or 0
    local s = base

    s = ApplyModifier(s, base, qb[quality] or 0, qm[quality])
    s = ApplyFamilyBonuses(s, base, entry, fb, fm, wl)
    return s
end

function EbonholdHub.Scoring.Score(entry, weight, settings)
    local s = EbonholdHub.Scoring.ScorePerQuality(entry, weight, settings, entry.quality)
    local base = weight or 0
    s = ApplyModifier(s, base, settings.noveltyValue or 0, settings.noveltyMode)
    return s
end

local function MatchesClass(entry, bitVal)
    if not bitVal then return true end
    if not entry.classMask or entry.classMask == 0 then return true end
    return bit.band(entry.classMask, bitVal) ~= 0
end

-- Returns (bestName, peakScore) where peakScore is the average of the top-5
-- weighted echoes for this class. Using a realistic average rather than the
-- absolute maximum keeps % thresholds from being over-aggressive.
function EbonholdHub.Scoring.GetEffectiveLockedEchoes()
    if EbonholdHub.BuildForm and EbonholdHub.BuildForm.GetEditingLockedEchoes then
        local p = EbonholdHub.BuildForm.GetEditingLockedEchoes()
        if p then return p end
    end
    local build = EbonholdHub.Build.GetActive()
    if build and build.lockedEchoes then return build.lockedEchoes end
    return EbonholdHub.Build.EmptyLockedEchoes()
end

-- Cache normalized locked echo names — spellId→name is fixed for a session.
local lockedNameCache = {}

function EbonholdHub.Scoring.MatchesLockedEntry(spellId, baseName, lockedSpellId)
    if not lockedSpellId then return false end
    if spellId and spellId == lockedSpellId then return true end
    if not baseName or baseName == "" then return false end
    local lockedName = lockedNameCache[lockedSpellId]
    if lockedName == nil then
        lockedName = EbonholdHub.Scoring.NormalizeEchoBaseName(GetSpellInfo(lockedSpellId)) or false
        lockedNameCache[lockedSpellId] = lockedName
    end
    return lockedName ~= nil and lockedName ~= "" and lockedName == baseName
end

function EbonholdHub.Scoring.IsLocked(spellId, baseName)
    if not spellId and (not baseName or baseName == "") then return false end
    if not baseName or baseName == "" then
        baseName = EbonholdHub.Scoring.NormalizeEchoBaseName(GetSpellInfo(spellId))
    end
    local lockeds = EbonholdHub.Scoring.GetEffectiveLockedEchoes()
    if not lockeds then return false end
    for i = 1, EbonholdHub.Build.GetLockedSlotCount() do
        if EbonholdHub.Scoring.MatchesLockedEntry(spellId, baseName, lockeds[i]) then
            return true
        end
    end
    return false
end

-- Pick the highest-priority locked slot present in the current offer.
function EbonholdHub.Scoring.FindLockedOffer(scored, lockedList)
    if not scored or not lockedList then return nil end
    for slotIdx = 1, EbonholdHub.Build.GetLockedSlotCount() do
        local lockedId = lockedList[slotIdx]
        if lockedId then
            for _, s in ipairs(scored) do
                if not s.isTierF
                   and EbonholdHub.Scoring.MatchesLockedEntry(s.spellId, s.name, lockedId) then
                    return s
                end
            end
        end
    end
    return nil
end

function EbonholdHub.Scoring.ResetRunEchoes()
    runPickedNames = {}
end

function EbonholdHub.Scoring.RegisterRunEcho(name)
    if name and name ~= "" then
        runPickedNames[name] = (runPickedNames[name] or 0) + 1
    end
end

function EbonholdHub.Scoring.IsPickedThisRun(name)
    local v = runPickedNames[name]
    if v == nil then return false end
    if type(v) == "boolean" then return v end
    return v > 0
end

function EbonholdHub.Scoring.GetRunPicksCount()
    local count = 0
    for _ in pairs(runPickedNames) do count = count + 1 end
    return count
end

-- Returns how many times this echo has been picked in the current run.
function EbonholdHub.Scoring.GetPickCount(name)
    if not name or name == "" then return 0 end
    return runPickedNames[name] or 0
end

-- Returns the effective tier of an echo for Auto Pick, considering how many
-- copies the player actually owns this run (grantedPerks), not addon logs.
function EbonholdHub.Scoring.GetEffectiveTier(name, quality, spellId)
    if not EbonholdHub.TierData then return "C" end
    local baseName = EbonholdHub.Scoring.NormalizeEchoBaseName(name) or name
    local tier = EbonholdHub.TierData.GetTierForPick(baseName, quality)
    local maxPicks = EbonholdHub.TierData.GetMaxPicks(baseName)  -- 0 = unlimited
    if maxPicks ~= 0 then
        -- Pass nil quality: a white→green rank upgrade still occupies the same echo slot.
        -- Counting only the offered quality would miss an owned copy of a different rank.
        local got = EbonholdHub.Scoring.GetGrantedCount(baseName, spellId, nil)
        if got >= maxPicks then
            return "REPEAT"
        end
    end
    return tier
end

-- Account/collection unlocks — used for Novelty bonus only.
function EbonholdHub.Scoring.IsGrantedEver(name)
    if not name or name == "" then return false end
    if not ProjectEbonhold or not ProjectEbonhold.PerkService then return false end
    local get = ProjectEbonhold.PerkService.GetGrantedPerks
    if not get then return false end
    local granted = get()
    return granted and granted[name] and true or false
end

function EbonholdHub.Scoring.IsGrantedThisRun(name)
    return EbonholdHub.Scoring.GetGrantedCount(name) > 0
end

function EbonholdHub.Scoring.RebuildRunEchoesFromSession()
    -- Session logs can list echoes Auto Pick *attempted* to take, not actually granted.
    -- Always prefer the live server cache shown in the Echoes panel.
    EbonholdHub.Scoring.SyncRunEchoesFromGranted()
end

function EbonholdHub.Scoring.FormatChoiceScore(choice)
    if not choice then return "", false end
    if choice.isZeroWeight or choice.isTierF then return "Skip", true end
    if choice.tier == "REPEAT" or choice.isDuplicate then return "Repeat", true end
    if choice.isBanned then return "Banned", true end
    if choice.tier and choice.tier ~= "REPEAT" then
        return choice.tier, choice.tier == "C"
    end
    return "", false
end

function EbonholdHub.Scoring.IsBanned(spellId)
    if not spellId then return false end
    -- F tier echoes are treated as banned; explicit ban list removed in v1.6
    local name = GetSpellInfo(spellId)
    if not name then return false end
    local data = ProjectEbonhold and ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[spellId]
    local quality = data and data.quality
    return EbonholdHub.TierData and EbonholdHub.TierData.GetTier(name, quality) == "F"
end

function EbonholdHub.Scoring.GetEffectiveSettings()
    if EbonholdHub.ViewRouter and EbonholdHub.ViewRouter.Current() == "buildTabs" then
        if EbonholdHub.BuildForm and EbonholdHub.BuildForm.GetEditingSettings then
            local s = EbonholdHub.BuildForm.GetEditingSettings()
            if s then return s end
        end
    end
    local build = EbonholdHub.Build.GetActive()
    if build and build.settings then return build.settings end
    return EbonholdHub.Build.DefaultSettings()
end
