EbonholdHub.EchoOwnership = {}

local QUALITY_SUFFIXES = {
    " %- common", " %- uncommon", " %- rare", " %- epic", " %- legendary",
}
local CLASS_PREFIXES = {
    "warrior %- ", "paladin %- ", "hunter %- ", "rogue %- ", "priest %- ",
    "death knight %- ", "shaman %- ", "mage %- ", "warlock %- ", "druid %- ",
}
local PREFIXES = {
    "tome of ", "codex of ", "scroll of ", "manual of ", "grimoire of ", "libram of ", "tablet of ",
}

local function StripQualitySuffix(name)
    if not name then return name end
    local lower = strlower(name)
    for _, suffix in ipairs(QUALITY_SUFFIXES) do
        if lower:sub(-#suffix) == suffix then
            return name:sub(1, -(#suffix + 1))
        end
    end
    return name
end

local function NormalizeEchoName(name)
    if not name or name == "" then return nil end
    name = name:gsub("’", "'"):gsub("‘", "'"):gsub("`", "'")
    local n = strlower(name)
    for _, prefix in ipairs(PREFIXES) do
        if n:sub(1, #prefix) == prefix then
            n = n:sub(#prefix + 1)
            break
        end
    end
    for _, prefix in ipairs(CLASS_PREFIXES) do
        if n:sub(1, #prefix) == prefix then
            n = n:sub(#prefix + 1)
            break
        end
    end
    for _, suffix in ipairs(QUALITY_SUFFIXES) do
        if n:sub(-#suffix) == suffix then
            n = n:sub(1, -(#suffix + 1))
            break
        end
    end
    return n
end

local function AddOwnedName(ownedLower, name)
    local norm = NormalizeEchoName(name)
    if norm then ownedLower[norm] = true end
    local stripped = StripQualitySuffix(name)
    if stripped ~= name then
        norm = NormalizeEchoName(stripped)
        if norm then ownedLower[norm] = true end
    end
end

local function RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, spellId)
    if type(spellId) ~= "number" or spellId <= 0 then return end
    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then return end

    ownedSpellIds[spellId] = true
    AddOwnedName(ownedLower, GetSpellInfo(spellId))

    local data = ProjectEbonhold.PerkDatabase[spellId]
    if data then
        if data.comment and data.comment ~= "" then
            AddOwnedName(ownedLower, data.comment)
            AddOwnedName(ownedLower, StripQualitySuffix(data.comment))
        end
        if data.groupId then ownedGroups[data.groupId] = true end
    end
end

local function ScanTableForSpellIds(value, seen, out)
    if type(value) == "number" then
        if value >= 200000 and value < 300000 then out[value] = true end
        return
    end
    if type(value) ~= "table" then return end
    if seen[value] then return end
    seen[value] = true
    for key, nested in pairs(value) do
        if type(key) == "number" and key >= 200000 and key < 300000 then
            out[key] = true
        end
        ScanTableForSpellIds(nested, seen, out)
    end
end

local function IngestGrantedTable(granted, ownedLower, ownedGroups, ownedSpellIds)
    if type(granted) ~= "table" then return end
    for key, value in pairs(granted) do
        if type(key) == "number" and key >= 200000 and key < 300000 then
            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, key)
        elseif type(key) == "string" and key ~= "" then
            AddOwnedName(ownedLower, key)
        end
        if type(value) == "number" and value >= 200000 and value < 300000 then
            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value)
        elseif type(value) == "string" and value ~= "" then
            AddOwnedName(ownedLower, value)
        elseif type(value) == "table" then
            local arrayLike = false
            for _, entry in ipairs(value) do
                arrayLike = true
                if type(entry) == "table" then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry.spellId or entry.id)
                    AddOwnedName(ownedLower, entry.name or entry.comment)
                elseif type(entry) == "number" and entry >= 200000 then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry)
                end
            end
            if not arrayLike then
                RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value.spellId or value.id)
                AddOwnedName(ownedLower, value.name or value.comment)
            end
        end
    end
    local extraSpellIds = {}
    ScanTableForSpellIds(granted, {}, extraSpellIds)
    for spellId in pairs(extraSpellIds) do
        RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, spellId)
    end
end

local function IngestLockedTable(locked, ownedLower, ownedGroups, ownedSpellIds)
    if type(locked) ~= "table" then return end
    for key, value in pairs(locked) do
        if type(value) == "number" then
            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value)
        elseif type(key) == "number" and type(value) == "number" then
            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value)
        elseif type(value) == "table" then
            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value.spellId or value.id)
            AddOwnedName(ownedLower, value.name or value.comment)
        end
    end
