EbonholdHub.Automation = {}

local function DbgEnabled()
    local apd = EbonholdHub.AutoPickDebug
    return apd and apd.IsEnabled and apd.IsEnabled()
end

local function Dbg(level, cat, msg, detail)
    if not DbgEnabled() then return end
    local apd = EbonholdHub.AutoPickDebug
    if apd and apd.Log then apd.Log(level, cat, msg, detail) end
end

local function PLOffer(scored, ctx)
    local pl = EbonholdHub.PickLog
    if pl and pl.Offer then pl.Offer(scored, ctx) end
end

local function PLDone(action, target, reason)
    local pl = EbonholdHub.PickLog
    if pl and pl.Done then pl.Done(action, target, reason) end
end

-- ── Tier configuration ────────────────────────────────────────────────────────
-- Numeric rank for each tier (higher = more desirable).
local TIER_BASE_SCORE = { S = 100, A = 70, B = 40, C = 20, F = 0, REPEAT = 0 }

-- Bonus added on top of the bundle's tier base score when a partner echo is already
-- owned in the current run.  Must exceed max(posBonus=9) + max(qualityBonus=30) = 39
-- so that bundle completion always ranks above any regular echo of the same tier.
local BUNDLE_BONUS = 40

-- Auto Pick Style (aggressionLevel 1-5): for each level:
--   banishTiers     – C may be listed but is only banished when rerolls are exhausted (4-5)
--   rerollBelowTier – reroll when the best available tier is <= this rank (nil = never)
--   freezeEnabled   – whether to try freezing runner-up echoes
-- F tier is always banished first. Reroll is tried before banishing C or duplicates.
local AGGRESSION_CONFIG = {
    [1] = { banishTiers = {"F"},         rerollBelowTier = nil, freezeEnabled = false }, -- Pick Only
    [2] = { banishTiers = {"F"},         rerollBelowTier = nil, freezeEnabled = false }, -- Light Touch
    [3] = { banishTiers = {"F"},         rerollBelowTier = "C", freezeEnabled = true  }, -- Normal
    [4] = { banishTiers = {"F","C"},     rerollBelowTier = "B", freezeEnabled = true  }, -- Smart (default)
    [5] = { banishTiers = {"F","C"},     rerollBelowTier = "A", freezeEnabled = true  }, -- Perfectionist
}

-- Max rerolls per echo-pick window before settling.
-- These are per-window caps; the dynamic conservation check below prevents
-- burning more than 1/3 of remaining run rerolls in a single window.
local REROLL_BUDGET_BY_LEVEL = { [3] = 2, [4] = 3, [5] = 5 }

-- Quality-aware synergy threshold for C-tier override.
-- A C-tier echo must reach this advisorFit score to prevent a reroll.
-- Higher quality = lower threshold (Epic C is worth keeping sooner than Common C).
-- Common (0) is set to 999 — effectively disabled: Common C echoes are always
-- rerolled when rerolls are available, regardless of synergy score. This prevents
-- the system from hoarding Common junk early in the run when rerolls are plentiful.
--
-- advisorFit scoring reference:
--   8  = class(2) + spec(2) + archetype(4) — any echo that "fits" the class
--   12 = above + family affinity or stat bonus — decent match
--   14 = above + at least one synergy tag — genuinely good match
--   16+ = strong synergy with multiple existing echoes — keep it
local SYNERGY_THRESHOLD_BY_QUALITY = {
    [0] = 999,  -- Common:    always reroll (never override)
    [1] = 18,   -- Uncommon:  very high synergy required
    [2] = 14,   -- Rare:      standard threshold (unchanged)
    [3] = 12,   -- Epic:      easier to justify keeping
    [4] = 10,   -- Legendary: almost always keep
}

-- Late run: conserve banishes for duplicates only (pick count, not character level).
local LATE_RUN_PICKS = 30
-- One C-tier banish replaces one reroll attempt, then pick — never a banish chain.
local BANISH_SUBSTITUTE_MAX = 1

local TIER_RANK = EbonholdHub.TierData and EbonholdHub.TierData.TIER_RANK or
    { S = 5, A = 4, B = 3, C = 2, F = 1 }

-- Quality bonus: makes higher-quality echoes clearly preferred within the same tier.
-- Especially important for C tier where base scores are all equal (20).
local QUALITY_BONUS = { [0] = 0, [1] = 5, [2] = 10, [3] = 20, [4] = 30 }

-- ── Module state ──────────────────────────────────────────────────────────────

local evalTimerFrame    = nil
local evalTimerElapsed  = 0
local evalTimerActive   = false
local pendingChoices    = nil
local origPerkUIShow    = nil
local freezeRoundActive       = false
local locallyFrozenIndices    = {}
local pickWindowRerollCount            = 0
local pickWindowBanishSubstituteCount  = 0
local lastCommittedPickCount           = nil

-- Pending echo selection — toast/stats fire only after the server confirms the pick.
local pendingSelection = nil
local MAX_SELECT_ATTEMPTS = 4
local manualFallbackHash = nil

local function SendSelectPerk(pick)
    -- Use the game's isRepeat flag for API routing — not our tier-based isRepeat.
    if pick and pick.choiceIsRepeat then
        ProjectEbonhold.PerkService.SelectPerk(pick.index - 1)
    else
        ProjectEbonhold.PerkService.SelectPerk(pick.spellId)
    end
end

local function UpdateStat(build, key)
    if build and build.stats then
        build.stats[key] = (build.stats[key] or 0) + 1
    end
end

local function LogAndToast(scored, action, targetIndex)
    Dbg("UI", "TOAST", string.format("LogAndToast: action=%s targetIndex=%s", tostring(action), tostring(targetIndex)))
    if EbonholdHub.ActivityFeed then
        EbonholdHub.ActivityFeed.Push(scored, action, targetIndex)
    else
        EbonholdHub.Toast.ShowAutomationResult(scored, action, targetIndex)
    end
    EbonholdHub.Session.LogAction(scored, action, targetIndex)
end

local function GetChoiceHash(choices)
    if not choices or #choices == 0 then return nil end
    local parts = {}
    for i, c in ipairs(choices) do
        parts[i] = tostring(c and c.spellId or 0)
    end
    return table.concat(parts, ",")
end

local function PickGrantedCount(pick)
    if not pick then return 0 end
    return EbonholdHub.Scoring.GetGrantedCount(pick.name, pick.spellId, pick.quality) or 0
end

local function PickWasGranted(pending)
    if not pending or not pending.pick then return false end
    local ps = ProjectEbonhold.PerkService
    if ps and ps.RequestGrantedPerks then ps.RequestGrantedPerks() end
    if EbonholdHub.Scoring.SyncRunEchoesFromGranted then
        EbonholdHub.Scoring.SyncRunEchoesFromGranted()
    end
    return PickGrantedCount(pending.pick) > (pending.grantedBefore or 0)
end

local function FinalizePendingSelection(pending)
    LogAndToast(pending.scored, pending.action, pending.targetIndex)

    if pending.build then
        local ok, err = pcall(UpdateStat, pending.build, "picks")
        if not ok then
            Dbg("ERROR", "PENDING", "UpdateStat failed: " .. tostring(err))
        end
    end
    if pending.pick then
        local ok, err = pcall(EbonholdHub.Build.TrackPickStat, pending.pick)
        if not ok then
            Dbg("ERROR", "PENDING", "TrackPickStat failed: " .. tostring(err))
        end
    end
    return true
