EbonholdHub.Build = {}

EbonholdHub.Build.LOCKED_SLOTS_DEFAULT = 6

function EbonholdHub.Build.GetLockedSlotCount()
    local n = EbonholdHub.Build.LOCKED_SLOTS_DEFAULT
    if ProjectEbonhold then
        local ps = ProjectEbonhold.PerkService
        if ps and ps.GetMaximumPermanentEchoes then
            local ok, v = pcall(ps.GetMaximumPermanentEchoes)
            if ok and type(v) == "number" and v > 0 then
                return math.floor(v)
            end
        end
        local perks = ProjectEbonhold.Perks
        if perks and type(perks.maximumPermanentEchoes) == "number" and perks.maximumPermanentEchoes > 0 then
            return math.floor(perks.maximumPermanentEchoes)
        end
    end
    return n
end

function EbonholdHub.Build.EmptyLockedEchoes()
    local out = {}
    for i = 1, EbonholdHub.Build.GetLockedSlotCount() do
        out[i] = nil
    end
    return out
end

function EbonholdHub.Build.NormalizeLockedEchoes(locked)
    locked = locked or {}
    local n = EbonholdHub.Build.GetLockedSlotCount()
    local out = {}
    for i = 1, n do
        out[i] = locked[i] or nil
    end
    return out
end

local function DefaultSettings()
    return {
        banishFamilyWhitelist = {},
        -- Tier system
        aggressionLevel  = 4,  -- 1=Pick Only .. 5=Perfectionist; 4=Smart (default)
    }
end

EbonholdHub.Build.DefaultSettings = DefaultSettings

local function EnsureSettings(build)
    build.settings = build.settings or DefaultSettings()
    local d = DefaultSettings()
    for k, v in pairs(d) do
        if build.settings[k] == nil then
            build.settings[k] = v
        elseif type(v) == "table" then
            for sk, sv in pairs(v) do
                if build.settings[k][sk] == nil then
                    build.settings[k][sk] = sv
                end
            end
        end
    end
end

EbonholdHub.Build.EnsureSettings = EnsureSettings

local function EnsureTalents(build)
    if EbonholdHub.Talents then
        EbonholdHub.Talents.EnsureBuild(build)
    else
        build.talents = build.talents or {}
        build.talentOrder = build.talentOrder or {}
    end
end

EbonholdHub.Build.EnsureTalents = EnsureTalents

local function CloneTable(t)
    if type(t) ~= "table" then return t end
    local copy = {}
    for k, v in pairs(t) do
        copy[CloneTable(k)] = CloneTable(v)
    end
    return copy
end

local function CloneStatTierOrder(order)
    if type(order) ~= "table" then return { S = {}, A = {}, B = {}, C = {}, pool = {} } end
    return {
        S    = CloneTable(order.S or {}),
        A    = CloneTable(order.A or {}),
        B    = CloneTable(order.B or {}),
        C    = CloneTable(order.C or {}),
        pool = CloneTable(order.pool or {}),
    }
end

local function ApplyDefaultAdvisorStatTiers(build)
    local SD = EbonholdHub.SynergyData
    if not SD or not SD.GetDefaultStatTiers or not build then return false end
    local tiers, order = SD.GetDefaultStatTiers(build.class, build.spec)
    if not tiers or not next(tiers) then return false end
    build.advisorStatTiers = CloneTable(tiers)
    build.advisorStatTierOrder = CloneStatTierOrder(order)
    build.advisorStatTiersFromDefaults = true
    return true
end

EbonholdHub.Build.ApplyDefaultAdvisorStatTiers = ApplyDefaultAdvisorStatTiers

local function EnsureAdvisorStatTiers(build)
    if not build then return end
    if build.advisorStatTiersCustomized then return end
    if build.advisorStatTiers and next(build.advisorStatTiers) then return end
    ApplyDefaultAdvisorStatTiers(build)
end

EbonholdHub.Build.EnsureAdvisorStatTiers = EnsureAdvisorStatTiers

function EbonholdHub.Build.CloneSettings(settings)
    return CloneTable(settings)
end