end

local function CollectSpellbookTomeIds()
    local spellbookIds = {}
    local numTabs = GetNumSpellTabs and GetNumSpellTabs() or 0
    for tabIdx = 1, numTabs do
        local tabName, _, offset, numSpells = GetSpellTabInfo(tabIdx)
        if tabName == "Echoes" then
            for slot = offset + 1, offset + numSpells do
                local link
                if GetSpellLink then
                    link = GetSpellLink(slot, BOOKTYPE_SPELL or "spell")
                    if not link then link = GetSpellLink(slot, "spell") end
                end
                local tomeSpellId = link and tonumber(link:match("spell:(%d+)"))
                if tomeSpellId then spellbookIds[tomeSpellId] = true end
            end
            break
        end
    end
    return spellbookIds
end

local function GetSpellbookPerkIndex()
    if spellbookPerkIndex then return spellbookPerkIndex end
    spellbookPerkIndex = {}
    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then return spellbookPerkIndex end
    for spellId, data in pairs(ProjectEbonhold.PerkDatabase) do
        if type(data) == "table" then
            local req = data.requiredSpell
            if req then
                if not spellbookPerkIndex[req] then spellbookPerkIndex[req] = {} end
                spellbookPerkIndex[req][spellId] = true
            end
            local tomeId = spellId + 100000
            if not spellbookPerkIndex[tomeId] then spellbookPerkIndex[tomeId] = {} end
            spellbookPerkIndex[tomeId][spellId] = true
        end
    end
    return spellbookPerkIndex
end

local function IngestSpellbookEchoes(spellbookIds, ownedLower, ownedGroups, ownedSpellIds)
    if not spellbookIds or not next(spellbookIds) then return end
    local index = GetSpellbookPerkIndex()
    for tomeId in pairs(spellbookIds) do
        local perks = index[tomeId]
        if perks then
            for spellId in pairs(perks) do
                RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, spellId)
            end
        end
    end
end

local function FlattenGrantedTable(granted, source, out)
    if type(granted) ~= "table" then return end
    for key, value in pairs(granted) do
        if type(key) == "string" and type(value) == "table" then
            local hasArray = false
            for i, entry in ipairs(value) do
                hasArray = true
                if type(entry) == "table" then
                    out[#out + 1] = {
                        name     = key,
                        spellId  = entry.spellId or entry.id,
                        quality  = entry.quality,
                        stack    = entry.stack,
                        maxStack = entry.maxStack,
                        source   = source,
                    }
                end
            end
            if not hasArray and (value.spellId or value.id) then
                out[#out + 1] = {
                    name     = key,
                    spellId  = value.spellId or value.id,
                    quality  = value.quality,
                    stack    = value.stack,
                    maxStack = value.maxStack,
                    source   = source,
                }
            end
        elseif type(key) == "number" and key >= 200000 and key < 300000 then
            out[#out + 1] = {
                spellId = key,
                name    = type(value) == "string" and value or nil,
                source  = source,
            }
        elseif type(value) == "number" and value >= 200000 and value < 300000 then
            out[#out + 1] = {
                spellId = value,
                name    = type(key) == "string" and key or nil,
                source  = source,
            }
        elseif type(value) == "table" and (value.spellId or value.id) then
            out[#out + 1] = {
                name     = type(key) == "string" and key or (value.name or value.comment),
                spellId  = value.spellId or value.id,
                quality  = value.quality,
                stack    = value.stack,
                maxStack = value.maxStack,
                source   = source,
            }
        end
    end
end

local function ResolveEchoesFromSpellbook(spellbookIds)
    local echoes = {}
    if not spellbookIds or not next(spellbookIds) then return echoes end
    for spellId, data in pairs(ProjectEbonhold.PerkDatabase) do
        if type(data) == "table" and (spellbookIds[data.requiredSpell] or spellbookIds[spellId + 100000]) then
            echoes[#echoes + 1] = {
                spellId = spellId,
                name    = data.comment,
                quality = data.quality,
            }
        end
    end
    table.sort(echoes, function(a, b) return (a.spellId or 0) < (b.spellId or 0) end)
    return echoes
end

