EbonholdHub.EchoAnalyzer = {}

local SynergyData = EbonholdHub.SynergyData
local TierData    = EbonholdHub.TierData

-- ── Performance caches ────────────────────────────────────────────────────────
-- buildTagSet: invalidated when build ID or S/A tier count changes (auto-pick only)
local buildTagSetCache      = nil
local buildTagSetBuildId    = nil
local buildTagSetTierCount  = nil
-- Per-spellId permanent caches (spell data doesn't change during a session)
local descriptionLowerCache = {}   -- spellId -> lowercased desc string
local echoTagDataCache      = {}   -- spellId -> { tags, synergiesWith }

-- ── Keyword → tag dictionary for auto-parsing spell descriptions ─────────────
-- Simple substring search (string.find with plain=true).
-- Longer / more specific phrases must come before shorter ones in iteration;
-- we iterate a sorted list to keep behaviour stable.

local KEYWORD_ENTRIES = {
    -- Phrases first (longer → more specific)
    { key = "over time",      tag = "dot"       },
    { key = "all enemies",    tag = "aoe"       },
    { key = "all nearby",     tag = "aoe"       },
    { key = "nearby enemies", tag = "aoe"       },
    { key = "chance to",      tag = "proc"      },
    { key = "when you",       tag = "proc"      },
    { key = "on hit",         tag = "proc"      },
    { key = "on kill",        tag = "proc"      },
    { key = "stacks",         tag = "stack"     },
    { key = "stacking",       tag = "stack"     },
    { key = "physical",       tag = "physical"  },
    { key = "bleed",          tag = "bleed"     },
    { key = "bleeding",       tag = "bleed"     },
    { key = "disease",        tag = "disease"   },
    { key = "poison",         tag = "poison"    },
    { key = "shadow",         tag = "shadow"    },
    { key = "darkness",       tag = "shadow"    },
    { key = "void",           tag = "shadow"    },
    { key = "holy",           tag = "holy"      },
    { key = "divine",         tag = "holy"      },
    { key = "sacred",         tag = "holy"      },
    { key = "light",          tag = "holy"      },
    { key = "fire",           tag = "fire"      },
    { key = "flame",          tag = "fire"      },
    { key = "burn",           tag = "fire"      },
    { key = "ignite",         tag = "fire"      },
    { key = "frost",          tag = "frost"     },
    { key = "freeze",         tag = "frost"     },
    { key = "frozen",         tag = "frost"     },
    { key = "cold",           tag = "frost"     },
    { key = "ice",            tag = "frost"     },
    { key = "nature",         tag = "nature"    },
    { key = "arcane",         tag = "arcane"    },
    { key = "lightning",      tag = "lightning" },
    { key = "thunder",        tag = "lightning" },
    { key = "shock",          tag = "lightning" },
    { key = "critical",       tag = "crit"      },
    { key = "crit",           tag = "crit"      },
    { key = "heal",           tag = "healing"   },
    { key = "restore",        tag = "healing"   },
    { key = "regenerat",      tag = "healing"   },
    { key = "absorb",         tag = "absorb"    },
    { key = "shield",         tag = "defensive" },
    { key = "block",          tag = "defensive" },
    { key = "damage",              tag = "damage"      },
    { key = "deal",                tag = "damage"      },
    { key = "melee",               tag = "melee"       },
    { key = "ranged",              tag = "ranged"      },
    { key = "channeled",           tag = "channeled"   },
    { key = "periodic",            tag = "dot"         },
    { key = "stack",               tag = "stack"       },
    { key = "trigger",             tag = "proc"        },
    -- ── Primary stats ────────────────────────────────────────────────────────
    { key = "strength",            tag = "strength"    },
    { key = "agility",             tag = "agility"     },
    { key = "intellect",           tag = "intellect"   },
    { key = "stamina",             tag = "stamina"     },
    { key = "spirit",              tag = "spirit"      },
    -- Generic "all stats" phrases map to the most universal primary stats
    { key = "all stats",           tag = "strength"    },
    { key = "all stats",           tag = "agility"     },
    { key = "all stats",           tag = "intellect"   },
    { key = "all stats",           tag = "stamina"     },
    { key = "primary stat",        tag = "strength"    },
    { key = "primary stat",        tag = "agility"     },
    { key = "primary stat",        tag = "intellect"   },
    { key = "main stat",           tag = "strength"    },
    { key = "main stat",           tag = "agility"     },
    { key = "main stat",           tag = "intellect"   },
    -- ── Secondary stats ──────────────────────────────────────────────────────
    { key = "spell power",         tag = "spellpower"  },
    { key = "spellpower",          tag = "spellpower"  },
    { key = "spell damage",        tag = "spellpower"  },
    { key = "attack power",        tag = "attackpower" },
    { key = "melee attack power",  tag = "attackpower" },
    { key = "ranged attack power", tag = "attackpower" },
    { key = "haste rating",        tag = "haste"       },
    { key = "haste",               tag = "haste"       },
    { key = "armor penetration",   tag = "arpen"       },
    { key = "armor pen",           tag = "arpen"       },
    { key = "increases armor",     tag = "armor"       },
    { key = "bonus armor",         tag = "armor"       },
    { key = "your armor",          tag = "armor"       },
    { key = "expertise rating",    tag = "expertise"   },
    { key = "expertise",           tag = "expertise"   },
    { key = "hit rating",          tag = "hit"         },
    { key = "spell hit",           tag = "hit"         },
    { key = "chance to hit",       tag = "hit"         },
    { key = "critical strike",     tag = "crit"        },
    { key = "crit chance",         tag = "crit"        },
    { key = "dodge rating",        tag = "dodge"       },
    { key = "parry rating",        tag = "parry"       },
    { key = "defense rating",      tag = "defense"     },
    { key = "defense skill",       tag = "defense"     },
    { key = "block rating",        tag = "block_stat"  },
    { key = "block value",         tag = "block_stat"  },
    -- ── Defensive / mitigation phrases (before generic "damage") ─────────────
    { key = "reduces all damage taken", tag = "defense"   },
    { key = "reduces damage taken",     tag = "defense"   },
    { key = "damage taken is reduced",  tag = "defense"   },
    { key = "after you dodge or parry", tag = "dodge"     },
    { key = "after you dodge or parry", tag = "parry"     },
    { key = "after you dodge",          tag = "dodge"     },
    { key = "after you parry",          tag = "parry"     },
    { key = "when struck",              tag = "defensive" },
    { key = "when you take damage",     tag = "defensive" },
    { key = "when you are hit",         tag = "defensive" },
    { key = "pulls nearby enemies",     tag = "defensive" },
    { key = "maximum health",           tag = "stamina"   },
    { key = "increases your maximum health", tag = "stamina" },
    { key = "mana per 5",          tag = "mp5"          },
    { key = "mp5",                 tag = "mp5"          },
    { key = "mana regen",          tag = "mp5"          },
    { key = "resilience",          tag = "resilience"   },
    -- ── Damage / healing bonuses ─────────────────────────────────────────────
    { key = "bonus damage",        tag = "damage_bonus" },
    { key = "increases damage",    tag = "damage_bonus" },
    { key = "bonus to damage",     tag = "damage_bonus" },
    { key = "damage dealt",        tag = "damage_bonus" },
    { key = "bonus healing",       tag = "healing_bonus"},
    { key = "increases healing",   tag = "healing_bonus"},
    { key = "healing done",        tag = "healing_bonus"},
    { key = "healing power",       tag = "healing_bonus"},
    { key = "absorbs",             tag = "absorb"       },
    { key = "absorbed",            tag = "absorb"       },
    { key = "damage absorption",   tag = "absorb"       },
    { key = "over time",           tag = "dot_damage"   },
    { key = "periodic damage",     tag = "dot_damage"   },
    { key = "bleed damage",        tag = "dot_damage"   },
    { key = "area",                tag = "aoe_damage"   },
    { key = "all nearby",          tag = "aoe_damage"   },
    { key = "all enemies",         tag = "aoe_damage"   },
    { key = "nearby enemies",      tag = "aoe_damage"   },
    { key = "reduces the cooldown",tag = "cooldown"     },
    { key = "reduce cooldown",     tag = "cooldown"     },
    { key = "cooldown reduced",    tag = "cooldown"     },
    { key = "cooldown reduction",  tag = "cooldown"     },
    -- ── Resources ────────────────────────────────────────────────────────────
    { key = "maximum mana",        tag = "mana"         },
    { key = "increases your mana", tag = "mana"         },
    { key = "mana pool",           tag = "mana"         },
    { key = "maximum energy",      tag = "energy"       },
    { key = "energy regenerat",    tag = "energy"       },
    { key = "restores energy",     tag = "energy"       },
    { key = "rage",                tag = "rage"         },
    { key = "runic power",         tag = "runicpower"   },
    { key = "runic energy",        tag = "runicpower"   },
    -- ── Pet / companion ──────────────────────────────────────────────────────
    { key = "pet",                 tag = "pet"          },
    { key = "companion",           tag = "pet"          },
    { key = "minion",              tag = "pet"          },
    { key = "summon",              tag = "pet"          },
    { key = "ghoul",               tag = "pet"          },
    -- ── Utility ──────────────────────────────────────────────────────────────
    { key = "movement speed",      tag = "movespeed"    },
    { key = "move speed",          tag = "movespeed"    },
    { key = "increases speed",     tag = "movespeed"    },
    -- ── Creature-type conditional mechanics ──────────────────────────────────
    -- Echoes with "when fighting X" are situational — they only activate
    -- against specific enemy creature types (Beast, Undead, Elemental, etc.).
    -- This tag triggers a score penalty in ScoreEcho to reflect their conditional nature.
    { key = "when fighting",       tag = "conditional_vs_type" },
    { key = "against undead",      tag = "conditional_vs_type" },
    { key = "against beast",       tag = "conditional_vs_type" },
    { key = "against elemental",   tag = "conditional_vs_type" },
    { key = "against demon",       tag = "conditional_vs_type" },
    { key = "against dragonkin",   tag = "conditional_vs_type" },
    { key = "against mechanical",  tag = "conditional_vs_type" },
    { key = "against giant",       tag = "conditional_vs_type" },
}

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

-- PerkDatabase may contain several spellIds with the same display name but different
-- classMask (e.g. Unstable Infusion → Rend for Warrior, Blood Plague for DK).
-- Only score variants the current class can actually use.
local function EntryMatchesPlayerClass(entry, playerClass)
    if not entry or not entry.classMask or entry.classMask == 0 then return true end
    local classBit = CLASS_BITS[playerClass]
    return classBit and bit.band(entry.classMask, classBit) ~= 0
end

-- Echo mechanic tags ↔ Advisor stat tier ids.
-- When the user assigns "DoT Dmg" (dot_damage) but the echo is tagged "dot", aliases bridge the gap.
-- Each row is bidirectional: both keys resolve to the other's weight if present.
local STAT_TAG_ALIAS_GROUPS = {
    { "dot",           "dot_damage"    },
    { "aoe",           "aoe_damage"    },
    { "damage",        "damage_bonus"  },
    { "healing",       "healing_bonus" },
    { "hot",           "healing_bonus", "healing" },
    { "cooldown_reduction", "cooldown" },
}

local STAT_TAG_ALIASES = {}
for _, group in ipairs(STAT_TAG_ALIAS_GROUPS) do
    for _, tag in ipairs(group) do
        local list = STAT_TAG_ALIASES[tag]
        if not list then
            list = {}
            STAT_TAG_ALIASES[tag] = list
        end
        for _, other in ipairs(group) do
            if other ~= tag then
                list[#list + 1] = other
            end
        end
    end
end

local function ResolveStatWeight(tag, weights)
    if not weights or not tag then return nil end
    local w = weights[tag]
    if w and w > 0 then return w end
    local aliases = STAT_TAG_ALIASES[tag]
    if aliases then
        for _, alt in ipairs(aliases) do
            w = weights[alt]
            if w and w > 0 then return w end
        end
    end
    return nil
end

-- ── Spec detection ────────────────────────────────────────────────────────────

-- Returns 1, 2, or 3 — the talent tree index with the most points spent.
-- Falls back to 1 if the API is unavailable (e.g. no talents yet).
local function GetPlayerSpecIndex()
    if not GetNumTalentTabs then return 1 end
    local numTabs = GetNumTalentTabs() or 0
    if numTabs == 0 then return 1 end
    local maxPoints = -1
    local specIdx   = 1
    for i = 1, numTabs do
        local _, _, points = GetTalentTabInfo(i)
        if points and points > maxPoints then
            maxPoints = points
            specIdx   = i
        end
    end
    return specIdx
end

-- Spec archetype: finer than DPS/TANK/HEAL — drives family compatibility checks.
-- TANK        → tank specs (Prot, Blood, etc.)
-- HEAL        → dedicated healers
-- MELEE_DPS   → melee damage (Ret, Arms, Enhancement, Feral, etc.)
-- RANGED_DPS  → physical ranged (Hunter)
-- CASTER_DPS  → spell DPS (Mage, Warlock, Shadow, Balance, Elemental)
local SPEC_ARCHETYPE = {
    WARRIOR     = { [1]="MELEE_DPS", [2]="MELEE_DPS", [3]="TANK"        },
    PALADIN     = { [1]="HEAL",      [2]="TANK",      [3]="MELEE_DPS"   },
    HUNTER      = { [1]="RANGED_DPS",[2]="RANGED_DPS",[3]="RANGED_DPS"  },
    ROGUE       = { [1]="MELEE_DPS", [2]="MELEE_DPS", [3]="MELEE_DPS"   },
    PRIEST      = { [1]="HEAL",      [2]="HEAL",      [3]="CASTER_DPS"  },
    DEATHKNIGHT = { [1]="TANK",      [2]="MELEE_DPS", [3]="MELEE_DPS"   },
    SHAMAN      = { [1]="CASTER_DPS",[2]="MELEE_DPS", [3]="HEAL"        },
    MAGE        = { [1]="CASTER_DPS",[2]="CASTER_DPS",[3]="CASTER_DPS"  },
    WARLOCK     = { [1]="CASTER_DPS",[2]="CASTER_DPS",[3]="CASTER_DPS"  },
    DRUID       = { [1]="CASTER_DPS",[2]="MELEE_DPS", [3]="HEAL"        },
}

-- Which PerkDatabase families each archetype can meaningfully use.
-- Echo passes if at least ONE of its families is in this allowed set.
-- Empty-family echoes (utility) are always allowed.
local ARCHETYPE_OK_FAMILIES = {
    TANK        = { Tank=true, Melee=true, Survivability=true },
    HEAL        = { Healer=true, Caster=true, Survivability=true },
    MELEE_DPS   = { Melee=true, Survivability=true },
    RANGED_DPS  = { Ranged=true, Melee=true, Survivability=true },
    CASTER_DPS  = { Caster=true, Survivability=true },
}

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 GetSpecArchetype(playerClass, specIdx)
    local map = SPEC_ARCHETYPE[playerClass]
    if map then return map[specIdx or 1] or "MELEE_DPS" end
    return "MELEE_DPS"
end

-- Returns { Tank=true, Melee=true, ... } from entry.families.
local function GetEchoFamilySet(entry)
    local set = {}
    if not entry.families then return set end
    for _, f in ipairs(entry.families) do
        local mapped = FAMILY_MAP[f]
        if mapped and mapped ~= "No family" then
            set[mapped] = true
        end
    end
    return set
end

-- True when the echo has families and none of them fit the player's archetype.
local function IsFamilyRoleMismatch(archetype, familySet)
    if not next(familySet) then return false end
    local allowed = ARCHETYPE_OK_FAMILIES[archetype]
    if not allowed then return false end
    for fam in pairs(familySet) do
        if allowed[fam] then return false end
    end
    return true
end

local function FamilySetLabel(familySet)
    local parts = {}
    for fam in pairs(familySet) do parts[#parts + 1] = fam end
    table.sort(parts)
    return table.concat(parts, ", ")
end

-- Bonus/penalty based on how well echo families match the spec archetype.
-- Tanks should prefer Tank-family echoes over hybrid DPS procs.
local function ApplyArchetypeAffinity(archetype, familySet, classScore, reasons)
    if archetype == "TANK" then
        if familySet.Tank then
            classScore = classScore + 4
            reasons[#reasons + 1] = "Tank affinity"
            local tankOnly = true
            for fam in pairs(familySet) do
                if fam ~= "Tank" and fam ~= "Survivability" then
                    tankOnly = false
                    break
                end
            end
            if tankOnly then
                classScore = classScore + 3
                reasons[#reasons + 1] = "Tank-exclusive"
            end
        elseif familySet.Melee and not familySet.Tank then
            classScore = classScore - 2
            reasons[#reasons + 1] = "Melee-only (low tank value)"
        end
    elseif archetype == "HEAL" then
        if familySet.Healer then
            classScore = classScore + 4
            reasons[#reasons + 1] = "Healer affinity"
            if not familySet.Melee and not familySet.Tank and not familySet.Ranged then
                classScore = classScore + 2
                reasons[#reasons + 1] = "Healer-exclusive"
            end
        end
    elseif archetype == "CASTER_DPS" then
        if familySet.Caster and not familySet.Melee and not familySet.Ranged then
            classScore = classScore + 3
            reasons[#reasons + 1] = "Caster-exclusive"
        elseif familySet.Caster and (familySet.Melee or familySet.Ranged) then
            -- Mixed-family echo (e.g. Caster DPS + Melee DPS): less focused for
            -- a pure caster; abilities like Holy damage marks are useless for
            -- Shadow/Arcane/Fire specs. Small penalty to prefer pure Caster echoes.
            classScore = classScore - 2
            reasons[#reasons + 1] = "Caster+physical hybrid (penalised for caster)"
        end
    elseif archetype == "RANGED_DPS" then
        if familySet.Ranged and not familySet.Melee and not familySet.Caster then
            classScore = classScore + 3
            reasons[#reasons + 1] = "Ranged-exclusive"
        end
    elseif archetype == "MELEE_DPS" then
        if familySet.Melee and not familySet.Tank and not familySet.Caster and not familySet.Ranged then
            classScore = classScore + 2
            reasons[#reasons + 1] = "Melee-exclusive"
        end
    end
    return classScore
end

-- Returns the best-matching profile table for (class, specIdx).
-- Falls back to ClassProfiles if no spec profile exists.
local function GetProfile(playerClass, specIdx)
    local sp = SynergyData and SynergyData.SpecProfiles
    if sp and sp[playerClass] then
        local profile = sp[playerClass][specIdx or 1]
        if profile then return profile end
    end
    -- Fallback: class-level profile
    local cp = SynergyData and SynergyData.ClassProfiles
    return cp and cp[playerClass]
end

local TIER_MULT = { S = 5, A = 4, B = 3, C = 2, F = 0 }

local QUALITY_LABEL = {
    [0] = "Common", [1] = "Uncommon", [2] = "Rare", [3] = "Epic", [4] = "Legendary",
}

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

local function StripQualitySuffix(name)
    if not name then return name end
    local patterns = {
        " %- Common$", " %- Uncommon$", " %- Rare$", " %- Epic$", " %- Legendary$",
    }
    for _, p in ipairs(patterns) do
        local s = name:match("^(.+)" .. p)
        if s then return s end
    end
    return name
end

-- Returns the base spell name (no quality suffix) for a spellId.
local function GetSpellBaseName(spellId)
    local rawName = GetSpellInfo(spellId)
    if not rawName then return nil end
    return StripQualitySuffix(rawName)
end

-- Returns { tag = true, ... } set from PerkDatabase families of this entry.
local function FamilyTagSet(entry)
    local set = {}
    if entry.families then
        for _, f in ipairs(entry.families) do
            local mapped = FAMILY_MAP[f]
            if mapped then set[mapped] = true end
        end
    end
    return set
end

-- Strip WoW color codes (|cAARRGGBB...text...|r) from a string.
-- Descriptions returned by utils.GetSpellDescription contain these codes;
-- stripping them gives clean text with only the actual values and words.
local function StripColorCodes(text)
    if not text then return "" end
    -- |c + 8 hex digits = open color tag  (e.g. |cff2cfe32)
    -- |r = close color tag
    return text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", "")
end

-- Lowercase description for trade-off / suppression checks.
-- Cached permanently per spellId — descriptions don't change during a session.
local function GetDescriptionLower(spellId)
    if not spellId or not (utils and utils.GetSpellDescription) then return "" end
    local cached = descriptionLowerCache[spellId]
    if cached ~= nil then return cached end
    local raw = utils.GetSpellDescription(spellId, 500, 1)
    local result = (raw and raw ~= "") and StripColorCodes(raw):lower() or ""
    descriptionLowerCache[spellId] = result
    return result
end

-- Extract the largest meaningful number from a spell description.
-- Color codes are stripped first so we don't accidentally pick up
-- hex digits embedded in |cAARRGGBB tags.
local function ExtractPowerValue(spellId)
    if not (utils and utils.GetSpellDescription) then return nil end
    local desc = GetDescriptionLower(spellId)
    if desc == "" then return nil end
    local maxVal = nil
    for numStr in desc:gmatch("%d+%.?%d*") do
        local n = tonumber(numStr)
        -- Skip tiny values (< 10) like durations/stacks/percentages,
        -- and skip huge values (> 999999) that are likely internal IDs.
        if n and n >= 10 and n <= 999999 then
            if not maxVal or n > maxVal then maxVal = n end
        end
    end
    return maxVal
end

-- Auto-parse text (description or echo name) and return a tag set.
-- Color codes are stripped before matching so keywords aren't obscured.
local function ParseTextTags(text)
    if not text or text == "" then return {} end
    local lower = StripColorCodes(text):lower()
    local tagSet = {}
    for _, entry in ipairs(KEYWORD_ENTRIES) do
        if lower:find(entry.key, 1, true) then
            tagSet[entry.tag] = true
        end
    end
    return tagSet
end

local function ParseDescriptionTags(spellId)
    if not spellId then return {} end
    local desc = GetDescriptionLower(spellId)
    if desc == "" then return {} end
    return ParseTextTags(desc)
end

-- Stats that must NOT score positively when the echo penalizes them.
-- e.g. "reduces your armor" must not trigger armor=S bonus.
local function GetSuppressedStatTags(spellId)
    local suppressed = {}
    local desc = GetDescriptionLower(spellId)
    if desc == "" then return suppressed end
    if desc:find("reduces your armor", 1, true) or desc:find("reduces armor", 1, true) then
        suppressed.armor = true
    end
    if desc:find("reduces your maximum health", 1, true)
        or (desc:find("maximum health", 1, true) and desc:find("reduces", 1, true)) then
        suppressed.stamina = true
    end
    if desc:find("reduces your mana", 1, true)
        or (desc:find("maximum mana", 1, true) and desc:find("reduces", 1, true)) then
        suppressed.mana = true
    end
    return suppressed
end

-- Detect echoes with harmful downsides for the current archetype.
local function GetHarmfulTradeoffReason(archetype, spellId)
    local desc = GetDescriptionLower(spellId)
    if desc == "" then return nil end

    if archetype == "TANK" or archetype == "HEAL" then
        if desc:find("reduces your armor", 1, true) or desc:find("reduces armor", 1, true) then
            return "Harmful trade-off: reduces armor"
        end
        if desc:find("reduces your maximum health", 1, true)
            or (desc:find("maximum health", 1, true) and desc:find("reduces", 1, true)) then
            return "Harmful trade-off: reduces max health"
        end
        if desc:find("damage taken", 1, true) and (desc:find("increased", 1, true) or desc:find("increase", 1, true)) then
            return "Harmful trade-off: increases damage taken"
        end
    end

    if archetype == "MELEE_DPS" or archetype == "CASTER_DPS" or archetype == "RANGED_DPS" then
        if desc:find("damage dealt", 1, true) and desc:find("reduced", 1, true) then
            return "Harmful trade-off: reduces damage dealt"
        end
        if desc:find("damage done", 1, true) and desc:find("reduced", 1, true) then
            return "Harmful trade-off: reduces damage done"
        end
    end

    return nil
end

-- Returns tags[], synergiesWith[] for a given spellId + base name.
-- Priority: manual SynergyData > auto description + name > empty.
-- Cached permanently per spellId — tags are deterministic and don't change.
local function GetEchoTagData(spellId, baseName)
    if spellId and echoTagDataCache[spellId] then
        local c = echoTagDataCache[spellId]
        return c[1], c[2]
    end

    local tagSet = {}

    -- WoW spell names use the Unicode RIGHT SINGLE QUOTATION MARK (U+2019, UTF-8: E2 80 99)
    -- in names like "Archmage's Mark", while EchoTags keys use ASCII apostrophe (U+0027).
    -- Normalise before lookup so manual tags are found for all apostrophe-containing echoes.
    local lookupName = baseName and baseName:gsub("\xE2\x80\x99", "'") or baseName

    -- Class-variant echoes arrive as "CLASS - BASE_NAME" (e.g. "Death Knight - Arcane Bombardment").
    -- Strip the class prefix and fall back to the base-name entry when no class-specific entry exists.
    local bare = lookupName and lookupName:match("^%u%a[%a ]* %- (.+)$")

    local echoTags = SynergyData and SynergyData.EchoTags
    local manual = echoTags and (echoTags[lookupName] or (bare and echoTags[bare]))
    if manual then
        for _, t in ipairs(manual.tags) do tagSet[t] = true end
    end

    for t in pairs(ParseDescriptionTags(spellId)) do tagSet[t] = true end
    for t in pairs(ParseTextTags(baseName)) do tagSet[t] = true end

    local tags = {}
    for t in pairs(tagSet) do tags[#tags + 1] = t end

    local synergiesWith = manual and (manual.synergiesWith or {}) or {}
    if spellId then
        echoTagDataCache[spellId] = { tags, synergiesWith }
    end
    return tags, synergiesWith
end

-- Build a fast lookup set from an array.
local function ToSet(arr)
    local s = {}
    for _, v in ipairs(arr) do s[v] = true end
    return s
end

local PRIMARY_STAT_IDS = { "strength", "agility", "intellect" }

local function GetPrimaryStatForProfile(profile)
    if not profile or not profile.preferredStats then return nil end
    local ps = profile.preferredStats
    local bestId, bestW = nil, -1
    for _, id in ipairs(PRIMARY_STAT_IDS) do
        local w = ps[id]
        if w and w > bestW then
            bestId, bestW = id, w
        end
    end
    return bestId
end

-- Stat-boost echoes (Strength Training, Agility Boost, etc.): reward matching
-- primary stat, penalize wrong one so rogues don't pick strength over agility.
local function ApplyPrimaryStatAffinity(tags, profile, classScore, reasons)
    local want = GetPrimaryStatForProfile(profile)
    if not want then return classScore end

    local tagSet = ToSet(tags)
    local found = {}
    for _, id in ipairs(PRIMARY_STAT_IDS) do
        if tagSet[id] then found[#found + 1] = id end
    end
    if #found ~= 1 then return classScore end

    if found[1] == want then
        classScore = classScore + 4
        reasons[#reasons + 1] = "Primary stat fit: " .. want
    else
        classScore = classScore - 6
        reasons[#reasons + 1] = string.format(
            "Wrong primary stat: %s (want %s)", found[1], want)
    end
    return classScore
end

-- ── Score-driven tier system ─────────────────────────────────────────────────
--
-- Tier is determined purely by (rawScore + qualityBonus), NOT by quality alone.
-- A poor-fit Epic stays C just like a poor-fit Common.
-- A great-fit Uncommon can reach B or even A.
--
-- Quality bonus added to rawScore before tier thresholds are applied.
-- Kept intentionally modest — quality should nudge, not override class/stat fit.
-- An Epic with zero class fit should still land in C; only a well-fitted Epic
-- gets pushed to A or S by quality.
--
--   Common   (0): +0.0
--   Uncommon (1): +0.5
--   Rare     (2): +1.5
--   Epic     (3): +3.0
--   Legendary(4): +5.0  (helps a good Legendary reach S)
local QUALITY_BONUS = { [0]=0.0, [1]=0.5, [2]=1.5, [3]=3.0, [4]=5.0 }

-- Tier thresholds — applied to (rawScore + qualityBonus).
-- Stat contribution: S→+3.0, A→+2.1, B→+1.2, C→+0.3 per matched stat tag.
-- Quality nudges: Rare+1.5, Epic+3.0, Legendary+5.0.
-- Calibrated so that:
--   No class/stat fit (wrong family, no stat matches):     C tier
--   Some tag overlap, C-tier stats, Rare quality:          C tier
--   Family match + 1 A-stat, Rare quality:                 B tier
--   Family + tags + 2 S-stats, Rare quality:               A tier
--   Family + tags + 2 S-stats, Epic quality:               A/S tier
--   Perfect-fit Legendary (family + tags + 3 S-stats):     S tier
local TIER_THRESHOLDS = {
    { min = 15.0, tier = "S" },
    { min = 10.0, tier = "A" },
    { min = 6.0,  tier = "B" },
    -- below 6.0 → C
}

-- Optional per-session user stat weight overrides.
-- Set via EchoAnalyzer.SetStatOverrides(weights); cleared on spec change.
-- Format: { statId = weight(0-10), ... }
local userStatOverrides = nil
-- Per-batch forced-F cache (built once per GetRecommendations call).
local batchForcedF = nil

function EbonholdHub.EchoAnalyzer.SetStatOverrides(weights)
    userStatOverrides = weights
end

function EbonholdHub.EchoAnalyzer.GetStatOverrides()
    return userStatOverrides
end

-- Checks whether the echo has been explicitly assigned a tier by the user.
local function IsManuallyTiered(baseName, quality)
    local build = EbonholdHub.Build and EbonholdHub.Build.GetActive and EbonholdHub.Build.GetActive()
    local tiers = build and build.echoTiers
    if not tiers then return false end
    local keyQ = baseName .. "|" .. tostring(math.floor(tonumber(quality) or 0))
    return tiers[keyQ] ~= nil or tiers[baseName] ~= nil
end

-- Returns (tier, isEstimated).
-- Tier is determined by (rawScore + qualityBonus) against TIER_THRESHOLDS.
-- Poor-fit Rare/Epic echoes score low → C tier regardless of quality.
-- Great-fit Common/Uncommon echoes score high → B or A tier.
local function GetEchoTier(baseName, quality, rawScore)
    -- Manual tier set by the user always wins.
    if TierData and IsManuallyTiered(baseName, quality) then
        return TierData.GetTier(baseName, quality), false
    end

    local q = quality or 0
    local bonus = QUALITY_BONUS[q] or 0.0
    local effectiveScore = (rawScore or 0) + bonus

    for _, entry in ipairs(TIER_THRESHOLDS) do
        if effectiveScore >= entry.min then
            return entry.tier, true
        end
    end
    return "C", true
end

-- Collects { [tag] = true } from the top-tier (S/A) echoes in the active build.
-- Cached per (build.id, S/A tier count) — recomputed only when the build changes.
local function GetBuildTagSet()
    local build = EbonholdHub.Build and EbonholdHub.Build.GetActive and EbonholdHub.Build.GetActive()
    if not build or not build.echoTiers then return {} end

    -- Cheap fingerprint: build ID + count of S/A entries.
    local bid = build.id or ""
    local saCount = 0
    for _, tier in pairs(build.echoTiers) do
        if tier == "S" or tier == "A" then saCount = saCount + 1 end
    end

    if buildTagSetCache and buildTagSetBuildId == bid and buildTagSetTierCount == saCount then
        return buildTagSetCache
    end

    local pdb = ProjectEbonhold and ProjectEbonhold.PerkDatabase
    local buildTags = {}

    if pdb then
        -- Build a reverse name→spellId index so we don't iterate the whole DB per echo.
        local nameIndex = {}
        for spellId, entry in pairs(pdb) do
            if entry.comment then
                local entryBase = StripQualitySuffix(entry.comment)
                if entryBase and entryBase ~= "" and not nameIndex[entryBase] then
                    nameIndex[entryBase] = spellId
                end
            end
        end

        for key, tier in pairs(build.echoTiers) do
            if tier == "S" or tier == "A" then
                local name = key:match("^(.+)|%d+$") or key
                local spellId = nameIndex[name]
                if spellId then
                    local tags, _ = GetEchoTagData(spellId, name)
                    for _, t in ipairs(tags) do buildTags[t] = true end
                end
            end
        end
    end

    buildTagSetCache     = buildTags
    buildTagSetBuildId   = bid
    buildTagSetTierCount = saCount
    return buildTags
end

-- ── Public API ───────────────────────────────────────────────────────────────

--[[
ScoreEcho(spellId, playerClass, buildTagSet, specIdx)
  specIdx: 1/2/3 talent tree index (optional; auto-detected if nil)
  Returns a result table:
    score            number  — total score
    classScore       number
    synergyScore     number
    tierMult         number
    tier             string  — "S"|"A"|"B"|"C"|"F"
    tierEstimated    bool    — true when tier was derived from quality (not manually set)
    tags             string[]
    reasons          string[]
]]
function EbonholdHub.EchoAnalyzer.ScoreEcho(spellId, playerClass, buildTagSet, specIdx)
    local result = {
        score = 0, classScore = 0, synergyScore = 0, tierMult = 1,
        tier = "C", tierEstimated = false, tags = {}, reasons = {},
    }
    if not spellId then return result end

    local pdb = ProjectEbonhold and ProjectEbonhold.PerkDatabase
    local entry = pdb and pdb[spellId]
    if not entry then return result end

    local baseName = GetSpellBaseName(spellId)
    if not baseName then return result end

    local quality = entry.quality or 0
    local tags, synergiesWith = GetEchoTagData(spellId, baseName)
    result.tags = tags

    local specIdxResolved = specIdx or GetPlayerSpecIndex()
    local archetype = GetSpecArchetype(playerClass, specIdxResolved)

    -- Curated blacklist (Brittle Armor on tanks, healing on DPS, etc.)
    if batchForcedF and batchForcedF[baseName] then
        result.tier          = "F"
        result.tierEstimated = false
        result.reasons[#result.reasons + 1] = "Blacklisted for this class/spec role"
        return result
    end

    -- Harmful trade-offs parsed from description (e.g. -30% armor for +10% crit).
    local tradeoffReason = GetHarmfulTradeoffReason(archetype, spellId)
    if tradeoffReason then
        result.tier          = "F"
        result.tierEstimated = true
        result.reasons[#result.reasons + 1] = tradeoffReason
        return result
    end

    local suppressedStats = GetSuppressedStatTags(spellId)

    -- ── Pass 1: raw scores (class fit + synergy) without tier multiplier ─────
    -- We need rawScore first so GetEchoTier can use it to bump Epic/Rare up.

    local classScore = 0
    local profile = GetProfile(playerClass, specIdx or GetPlayerSpecIndex())

    -- Class restriction check.
    -- classMask == 0  → universal (all classes can use it).
    -- classMask != 0  → restricted to specific classes.
    --   • THIS class matches  → class-specific echo, fits perfectly (+2).
    --   • THIS class mismatch → echo is useless here (DK ability for a Pally
    --     etc.) → force F tier immediately, skip all further scoring.
    local classBit = CLASS_BITS[playerClass]
    if not entry.classMask or entry.classMask == 0 then
        -- Universal echo — usable by everyone.
        classScore = classScore + 2
    elseif classBit and bit.band(entry.classMask, classBit) ~= 0 then
        -- Class-specific echo that matches this class.
        classScore = classScore + 2
    else
        -- Class-restricted to a different class → instantly F, never scored.
        result.tier          = "F"
        result.tierEstimated = true
        result.reasons[#result.reasons + 1] =
            "Class-restricted: this echo is designed for a different class"
        return result
    end

    -- Role/family gate: echo families must match the player's spec archetype.
    local echoFamilies = GetEchoFamilySet(entry)
    if IsFamilyRoleMismatch(archetype, echoFamilies) then
        result.tier          = "F"
        result.tierEstimated = true
        result.reasons[#result.reasons + 1] = string.format(
            "Role mismatch: families [%s] not suitable for %s",
            FamilySetLabel(echoFamilies), archetype)
        return result
    end

    -- Boost/penalize based on archetype ↔ family fit (tank/heal/dps identity).
    classScore = ApplyArchetypeAffinity(archetype, echoFamilies, classScore, result.reasons)

    if profile then
        -- +2 per matching preferred family
        local prefFamilySet = ToSet(profile.preferredFamilies)
        if entry.families then
            for _, f in ipairs(entry.families) do
                local mapped = FAMILY_MAP[f]
                if mapped and prefFamilySet[mapped] then
                    classScore = classScore + 2
                    result.reasons[#result.reasons + 1] = "Family: " .. mapped
                end
            end
        end

        -- +1 per mechanic/school tag matching preferredTags
        local prefTagSet = ToSet(profile.preferredTags)
        for _, t in ipairs(tags) do
            if prefTagSet[t] then
                classScore = classScore + 1
                result.reasons[#result.reasons + 1] = "Class fit: " .. t
            end
        end

        -- Stat scoring: each stat tag found in the echo description is scored
        -- against the effective stat weight table.
        -- Base: S(10)→+3.0, A(7)→+2.1, B(4)→+1.2, C(1)→+0.3 per matched stat.
        -- Leftmost stat in a tier keeps full base; each slot right −0.3 weight.
        --
        -- Priority rules:
        --   1. If the user has configured their stat tier-list (userStatOverrides is set),
        --      use ONLY those explicit choices. Stats the user left unassigned contribute
        --      ZERO — the tier-list is treated as authoritative.
        --   2. If the user has configured nothing at all (first-run or fresh build),
        --      fall back to the spec profile defaults so the advisor still works.
        do
            local weights  -- { statId = number 0-10 }
            if userStatOverrides and next(userStatOverrides) then
                -- User has explicitly configured stat priorities → use only those.
                -- Anything not placed by the user is not a priority → weight 0.
                weights = userStatOverrides
            else
                -- No user configuration yet → fall back to spec profile defaults.
                local ps = profile.preferredStats
                if ps then
                    local firstK, firstV = next(ps)
                    if type(firstK) == "string" and type(firstV) == "number" then
                        weights = ps
                    else
                        weights = {}
                        for _, s in ipairs(ps) do weights[s] = 5 end
                    end
                end
            end

            if weights then
                for _, t in ipairs(tags) do
                    if not suppressedStats[t] then
                        local w = ResolveStatWeight(t, weights)
                        if w then
                            local pts = (w / 10) * 3   -- S→+3.0, A→+2.1, B→+1.2, C→+0.3
                            classScore = classScore + pts
                            result.reasons[#result.reasons + 1] = string.format("Stat: %s (w%.1f, +%.1f)", t, w, pts)
                        end
                    end
                end
            end
        end
    end
    classScore = ApplyPrimaryStatAffinity(tags, profile, classScore, result.reasons)
    result.classScore = classScore

    local synergyScore = 0
    if buildTagSet then
        for _, synTag in ipairs(synergiesWith) do
            if buildTagSet[synTag] then
                synergyScore = synergyScore + 3
                result.reasons[#result.reasons + 1] = "Synergy: " .. synTag
            end
        end
        local tagSet = ToSet(tags)
        for buildTag in pairs(buildTagSet) do
            if tagSet[buildTag] then
                synergyScore = synergyScore + 1
                local alreadyReasoned = false
                for _, r in ipairs(result.reasons) do
                    if r == "Synergy: " .. buildTag then alreadyReasoned = true; break end
                end
                if not alreadyReasoned then
                    result.reasons[#result.reasons + 1] = "Build overlap: " .. buildTag
                end
            end
        end
    end
    result.synergyScore = synergyScore

    -- ── Creature-type conditional → always F tier ─────────────────────────────
    -- Echoes tagged "conditional_vs_type" (Beast Slayer, Demon Slayer, etc.)
    -- only provide their effect when fighting a specific creature type.
    -- These are too situational to recommend; force F tier unconditionally.
    do
        for _, t in ipairs(tags) do
            if t == "conditional_vs_type" then
                result.tier          = "F"
                result.tierEstimated = true
                result.reasons[#result.reasons + 1] =
                    "Conditional: only active vs a specific creature type (Slayer/Bane echoes are situational)"
                return result
            end
        end
    end

    -- ── Pass 2: tier — score-driven, quality is a bonus not a floor ──────────
    local rawScore = classScore + synergyScore
    local tier, tierEstimated = GetEchoTier(baseName, quality, rawScore)

    -- Add a note when the estimated tier is better than C, so the tooltip
    -- shows why the echo ranked where it did.
    if tierEstimated and tier ~= "C" and tier ~= "F" then
        local qBonus = QUALITY_BONUS[quality or 0] or 0
        result.reasons[#result.reasons + 1] = string.format(
            "Score %.1f + quality bonus %.1f = %.1f (>= %.1f for %s)",
            rawScore, qBonus, rawScore + qBonus,
            (tier == "S" and 15 or tier == "A" and 10 or 6), tier)
    end

    result.tier          = tier
    result.tierEstimated = tierEstimated
    result.tierMult      = TIER_MULT[tier] or 1

    -- Within-tier tiebreaker: higher quality + higher rawScore ranks first.
    local qualityBonus = (quality or 0) * 0.1
    result.score = rawScore * result.tierMult + qualityBonus

    -- Potency indicator: largest number found in the description.
    result.power = ExtractPowerValue(spellId)

    return result
end

--[[
GetRecommendations(playerClass, limit, specIdx)
  specIdx: 1/2/3 (optional; auto-detected if nil)
  Iterates ALL PerkDatabase entries — every echo at every quality level.
  Quality affects the estimated tier (Common=C, Rare=B, Epic=A, Legendary=S)
  and adds a small score tiebreaker within the same tier.
  Each result: { spellId, name, qualLabel, score, tier, tierEstimated, quality,
                 tags, reasons, specName, power }
  limit: max results (default 150)
]]
function EbonholdHub.EchoAnalyzer.GetRecommendations(playerClass, limit, specIdx)
    limit   = limit   or 150
    specIdx = specIdx or GetPlayerSpecIndex()
    local pdb = ProjectEbonhold and ProjectEbonhold.PerkDatabase
    if not pdb then return {} end

    batchForcedF = SynergyData and SynergyData.BuildForcedFSet
        and SynergyData.BuildForcedFSet(playerClass, specIdx) or nil

    -- Build the tag set from the active build once (expensive traversal).
    local buildTagSet = GetBuildTagSet()

    -- Resolve spec name for display
    local profile = GetProfile(playerClass, specIdx)
    local specName = profile and profile.specName or nil

    -- Score every entry independently — all qualities of each echo are separate rows.
    -- All tiers S through F are included; F-tier entries sort to the bottom (tierMult=0).
    local results = {}
    for spellId, entry in pairs(pdb) do
        if entry.comment and entry.comment ~= "" then
            if not EntryMatchesPlayerClass(entry, playerClass) then
                -- Skip other-class variants of class-specific echoes (same name, different spellId).
            else
                local r = EbonholdHub.EchoAnalyzer.ScoreEcho(spellId, playerClass, buildTagSet, specIdx)
                local baseName = StripQualitySuffix(entry.comment)
                local q        = entry.quality or 0
                results[#results + 1] = {
                    spellId       = spellId,
                    name          = baseName,
                    qualLabel     = QUALITY_LABEL[q] or "Common",
                    score         = r.score,
                    tier          = r.tier,
                    tierEstimated = r.tierEstimated,
                    quality       = q,
                    tags          = r.tags,
                    reasons       = r.reasons,
                    specName      = specName,
                    power         = r.power,
                }
            end
        end
    end

    table.sort(results, function(a, b)
        -- F always sinks to the bottom regardless of score
        local aF = (a.tier == "F") and 1 or 0
        local bF = (b.tier == "F") and 1 or 0
        if aF ~= bF then return aF < bF end
        -- Primary: score descending
        if a.score ~= b.score then return a.score > b.score end
        -- Tie-break: tier mult then quality
        local ra = TIER_MULT[a.tier] or 0
        local rb = TIER_MULT[b.tier] or 0
        if ra ~= rb then return ra > rb end
        return (a.quality or 0) > (b.quality or 0)
    end)

    if #results > limit then
        local trimmed = {}
        for i = 1, limit do trimmed[i] = results[i] end
        return trimmed
    end
    return results
end

--[[
GetSynergies(spellId)
  Returns echoes from the active build that synergize with the given spellId.
  Each item: { spellId, name, sharedTags[] }
]]
function EbonholdHub.EchoAnalyzer.GetSynergies(spellId)
    local pdb = ProjectEbonhold and ProjectEbonhold.PerkDatabase
    if not pdb or not spellId then return {} end

    local baseName = GetSpellBaseName(spellId)
    if not baseName then return {} end

    local tags, synergiesWith = GetEchoTagData(spellId, baseName)
    local tagSet = ToSet(tags)
    local synergySet = ToSet(synergiesWith)

    local build = EbonholdHub.Build and EbonholdHub.Build.GetActive and EbonholdHub.Build.GetActive()
    if not build or not build.echoTiers then return {} end

    local found = {}
    for key, tier in pairs(build.echoTiers) do
        local name = key:match("^(.+)|%d+$") or key
        for sid, entry in pairs(pdb) do
            if entry.comment and StripQualitySuffix(entry.comment) == name then
                local otherTags, _ = GetEchoTagData(sid, name)
                local shared = {}
                for _, ot in ipairs(otherTags) do
                    if tagSet[ot] or synergySet[ot] then
                        shared[#shared + 1] = ot
                    end
                end
                if #shared > 0 then
                    found[#found + 1] = {
                        spellId    = sid,
                        name       = name,
                        tier       = tier,
                        sharedTags = shared,
                    }
                end
                break
            end
        end
    end
    return found
end

-- Returns the player's current spec index (1/2/3) and its display name.
function EbonholdHub.EchoAnalyzer.GetCurrentSpec(playerClass)
    local idx = GetPlayerSpecIndex()
    local profile = GetProfile(playerClass, idx)
    local name = profile and profile.specName or ("Spec " .. idx)
    return idx, name
end

-- ── Auto Pick integration ─────────────────────────────────────────────────────
-- Uses the same class/spec/stat scoring as the Advisor tab when picking among C-tier echoes.

local STAT_TIER_WEIGHT_AP = { S = 10, A = 7, B = 4, C = 1 }
local STAT_TIER_POSITION_STEP_AP = 0.3
local STAT_IDS_AP = {
    "strength", "agility", "intellect", "stamina", "spirit",
    "spellpower", "attackpower", "crit", "haste", "hit", "expertise", "arpen", "spellpen",
    "damage_bonus", "healing_bonus", "absorb", "dot_damage", "aoe_damage", "cooldown",
    "mana", "mp5", "energy", "rage", "runicpower", "pet", "proc", "stack",
    "dodge", "parry", "block_stat", "defense", "armor", "movespeed", "resilience",
    "physical", "holy", "fire", "frost", "nature", "shadow", "arcane", "lightning",
    "bleed", "poison", "disease",
}

local autoPickSavedOverrides = nil
local autoPickSavedForcedF   = nil
local autoPickClass          = nil
local autoPickSpec           = nil

local function BuildDefaultStatOrderAP(tiers)
    local order = { S = {}, A = {}, B = {}, C = {}, pool = {} }
    for _, id in ipairs(STAT_IDS_AP) do
        local tier = tiers[id] or "pool"
        local list = order[tier]
        if list then list[#list + 1] = id end
    end
    return order
end

local function SyncStatOrderAP(tiers, order)
    order = order or BuildDefaultStatOrderAP(tiers)
    local synced = { S = {}, A = {}, B = {}, C = {}, pool = {} }
    local seen = {}
    for _, sec in ipairs({ "S", "A", "B", "C", "pool" }) do
        for _, id in ipairs(order[sec] or {}) do
            local belongs = (sec == "pool" and not tiers[id]) or (tiers[id] == sec)
            if belongs and not seen[id] then
                synced[sec][#synced[sec] + 1] = id
                seen[id] = true
            end
        end
    end
    for _, id in ipairs(STAT_IDS_AP) do
        if not seen[id] then
            local sec = tiers[id] or "pool"
            synced[sec][#synced[sec] + 1] = id
            seen[id] = true
        end
    end
    return synced
end

local function ComputeStatWeightsFromBuild(build)
    if not build or not build.advisorStatTiers or not next(build.advisorStatTiers) then
        return nil
    end
    local tiers = build.advisorStatTiers
    local order = build.advisorStatTierOrder
    if not order or not next(order) then
        order = BuildDefaultStatOrderAP(tiers)
    else
        order = SyncStatOrderAP(tiers, order)
    end
    local weights = {}
    for _, secId in ipairs({ "S", "A", "B", "C" }) do
        local base = STAT_TIER_WEIGHT_AP[secId]
        for pos, statId in ipairs(order[secId] or {}) do
            if tiers[statId] == secId and base then
                local w = base - (pos - 1) * STAT_TIER_POSITION_STEP_AP
                if w < 0.1 then w = 0.1 end
                weights[statId] = w
            end
        end
    end
    return weights
end

local function ResolveBuildClassSpec(build)
    local class = build and build.class
    local spec  = build and build.spec
    if not class or class == "" then
        local _, token = UnitClass("player")
        class = token
    end
    if not spec then
        spec = GetPlayerSpecIndex()
    end
    return class, spec
end

function EbonholdHub.EchoAnalyzer.BeginAutoPickScoring(build)
    if not build then return false end
    if EbonholdHub.Build and EbonholdHub.Build.EnsureAdvisorStatTiers then
        EbonholdHub.Build.EnsureAdvisorStatTiers(build)
    end
    autoPickSavedOverrides = userStatOverrides
    autoPickSavedForcedF   = batchForcedF
    autoPickClass, autoPickSpec = ResolveBuildClassSpec(build)

    batchForcedF = SynergyData and SynergyData.BuildForcedFSet
        and SynergyData.BuildForcedFSet(autoPickClass, autoPickSpec) or nil

    local weights = ComputeStatWeightsFromBuild(build)
    if weights and next(weights) then
        userStatOverrides = weights
    end
    return true
end

function EbonholdHub.EchoAnalyzer.EndAutoPickScoring()
    userStatOverrides = autoPickSavedOverrides
    batchForcedF      = autoPickSavedForcedF
    autoPickClass     = nil
    autoPickSpec      = nil
    autoPickSavedOverrides = nil
    autoPickSavedForcedF   = nil
end

-- Raw class+synergy fit (no tier multiplier). Used to break ties among C-tier offers.
function EbonholdHub.EchoAnalyzer.GetAutoPickFit(spellId)
    if not spellId or not autoPickClass then return 0 end
    local r = EbonholdHub.EchoAnalyzer.ScoreEcho(
        spellId, autoPickClass, GetBuildTagSet(), autoPickSpec
    )
    if r.tier == "F" then return -50 end
    return (r.classScore or 0) + (r.synergyScore or 0)
end

-- Pre-warm the build tag cache when the module loads / build changes.
function EbonholdHub.EchoAnalyzer.Init()
    -- Nothing to initialise at load time; GetBuildTagSet() is called lazily.
end