function EbonholdHub.Build.Checksum(build)
    local parts = {
        build.title or "",
        build.class or "",
        tostring(build.spec or 1),
        build.comments or "",
    }
    local le = build.lockedEchoes or {}
    for i = 1, EbonholdHub.Build.GetLockedSlotCount() do
        parts[#parts + 1] = tostring(le[i] or "nil")
    end
    if build.echoTiers then
        local names = {}
        for name in pairs(build.echoTiers) do names[#names + 1] = name end
        table.sort(names)
        for _, name in ipairs(names) do
            parts[#parts + 1] = name .. "=" .. tostring(build.echoTiers[name])
        end
    end
    parts[#parts + 1] = tostring(build.automationEnabled and 1 or 0)
    if EbonholdHub.AffixLoadout then
        parts[#parts + 1] = EbonholdHub.AffixLoadout.ChecksumPart(build.affixLoadout)
    end
    if EbonholdHub.EquipmentLoadout then
        parts[#parts + 1] = EbonholdHub.EquipmentLoadout.ChecksumPartForBuild(build)
    end
    local s = CloneTable(build.settings or DefaultSettings())
    parts[#parts + 1] = EbonholdHub.ExportImport and EbonholdHub.ExportImport.JSONEncode and EbonholdHub.ExportImport.JSONEncode(s) or ""
    return table.concat(parts, "|")
end

local function EnsureStats(build)
    build.stats = build.stats or {
        echoesSeen    = 0,
        runsCompleted = 0,
        runsReset     = 0,
        picks         = 0,
        rerollsUsed   = 0,
        banishesUsed  = 0,
        freezesUsed   = 0,
        qualityPicks  = { 0, 0, 0, 0, 0 },
        mostPicked    = {},
        mostBanned    = {},
    }
    build.stats.qualityPicks = build.stats.qualityPicks or { 0, 0, 0, 0, 0 }
    build.stats.mostPicked   = build.stats.mostPicked   or {}
    build.stats.mostBanned   = build.stats.mostBanned   or {}
    if build.automationEnabled == nil then build.automationEnabled = true end
    if not build.author then build.author = "Unknown" end
    if not build.lastModified then build.lastModified = date("%Y-%m-%d %H:%M:%S") end
    if build.isPublic == nil then build.isPublic = false end
    if build.validated == nil then build.validated = false end
    if build.copiedFrom == nil then build.copiedFrom = nil end
end

local activeChangeCallbacks = {}

local function Notify()
    for i = 1, #activeChangeCallbacks do
        activeChangeCallbacks[i]()
    end
end

function EbonholdHub.Build.OnActiveChanged(fn)
    activeChangeCallbacks[#activeChangeCallbacks + 1] = fn
end


function EbonholdHub.Build.NewId()
    local name = UnitName("player") or "unknown"
    return tostring(time()) .. "-" .. tostring(math.random(1, 1000000000)) .. "-" .. name
end

function EbonholdHub.Build.NewObjectId()
    return string.format("%08x%04x%04x%04x%04x",
        time(),
        math.random(0, 65535),
        math.random(0, 65535),
        math.random(0, 65535),
        math.random(0, 65535))
end


local function PlayerClassToken()
    return select(2, UnitClass("player"))
end

local function PlayerTopTalentTab()
    local best, bestPoints = 1, -1
    for i = 1, 3 do
        local _, _, pointsSpent = GetTalentTabInfo(i)
        pointsSpent = pointsSpent or 0
        if pointsSpent > bestPoints then
            best, bestPoints = i, pointsSpent
        end
    end
    return best
end

EbonholdHub.Build.PlayerClassToken   = PlayerClassToken
EbonholdHub.Build.PlayerTopTalentTab = PlayerTopTalentTab


function EbonholdHub.Build.EnsureDatabase()
    EbonholdHubDB.builds = EbonholdHubDB.builds or {}
    for _, b in pairs(EbonholdHubDB.builds) do
        EnsureSettings(b)
        EnsureTalents(b)
        EnsureStats(b)
        if EbonholdHub.AffixLoadout then
            EbonholdHub.AffixLoadout.Ensure(b)
        else
            b.affixLoadout = b.affixLoadout or {}
        end
        if EbonholdHub.EquipmentLoadout then
            EbonholdHub.EquipmentLoadout.MigrateBuild(b)
        else
            b.equipmentLoadout = b.equipmentLoadout or {}
        end
        -- Migrate old echoWeights to tier system on first load
        if EbonholdHub.TierData then
            EbonholdHub.TierData.MigrateWeights(b)
        end
        EnsureAdvisorStatTiers(b)
        b.lockedEchoes = EbonholdHub.Build.NormalizeLockedEchoes(b.lockedEchoes)
    end
end


function EbonholdHub.Build.List()
    local out = {}
    for _, b in pairs(EbonholdHubDB.builds) do
        out[#out + 1] = b
    end
    table.sort(out, function(a, b) return (a.title or "") < (b.title or "") end)
    return out
end

function EbonholdHub.Build.ListPublic()
    local out = {}
    if EbonholdHub.Catalog and EbonholdHub.Catalog.List then
        for _, b in ipairs(EbonholdHub.Catalog.List()) do
            out[#out + 1] = b
        end
    end
    table.sort(out, function(a, b) return (a.lastModified or "") > (b.lastModified or "") end)
    return out
end

function EbonholdHub.Build.Get(id)
    if not id then return nil end
    return EbonholdHubDB.builds[id]
end

function EbonholdHub.Build.GetActive()
    local build = EbonholdHub.Build.Get(EbonholdHubCharDB.activeBuildId)
    if build then EnsureAdvisorStatTiers(build) end
    return build
end

function EbonholdHub.Build.SetActive(id)
    if EbonholdHubCharDB.activeBuildId == id then return end
    EbonholdHubCharDB.activeBuildId = id
    Notify()
end

function EbonholdHub.Build.NewObject(data)
    local id = EbonholdHub.Build.NewObjectId()
    local build = {
        id              = id,
        title           = data.title or "Untitled",
        class           = data.class or PlayerClassToken(),
        spec            = data.spec or PlayerTopTalentTab(),
        comments        = data.comments or "",
        lockedEchoes     = EbonholdHub.Build.NormalizeLockedEchoes(data.lockedEchoes),
        echoTiers        = data.echoTiers or {},
        echoTierOrder    = data.echoTierOrder or {},
        echoMaxPicks     = data.echoMaxPicks or {},
        echoBundles      = data.echoBundles or {},
        loadoutEchoSpellIds = data.loadoutEchoSpellIds and CloneTable(data.loadoutEchoSpellIds) or nil,
        advisorStatTiers         = data.advisorStatTiers or {},
        advisorStatTierOrder     = data.advisorStatTierOrder or {},
        advisorStatTiersCustomized = data.advisorStatTiersCustomized or false,
        affixLoadout    = EbonholdHub.AffixLoadout and EbonholdHub.AffixLoadout.Clone(data.affixLoadout) or (data.affixLoadout or {}),
        equipmentLoadout = EbonholdHub.EquipmentLoadout and EbonholdHub.EquipmentLoadout.Clone(data.equipmentLoadout) or (data.equipmentLoadout or {}),
        equipmentSets = EbonholdHub.EquipmentLoadout and EbonholdHub.EquipmentLoadout.CloneSets(data.equipmentSets) or nil,
        equipmentSetIndex = data.equipmentSetIndex,
        talents         = EbonholdHub.Talents and EbonholdHub.Talents.ClonePlan(data.talents) or (data.talents or {}),
        talentOrder     = EbonholdHub.Talents and EbonholdHub.Talents.CloneOrder(data.talentOrder) or (data.talentOrder or {}),
        settings        = data.settings or DefaultSettings(),
        version         = 1,
        author          = data.author or UnitName("player") or "Unknown",
        lastModified    = data.lastModified or date("%Y-%m-%d %H:%M:%S"),
        automationEnabled = (data.automationEnabled ~= nil) and data.automationEnabled or true,
        isPublic         = data.isPublic or false,
        validated         = data.validated or false,
        copiedFrom        = data.copiedFrom or nil,
        stats            = {
            echoesSeen    = 0,
            runsCompleted = 0,
            runsReset     = 0,
            picks         = 0,
            rerollsUsed   = 0,
            banishesUsed  = 0,
            freezesUsed   = 0,
            qualityPicks  = { 0, 0, 0, 0, 0 },
            mostPicked    = {},
            mostBanned    = {},
        },
    }
    if EbonholdHub.EquipmentLoadout then
        EbonholdHub.EquipmentLoadout.MigrateBuild(build)
    end
    EnsureAdvisorStatTiers(build)
    build._checksum = EbonholdHub.Build.Checksum(build)
    return build
end

function EbonholdHub.Build.Create(data)
    local build = EbonholdHub.Build.NewObject(data)

    -- Flush pending tier data into the new build
    if EbonholdHub.TierData then
        EbonholdHub.TierData.CommitEdit(build)
    end

    if EbonholdHubDB.pendingAffixLoadout and next(EbonholdHubDB.pendingAffixLoadout) and EbonholdHub.AffixLoadout then
        build.affixLoadout = EbonholdHub.AffixLoadout.Clone(EbonholdHubDB.pendingAffixLoadout)
        EbonholdHubDB.pendingAffixLoadout = nil
    else
        EbonholdHubDB.pendingAffixLoadout = nil
    end

    if EbonholdHubDB.pendingEquipmentSets and EbonholdHub.EquipmentLoadout then
        local payload = EbonholdHub.EquipmentLoadout.PendingPayload()
        EbonholdHub.EquipmentLoadout.ApplySetsToBuild(build, payload.equipmentSets, payload.equipmentSetIndex)
        EbonholdHub.EquipmentLoadout.ClearPending()
    elseif EbonholdHubDB.pendingEquipmentLoadout and next(EbonholdHubDB.pendingEquipmentLoadout) and EbonholdHub.EquipmentLoadout then
        build.equipmentLoadout = EbonholdHub.EquipmentLoadout.Clone(EbonholdHubDB.pendingEquipmentLoadout)
        EbonholdHub.EquipmentLoadout.MigrateBuild(build)
        EbonholdHub.EquipmentLoadout.ClearPending()
    else
        EbonholdHub.EquipmentLoadout.ClearPending()
    end

    EbonholdHubDB.builds[build.id] = build
    return build
end

local function CountTalentPoints(plan)
    if not plan or not EbonholdHub.Talents then return 0 end
    return EbonholdHub.Talents.TotalPoints(plan)
end

local function ApplyEchoTiersToBuild(build, echoTiers)
    if not build or not echoTiers or not next(echoTiers) then return end
    build.echoTiers     = {}
    build.echoTierOrder = {}
    build.echoMaxPicks  = {}
    for name, tier in pairs(echoTiers) do
        if tier ~= "C" then
            build.echoTiers[name] = tier
            build.echoTierOrder[tier] = build.echoTierOrder[tier] or {}
            build.echoTierOrder[tier][#build.echoTierOrder[tier] + 1] = name
        end
    end
end

function EbonholdHub.Build.CreateFromCharacter(options)
    options = options or {}

    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then
        return nil, "ProjectEbonhold is not loaded."
    end

    EbonholdHubDB._isEditingBuild = nil
    EbonholdHubDB.pendingAffixLoadout = nil
    EbonholdHub.EquipmentLoadout.ClearPending()

    local echoTiers, lockedEchoes, echoCount, lockedCount, echoErr =
        EbonholdHub.CharacterEchoes.CollectEchoData()

    if echoErr and echoCount == 0 and lockedCount == 0 then
        lockedEchoes = EbonholdHub.Build.NormalizeLockedEchoes(lockedEchoes)
    end

    local affixLoadout = {}
    local affixCount = 0
    if EbonholdHub.AffixLoadout then
        affixCount = EbonholdHub.AffixLoadout.CopyFromPlayerTo(affixLoadout)
    end

    local equipmentLoadout = {}
    local gearCount = 0
    if EbonholdHub.EquipmentLoadout then
        gearCount = EbonholdHub.EquipmentLoadout.CopyFromPlayerTo(equipmentLoadout)
    end

    local talents = {}
    local talentOrder = {}
    if EbonholdHub.Talents then
        local trees = EbonholdHub.Talents.ScanTrees()
        if trees then
            EbonholdHub.Talents.RefreshPlayerRanks(trees)
            local plan, order = EbonholdHub.Talents.ReadPlayerPlan(trees)
            talents = EbonholdHub.Talents.ClonePlan(plan)
            talentOrder = EbonholdHub.Talents.CloneOrder(order)
        end
    end

    local talentPoints = CountTalentPoints(talents)
    if echoCount == 0 and lockedCount == 0 and affixCount == 0
        and gearCount == 0 and talentPoints == 0 then
        return nil, echoErr or "Nothing to copy from this character."
    end

    local playerName = UnitName("player") or "Character"
    local build = EbonholdHub.Build.Create({
        title = options.title or (playerName .. " snapshot"),
        class = PlayerClassToken(),
        spec = PlayerTopTalentTab(),
        comments = options.comments or "Copied from character.",
        lockedEchoes = EbonholdHub.Build.NormalizeLockedEchoes(lockedEchoes),
        affixLoadout = affixLoadout,
        equipmentLoadout = equipmentLoadout,
        talents = talents,
        talentOrder = talentOrder,
    })

    -- Apply tier assignments from character echoes directly into the build
    ApplyEchoTiersToBuild(build, echoTiers)

    EbonholdHub.Build.SetActive(build.id)
    if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
        EbonholdHub.BuildList.Refresh()
    end

    return build, {
        echoes = echoCount,
        locked = lockedCount,
        affixes = affixCount,
        gear = gearCount,
        talents = talentPoints,
    }
end

local function UnitClassToken(unit)
    unit = unit or "target"
    if not UnitExists(unit) then return PlayerClassToken() end
    return select(2, UnitClass(unit)) or PlayerClassToken()
end

local function UnitTopTalentTab(unit, isInspect)
    unit = unit or "target"
    isInspect = isInspect and unit == "target"
    local best, bestPoints = 1, -1
    for i = 1, 3 do
        local _, _, pointsSpent = GetTalentTabInfo(i, isInspect)
        pointsSpent = pointsSpent or 0
        if pointsSpent > bestPoints then
            best, bestPoints = i, pointsSpent
        end
    end
    return best
end

function EbonholdHub.Build.CreateFromInspect(options)
    options = options or {}
    local unit = options.unit or "target"

    if not UnitExists(unit) then
        return nil, "No target selected."
    end
    if not UnitIsPlayer(unit) then
        return nil, "Target is not a player."
    end

    EbonholdHubDB._isEditingBuild = nil
    EbonholdHubDB.pendingAffixLoadout = nil
    EbonholdHub.EquipmentLoadout.ClearPending()

    local echoTiers, lockedEchoes, echoCount, lockedCount =
        {}, EbonholdHub.Build.EmptyLockedEchoes(), 0, 0
    local inspectPerksBlocked = false
    if EbonholdHub.CharacterEchoes and EbonholdHub.CharacterEchoes.CollectInspectEchoData then
        local echoErr
        echoTiers, lockedEchoes, echoCount, lockedCount, echoErr =
            EbonholdHub.CharacterEchoes.CollectInspectEchoData(unit, {
                prefetchedGranted = options.prefetchedGranted,
                prefetchedLocked = options.prefetchedLocked,
                playerRunBaseline = options.playerRunBaseline,
                playerLockedBaseline = options.playerLockedBaseline,
                useRunDiff = options.useRunDiff,
            })
        inspectPerksBlocked = echoErr == "INSPECT_PERKS_PLAYER_CACHE"
        echoTiers = echoTiers or {}
        lockedEchoes = EbonholdHub.Build.NormalizeLockedEchoes(lockedEchoes)
        echoCount = echoCount or 0
        lockedCount = lockedCount or 0
    end

    local affixLoadout = {}
    local affixCount = 0
    if EbonholdHub.AffixLoadout then
        affixCount = EbonholdHub.AffixLoadout.CopyFromUnitTo(unit, affixLoadout)
    end

    local equipmentLoadout = {}
    local gearCount = 0
    if EbonholdHub.EquipmentLoadout then
        gearCount = EbonholdHub.EquipmentLoadout.CopyFromUnitTo(unit, equipmentLoadout)
    end

    if affixCount == 0 and gearCount == 0 and echoCount == 0 and lockedCount == 0 then
        local name = UnitName(unit) or "target"
        return nil, "No gear, affixes, or echoes found on " .. name .. ". Open Inspect and try again."
    end

    local targetName = UnitName(unit) or "Inspect"
    local commentParts = { "Copied from inspect (" .. targetName .. ")." }
    if inspectPerksBlocked or (echoCount == 0 and lockedCount == 0) then
        commentParts[#commentParts + 1] =
            "Echoes not copied via inspect — import Echo Journal loadout (EBH1:...) instead."
    end

    local build = EbonholdHub.Build.Create({
        title = options.title or (targetName .. " inspect"),
        class = UnitClassToken(unit),
        spec = UnitTopTalentTab(unit, options.useInspectTalents),
        comments = options.comments or table.concat(commentParts, " "),
        lockedEchoes = lockedEchoes,
        affixLoadout = affixLoadout,
        equipmentLoadout = equipmentLoadout,
        talents = {},
        talentOrder = {},
    })

    ApplyEchoTiersToBuild(build, echoTiers)

    EbonholdHub.Build.SetActive(build.id)
    if EbonholdHub.BuildList and EbonholdHub.BuildList.Refresh then
        EbonholdHub.BuildList.Refresh()
    end

    return build, {
        affixes = affixCount,
        gear = gearCount,
        echoes = echoCount,
        locked = lockedCount,
        inspectPerksBlocked = inspectPerksBlocked,
    }
end

local inspectCopyFrame
local inspectCopyWaitFrame
local inspectCopyStartedAt
local inspectCopyPollElapsed
local inspectPerkCache = {}
local INSPECT_COPY_TIMEOUT = 5
local INSPECT_PERK_POLL_INTERVAL = 0.15

local function ResetInspectPerkCache()
    inspectPerkCache = {}
end

local function CapturePlayerPerkBaseline()
    ResetInspectPerkCache()
    local perks = ProjectEbonhold and ProjectEbonhold.Perks
    if not perks then return end

    inspectPerkCache.playerRunBaseline = perks.grantedPerks
    inspectPerkCache.playerLockedBaseline = perks.lockedPerks

    local EO = EbonholdHub.EchoOwnership
    if EO and EO.RunEchoSignatureMap then
        inspectPerkCache.playerRunBaselineSig = EO.RunEchoSignatureMap(perks.grantedPerks)
    end
end

local function ConsiderTargetPerkSnapshot()
    local ps = ProjectEbonhold and ProjectEbonhold.PerkService
    local EO = EbonholdHub.EchoOwnership
    if not ps or not ps.GetGrantedPerks or not EO then return end

    local granted = ps.GetGrantedPerks("target")
    local locked = ps.GetLockedPerks and ps.GetLockedPerks("target")
    local baseline = inspectPerkCache.playerRunBaseline
    local score = baseline and EO.CountRunEchoDiff(granted, baseline) or 0

    if score > (inspectPerkCache.bestDiffScore or -1) then
        inspectPerkCache.bestDiffScore = score
        inspectPerkCache.granted = granted
        inspectPerkCache.locked = locked
    elseif inspectPerkCache.granted == nil then
        inspectPerkCache.granted = granted
        inspectPerkCache.locked = locked
        inspectPerkCache.bestDiffScore = score
    end
end

local function SnapshotTargetPerks()
    ConsiderTargetPerkSnapshot()
end

local function CanInspectTarget()
    if not UnitExists("target") then
        return false, "No target. Select a player first."
    end
    if not UnitIsPlayer("target") then
        return false, "Target is not a player."
    end
    if CanInspect and not CanInspect("target") then
        local name = UnitName("target") or "target"
        return false, "Cannot inspect " .. name .. " (too far away or unavailable)."
    end
    return true
end

-- NotifyInspect first (silent perk sync), then InspectUnit for gear paper doll.
local function RequestTargetInspect()
    if NotifyInspect then
        NotifyInspect("target")
    end
    ConsiderTargetPerkSnapshot()
    if InspectUnit then
        InspectUnit("target")
    end
    ConsiderTargetPerkSnapshot()
end

local function CancelInspectCopyWait()
    if inspectCopyFrame then
        inspectCopyFrame:UnregisterEvent("INSPECT_READY")
    end
    if inspectCopyWaitFrame then
        inspectCopyWaitFrame:SetScript("OnUpdate", nil)
    end
    inspectCopyStartedAt = nil
    inspectCopyPollElapsed = nil
end

local function ShowCopyInspectResult(build, summary, err)
    if not build then
        if EbonholdHub.Toast then
            EbonholdHub.Toast.Show(err or "Could not copy from inspect.")
        end
        return false
    end

    local parts = {}
    if summary.affixes and summary.affixes > 0 then
        parts[#parts + 1] = summary.affixes .. " affix" .. (summary.affixes == 1 and "" or "es")
    end
    if summary.gear and summary.gear > 0 then
        parts[#parts + 1] = summary.gear .. " gear slot" .. (summary.gear == 1 and "" or "s")
    end
    if summary.echoes and summary.echoes > 0 then
        parts[#parts + 1] = summary.echoes .. " echo" .. (summary.echoes == 1 and "" or "es")
    end
    if summary.locked and summary.locked > 0 then
        parts[#parts + 1] = summary.locked .. " locked slot" .. (summary.locked == 1 and "" or "s")
    end

    local message = "Inspect build created."
    if #parts > 0 then
        message = message .. " Copied " .. table.concat(parts, ", ") .. "."
    end
    if summary.inspectPerksBlocked then
        message = message .. " Echoes skipped — use Echo Journal Export (EBH1:...) + Import Build."
    elseif not (summary.echoes and summary.echoes > 0) and not (summary.locked and summary.locked > 0) then
        message = message .. " Echoes not copied."
    end

    if EbonholdHub.Toast then
        EbonholdHub.Toast.Show(message)
    end
    if EbonholdHub.ViewRouter then
        EbonholdHub.ViewRouter.Show("buildOverview", { build = build })
    end
    if EbonholdHub.MainWindow and EbonholdHub.MainWindow._frame then
        EbonholdHub.MainWindow._frame:Show()
    end
    return true
end

local function CompleteCopyInspect(useInspectTalents)
    CancelInspectCopyWait()
    if not inspectPerkCache.granted then
        SnapshotTargetPerks()
    end

    local isOtherPlayer = UnitExists("target") and not UnitIsUnit("target", "player")
    local build, summary = EbonholdHub.Build.CreateFromInspect({
        useInspectTalents = useInspectTalents,
        prefetchedGranted = inspectPerkCache.granted,
        prefetchedLocked = inspectPerkCache.locked,
        playerRunBaseline = inspectPerkCache.playerRunBaseline,
        playerLockedBaseline = inspectPerkCache.playerLockedBaseline,
        useRunDiff = isOtherPlayer and inspectPerkCache.playerRunBaseline ~= nil,
    })

    ResetInspectPerkCache()

    if not build then
        ShowCopyInspectResult(nil, nil, summary)
        return false
    end

    ShowCopyInspectResult(build, summary)
    return true
end

function EbonholdHub.Build.BeginCopyInspect(forceNow)
    local ok, err = CanInspectTarget()
    if not ok then
        ShowCopyInspectResult(nil, nil, err)
        return false
    end

    CapturePlayerPerkBaseline()
    RequestTargetInspect()

    if forceNow then
        return CompleteCopyInspect(false)
    end

    inspectCopyStartedAt = GetTime()
    inspectCopyPollElapsed = 0

    if not inspectCopyFrame then
        inspectCopyFrame = CreateFrame("Frame")
        inspectCopyFrame:RegisterEvent("INSPECT_READY")
        inspectCopyFrame:SetScript("OnEvent", function(self, event, arg1)
            if event ~= "INSPECT_READY" then return end
            if arg1 and UnitGUID("target") and arg1 ~= UnitGUID("target") then
                return
            end
            if not inspectCopyStartedAt then return end
            SnapshotTargetPerks()
            if inspectCopyWaitFrame then
                inspectCopyWaitFrame:SetScript("OnUpdate", nil)
            end
            inspectCopyStartedAt = nil
            local function finish()
                CompleteCopyInspect(true)
            end
            if C_Timer and C_Timer.After then
                C_Timer.After(0.15, finish)
            else
                finish()
            end
        end)
    end

    if not inspectCopyWaitFrame then
        inspectCopyWaitFrame = CreateFrame("Frame")
    end

    inspectCopyFrame:RegisterEvent("INSPECT_READY")
    inspectCopyWaitFrame:SetScript("OnUpdate", function(_, elapsed)
        if not inspectCopyStartedAt then return end

        inspectCopyPollElapsed = (inspectCopyPollElapsed or 0) + (elapsed or 0)
        if inspectCopyPollElapsed >= INSPECT_PERK_POLL_INTERVAL then
            inspectCopyPollElapsed = 0
            ConsiderTargetPerkSnapshot()
        end

        if (GetTime() - inspectCopyStartedAt) >= INSPECT_COPY_TIMEOUT then
            CompleteCopyInspect(false)
        end
    end)

    local targetName = UnitName("target") or "target"
    if EbonholdHub.Toast then
        EbonholdHub.Toast.Show("Inspecting " .. targetName .. "... waiting up to "
            .. INSPECT_COPY_TIMEOUT .. "s for gear + echoes. Use /ebh copyinspect now to skip wait.")
    end
    return true
end

function EbonholdHub.Build.HandleSlash(msg)
    msg = strtrim(msg or ""):lower()
    if msg == "copyinspect" or msg == "copyinspect echoes" then
        EbonholdHub.Build.BeginCopyInspect(false)
        return true
    end
    if msg == "copyinspect now" then
        EbonholdHub.Build.BeginCopyInspect(true)
        return true
    end
    if msg == "probeinspectperks" or msg == "probeinspectperks inspect" then
        EbonholdHub.Build.ProbeInspectPerks(msg:find("inspect", 1, true) ~= nil)
        return true
    end
    if msg == "probeloadoutui" then
        if EbonholdHub.JournalLoadoutHook and EbonholdHub.JournalLoadoutHook.Probe then
            EbonholdHub.JournalLoadoutHook.Probe()
        end
        return true
    end
    return false
end

function EbonholdHub.Build.ProbeInspectPerks(withInspect)
    local ps = ProjectEbonhold and ProjectEbonhold.PerkService
    local EO = EbonholdHub.EchoOwnership
    if not ps or not ps.GetGrantedPerks or not EO then
        print("|cffff6666[EBH]|r ProjectEbonhold PerkService not available.")
        return
    end

    local function report(label)
        local function countKeys(t)
            local n = 0
            if type(t) == "table" then
                for _ in pairs(t) do n = n + 1 end
            end
            return n
        end

        local function countRun(t)
            local n = 0
            for _ in pairs(EO.RunEchoSignatureMap(t)) do n = n + 1 end
            return n
        end

        local player = ps.GetGrantedPerks("player")
        local target = ps.GetGrantedPerks("target")
        local perks = ProjectEbonhold.Perks
        local live = perks and perks.grantedPerks
        local sameTable = player == target
        local diff = EO.CountRunEchoDiff(target, player)

        print(string.format(
            "|cff66ccff[EBH probe %s]|r player keys=%d run=%d | target keys=%d run=%d | diff=%d | same table=%s | live==player=%s",
            label,
            countKeys(player), countRun(player),
            countKeys(target), countRun(target),
            diff,
            tostring(sameTable),
            tostring(live == player)
        ))

        if sameTable then
            print("|cffff9966[EBH probe]|r GetGrantedPerks(\"target\") is your player cache — inspect echo copy cannot work until MPQ fixes per-target sync.")
        elseif diff == 0 then
            print("|cffff9966[EBH probe]|r Separate tables but identical run echoes — target may match your build or API still stale.")
        end
    end

    if withInspect and UnitExists("target") and not UnitIsUnit("target", "player") then
        print("|cff66ccff[EBH probe]|r before inspect, target: " .. (UnitName("target") or "?"))
        report("before")
        if NotifyInspect then NotifyInspect("target") end
        if InspectUnit then InspectUnit("target") end
        if C_Timer and C_Timer.After then
            C_Timer.After(0.5, function() report("after 0.5s") end)
            C_Timer.After(2.0, function() report("after 2s") end)
        else
            report("after")
        end
        return
    end

    if UnitExists("target") then
        print("|cff66ccff[EBH probe]|r target: " .. (UnitName("target") or "?"))
    end
    report("now")
    print("|cffcccccc[EBH probe]|r Tip: /ebh probeinspectperks inspect — re-check after NotifyInspect")
end

function EbonholdHub.Build.UpdateFromPublic(localBuild, publicBuild)
    localBuild.title            = publicBuild.title            or localBuild.title
    localBuild.class            = publicBuild.class            or localBuild.class
    localBuild.spec             = publicBuild.spec             or localBuild.spec
    localBuild.comments         = publicBuild.comments         or localBuild.comments
    localBuild.lockedEchoes = EbonholdHub.Build.EmptyLockedEchoes()
    for i = 1, EbonholdHub.Build.GetLockedSlotCount() do
        localBuild.lockedEchoes[i] = (publicBuild.lockedEchoes and publicBuild.lockedEchoes[i]) or nil
    end
    if publicBuild.settings then
        localBuild.settings = EbonholdHub.Build.CloneSettings(publicBuild.settings)
    end
    if publicBuild.automationEnabled ~= nil then
        localBuild.automationEnabled = publicBuild.automationEnabled
    end
    -- Copy tier data from public build
    localBuild.echoTiers     = {}
    localBuild.echoTierOrder = {}
    localBuild.echoMaxPicks  = {}
    localBuild.echoBundles   = {}
    if publicBuild.echoTiers then
        for k, v in pairs(publicBuild.echoTiers)     do localBuild.echoTiers[k]     = v end
    end
    if publicBuild.echoTierOrder then
        for k, v in pairs(publicBuild.echoTierOrder) do localBuild.echoTierOrder[k] = v end
    end
    if publicBuild.echoMaxPicks then
        for k, v in pairs(publicBuild.echoMaxPicks)  do localBuild.echoMaxPicks[k]  = v end
    end
    if publicBuild.echoBundles then
        for i, b in ipairs(publicBuild.echoBundles) do
            local echoesCopy = {}
            for j, n in ipairs(b.echoes or {}) do echoesCopy[j] = n end
            localBuild.echoBundles[i] = { id = b.id, tier = b.tier, echoes = echoesCopy }
        end
    end
    -- Migrate old echoWeights if tiers not present
    if not next(localBuild.echoTiers) and publicBuild.echoWeights then
        localBuild.echoWeights = publicBuild.echoWeights
        if EbonholdHub.TierData then EbonholdHub.TierData.MigrateWeights(localBuild) end
    end
    if publicBuild.affixLoadout and EbonholdHub.AffixLoadout then
        localBuild.affixLoadout = EbonholdHub.AffixLoadout.Clone(publicBuild.affixLoadout)
    end
    if publicBuild.equipmentSets and EbonholdHub.EquipmentLoadout then
        EbonholdHub.EquipmentLoadout.ApplySetsToBuild(localBuild, publicBuild.equipmentSets, publicBuild.equipmentSetIndex)
    elseif publicBuild.equipmentLoadout and EbonholdHub.EquipmentLoadout then
        localBuild.equipmentLoadout = EbonholdHub.EquipmentLoadout.Clone(publicBuild.equipmentLoadout)
        EbonholdHub.EquipmentLoadout.MigrateBuild(localBuild)
    end
    if publicBuild.talents then
        localBuild.talents = EbonholdHub.Talents and EbonholdHub.Talents.ClonePlan(publicBuild.talents) or publicBuild.talents
    end
    if publicBuild.talentOrder then
        localBuild.talentOrder = EbonholdHub.Talents and EbonholdHub.Talents.CloneOrder(publicBuild.talentOrder) or publicBuild.talentOrder
    end
    if publicBuild.copiedFrom then
        localBuild.copiedFrom = publicBuild.copiedFrom
    end
    localBuild._importedAt = publicBuild.lastModified
    localBuild.lastModified = date("%Y-%m-%d %H:%M:%S")
    localBuild.version = (localBuild.version or 1) + 1
    localBuild._checksum = EbonholdHub.Build.Checksum(localBuild)
    return localBuild
end

function EbonholdHub.Build.Save(id, data)
    local build = EbonholdHubDB.builds[id]
    if not build then return nil end
    local oldChecksum = build._checksum
    local classChanged = data.class and data.class ~= build.class
    local specChanged  = data.spec and data.spec ~= build.spec
    build.title           = data.title           or build.title
    build.class           = data.class           or build.class
    build.spec            = data.spec            or build.spec
    if (classChanged or specChanged) and not build.advisorStatTiersCustomized then
        ApplyDefaultAdvisorStatTiers(build)
    end
    build.comments        = data.comments        or build.comments
    build.lockedEchoes = EbonholdHub.Build.NormalizeLockedEchoes(data.lockedEchoes or build.lockedEchoes)
    if data.settings then build.settings = data.settings end
    -- Flush pending tier data when saving
    if EbonholdHub.TierData then
        EbonholdHub.TierData.CommitEdit(build)
    end
    if data.affixLoadout and EbonholdHub.AffixLoadout then
        build.affixLoadout = EbonholdHub.AffixLoadout.Clone(data.affixLoadout)
    end
    if data.equipmentSets and EbonholdHub.EquipmentLoadout then
        EbonholdHub.EquipmentLoadout.ApplySetsToBuild(build, data.equipmentSets, data.equipmentSetIndex)
    elseif data.equipmentLoadout and EbonholdHub.EquipmentLoadout then
        build.equipmentLoadout = EbonholdHub.EquipmentLoadout.Clone(data.equipmentLoadout)
        EbonholdHub.EquipmentLoadout.MigrateBuild(build)
    end
    if data.talents then
        build.talents = EbonholdHub.Talents and EbonholdHub.Talents.ClonePlan(data.talents) or data.talents
    end
    if data.talentOrder then
        build.talentOrder = EbonholdHub.Talents and EbonholdHub.Talents.CloneOrder(data.talentOrder) or data.talentOrder
    end
    if data.automationEnabled ~= nil then build.automationEnabled = data.automationEnabled end
    if data.isPublic ~= nil then build.isPublic = data.isPublic end
    build.version         = (build.version or 1) + 1
    build._checksum       = EbonholdHub.Build.Checksum(build)
    if build._checksum ~= oldChecksum then
        build.lastModified = date("%Y-%m-%d %H:%M:%S")
        build.validated = false
        local playerName = UnitName("player") or "Unknown"
        if build.author and build.author ~= playerName then
            build.copiedFrom = build.author
            build.author = playerName
            build.validated = false
            build.importedFrom = nil
            local newId = EbonholdHub.Build.NewObjectId()
            build.id = newId
            EbonholdHubDB.builds[newId] = build
            EbonholdHubDB.builds[id] = nil
            if EbonholdHubCharDB.activeBuildId == id then
                EbonholdHubCharDB.activeBuildId = newId
                Notify()
            end
        end
    end
    if classChanged and EbonholdHubCharDB.activeBuildId == id then
        Notify()
    end
    return build
end

function EbonholdHub.Build.Delete(id)
    if not id then return end
    EbonholdHubDB.builds[id] = nil
    if EbonholdHubCharDB.activeBuildId == id then
        EbonholdHubCharDB.activeBuildId = nil
        Notify()
    end
end

function EbonholdHub.Build.TrackPickStat(pick)
    local build = EbonholdHub.Build.GetActive()
    if not build or not pick or not pick.name then return end
    EnsureStats(build)
    local q = pick.quality or 0
    build.stats.qualityPicks[q] = (build.stats.qualityPicks[q] or 0) + 1
    build.stats.mostPicked[pick.name] = (build.stats.mostPicked[pick.name] or 0) + 1
end

function EbonholdHub.Build.TrackBanStat(echo)
    local build = EbonholdHub.Build.GetActive()
    if not build or not echo or not echo.name then return end
    EnsureStats(build)
    build.stats.mostBanned[echo.name] = (build.stats.mostBanned[echo.name] or 0) + 1
end

function EbonholdHub.Build.BackfillStatsFromSession()
    local build = EbonholdHub.Build.GetActive()
    if not build or not EbonholdHub.Session then return end
    EnsureStats(build)

    local qSum = 0
    for q = 0, 4 do
        qSum = qSum + (build.stats.qualityPicks[q] or 0)
    end
    if qSum > 0 then return end

    local merged = {
        picks = 0,
        qualityPicks = { [0] = 0, [1] = 0, [2] = 0, [3] = 0, [4] = 0 },
        mostPicked = {},
        mostBanned = {},
    }

    for _, session in ipairs(EbonholdHubDB.sessions or {}) do
        local agg = EbonholdHub.Session.AggregateStats(session.logs)
        merged.picks = merged.picks + (agg.picks or 0)
        for q = 0, 4 do
            merged.qualityPicks[q] = merged.qualityPicks[q] + (agg.qualityPicks[q] or 0)
        end
        for name, count in pairs(agg.mostPicked or {}) do
            merged.mostPicked[name] = (merged.mostPicked[name] or 0) + count
        end
        for name, count in pairs(agg.mostBanned or {}) do
            merged.mostBanned[name] = (merged.mostBanned[name] or 0) + count
        end
    end

    if merged.picks == 0 then return end

    for q = 0, 4 do
        build.stats.qualityPicks[q] = merged.qualityPicks[q]
    end
    build.stats.mostPicked = merged.mostPicked
    build.stats.mostBanned = merged.mostBanned
end