function EbonholdHub.EchoOwnership.CollectSnapshot()
    local snapshot = {
        character        = UnitName("player"),
        realm            = GetRealmName and GetRealmName() or nil,
        class            = select(2, UnitClass("player")),
        collectedAt      = date("%Y-%m-%d %H:%M:%S"),
        cachedPerkCounts = {},
        spellbookTomes   = {},
        spellbookEchoes  = {},
        grantedEntries   = {},
        lockedEntries    = {},
        ownedSpellIds    = {},
    }

    if ProjectEbonholdDB and type(ProjectEbonholdDB.cachedPerkCounts) == "table" then
        for name, count in pairs(ProjectEbonholdDB.cachedPerkCounts) do
            if type(name) == "string" then
                snapshot.cachedPerkCounts[name] = count
            end
        end
    end

    local spellbookIds = CollectSpellbookTomeIds()
    for tomeId in pairs(spellbookIds) do
        snapshot.spellbookTomes[#snapshot.spellbookTomes + 1] = {
            spellId = tomeId,
            name    = GetSpellInfo(tomeId) or "",
        }
    end
    table.sort(snapshot.spellbookTomes, function(a, b) return (a.spellId or 0) < (b.spellId or 0) end)
    snapshot.spellbookEchoes = ResolveEchoesFromSpellbook(spellbookIds)

    local service = ProjectEbonhold and ProjectEbonhold.PerkService
    if service then
        if service.RequestGrantedPerks then service.RequestGrantedPerks() end
        if service.GetGrantedPerks then
            FlattenGrantedTable(service.GetGrantedPerks(), "PerkService.GetGrantedPerks", snapshot.grantedEntries)
        end
        if service.GetLockedPerks then
            local locked = service.GetLockedPerks()
            if type(locked) == "table" then
                for key, value in pairs(locked) do
                    if type(value) == "number" and value >= 200000 then
                        snapshot.lockedEntries[#snapshot.lockedEntries + 1] = {
                            slot    = type(key) == "number" and key or nil,
                            spellId = value,
                        }
                    elseif type(key) == "number" and type(value) == "number" and value >= 200000 then
                        snapshot.lockedEntries[#snapshot.lockedEntries + 1] = {
                            slot    = key,
                            spellId = value,
                        }
                    elseif type(value) == "table" then
                        snapshot.lockedEntries[#snapshot.lockedEntries + 1] = {
                            slot    = type(key) == "number" and key or nil,
                            spellId = value.spellId or value.id,
                            quality = value.quality,
                            stack   = value.stack,
                        }
                    end
                end
            end
        end
    end

    if ProjectEbonhold and ProjectEbonhold.Perks and ProjectEbonhold.Perks.grantedPerks then
        FlattenGrantedTable(ProjectEbonhold.Perks.grantedPerks, "Perks.grantedPerks", snapshot.grantedEntries)
    end

    local _, _, ownedSpellIds = EbonholdHub.EchoOwnership.CollectOwnedSets()
    for spellId in pairs(ownedSpellIds) do
        snapshot.ownedSpellIds[spellId] = true
    end

    for _, entry in ipairs(snapshot.grantedEntries) do
        if entry.spellId and not entry.name then
            local data = ProjectEbonhold.PerkDatabase[entry.spellId]
            entry.name = data and data.comment or GetSpellInfo(entry.spellId)
        end
    end
    for _, entry in ipairs(snapshot.lockedEntries) do
        if entry.spellId and not entry.name then
            local data = ProjectEbonhold.PerkDatabase[entry.spellId]
            entry.name = data and data.comment or GetSpellInfo(entry.spellId)
        end
    end

    table.sort(snapshot.grantedEntries, function(a, b)
        local an = (a.name or ""):lower()
        local bn = (b.name or ""):lower()
        if an ~= bn then return an < bn end
        return (a.spellId or 0) < (b.spellId or 0)
    end)
    table.sort(snapshot.lockedEntries, function(a, b)
        return (a.slot or 99) < (b.slot or 99)
    end)

    return snapshot
end

function EbonholdHub.EchoOwnership.FlattenGrantedPerks(granted, source)
    local out = {}
    FlattenGrantedTable(granted, source or "granted", out)
    return out
end

function EbonholdHub.EchoOwnership.CollectGrantedSets(granted, locked)
    local ownedLower = {}
    local ownedGroups = {}
    local ownedSpellIds = {}

    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then
        return ownedLower, ownedGroups, ownedSpellIds
    end

    IngestGrantedTable(granted, ownedLower, ownedGroups, ownedSpellIds)
    IngestLockedTable(locked, ownedLower, ownedGroups, ownedSpellIds)
    return ownedLower, ownedGroups, ownedSpellIds