end

local function ConfirmPendingSelection(optimistic)
    local pending = pendingSelection
    pendingSelection = nil
    if not pending then
        Dbg("DETAIL", "PENDING", "ConfirmPendingSelection: no pending")
        return false
    end

    if PickWasGranted(pending) then
        Dbg("ACTION", "PENDING", "Pick confirmed: " .. (pending.action or "?") .. " → " .. (pending.pick and pending.pick.name or "?"))
        return FinalizePendingSelection(pending)
    end

    if optimistic and pending.scored and pending.action then
        Dbg("WARN", "PENDING", "Grant not in cache — logging anyway (window advanced): "
            .. (pending.pick and pending.pick.name or "?"))
        return FinalizePendingSelection(pending)
    end

    if DbgEnabled() then
        Dbg("WARN", "PENDING", "ConfirmPendingSelection: pick not granted yet", EbonholdHub.AutoPickDebug.Serialize({
            action = pending.action, pick = pending.pick and pending.pick.name,
            grantedBefore = pending.grantedBefore, grantedNow = PickGrantedCount(pending.pick),
        }))
    end
    return false
end

local function CancelPendingSelection()
    Dbg("WARN", "PENDING", "CancelPendingSelection — max retries exceeded, showing manual UI")
    pendingSelection = nil
end

local function RetryPendingSelection()
    local pending = pendingSelection
    if not pending or not pending.pick then return false end
    local pick = pending.pick
    Dbg("WARN", "PENDING", string.format(
        "Retry SelectPerk attempt %d — %s (index=%s spellId=%s choiceIsRepeat=%s tierRepeat=%s)",
        (pending.attempts or 1) + 1, pick.name or "?", tostring(pick.index),
        tostring(pick.spellId), tostring(pick.choiceIsRepeat), tostring(pick.isRepeat)
    ))
    SendSelectPerk(pick)
    pending.attempts = (pending.attempts or 1) + 1
    return true
end

-- Returns false only after several failed attempts on the same offer (then show manual UI).
local function ResolvePendingSelection(choices)
    if not pendingSelection then return true end

    local currentHash = GetChoiceHash(choices)
    local windowAdvanced = (not choices or #choices == 0
        or currentHash ~= pendingSelection.choiceHash)

    Dbg("DETAIL", "PENDING", string.format(
        "ResolvePending: windowAdvanced=%s hash=%s expected=%s choices=%s",
        tostring(windowAdvanced), tostring(currentHash),
        tostring(pendingSelection.choiceHash), choices and #choices or 0
    ))

    if windowAdvanced then
        local confirmed = ConfirmPendingSelection(true)
        Dbg(confirmed and "ACTION" or "WARN", "PENDING",
            "Window advanced — confirm " .. (confirmed and "OK" or "FAILED (grant not detected)"))
        return true
    end

    -- Same offer still open: server may still be applying the pick.
    if PickWasGranted(pendingSelection) then
        Dbg("ACTION", "PENDING", "Same window — grant detected, confirming")
        ConfirmPendingSelection()
        return true
    end

    local attempts = pendingSelection.attempts or 1
    if attempts < MAX_SELECT_ATTEMPTS then
        RetryPendingSelection()
        return true
    end

    CancelPendingSelection()
    return false
end

local function QueueSelectAttempt(pick, scored, action, choiceHash, build)
    Dbg("ACTION", "SELECT", string.format(
        "QueueSelectAttempt: %s → #%d %s (spellId=%s q=%s tier=%s score=%.1f choiceIsRepeat=%s tierRepeat=%s)",
        action, pick.index or 0, pick.name or "?", tostring(pick.spellId),
        tostring(pick.quality), tostring(pick.tier), pick.score or 0,
        tostring(pick.choiceIsRepeat), tostring(pick.isRepeat)
    ))
    SendSelectPerk(pick)

    pendingSelection = {
        pick          = pick,
        scored        = scored,
        action        = action,
        targetIndex   = pick.index,
        choiceHash    = choiceHash,
        build         = build,
        grantedBefore = PickGrantedCount(pick),
        attempts      = 1,
    }
    if DbgEnabled() then
        EbonholdHub.AutoPickDebug.LogPending(pendingSelection, "queued")
    end
end

local function GetEvalDelay()
    return (EbonholdHubDB.globalSettings and EbonholdHubDB.globalSettings.evalDelay) or 2
end

local function StartEvalTimer()
    if not evalTimerFrame then
        evalTimerFrame = CreateFrame("Frame")
        evalTimerFrame:SetScript("OnUpdate", function(self, dt)
            evalTimerElapsed = evalTimerElapsed + dt
            if evalTimerElapsed >= GetEvalDelay() then
                evalTimerActive = false
                evalTimerFrame:Hide()
                Dbg("HOOK", "TIMER", string.format(
                    "Eval timer fired (delay=%.2fs, choices=%s)",
                    GetEvalDelay(), pendingChoices and #pendingChoices or 0
                ))
                if EbonholdHub.Automation.Evaluate() then
                    Dbg("HOOK", "TIMER", "Evaluate() returned true — UI stays hidden")
                    manualFallbackHash = nil
                    pendingChoices = nil
                    return
                end
                -- Only fall back to the vanilla UI if the perk choice window is still
                -- actually open.  After death the window closes (choices become empty)
                -- but pendingChoices may still hold the stale offer from the previous
                -- pick; calling origPerkUIShow with that data can crash the client.
                local liveChoices = ProjectEbonhold
                    and ProjectEbonhold.PerkService
                    and ProjectEbonhold.PerkService.GetCurrentChoice
                    and ProjectEbonhold.PerkService.GetCurrentChoice()
                local choicesStillLive = liveChoices and #liveChoices > 0
                if pendingChoices and origPerkUIShow and choicesStillLive then
                    Dbg("UI", "FALLBACK", "Evaluate() returned false — showing vanilla PerkUI")
                    if DbgEnabled() then
                        EbonholdHub.AutoPickDebug.LogChoices("fallback PerkUI.Show", pendingChoices)
                    end
                    manualFallbackHash = GetChoiceHash(pendingChoices)
                    origPerkUIShow(pendingChoices)
                else
                    if pendingChoices and not choicesStillLive then
                        Dbg("INFO", "FALLBACK", "Skipping fallback UI — perk window already closed (death/reset)")
                    else
                        Dbg("WARN", "FALLBACK", "No fallback UI — pendingChoices="
                            .. tostring(pendingChoices ~= nil) .. " origShow=" .. tostring(origPerkUIShow ~= nil))
                    end
                end
                pendingChoices = nil
            end
        end)
    end
    evalTimerElapsed = 0
    evalTimerActive = true
    Dbg("HOOK", "TIMER", "StartEvalTimer (delay=" .. GetEvalDelay() .. "s)")
    evalTimerFrame:Show()
end

-- ── Helpers ───────────────────────────────────────────────────────────────────