end

-- Run echoes only: Perks.grantedPerks shape ("Echo Name" -> stack array).
-- Skips numeric keys and deep spell-id scan (account-wide data from GetGrantedPerks).
function EbonholdHub.EchoOwnership.CollectRunGrantedSets(granted)
    local ownedLower = {}
    local ownedGroups = {}
    local ownedSpellIds = {}

    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then
        return ownedLower, ownedGroups, ownedSpellIds
    end
    if type(granted) ~= "table" then
        return ownedLower, ownedGroups, ownedSpellIds
    end

    for key, value in pairs(granted) do
        if type(key) == "string" and key ~= "" and type(value) == "table" then
            AddOwnedName(ownedLower, key)
            local hasStack = false
            for _, entry in ipairs(value) do
                hasStack = true
                if type(entry) == "table" then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry.spellId or entry.id)
                elseif type(entry) == "number" and entry >= 200000 then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry)
                end
            end
            if not hasStack and (value.spellId or value.id) then
                RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, value.spellId or value.id)
            end
        end
    end

    return ownedLower, ownedGroups, ownedSpellIds
end

function EbonholdHub.EchoOwnership.RunEchoSignatureMap(granted)
    local out = {}
    if type(granted) ~= "table" then return out end

    for key, value in pairs(granted) do
        if type(key) == "string" and key ~= "" and type(value) == "table" then
            local norm = NormalizeEchoName(key)
            if norm then
                local parts = {}
                for _, entry in ipairs(value) do
                    if type(entry) == "table" then
                        parts[#parts + 1] = table.concat({
                            tostring(entry.spellId or entry.id or 0),
                            tostring(entry.stack or 1),
                            tostring(entry.quality or 0),
                        }, ":")
                    elseif type(entry) == "number" then
                        parts[#parts + 1] = tostring(entry) .. ":1:0"
                    end
                end
                if #parts == 0 and (value.spellId or value.id) then
                    parts[1] = table.concat({
                        tostring(value.spellId or value.id),
                        tostring(value.stack or 1),
                        tostring(value.quality or 0),
                    }, ":")
                end
                table.sort(parts)
                out[norm] = table.concat(parts, ",")
            end
        end
    end

    return out
end

function EbonholdHub.EchoOwnership.CountRunEchoDiff(targetGranted, baselineGranted)
    local baselineSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap(baselineGranted)
    local targetSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap(targetGranted)
    local diff = 0

    for name, sig in pairs(targetSig) do
        if baselineSig[name] ~= sig then
            diff = diff + 1
        end
    end

    for name in pairs(baselineSig) do
        if not targetSig[name] then
            diff = diff + 1
        end
    end

    return diff
end

function EbonholdHub.EchoOwnership.CountRunEchoKeys(granted)
    local n = 0
    for _ in pairs(EbonholdHub.EchoOwnership.RunEchoSignatureMap(granted)) do
        n = n + 1
    end
    return n
end

function EbonholdHub.EchoOwnership.TargetRunSnapshotLooksPartial(targetGranted, baselineGranted)
    local baselineCount = EbonholdHub.EchoOwnership.CountRunEchoKeys(baselineGranted)
    local targetCount = EbonholdHub.EchoOwnership.CountRunEchoKeys(targetGranted)
    if baselineCount < 8 or targetCount == 0 or targetCount >= baselineCount then
        return false
    end

    local baselineSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap(baselineGranted)
    local targetSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap(targetGranted)
    local missingFromTarget = 0
    for name in pairs(baselineSig) do
        if not targetSig[name] then
            missingFromTarget = missingFromTarget + 1
        end
    end

    return missingFromTarget >= math.max(5, math.floor(baselineCount * 0.25))
end

function EbonholdHub.EchoOwnership.CollectRunGrantedSetsDiff(targetGranted, baselineGranted)
    local baselineSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap(baselineGranted)
    local filtered = {}

    if type(targetGranted) == "table" then
        for key, value in pairs(targetGranted) do
            if type(key) == "string" and key ~= "" and type(value) == "table" then
                local norm = NormalizeEchoName(key)
                local targetSig = EbonholdHub.EchoOwnership.RunEchoSignatureMap({ [key] = value })
                if norm and baselineSig[norm] ~= targetSig[norm] then
                    filtered[key] = value
                end
            end
        end
    end

    return EbonholdHub.EchoOwnership.CollectRunGrantedSets(filtered)
end

function EbonholdHub.EchoOwnership.GrantedSpellIdFingerprint(granted, locked)
    local _, _, ownedSpellIds = EbonholdHub.EchoOwnership.CollectGrantedSets(granted, locked)
    local ids = {}
    for spellId in pairs(ownedSpellIds) do
        ids[#ids + 1] = spellId
    end
    table.sort(ids)
    return table.concat(ids, ",")
end

function EbonholdHub.EchoOwnership.LockedSlotFingerprint(locked)
    if type(locked) ~= "table" then return "" end

    local slots = { "", "", "", "", "" }
    for index = 1, 5 do
        local value = locked[index]
        if type(value) == "number" then
            slots[index] = tostring(value)
        elseif type(value) == "table" then
            slots[index] = tostring(value.spellId or value.id or "")
        end
    end

    if slots[1] == "" then
        local ordered = {}
        for key, value in pairs(locked) do
            if type(value) == "number" then
                if type(key) == "number" and key >= 1 and key <= 5 then
                    ordered[key] = value
                else
                    ordered[#ordered + 1] = value
                end
            elseif type(key) == "number" and type(value) == "number" and key >= 1 and key <= 5 then
                ordered[key] = value
            end
        end
        for index = 1, 5 do
            if ordered[index] then
                slots[index] = tostring(ordered[index])
            end
        end
    end

    return table.concat(slots, ":")
end

function EbonholdHub.EchoOwnership.IsTargetPerkPlayerLeak(granted, locked, snapshot)
    if not snapshot or not UnitExists("target") or UnitIsUnit("target", "player") then
        return false
    end

    local service = ProjectEbonhold and ProjectEbonhold.PerkService
    if not service then return false end

    if granted and service.GetGrantedPerks("player") == granted then
        return true
    end
    if locked and service.GetLockedPerks and service.GetLockedPerks("player") == locked then
        return true
    end

    local grantedFp = EbonholdHub.EchoOwnership.GrantedSpellIdFingerprint(granted)
    local lockedFp = EbonholdHub.EchoOwnership.LockedSlotFingerprint(locked)
    if snapshot.grantedFingerprint and snapshot.grantedFingerprint ~= ""
        and grantedFp == snapshot.grantedFingerprint
        and snapshot.lockedFingerprint and snapshot.lockedFingerprint ~= ""
        and lockedFp == snapshot.lockedFingerprint then
        return true
    end

    return false
end

function EbonholdHub.EchoOwnership.NormalizeName(name)
    return NormalizeEchoName(name)
end

function EbonholdHub.EchoOwnership.CanonicalNameFromSpellId(spellId)
    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then return nil end
    local data = ProjectEbonhold.PerkDatabase[spellId]
    if data and data.comment and data.comment ~= "" then
        return StripQualitySuffix(data.comment)
    end
    if EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildBestByName then
        local best = EbonholdHub.EchoTableRows.BuildBestByName()
        for name, entry in pairs(best) do
            if entry.spellId == spellId then return name end
            for _, sid in pairs(entry.spellIds or {}) do
                if sid == spellId then return name end
            end
        end
    end
    return nil
end

-- Returns owned sets for the "Owned" pool filter.
-- Primary source: PerkService.GetDiscoveredEchoes() → {[spellId]=1,...}
-- This is the exact same data the Echo Journal uses for "Known tomes only".
-- Fallback: ProjectEbonholdDB.cachedPerkCounts when PerkService is unavailable.
function EbonholdHub.EchoOwnership.CollectTomeOwnedSets()
    local ownedLower    = {}
    local ownedGroups   = {}
    local ownedSpellIds = {}

    local service = ProjectEbonhold and ProjectEbonhold.PerkService

    -- Primary: {[spellId] = 1, ...} — permanently unlocked via tomes.
    -- groupId registration means all quality variants of the same echo are matched.
    if service and service.GetDiscoveredEchoes then
        local discovered = service.GetDiscoveredEchoes()
        if type(discovered) == "table" then
            for spellId in pairs(discovered) do
                if type(spellId) == "number" and spellId > 0 then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, spellId)
                end
            end
        end
    end

    -- Fallback: name-based match via cachedPerkCounts (count > 0).
    -- Used when PerkService is not available.
    if not service and ProjectEbonholdDB
            and type(ProjectEbonholdDB.cachedPerkCounts) == "table" then
        local byName = EbonholdHub.EchoTableRows
            and EbonholdHub.EchoTableRows.BuildBestByName
            and EbonholdHub.EchoTableRows.BuildBestByName()
        for name, count in pairs(ProjectEbonholdDB.cachedPerkCounts) do
            if type(name) == "string" and (type(count) ~= "number" or count > 0) then
                AddOwnedName(ownedLower, name)
                local entry = byName and byName[name]
                if entry then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry.spellId)
                    for _, sid in pairs(entry.spellIds or {}) do
                        RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, sid)
                    end
                end
            end
        end
    end

    return ownedLower, ownedGroups, ownedSpellIds
end

function EbonholdHub.EchoOwnership.CollectOwnedSets()
    local ownedLower = {}
    local ownedGroups = {}
    local ownedSpellIds = {}

    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then
        return ownedLower, ownedGroups, ownedSpellIds
    end

    local spellbookIds = CollectSpellbookTomeIds()
    IngestSpellbookEchoes(spellbookIds, ownedLower, ownedGroups, ownedSpellIds)

    local service = ProjectEbonhold.PerkService
    if ProjectEbonhold.Perks and ProjectEbonhold.Perks.grantedPerks then
        IngestGrantedTable(ProjectEbonhold.Perks.grantedPerks, ownedLower, ownedGroups, ownedSpellIds)
    elseif service and service.GetGrantedPerks then
        IngestGrantedTable(service.GetGrantedPerks(), ownedLower, ownedGroups, ownedSpellIds)
    end
    if ProjectEbonhold.Perks and ProjectEbonhold.Perks.lockedPerks then
        IngestLockedTable(ProjectEbonhold.Perks.lockedPerks, ownedLower, ownedGroups, ownedSpellIds)
    elseif service and service.GetLockedPerks then
        IngestLockedTable(service.GetLockedPerks(), ownedLower, ownedGroups, ownedSpellIds)
    end

    if ProjectEbonholdDB and type(ProjectEbonholdDB.cachedPerkCounts) == "table" then
        for name, count in pairs(ProjectEbonholdDB.cachedPerkCounts) do
            if type(name) == "string" and (type(count) ~= "number" or count > 0) then
                AddOwnedName(ownedLower, name)
                if EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildBestByName then
                    local entry = EbonholdHub.EchoTableRows.BuildBestByName()[name]
                    if entry then
                        RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry.spellId)
                        for _, sid in pairs(entry.spellIds or {}) do
                            RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, sid)
                        end
                    end
                end
            end
        end
    end

    return ownedLower, ownedGroups, ownedSpellIds