local function GetRunData()
    if EbonholdPlayerRunData and EbonholdPlayerRunData.remainingBanishes ~= nil then
        return EbonholdPlayerRunData
    end
    if ProjectEbonhold and ProjectEbonhold.PlayerRunService then
        local get = ProjectEbonhold.PlayerRunService.GetCurrentData
        if get then return get() end
    end
    return nil
end

local function GetAggressionConfig(settings)
    local level = (settings and settings.aggressionLevel) or 4
    level = math.max(1, math.min(5, math.floor(level)))
    return AGGRESSION_CONFIG[level]
end

-- Ramp-up: thresholds soften for the first RAMP_PICKS of a run.
-- Effective aggression is one level lower until picks >= RAMP_PICKS.
local RAMP_PICKS = 5

local function GetEffectiveAggression(settings)
    local cfg = GetAggressionConfig(settings)
    local picks = EbonholdHub.Scoring.GetRunPicksCount()
    if picks < RAMP_PICKS then
        local level = (settings and settings.aggressionLevel) or 4
        level = math.max(1, math.min(5, math.floor(level)))
        local rampedLevel = math.max(1, level - 1)
        return AGGRESSION_CONFIG[rampedLevel]
    end
    return cfg
end

local function GetRemainingRerolls(runData)
    if not runData then return 0 end
    return math.max(0, (runData.totalRerolls or 0) - (runData.usedRerolls or 0))
end

local function GetRemainingBanishes(runData)
    if not runData then return 0 end
    return math.max(0, runData.remainingBanishes or 0)
end

local function GetRerollBudget(settings, runData)
    local level = math.max(1, math.min(5, math.floor((settings and settings.aggressionLevel) or 4)))
    local base = REROLL_BUDGET_BY_LEVEL[level] or 3
    -- Conservation cap: never spend more than 1/3 of remaining rerolls in one window.
    -- This prevents burning all rerolls on a few bad early windows.
    if runData then
        local remaining = GetRemainingRerolls(runData)
        local cap = math.max(1, math.floor(remaining / 3))
        return math.min(base, cap)
    end
    return base
end

local function SyncPickWindowCounters()
    local picks = EbonholdHub.Scoring.GetRunPicksCount()
    if lastCommittedPickCount == nil then
        lastCommittedPickCount = picks
    elseif picks > lastCommittedPickCount then
        pickWindowRerollCount = 0
        pickWindowBanishSubstituteCount = 0
        lastCommittedPickCount = picks
    end
end

local function RecordRerollUsed()
    pickWindowRerollCount = pickWindowRerollCount + 1
end

local function RecordBanishSubstituteUsed()
    pickWindowBanishSubstituteCount = pickWindowBanishSubstituteCount + 1
end

local function IsLateRun()
    return EbonholdHub.Scoring.GetRunPicksCount() >= LATE_RUN_PICKS
end

local function TryBanishOne(scored, build, runData, predicate, reason)
    if GetRemainingBanishes(runData) <= 0 then return false end
    for _, s in ipairs(scored) do
        if predicate(s) and not s.isFrozen and not s.isCarried and not s.isProtected then
            Dbg("ACTION", "BANISH", string.format(
                "Banish #%d %s (%s)", s.index, s.name or "?", reason or "?"
            ))
            local ok = ProjectEbonhold.PerkService.BanishPerk(s.index - 1)
            Dbg("ACTION", "BANISH", "BanishPerk → " .. tostring(ok))
            if ok then
                UpdateStat(build, "banishesUsed")
                EbonholdHub.Build.TrackBanStat(s)
                table.sort(scored, function(a, b) return a.index < b.index end)
                PLDone("BANISH", s.name or "?", string.format(
                    "%s — tier=%s score=%.1f", reason or "?", s.baseTier or "?", s.score or 0
                ))
                LogAndToast(scored, "Banish", s.index)
                -- Re-schedule evaluation for the remaining choices after the banish.
                -- Without this, pendingChoices gets cleared by the timer before
                -- UpdateSinglePerk fires, causing the UpdateSinglePerk guard
                -- (if pendingChoices) to block the timer restart and leaving the
                -- perk window stuck.
                StartEvalTimer()
                return true
            end
        elseif predicate(s) then
            Dbg("DETAIL", "BANISH", string.format(
                "Skip banish #%d %s — frozen=%s carried=%s protected=%s",
                s.index, s.name or "?", tostring(s.isFrozen), tostring(s.isCarried), tostring(s.isProtected)
            ))
        end
    end
    return false
end

local function TryBanishF(scored, build, runData)
    return TryBanishOne(scored, build, runData, function(s) return s.isTierF end, "F-tier")
end

-- Banish duplicates and C-tier after reroll step.
-- C = one reroll substitute per pick window (early/mid run only). Late run: duplicates only.
local function TryBanishDisposable(scored, build, runData, settings, hasDesired, banishTierSet, rerollBudgetExhausted)
    local level = math.max(1, math.min(5, math.floor((settings and settings.aggressionLevel) or 4)))
    if level < 4 or GetRemainingBanishes(runData) <= 0 then return false end

    local remainingRerolls = GetRemainingRerolls(runData)
    local maySpendDupBanish = remainingRerolls <= 0 or rerollBudgetExhausted
    local lateRun = IsLateRun()
    local substituteBudgetLeft = pickWindowBanishSubstituteCount < BANISH_SUBSTITUTE_MAX

    local function shouldBanishRepeat(s)
        return s.isRepeat and not hasDesired and maySpendDupBanish
    end

    local function shouldBanishC(s)
        if lateRun or not substituteBudgetLeft then return false end
        -- Never banish a bundle-boosted echo — it scored high because the bundle
        -- main is owned and we actively want to complete the combo.
        if s.isBundleBoosted then return false end
        -- Never banish an echo that passes its quality-specific synergy threshold.
        -- If we skipped the reroll because this echo had high advisor fit, banishing
        -- it immediately afterwards would contradict that decision entirely.
        local fit = s.advisorFit or 0
        local q   = s.quality or 0
        if fit >= (SYNERGY_THRESHOLD_BY_QUALITY[q] or 14) then return false end
        return not s.isRepeat and banishTierSet["C"] and s.baseTier == "C"
            and not hasDesired and remainingRerolls <= 0
    end

    if TryBanishOne(scored, build, runData, shouldBanishRepeat, "duplicate") then
        return true
    end
    if TryBanishOne(scored, build, runData, shouldBanishC, "C-tier (reroll substitute)") then
        RecordBanishSubstituteUsed()
        return true
    end
    return false
end