end

-- Lightweight ownership for build overview Echoes tab (no spellbook / Perks deep scan).
function EbonholdHub.EchoOwnership.CollectOverviewOwnedSets()
    local ownedLower = {}
    local ownedGroups = {}
    local ownedSpellIds = {}

    if ProjectEbonhold and ProjectEbonhold.Perks then
        if ProjectEbonhold.Perks.grantedPerks then
            IngestGrantedTable(ProjectEbonhold.Perks.grantedPerks, ownedLower, ownedGroups, ownedSpellIds)
        end
        if ProjectEbonhold.Perks.lockedPerks then
            IngestLockedTable(ProjectEbonhold.Perks.lockedPerks, ownedLower, ownedGroups, ownedSpellIds)
        end
    end

    if ProjectEbonholdDB and type(ProjectEbonholdDB.cachedPerkCounts) == "table" then
        local byName = EbonholdHub.EchoTableRows
            and EbonholdHub.EchoTableRows.BuildBestByName
            and EbonholdHub.EchoTableRows.BuildBestByName()
        for name, count in pairs(ProjectEbonholdDB.cachedPerkCounts) do
            if type(name) == "string" and (type(count) ~= "number" or count > 0) then
                AddOwnedName(ownedLower, name)
                local entry = byName and byName[name]
                if entry then
                    RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, entry.spellId)
                    for _, sid in pairs(entry.spellIds or {}) do
                        RegisterSpellId(ownedLower, ownedGroups, ownedSpellIds, sid)
                    end
                end
            end
        end
    end

    return ownedLower, ownedGroups, ownedSpellIds
end

function EbonholdHub.EchoOwnership.IsOwned(canonicalName, entry, ownedLower, ownedGroups, ownedSpellIds)
    ownedLower = ownedLower or {}
    ownedSpellIds = ownedSpellIds or {}
    ownedGroups = ownedGroups or {}

    local norm = NormalizeEchoName(canonicalName)
    if norm and ownedLower[norm] then return true end

    if entry then
        if entry.spellId and ownedSpellIds[entry.spellId] then return true end
        for _, spellId in pairs(entry.spellIds or {}) do
            if ownedSpellIds[spellId] then return true end
        end
        local data = entry.spellId and ProjectEbonhold.PerkDatabase[entry.spellId]
        if data and data.groupId and ownedGroups[data.groupId] then return true end
    end

    if ProjectEbonhold.PerkService and ProjectEbonhold.PerkService.GetGrantedPerks then
        local granted = ProjectEbonhold.PerkService.GetGrantedPerks()
        if type(granted) == "table" and norm then
            for key, value in pairs(granted) do
                if type(key) == "string" and NormalizeEchoName(key) == norm then
                    return true
                end
                if type(value) == "table" then
                    local grantedId = value.spellId or value.id
                    if entry and grantedId then
                        if entry.spellId == grantedId then return true end
                        for _, spellId in pairs(entry.spellIds or {}) do
                            if spellId == grantedId then return true end
                        end
                    end
                    if grantedId and ownedSpellIds[grantedId] and entry then
                        local grantedData = ProjectEbonhold.PerkDatabase[grantedId]
                        local entryData = entry.spellId and ProjectEbonhold.PerkDatabase[entry.spellId]
                        if grantedData and entryData and grantedData.groupId and grantedData.groupId == entryData.groupId then
                            return true
                        end
                    end
                end
            end
        end
    end

    return false
end

function EbonholdHub.EchoOwnership.IsOwnedFromSets(canonicalName, entry, ownedLower, ownedGroups, ownedSpellIds)
    ownedLower = ownedLower or {}
    ownedSpellIds = ownedSpellIds or {}
    ownedGroups = ownedGroups or {}

    local norm = NormalizeEchoName(canonicalName)
    if norm and ownedLower[norm] then return true end

    if entry then
        if entry.spellId and ownedSpellIds[entry.spellId] then return true end
        for _, spellId in pairs(entry.spellIds or {}) do
            if ownedSpellIds[spellId] then return true end
        end
        local data = entry.spellId and ProjectEbonhold.PerkDatabase[entry.spellId]
        if data and data.groupId and ownedGroups[data.groupId] then return true end
    end

    return false
end

local cache
local overviewCache
local journalStatsCache
local spellbookPerkIndex
local refreshListeners = {}
local eventDebounceFrame

local function ClearOwnershipCaches()
    cache = nil
    overviewCache = nil
    journalStatsCache = nil
end

local function NotifyRefresh()
    for _, cb in ipairs(refreshListeners) do
        pcall(cb)
    end
end

function EbonholdHub.EchoOwnership.Invalidate()
    ClearOwnershipCaches()
    NotifyRefresh()
end

function EbonholdHub.EchoOwnership.InvalidateCache()
    ClearOwnershipCaches()
end