-- Scores an individual offered choice using tier data.
-- Returns a rich table or nil if the echo is unknown.
local function ScoreChoice(choice)
    local spellId = choice.spellId
    local data = ProjectEbonhold.PerkDatabase and ProjectEbonhold.PerkDatabase[spellId]
    if not data then
        Dbg("WARN", "SCORE", "Unknown spellId in PerkDatabase: " .. tostring(spellId))
        return nil
    end

    local rawName = GetSpellInfo(spellId)
    local baseName = EbonholdHub.Scoring.NormalizeEchoBaseName(rawName) or rawName
    if not baseName or baseName == "" then
        Dbg("WARN", "SCORE", "Empty name for spellId " .. tostring(spellId))
        return nil
    end

    -- Use the actual quality of the offered echo for quality-aware tier lookup.
    local quality = choice.quality or data.quality

    local effectiveTier = EbonholdHub.Scoring.GetEffectiveTier(baseName, quality, spellId)
    local baseTier      = EbonholdHub.TierData.GetTier(baseName, quality)

    -- Stackable echoes (maxPicks=0): game may flag a second stack as isRepeat.
    if effectiveTier ~= "REPEAT" and choice.isRepeat then
        local maxPicks = EbonholdHub.TierData.GetMaxPicks(baseName)
        if maxPicks == 0 and EbonholdHub.Scoring.IsEchoOwnedInRun(spellId, baseName, quality) then
            effectiveTier = "REPEAT"
        end
    end

    -- Position within tier: echoes earlier in the order get a tiny bonus.
    -- Order keys may be bare names (legacy) or "name|quality" (quality-specific).
    local posBonus = 0
    if effectiveTier ~= "C" and effectiveTier ~= "REPEAT" and effectiveTier ~= "F" then
        local order = EbonholdHub.TierData.GetOrder(effectiveTier) or {}
        for i, k in ipairs(order) do
            local kname, kquality = k:match("^(.+)|(%d+)$")
            if kname then
                kquality = tonumber(kquality)
            else
                kname    = k
                kquality = nil
            end
            -- Match if names agree AND (key is name-only OR qualities match)
            if kname == baseName and (kquality == nil or kquality == quality) then
                posBonus = math.max(0, 10 - i)
                break
            end
        end
    end

    local qualityBonus = QUALITY_BONUS[choice.quality or 0] or 0

    local baseScore
    if effectiveTier == "REPEAT" then
        baseScore = math.max(0, (TIER_BASE_SCORE[baseTier] or 20) / 2)
    else
        baseScore = TIER_BASE_SCORE[effectiveTier] or TIER_BASE_SCORE["C"]
    end

    -- Bundle boost: support echoes get boosted when their main is already owned.
    -- Main echoes also get boosted (so the player is encouraged to pick the trigger first).
    local bundleBoost
    if effectiveTier ~= "REPEAT" and effectiveTier ~= "F" then
        if EbonholdHub.TierData.GetBundleBoost then
            bundleBoost = EbonholdHub.TierData.GetBundleBoost(baseName, quality)
        end
        if not bundleBoost and EbonholdHub.TierData.GetMainEchoBoost then
            bundleBoost = EbonholdHub.TierData.GetMainEchoBoost(baseName, quality)
        end
        if bundleBoost then
            local bundleBase = TIER_BASE_SCORE[bundleBoost.tier] or TIER_BASE_SCORE["S"]
            baseScore = bundleBase + BUNDLE_BONUS
        end
    end

    local advisorFit = 0
    if effectiveTier == "C" and EbonholdHub.EchoAnalyzer and EbonholdHub.EchoAnalyzer.GetAutoPickFit then
        advisorFit = EbonholdHub.EchoAnalyzer.GetAutoPickFit(spellId) or 0
    end

    local score
    if effectiveTier == "C" and not bundleBoost then
        -- Among unassigned C echoes: class/spec fit from Advisor beats item quality,
        -- but quality should be a meaningful tiebreaker (epic > rar > unc > com when
        -- advisor fit is similar). Weight: com=0, unc=2, rar=4, ep=6, leg=8.
        local qualityTiebreak = (choice.quality or 0) * 2
        -- Clamp: negative advisor fit (wrong class/spec) removes the bonus but never
        -- drives score below the neutral baseline. Explicitly F-tiered echoes are
        -- already routed to the tierF bucket via isTierF — no double-penalty needed.
        local effectiveAdvisorFit = math.max(0, advisorFit)
        score = baseScore + (effectiveAdvisorFit * 2) + qualityTiebreak
        score = math.min(score, TIER_BASE_SCORE["B"] - 1)
    else
        score = baseScore + posBonus + qualityBonus
    end

    -- Freeze/carry penalty: slightly reduce score to deprioritize frozen echoes
    -- that reappear, so fresher alternatives are preferred.
    if (choice.isFrozen or choice.isCarried) then
        score = score * 0.9
    end

    -- Score breakdown components (used by PickLog for human-readable output)
    local scoreBase
    if bundleBoost then
        scoreBase = (TIER_BASE_SCORE[bundleBoost.tier] or TIER_BASE_SCORE["S"]) + BUNDLE_BONUS
    elseif effectiveTier == "REPEAT" then
        scoreBase = math.max(0, (TIER_BASE_SCORE[baseTier] or 20) / 2)
    else
        scoreBase = TIER_BASE_SCORE[effectiveTier] or TIER_BASE_SCORE["C"]
    end

    return {
        index        = 0,
        spellId      = spellId,
        name         = baseName,
        quality      = choice.quality,
        score        = score,
        selectScore  = score,   -- compat alias for Session/Toast
        tier         = effectiveTier,
        baseTier     = baseTier,
        isFrozen     = choice.isFrozen,
        isCarried    = choice.isCarried,
        isTierF      = (baseTier == "F"),
        isZeroWeight = (baseTier == "F"),  -- compat alias for FormatChoiceScore
        isRepeat     = (effectiveTier == "REPEAT"),
        isDuplicate  = (effectiveTier == "REPEAT"),  -- compat alias
        choiceIsRepeat = choice.isRepeat == true,
        advisorFit     = advisorFit,
        -- Bundle-boosted: echo whose score was elevated because it is part of a bundle
        -- (either the main trigger not yet owned, or a support whose main is already owned).
        -- Must be treated as "desired" so it is never rerolled or banished.
        isBundleBoosted    = bundleBoost ~= nil,
        bundleBoostTier    = bundleBoost and bundleBoost.tier or nil,
        isBundleMainEcho   = bundleBoost ~= nil and bundleBoost.isMainEcho == true,
        -- Score breakdown for PickLog
        -- For C-tier, scoreQuality reflects the actual qualityTiebreak used (quality*2),
        -- not the S/A/B-tier QUALITY_BONUS — keeps the log breakdown accurate.
        scoreBase    = scoreBase,
        scorePos     = posBonus,
        scoreQuality = (effectiveTier == "C" and not bundleBoost)
                       and ((choice.quality or 0) * 2)
                       or qualityBonus,
        scoreAdvisor = advisorFit,
        data         = data,
    }
end

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

local function IsProtected(data, whitelist)
    if not whitelist or next(whitelist) == nil then return false end
    local families = data and data.families
    if not families or #families == 0 then
        return whitelist["No family"] or false
    end
    for _, fam in ipairs(families) do
        local key = NormFamily(fam) or fam
        if whitelist[key] then return true end
    end
    return false
end

-- ── Selection helpers ─────────────────────────────────────────────────────────

-- Returns the best scored echo we actively want (S/A/B tier only, not C/F/REPEAT).
local function GetBestDesired(scored)
    local best, bestScore = nil, nil
    for _, s in ipairs(scored) do
        -- Bundle-boosted C echoes count as "desired" — they have artificially high
        -- scores and should never be rerolled or banished away.
        if not s.isTierF and not s.isRepeat and (s.tier ~= "C" or s.isBundleBoosted) then
            if bestScore == nil or s.score > bestScore then
                best, bestScore = s, s.score
            end
        end
    end
    return best, bestScore