function EbonholdHub.EchoOwnership.RegisterRefresh(fn)
    if type(fn) == "function" then
        refreshListeners[#refreshListeners + 1] = fn
    end
end

function EbonholdHub.EchoOwnership.GetOwnedSets()
    if cache then
        return cache.ownedLower, cache.ownedGroups, cache.ownedSpellIds
    end
    local ownedLower, ownedGroups, ownedSpellIds = EbonholdHub.EchoOwnership.CollectOwnedSets()
    cache = {
        ownedLower    = ownedLower,
        ownedGroups   = ownedGroups,
        ownedSpellIds = ownedSpellIds,
    }
    return ownedLower, ownedGroups, ownedSpellIds
end

function EbonholdHub.EchoOwnership.GetOverviewOwnedSets()
    if overviewCache then
        return overviewCache.ownedLower, overviewCache.ownedGroups, overviewCache.ownedSpellIds
    end
    local ownedLower, ownedGroups, ownedSpellIds = EbonholdHub.EchoOwnership.CollectOverviewOwnedSets()
    overviewCache = {
        ownedLower    = ownedLower,
        ownedGroups   = ownedGroups,
        ownedSpellIds = ownedSpellIds,
    }
    return ownedLower, ownedGroups, ownedSpellIds
end

function EbonholdHub.EchoOwnership.IsAssignedEchoOwned(echo)
    if not echo or not echo.name then return false end

    local ownedLower, ownedGroups, ownedSpellIds = EbonholdHub.EchoOwnership.GetOwnedSets()
    if echo.spellId and ownedSpellIds[echo.spellId] then return true end

    -- Quality-specific tier key ("name|quality"): only that exact variant counts.
    local _, keyQuality = EbonholdHub.TierData and EbonholdHub.TierData.ParseTierKey(echo.key or "")
    if keyQuality ~= nil then return false end

    local entry
    if EbonholdHub.EchoTableRows and EbonholdHub.EchoTableRows.BuildBestByName then
        entry = EbonholdHub.EchoTableRows.BuildBestByName()[echo.name]
    end
    return EbonholdHub.EchoOwnership.IsOwnedFromSets(echo.name, entry, ownedLower, ownedGroups, ownedSpellIds)
end

function EbonholdHub.EchoOwnership.CountAssignedEchoes(assigned)
    local total, have, need = 0, 0, 0
    if not assigned then return total, have, need end

    for _, list in pairs(assigned) do
        for _, echo in ipairs(list) do
            total = total + 1
            if EbonholdHub.EchoOwnership.IsAssignedEchoOwned(echo) then
                have = have + 1
            else
                need = need + 1
            end
        end
    end
    return total, have, need
end

local function GetPerkPicksMade()
    if not ProjectEbonholdDB or type(ProjectEbonholdDB.perkPicksMade) ~= "table" then
        return nil
    end
    local name = UnitName("player")
    if not name or name == "" then return nil end
    local realm = (GetRealmName and GetRealmName()) or "Unknown"
    local keys = {
        realm .. "\t" .. name,
        "Unknown\t" .. name,
    }
    for _, key in ipairs(keys) do
        local value = ProjectEbonholdDB.perkPicksMade[key]
        if type(value) == "number" then
            return value
        end
    end
    return nil
end

local function CountCachedPerkStats()
    if not ProjectEbonholdDB or type(ProjectEbonholdDB.cachedPerkCounts) ~= "table" then
        return 0, 0
    end
    local unique, stacks = 0, 0
    for name, count in pairs(ProjectEbonholdDB.cachedPerkCounts) do
        if type(name) == "string" and type(count) == "number" and count > 0 then
            unique = unique + 1
            stacks = stacks + count
        elseif type(name) == "string" then
            unique = unique + 1
        end
    end
    return stacks, unique
end

-- Unique echo types in your Echo Book (cachedPerkCounts). Fast, no API scans.
function EbonholdHub.EchoOwnership.GetJournalEchoStats()
    if journalStatsCache then
        return journalStatsCache.owned, journalStatsCache.source
    end

    local _, unique = CountCachedPerkStats()
    local owned = unique
    local source = owned > 0 and "book" or "none"

    journalStatsCache = { owned = owned, source = source }
    return owned, source
end

function EbonholdHub.EchoOwnership.Init()
    if EbonholdHub.EchoOwnership._initialized then return end
    EbonholdHub.EchoOwnership._initialized = true

    local frame = CreateFrame("Frame")
    frame:RegisterEvent("SPELLS_CHANGED")
    frame:RegisterEvent("LEARNED_SPELL_IN_TAB")
    frame:RegisterEvent("PLAYER_ENTERING_WORLD")
    frame:SetScript("OnEvent", function(_, event)
        ClearOwnershipCaches()
        if event == "PLAYER_ENTERING_WORLD" then
            return
        end
        if not eventDebounceFrame then
            eventDebounceFrame = CreateFrame("Frame")
            eventDebounceFrame:Hide()
            eventDebounceFrame:SetScript("OnUpdate", function(self, elapsed)
                self.wait = (self.wait or 0) + elapsed
                if self.wait < 0.5 then return end
                self.wait = 0
                self:Hide()
                NotifyRefresh()
            end)
        end
        eventDebounceFrame.wait = 0
        eventDebounceFrame:Show()
    end)

    local ej = ProjectEbonhold and ProjectEbonhold.EchoJournal
    if ej and ej.OnDataChanged and not ej._ebhOnDataWrapped then
        local orig = ej.OnDataChanged
        ej.OnDataChanged = function(...)
            if orig then orig(...) end
            ClearOwnershipCaches()
        end
        ej._ebhOnDataWrapped = true
    end
end