end

-- Returns the best tier ID available among the scored echoes (ignoring F/REPEAT).
-- Bundle-boosted C echoes use the bundle's tier for rank calculation so they are
-- never treated as "weak offer" that would trigger an unnecessary reroll.
local function GetBestAvailableTier(scored)
    local bestRank = 0
    local bestTier = "F"
    for _, s in ipairs(scored) do
        if not s.isTierF then
            local rank
            if s.isRepeat then
                rank = TIER_RANK[s.baseTier] and math.max(0, TIER_RANK[s.baseTier] - 2) or 0
            elseif s.isBundleBoosted then
                -- Use the bundle's tier rank, not the echo's own C tier rank
                rank = TIER_RANK[s.bundleBoostTier] or TIER_RANK[s.tier] or 0
            else
                rank = TIER_RANK[s.tier] or 0
            end
            if rank > bestRank then
                bestRank = rank
                if s.isRepeat then
                    bestTier = "REPEAT:" .. s.baseTier
                elseif s.isBundleBoosted then
                    bestTier = s.bundleBoostTier .. "-Bundle"
                else
                    bestTier = s.tier
                end
            end
        end
    end
    return bestTier, bestRank
end

-- ── Select / Freeze ───────────────────────────────────────────────────────────

local function TrySelect(scored, settings, build, choiceHash)
    -- Group into buckets (priority: desired > repeat > F)
    local desired, repeats, tierF = {}, {}, {}
    for _, s in ipairs(scored) do
        if s.isTierF then
            tierF[#tierF + 1] = s
        elseif s.isRepeat then
            repeats[#repeats + 1] = s
        else
            desired[#desired + 1] = s
        end
    end

    local function sortByScore(list)
        table.sort(list, function(a, b)
            if a.score ~= b.score then return a.score > b.score end
            return (a.quality or 0) > (b.quality or 0)
        end)
    end

    local candidates
    if #desired > 0 then
        sortByScore(desired)
        local bestDesiredScore = desired[1].score or 0
        -- Safety net: if every "desired" echo has a negative score (e.g. stale data
        -- from a previous session bled into advisor fit), fall back to repeats first,
        -- then tierF, rather than forcing a pick with a negative score.
        if bestDesiredScore < 0 then
            local fallback = nil
            if #repeats > 0 then
                sortByScore(repeats)
                if (repeats[1].score or 0) >= 0 then
                    fallback = repeats
                end
            end
            if not fallback and #tierF > 0 then
                sortByScore(tierF)
                if (tierF[1].score or 0) > bestDesiredScore then
                    fallback = tierF
                end
            end
            if fallback then
                Dbg("INFO", "SELECT", string.format(
                    "All desired scores negative (best=%.1f) — falling back to safer bucket",
                    bestDesiredScore
                ))
                candidates = fallback
            else
                candidates = desired
            end
        else
            candidates = desired
        end
    elseif #repeats > 0 then
        candidates = repeats
    elseif #tierF > 0 then
        candidates = tierF
    else
        Dbg("ERROR", "SELECT", "TrySelect: no candidates in desired/repeat/tierF buckets")
        return false, nil
    end

    sortByScore(candidates)
    local pick = candidates[1]

    local reason
    if pick.isBundleBoosted then
        reason = string.format("BUNDLE-%s boosted: main echo owned, completing combo; score=%.1f", pick.bundleBoostTier or "?", pick.score or 0)
    elseif pick.isRepeat then
        reason = string.format("forced repeat pick (desired score negative/unavailable); best=%s score=%.1f", pick.baseTier or "?", pick.score or 0)
    else
        reason = string.format("best desired echo; tier=%s score=%.1f", pick.tier or "?", pick.score or 0)
    end
    PLDone("PICK", pick.name or "?", reason)

    QueueSelectAttempt(
        pick,
        scored,
        pick.isRepeat and "Select (Repeat)" or "Select",
        choiceHash,
        build
    )
    return true, pick
end

local function GetFreezeThresholds(settings)
    local level = math.max(1, math.min(5, math.floor((settings and settings.aggressionLevel) or 4)))
    -- Runner-up must always be A or S — B tier is not worth a freeze charge.
    local minRunnerUp = TIER_RANK["A"]
    -- Best echo: B+ on Normal; A+ on Smart / Perfectionist and late run.
    local minBest = TIER_RANK["B"]
    if level >= 4 or IsLateRun() then
        minBest = TIER_RANK["A"]
    end
    return minBest, minRunnerUp
end

local function TryFreezeRunnerUp(scored, settings, aggrCfg, runData, build)
    if not aggrCfg.freezeEnabled then
        Dbg("DETAIL", "FREEZE", "Freeze disabled by aggression config")
        return false
    end
    if not runData or (runData.totalFreezes or 0) - (runData.usedFreezes or 0) <= 0 then
        Dbg("DETAIL", "FREEZE", "No freeze charges")
        return false
    end

    -- Collect viable (unfrozen, not F tier, not Repeat, not already frozen locally).
    -- C tier is normally excluded, but bundle-boosted C echoes are kept — they
    -- scored high and deserve freeze consideration like any A/S echo.
    local viable  = {}
    for _, s in ipairs(scored) do
        if not s.isTierF and (s.tier ~= "C" or s.isBundleBoosted) and not s.isRepeat
           and not s.isFrozen and not s.isCarried
           and not locallyFrozenIndices[s.index] then
            viable[#viable + 1] = s
        end
    end

    if #viable < 2 then
        Dbg("DETAIL", "FREEZE", "Not enough viable echoes to freeze (" .. #viable .. ")")
        return false
    end

    table.sort(viable, function(a, b)
        if a.score ~= b.score then return a.score > b.score end
        return (a.quality or 0) > (b.quality or 0)
    end)

    local minBestRank, minRunnerUpRank = GetFreezeThresholds(settings)
    -- For bundle-boosted echoes use the bundle's tier rank, not the raw C-tier rank.
    local function effectiveRank(s)
        if s.isBundleBoosted then return TIER_RANK[s.bundleBoostTier] or TIER_RANK[s.tier] or 0 end
        return TIER_RANK[s.tier] or 0
    end
    local bestTierRank    = effectiveRank(viable[1])
    local runnerUpTierRank = effectiveRank(viable[2])

    if bestTierRank < minBestRank then
        Dbg("DETAIL", "FREEZE", string.format(
            "Best tier too low for freeze: %s (need rank>=%d)",
            tostring(viable[1].tier), minBestRank
        ))
        return false
    end
    if runnerUpTierRank < minRunnerUpRank then
        Dbg("DETAIL", "FREEZE", string.format(
            "Runner-up tier too low for freeze: %s (need A/S, got rank %d)",
            tostring(viable[2].tier), runnerUpTierRank
        ))
        return false
    end

    -- Freeze the runner-up (second best) so it carries into the next round.
    -- This way we pick the best now and get another shot at the second best later.
    local toFreeze = viable[2]
    Dbg("ACTION", "FREEZE", string.format(
        "Freeze runner-up #%d %s (best=#%d %s)",
        toFreeze.index, toFreeze.name or "?", viable[1].index, viable[1].name or "?"
    ))

    local ok = ProjectEbonhold.PerkService.FreezePerk(toFreeze.index - 1)
    Dbg("ACTION", "FREEZE", "FreezePerk → " .. tostring(ok))
    if not ok then return false end

    UpdateStat(build, "freezesUsed")
    locallyFrozenIndices[toFreeze.index] = true
    if runData.usedFreezes ~= nil then
        runData.usedFreezes = runData.usedFreezes + 1
    end
    PLDone("FREEZE", toFreeze.name or "?", string.format(
        "runner-up frozen (tier=%s score=%.1f); best is %s score=%.1f — will pick best now, retry frozen next round",
        toFreeze.tier or "?", toFreeze.score or 0, viable[1].name or "?", viable[1].score or 0
    ))
    LogAndToast(scored, "Freeze", toFreeze.index)
    freezeRoundActive = true
    StartEvalTimer()
    return true
end

-- ── Main evaluation ───────────────────────────────────────────────────────────

local evalInProgress = false

function EbonholdHub.Automation.Evaluate()
    if evalInProgress then
        Dbg("WARN", "EVAL", "Evaluate() skipped — already in progress")
        return false
    end
    evalInProgress = true
    Dbg("INFO", "EVAL", "── Evaluate() start ──")

    local function body()
        local build = EbonholdHub.Build.GetActive()
        if not build then
            Dbg("WARN", "EVAL", "Abort: no active build")
            return false
        end
        if not build.automationEnabled then
            Dbg("WARN", "EVAL", "Abort: automation disabled on build \"" .. (build.title or "?") .. "\"")
            return false
        end
        if not EbonholdHub.TierData then
            Dbg("ERROR", "EVAL", "Abort: TierData not loaded")
            return false
        end

        local choices = ProjectEbonhold.PerkService.GetCurrentChoice()
        if not choices or #choices == 0 then
            Dbg("DETAIL", "EVAL", "No current choices from PerkService")
            if EbonholdHub.Toast and EbonholdHub.Toast.NotifyPerkChoiceEnded then
                EbonholdHub.Toast.NotifyPerkChoiceEnded()
            end
            if pendingSelection then ConfirmPendingSelection(true) end
            return false
        end

        if DbgEnabled() then
            EbonholdHub.AutoPickDebug.LogChoices("GetCurrentChoice", choices)
        end

        -- Refresh run-echo cache from the server before scoring (avoids stale labels).
        local ps = ProjectEbonhold.PerkService
        if ps and ps.RequestGrantedPerks then ps.RequestGrantedPerks() end
        if EbonholdHub.Scoring.SyncRunEchoesFromGranted then
            EbonholdHub.Scoring.SyncRunEchoesFromGranted()
        end
        SyncPickWindowCounters()

        local choiceHash = GetChoiceHash(choices)
        Dbg("DETAIL", "EVAL", "choiceHash=" .. tostring(choiceHash))

        -- Waiting for server to confirm a previous SelectPerk — retry or hold UI hidden.
        if pendingSelection then
            Dbg("INFO", "PENDING", "Pending selection blocks new eval")
            if DbgEnabled() then
                EbonholdHub.AutoPickDebug.LogPending(pendingSelection, "blocking eval")
            end
            if not ResolvePendingSelection(choices) then
                Dbg("WARN", "EVAL", "Pending resolution failed — returning false for manual UI")
                return false
            end
            if pendingSelection then
                Dbg("INFO", "EVAL", "Still waiting for server — UI hidden (return true)")
                return true
            end
        end

        local settings  = EbonholdHub.Scoring.GetEffectiveSettings()
        local aggrCfg   = GetEffectiveAggression(settings)
        local runData   = GetRunData()
        local lockedList = build.lockedEchoes or {}
        local whitelist = settings.banishFamilyWhitelist or {}

        Dbg("DETAIL", "EVAL", string.format(
            "aggression=%s rampPicks=%d runPicks=%d lateRun=%s banishTiers=%s rerollBelow=%s freeze=%s windowRerolls=%d/%d windowBanSubst=%d/%d",
            tostring(settings.aggressionLevel or 4),
            RAMP_PICKS,
            EbonholdHub.Scoring.GetRunPicksCount(),
            tostring(IsLateRun()),
            table.concat(aggrCfg.banishTiers or {}, ","),
            tostring(aggrCfg.rerollBelowTier),
            tostring(aggrCfg.freezeEnabled),
            pickWindowRerollCount,
            GetRerollBudget(settings, runData),
            pickWindowBanishSubstituteCount,
            BANISH_SUBSTITUTE_MAX
        ))
        if runData then
            Dbg("DETAIL", "EVAL", string.format(
                "runData: banishes=%s rerolls=%s/%s freezes=%s/%s",
                tostring(runData.remainingBanishes),
                tostring(runData.usedRerolls), tostring(runData.totalRerolls),
                tostring(runData.usedFreezes), tostring(runData.totalFreezes)
            ))
        else
            Dbg("WARN", "EVAL", "runData unavailable")
        end

        -- Build set of banishable tier IDs
        local banishTierSet = {}
        for _, t in ipairs(aggrCfg.banishTiers) do banishTierSet[t] = true end

        -- Score all offered choices (Advisor fit applied for C-tier via EchoAnalyzer)
        local scored = {}
        local advisorScoring = EbonholdHub.EchoAnalyzer
            and EbonholdHub.EchoAnalyzer.BeginAutoPickScoring
            and EbonholdHub.EchoAnalyzer.BeginAutoPickScoring(build)
        for i, choice in ipairs(choices) do
            local s = ScoreChoice(choice)
            if s then
                s.index       = i
                s.isProtected = IsProtected(s.data, whitelist)
                s.isLocked    = EbonholdHub.Scoring.IsLocked(s.spellId, s.name)
                scored[#scored + 1] = s
            else
                Dbg("WARN", "SCORE", "Choice #" .. i .. " could not be scored (spellId=" .. tostring(choice.spellId) .. ")")
            end
        end
        if advisorScoring and EbonholdHub.EchoAnalyzer.EndAutoPickScoring then
            EbonholdHub.EchoAnalyzer.EndAutoPickScoring()
        end
        if #scored == 0 then
            Dbg("ERROR", "EVAL", "Abort: zero scorable echoes — all unknown to tier data")
            return false
        end
        if DbgEnabled() then
            EbonholdHub.AutoPickDebug.LogScored(scored)
        end

        -- PickLog: record offer context (level, resources, aggr)
        PLOffer(scored, {
            level    = UnitLevel("player"),
            pickNum  = EbonholdHub.Scoring.GetRunPicksCount and EbonholdHub.Scoring.GetRunPicksCount() or 0,
            rerolls  = runData and GetRemainingRerolls(runData) or 0,
            banishes = runData and GetRemainingBanishes(runData) or 0,
            aggr     = settings and settings.aggressionLevel or 4,
        })

        -- TEMP offer logger: records every offer (regardless of pick outcome)
        -- for drop-rate analysis. Remove after data is collected.
        do
            local log = EbonholdHubDB.offerLog
            if log then
                local tiers = {}
                for _, s in ipairs(scored) do
                    tiers[#tiers + 1] = s.isRepeat and ("R:" .. (s.baseTier or "?")) or (s.baseTier or "?")
                end
                log[#log + 1] = {
                    level  = UnitLevel("player"),
                    reroll = pickWindowRerollCount,
                    tiers  = tiers,
                }
                -- Keep cap to avoid bloating SavedVariables
                if #log > 3000 then
                    table.remove(log, 1)
                end
            end
        end

        --------------------------------------------------------------------
        -- ALL-REPEAT ROUND: every offered echo is already owned by the player.
        --
        -- Strategy (in order):
        --   1. If reroll charges remain → reroll for fresh choices.
        --   2. Otherwise → pick the best repeat by base tier + quality,
        --      ignoring the maxPicks cap (we MUST pick something to unblock).
        --
        -- The hash guard ensures that if SelectPerk(index) silently fails
        -- and UpdateSinglePerk re-fires, we fall through to origPerkUIShow
        -- instead of looping forever.
        --------------------------------------------------------------------
        local allRepeat = true
        for _, s in ipairs(scored) do
            if not s.isRepeat then allRepeat = false; break end
        end
        if allRepeat then
            Dbg("INFO", "EVAL", "All-repeat round detected")
            local canReroll = aggrCfg.rerollBelowTier ~= nil
            local rerollBudget = GetRerollBudget(settings, runData)
            -- 1. Reroll if style allows, charges remain, and per-window budget allows.
            if canReroll and runData and GetRemainingRerolls(runData) > 0
               and pickWindowRerollCount < rerollBudget then
                local ok = ProjectEbonhold.PerkService.RequestReroll()
                Dbg("ACTION", "REROLL", "All-repeat reroll → " .. tostring(ok))
                if ok then
                    UpdateStat(build, "rerollsUsed")
                    RecordRerollUsed()
                    PLDone("REROLL", "all-repeat", "all 3 echoes already owned, rerolling for fresh choices")
                    LogAndToast(scored, "Reroll", 0)
                    return true
                end
            end

            -- 2. No rerolls: pick the best repeat echo.
            table.sort(scored, function(a, b)
                local sa = (TIER_BASE_SCORE[a.baseTier] or 20) + (QUALITY_BONUS[a.quality or 0] or 0)
                local sb = (TIER_BASE_SCORE[b.baseTier] or 20) + (QUALITY_BONUS[b.quality or 0] or 0)
                if sa ~= sb then return sa > sb end
                return (a.quality or 0) > (b.quality or 0)
            end)
            local pick = scored[1]
            Dbg("ACTION", "SELECT", "All-repeat pick: " .. (pick.name or "?"))
            PLDone("PICK", pick.name or "?", string.format("all-repeat forced pick (no rerolls): best repeat %s score=%.1f", pick.baseTier or "?", pick.score or 0))
            QueueSelectAttempt(pick, scored, "Select (Repeat)", choiceHash, build)
            return true
        end

        --------------------------------------------------------------------
        -- PRE-CHECK: build locked slot → top priority when offered (any quality).
        -- These are wishlist targets from the build, not in-game perk locks.
        --------------------------------------------------------------------
        local lockedPick = EbonholdHub.Scoring.FindLockedOffer(scored, lockedList)
        if lockedPick then
            Dbg("ACTION", "SELECT", "Locked echo pick: " .. (lockedPick.name or "?"))
            PLDone("PICK", lockedPick.name or "?", "LOCKED echo from build wishlist — top priority override")
            QueueSelectAttempt(lockedPick, scored, "Select (Locked)", choiceHash, build)
            return true
        end

        -- What is the best tier available right now?
        local bestTier, bestRank = GetBestAvailableTier(scored)
        local rerollThresholdRank = aggrCfg.rerollBelowTier and TIER_RANK[aggrCfg.rerollBelowTier] or 0

        -- Does the offer have anything we actively want?
        local bestDesired, bestDesiredScore = GetBestDesired(scored)
        local hasDesired = bestDesired ~= nil

        -- Synergy override: if any non-repeat C-tier echo has a high-enough advisor fit
        -- (accounting for echo quality), it is considered a genuine build match and
        -- will NOT be rerolled away.
        -- Common-quality (0) echoes are never kept via synergy override — always reroll them.
        -- Higher-quality echoes use progressively lower thresholds.
        local hasSynergyC = false
        local bestSynergyFit = 0
        local bestSynergyName = nil
        for _, s in ipairs(scored) do
            if not s.isRepeat and s.baseTier == "C" then
                local fit = s.advisorFit or 0
                local q   = s.quality or 0
                local threshold = SYNERGY_THRESHOLD_BY_QUALITY[q] or 14
                if fit >= threshold and fit > bestSynergyFit then
                    hasSynergyC   = true
                    bestSynergyFit = fit
                    bestSynergyName = s.name
                end
            end
        end

        Dbg("DETAIL", "EVAL", string.format(
            "bestTier=%s rank=%d rerollThreshold=%d hasDesired=%s bestDesired=%s (%.1f) hasSynergyC=%s fit=%d (%s)",
            tostring(bestTier), bestRank, rerollThresholdRank, tostring(hasDesired),
            bestDesired and bestDesired.name or "nil", bestDesiredScore or 0,
            tostring(hasSynergyC), bestSynergyFit, bestSynergyName or "nil"
        ))

        -- "keepOffer" = true when we have at least something pickable (not all F)
        local allF = true
        for _, s in ipairs(scored) do
            if not s.isTierF then allF = false; break end
        end
        local keepOffer = not allF

        local rerollBudget = GetRerollBudget(settings, runData)
        local rerollBudgetExhausted = pickWindowRerollCount >= rerollBudget

        --------------------------------------------------------------------
        -- 1. BANISH F-tier trash (always, before reroll)
        --------------------------------------------------------------------
        if TryBanishF(scored, build, runData) then
            return true
        end

        --------------------------------------------------------------------
        -- 2. REROLL — spend rerolls first on weak offers (per-window budget)
        --------------------------------------------------------------------
        if runData and GetRemainingRerolls(runData) > 0
           and rerollThresholdRank > 0
           and bestRank <= rerollThresholdRank
           and not hasDesired
           and not hasSynergyC
           and not rerollBudgetExhausted then

            Dbg("ACTION", "REROLL", string.format(
                "Reroll: bestRank=%d <= threshold=%d, window %d/%d",
                bestRank, rerollThresholdRank, pickWindowRerollCount, rerollBudget
            ))
            local ok = ProjectEbonhold.PerkService.RequestReroll()
            Dbg("ACTION", "REROLL", "RequestReroll → " .. tostring(ok))
            if ok then
                UpdateStat(build, "rerollsUsed")
                RecordRerollUsed()
                PLDone("REROLL", bestTier .. "-tier offer", string.format(
                    "bestRank=%d (%s) <= threshold=%d (%s), window %d/%d, no desired echo",
                    bestRank, bestTier, rerollThresholdRank, aggrCfg.rerollBelowTier or "?",
                    pickWindowRerollCount, rerollBudget
                ))
                LogAndToast(scored, "Reroll", 0)
                return true
            end
        else
            if hasSynergyC then
                Dbg("INFO", "REROLL", string.format(
                    "Skip reroll — synergy C kept: %s (fit=%d)",
                    bestSynergyName or "?", bestSynergyFit
                ))
            else
                Dbg("DETAIL", "REROLL", string.format(
                    "Skip reroll: charges=%s threshold=%d bestRank=%d hasDesired=%s hasSynergyC=%s window=%d/%d",
                    tostring(GetRemainingRerolls(runData)),
                    rerollThresholdRank, bestRank, tostring(hasDesired), tostring(hasSynergyC),
                    pickWindowRerollCount, rerollBudget
                ))
            end
        end

        --------------------------------------------------------------------
        -- 3. BANISH duplicates / C — only after reroll step; C when no rerolls
        --------------------------------------------------------------------
        if TryBanishDisposable(scored, build, runData, settings, hasDesired, banishTierSet, rerollBudgetExhausted) then
            return true
        end
        if GetRemainingBanishes(runData) <= 0 then
            Dbg("DETAIL", "BANISH", "No banish charges or runData missing")
        end

        table.sort(scored, function(a, b) return a.index < b.index end)

        --------------------------------------------------------------------
        -- 4. FREEZE runner-up if the offer is worth keeping
        --------------------------------------------------------------------
        if keepOffer and TryFreezeRunnerUp(scored, settings, aggrCfg, runData, build) then
            Dbg("ACTION", "FREEZE", "Freeze runner-up succeeded — re-eval after timer")
            return true
        end
        Dbg("DETAIL", "FREEZE", "No freeze this round (keepOffer=" .. tostring(keepOffer) .. ")")

        --------------------------------------------------------------------
        -- 5. SELECT
        --------------------------------------------------------------------
        locallyFrozenIndices = {}
        freezeRoundActive    = false

        local ok, pick = TrySelect(scored, settings, build, choiceHash)
        if ok then
            Dbg("ACTION", "SELECT", "TrySelect queued: " .. (pick and pick.name or "?"))
        else
            Dbg("ERROR", "SELECT", "TrySelect failed — no candidates")
        end
        return ok
    end

    local callOk, result = pcall(body)
    evalInProgress = false
    if not callOk then
        -- result is the error message when pcall fails
        Dbg("ERROR", "EVAL", "Evaluate() body threw: " .. tostring(result))
        -- Ensure EchoAnalyzer state is cleaned up if BeginAutoPickScoring was
        -- called but EndAutoPickScoring never ran (error mid-loop).
        if EbonholdHub.EchoAnalyzer and EbonholdHub.EchoAnalyzer.EndAutoPickScoring then
            pcall(EbonholdHub.EchoAnalyzer.EndAutoPickScoring)
        end
        return false
    end
    Dbg("INFO", "EVAL", "── Evaluate() end → " .. tostring(result) .. " ──")
    return result
end

-- Peak / cache kept for compat — no longer used for thresholds but Toast may call it
function EbonholdHub.Automation.GetPeak()
    return 100  -- tiers are normalized, peak is always 100
end

function EbonholdHub.Automation.ResetPeakCache()
    -- no-op; kept for callers in Session.lua
end

-- Called on run end (death / session reset) to discard any in-flight pick state
-- from the previous run so stale choices don't bleed into the new run.
function EbonholdHub.Automation.ResetPickState()
    pendingSelection  = nil
    pendingChoices    = nil
    manualFallbackHash = nil
    pickWindowRerollCount           = 0
    pickWindowBanishSubstituteCount = 0
    lastCommittedPickCount          = nil
    freezeRoundActive               = false
    locallyFrozenIndices            = {}
    evalInProgress                  = false
    if evalTimerFrame then
        evalTimerFrame:Hide()
        evalTimerActive = false
    end
end

function EbonholdHub.Automation.Init()
    if not ProjectEbonhold or not ProjectEbonhold.PerkUI then
        Dbg("ERROR", "HOOK", "Init aborted — ProjectEbonhold.PerkUI missing")
        return
    end
    if ProjectEbonhold.PerkUI._ebonholdHubHooked then
        Dbg("DETAIL", "HOOK", "Init skipped — already hooked")
        return
    end

    local PerkUI = ProjectEbonhold.PerkUI

    origPerkUIShow = PerkUI.Show
    PerkUI.Show = function(choices)
        Dbg("HOOK", "PerkUI", "PerkUI.Show intercepted (" .. (choices and #choices or 0) .. " choices)")
        if DbgEnabled() then
            EbonholdHub.AutoPickDebug.LogChoices("PerkUI.Show", choices)
        end

        local choiceHash = GetChoiceHash(choices)
        if manualFallbackHash and choiceHash == manualFallbackHash then
            Dbg("UI", "FALLBACK", "Manual pick UI active — skip auto eval for hash " .. tostring(choiceHash))
            pendingChoices = choices
            if origPerkUIShow then origPerkUIShow(choices) end
            return
        end
        if manualFallbackHash and choiceHash ~= manualFallbackHash then
            Dbg("UI", "FALLBACK", "Offer changed — resuming auto pick")
            manualFallbackHash = nil
        end

        if pendingSelection then
            local newHash = GetChoiceHash(choices)
            if newHash ~= pendingSelection.choiceHash then
                Dbg("INFO", "PENDING", "New choices while pending — try confirm (hash " .. tostring(newHash) .. ")")
                ConfirmPendingSelection(true)
            end
        end
        pendingChoices = choices
        StartEvalTimer()
    end

    hooksecurefunc(PerkUI, "UpdateSinglePerk", function()
        -- Only restart the eval timer when there are active pending choices.
        -- After death, ResetPickState() clears pendingChoices. Without this
        -- guard, each echo removal on death fires UpdateSinglePerk and
        -- re-schedules Evaluate() 2s later — which then calls
        -- RequestGrantedPerks() against an already-ended run.
        if pendingChoices then
            Dbg("HOOK", "PerkUI", "UpdateSinglePerk — restart eval timer")
            StartEvalTimer()
        end
    end)

    if PerkUI.Hide then
        hooksecurefunc(PerkUI, "Hide", function()
            Dbg("HOOK", "PerkUI", "PerkUI.Hide — schedule toast dismiss")
            if EbonholdHub.Toast and EbonholdHub.Toast.NotifyPerkChoiceEnded then
                EbonholdHub.Toast.NotifyPerkChoiceEnded()
            end
        end)
    end

    PerkUI._ebonholdHubHooked = true
    Dbg("INFO", "HOOK", "PerkUI hooks installed")
end

-- Exported for debugger status panel
function EbonholdHub.Automation._GetDebugState()
    return {
        evalTimerActive        = evalTimerActive,
        evalInProgress         = evalInProgress,
        pendingSelection       = pendingSelection ~= nil,
        freezeRoundActive      = freezeRoundActive,
        pendingChoices         = pendingChoices and #pendingChoices or 0,
        manualFallbackHash     = manualFallbackHash,
        pickWindowRerollCount           = pickWindowRerollCount,
        pickWindowBanishSubstituteCount = pickWindowBanishSubstituteCount,
        lastCommittedPickCount          = lastCommittedPickCount,
    }
end

-- Exported for testing
EbonholdHub.Automation._ScoreChoice      = ScoreChoice
EbonholdHub.Automation._TrySelect        = TrySelect
EbonholdHub.Automation._IsProtected      = IsProtected
EbonholdHub.Automation._ResetFreezeRound = function()
    freezeRoundActive    = false
    locallyFrozenIndices = {}
end
