local addonName, ZL = ...

local CATEGORY_ORDER = {
    { key = "achievements", name = "Achievements" },
    { key = "pets", name = "Battle Pets" },
    { key = "decor", name = "Decor" },
    { key = "mounts", name = "Mounts" },
    { key = "quests", name = "Quests" },
    { key = "rares", name = "Rare Drops" },
    { key = "transmogs", name = "Transmog" },
}

local function URLEncode(value)
    local plain = tostring(value or ""):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|T.-|t", "")
    return plain:gsub("([^%w%-_%.~])", function(character)
        return string.format("%%%02X", string.byte(character))
    end)
end

local function NewCategories(includeInstances)
    local categories, byKey = {}, {}
    for _, descriptor in ipairs(CATEGORY_ORDER) do
        local category = { key = descriptor.key, name = descriptor.name, done = 0, total = 0, objectives = {} }
        categories[#categories + 1] = category
        byKey[descriptor.key] = category
        if includeInstances and descriptor.key == "decor" then
            local instances = { key = "instances", name = "Instances", done = 0, total = 0, objectives = {}, instances = {} }
            categories[#categories + 1] = instances
            byKey.instances = instances
        end
    end
    return categories, byKey
end

local function IsComplete(att, group)
    -- Prefer Blizzard's live collection APIs so Zone Locked updates on the
    -- acquisition event rather than waiting for ATT's next full cache pass.
    if group.sourceID and C_TransmogCollection.GetAppearanceSourceInfo then
        local info = C_TransmogCollection.GetAppearanceSourceInfo(group.sourceID)
        if info and info.isCollected ~= nil then return info.isCollected and true or false end
    end
    if group.questID and C_QuestLog.IsQuestFlaggedCompleted then
        return C_QuestLog.IsQuestFlaggedCompleted(group.questID) and true or false
    end
    if group.achievementID and not group.criteriaID then
        local _, _, _, completed = GetAchievementInfo(group.achievementID)
        if completed ~= nil then return completed and true or false end
    end
    -- ATT's mountID is commonly the mount spell ID, whereas
    -- GetMountInfoByID expects the Mount Journal record ID. Translate it
    -- before making the live query; if no translation exists, the ATT
    -- account-wide collected field below remains authoritative.
    if group.mountID and C_MountJournal.GetMountFromSpell and C_MountJournal.GetMountInfoByID then
        local journalMountID = C_MountJournal.GetMountFromSpell(group.mountID)
        if journalMountID then
            local _, _, _, _, _, _, _, _, _, _, collected = C_MountJournal.GetMountInfoByID(journalMountID)
            if collected ~= nil then return collected and true or false end
        end
    end
    if group.speciesID and C_PetJournal.GetNumCollectedInfo then
        return (C_PetJournal.GetNumCollectedInfo(group.speciesID) or 0) > 0
    end
    if group.decorID and C_HousingCatalog and C_HousingCatalog.GetCatalogEntryInfoByRecordID and
        Enum and Enum.HousingCatalogEntryType then
        local ok, info = pcall(C_HousingCatalog.GetCatalogEntryInfoByRecordID,
            Enum.HousingCatalogEntryType.Decor, group.decorID, true)
        if ok and info then
            return ((info.numPlaced or 0) + (info.quantity or 0) + (info.remainingRedeemable or 0)) > 0
        end
    end
    -- Fall back to ATT for collectible types without a direct Blizzard query.
    local okCollected, collected = pcall(function() return group.collected end)
    if okCollected and collected then return true end
    local okSaved, saved = pcall(function() return group.saved end)
    return okSaved and saved and true or false
end

local function SafeValue(group, field)
    local ok, value = pcall(function() return group[field] end)
    return ok and value or nil
end

local function GetSourceMetadata(group, zoneName, fallbackMapID)
    local current, source, coordX, coordY, coordMapID = group
    for depth = 1, 8 do
        if not current then break end
        if depth > 1 and not source then
            source = SafeValue(current, "text") or SafeValue(current, "name")
        end

        local coord = SafeValue(current, "coord")
        if type(coord) == "table" and type(coord[1]) == "number" then
            coordX, coordY, coordMapID = coord[1], coord[2], coord[3] or fallbackMapID
        else
            local coords = SafeValue(current, "coords")
            if type(coords) == "table" then
                if type(coords[1]) == "table" and type(coords[1][1]) == "number" then
                    coordX, coordY, coordMapID = coords[1][1], coords[1][2], coords[1][3] or fallbackMapID
                else
                    for mapID, values in pairs(coords) do
                        local first = type(values) == "table" and values[1]
                        if type(first) == "table" and type(first[1]) == "number" then
                            coordX, coordY, coordMapID = first[1], first[2], tonumber(mapID) or fallbackMapID
                            break
                        elseif type(values) == "table" and type(values[1]) == "number" then
                            coordX, coordY, coordMapID = values[1], values[2], tonumber(mapID) or fallbackMapID
                            break
                        end
                    end
                end
            end
        end
        if coordX then break end
        current = SafeValue(current, "parent")
    end

    local location = zoneName
    if coordX and coordY then
        if coordX <= 1 and coordY <= 1 then coordX, coordY = coordX * 100, coordY * 100 end
        local mapInfo = coordMapID and C_Map.GetMapInfo(coordMapID)
        location = string.format("%s (%.1f, %.1f)", (mapInfo and mapInfo.name) or zoneName, coordX, coordY)
    end
    return source, location, coordMapID or fallbackMapID, coordX, coordY
end

local function GetTransmogSourceType(att, group)
    local current = group
    local vendorHeader = att and att.HeaderConstants and att.HeaderConstants.VENDORS or -58
    local commonVendorHeader = att and att.HeaderConstants and att.HeaderConstants.COMMON_VENDOR_ITEMS or -22
    for _ = 1, 10 do
        if not current then break end
        if SafeValue(current, "questID") then return "quest" end
        local headerID = SafeValue(current, "headerID")
        if SafeValue(current, "isVendor") or SafeValue(current, "vendorID") or
            headerID == vendorHeader or headerID == commonVendorHeader then
            return "vendor"
        end
        current = SafeValue(current, "parent")
    end
    return "zone"
end

local function AddObjective(att, category, group, id, fallbackName, seen, zoneName, mapID, instanceName)
    local unique = category.key .. ":" .. tostring(id)
    if seen[unique] then return end
    seen[unique] = true

    local complete = IsComplete(att, group)
    category.total = category.total + 1
    if complete then category.done = category.done + 1 end
    local source, location, coordMapID, coordX, coordY = GetSourceMetadata(group, zoneName, mapID)
    local objectiveName = SafeValue(group, "text") or SafeValue(group, "name") or fallbackName
    local sourceID = SafeValue(group, "sourceID")
    local itemID = SafeValue(group, "itemID")
    local sourceInfo = sourceID and C_TransmogCollection.GetSourceInfo and C_TransmogCollection.GetSourceInfo(sourceID)
    itemID = itemID or (sourceInfo and sourceInfo.itemID) or
        (sourceID and C_TransmogCollection.GetSourceItemID and C_TransmogCollection.GetSourceItemID(sourceID))
    local itemName, itemLink, itemIcon
    if itemID then
        itemName, itemLink, _, _, _, _, _, _, _, itemIcon = C_Item.GetItemInfo(itemID)
        if not itemName and C_Item.RequestLoadItemDataByID then C_Item.RequestLoadItemDataByID(itemID) end
    end
    if itemName and (not objectiveName or objectiveName == fallbackName or
        (type(objectiveName) == "string" and
            (objectiveName:match("^Appearance %d+$") or objectiveName:match("^Item %d+$")))) then
        objectiveName = itemName
    end
    local spellID = SafeValue(group, "spellID")
    local wowheadURL
    if category.key == "achievements" then
        wowheadURL = "https://www.wowhead.com/achievement=" .. id
    elseif category.key == "quests" then
        wowheadURL = "https://www.wowhead.com/quest=" .. id
    elseif category.key == "pets" then
        wowheadURL = "https://www.wowhead.com/battle-pet=" .. id
    elseif itemID then
        wowheadURL = "https://www.wowhead.com/item=" .. itemID
    elseif category.key == "mounts" and spellID then
        wowheadURL = "https://www.wowhead.com/spell=" .. spellID
    else
        wowheadURL = "https://www.wowhead.com/search?q=" .. URLEncode(objectiveName)
    end
    category.objectives[#category.objectives + 1] = {
        id = id,
        name = objectiveName,
        icon = SafeValue(group, "icon") or itemIcon,
        link = SafeValue(group, "link") or itemLink,
        complete = complete,
        source = source,
        location = location,
        instanceName = instanceName,
        categoryKey = category.key,
        itemID = itemID,
        sourceID = sourceID,
        decorID = SafeValue(group, "decorID"),
        coordMapID = coordMapID,
        coordX = coordX,
        coordY = coordY,
        transmogSource = category.key == "transmogs" and GetTransmogSourceType(att, group) or nil,
        wowheadURL = wowheadURL,
    }
end

local GROUP_SIZE_CACHE = setmetatable({}, { __mode = "k" })
local function GroupSize(group, seen)
    if type(group) ~= "table" or (seen and seen[group]) then return 0 end
    if not seen and GROUP_SIZE_CACHE[group] then return GROUP_SIZE_CACHE[group] end
    local isRoot = not seen
    seen = seen or {}
    seen[group] = true
    local size = 1
    for _, child in ipairs(SafeValue(group, "g") or {}) do size = size + GroupSize(child, seen) end
    if isRoot then GROUP_SIZE_CACHE[group] = size end
    return size
end

local function BestCachedGroup(att, field, id)
    local container = att.GetRawFieldContainer and att.GetRawFieldContainer(field)
    if not container and att.SearchForFieldContainer then container = att.SearchForFieldContainer(field) end
    local groups = container and container[id]
    if type(groups) ~= "table" then return att.SearchForObject and att.SearchForObject(field, id, "field") end
    local best, bestSize
    for _, candidate in ipairs(groups) do
        if SafeValue(candidate, field) == id then
            local size = GroupSize(candidate)
            if not bestSize or size > bestSize then best, bestSize = candidate, size end
        end
    end
    return best or (att.SearchForObject and att.SearchForObject(field, id, "field"))
end

local function CharacterCanUse(att, group)
    if group.u == 1 or group.u == 2 then return false end
    if att.CurrentCharacterFilters then
        local ok, eligible = pcall(att.CurrentCharacterFilters, group)
        if ok and not eligible then return false end
    end
    return true
end

local function BuildFromMap(att, mapGroup, mapID, zoneName, linkedGroups)
    local categories, byKey = NewCategories(true)

    local function Visit(group, targetByKey, seen, inRareGroup, contextName, contextMapID, instanceName, skipInstances)
        if not CharacterCanUse(att, group) then return end
        if skipInstances and SafeValue(group, "instanceID") then return end
        inRareGroup = inRareGroup or group.headerID == -46
        contextName = contextName or zoneName
        contextMapID = contextMapID or mapID

        if group.achievementID and not group.criteriaID and not SafeValue(group, "isGuild") and
            not SafeValue(group, "guildAchievementID") then
            AddObjective(att, targetByKey.achievements, group, group.achievementID,
                "Achievement " .. group.achievementID, seen, contextName, contextMapID, instanceName)
        elseif group.questID and not group.repeatable and not group.isDaily and not group.isWeekly and not group.isBreadcrumb then
            AddObjective(att, targetByKey.quests, group, group.questID, "Quest " .. group.questID,
                seen, contextName, contextMapID, instanceName)
        elseif group.mountID then
            AddObjective(att, targetByKey.mounts, group, group.mountID, "Mount " .. group.mountID,
                seen, contextName, contextMapID, instanceName)
        end

        if group.speciesID then
            AddObjective(att, targetByKey.pets, group, group.speciesID, "Battle Pet " .. group.speciesID,
                seen, contextName, contextMapID, instanceName)
        end

        if group.decorID then
            AddObjective(att, targetByKey.decor, group, group.decorID, "Decor " .. group.decorID,
                seen, contextName, contextMapID, instanceName)
        end

        if group.sourceID and not inRareGroup then
            AddObjective(att, targetByKey.transmogs, group, group.sourceID, "Appearance " .. group.sourceID,
                seen, contextName, contextMapID, instanceName)
        end

        if inRareGroup and group.itemID then
            AddObjective(att, targetByKey.rares, group, group.itemID, "Rare drop " .. group.itemID,
                seen, contextName, contextMapID, instanceName)
        end

        local children = group.g
        if children then
            for index = 1, #children do
                Visit(children[index], targetByKey, seen, inRareGroup, contextName, contextMapID, instanceName, skipInstances)
            end
        end
    end

    Visit(mapGroup, byKey, {}, false, nil, nil, nil, true)
    local includedInstances = {}
    for _, linked in ipairs(linkedGroups or {}) do
        local instanceCategories, instanceByKey = NewCategories(false)
        Visit(linked.group, instanceByKey, {}, false, linked.name, linked.mapID, linked.name, false)
        local instanceDone, instanceTotal = 0, 0
        for _, category in ipairs(instanceCategories) do
            instanceDone = instanceDone + category.done
            instanceTotal = instanceTotal + category.total
        end
        local instanceData = {
            mapID = linked.mapID,
            name = linked.name,
            kind = linked.kind or "Instance",
            categories = instanceCategories,
            done = instanceDone,
            total = instanceTotal,
        }
        includedInstances[#includedInstances + 1] = instanceData
        byKey.instances.instances[#byKey.instances.instances + 1] = instanceData
        byKey.instances.done = byKey.instances.done + instanceDone
        byKey.instances.total = byKey.instances.total + instanceTotal
    end
    local done, total = 0, 0
    for _, category in ipairs(categories) do
        done = done + category.done
        total = total + category.total
    end
    return {
        mapID = mapID, name = zoneName, categories = categories,
        done = done, total = total, catalogComplete = true,
        eligible = true, provider = #includedInstances > 0 and "ATT + instances" or "ATT",
        instances = includedInstances,
    }
end

function ZL:RequestATTRefresh(reason, delay)
    local att = self.ATT
    if not att or not att.RefreshCollections then
        self:ScheduleRefresh(delay or 0.05)
        return
    end
    self.attRefreshQueued = true
    if self.attRefreshTimerPending then return end
    self.attRefreshTimerPending = true
    C_Timer.After(delay or 0.08, function()
        self.attRefreshTimerPending = false
        if not self.attRefreshQueued or self.ATT ~= att then return end
        self.attRefreshQueued = false
        if self.attRefreshInProgress then
            self.attRefreshAfterCurrent = true
            return
        end
        self.attRefreshInProgress = true
        att.RefreshCollections()
    end)
end

function ZL:InitATTProvider()
    local att = _G.AllTheThings or _G.ATTC
    if not att or not att.AddEventHandler then return end
    self.ATT = att
    self.ATTReady = false

    local function MarkReady()
        self.attRefreshInProgress = false
        self.ATTReady = true
        self:IndexATTInstances()
        self._uiSignature = nil
        self:ScheduleRefresh(0.05)
        if self.attRefreshAfterCurrent or self.attRefreshQueued then
            self.attRefreshAfterCurrent = false
            self:RequestATTRefresh("queued change", 0.05)
        end
    end
    att.AddEventHandler("OnRefreshCollectionsDone", function()
        MarkReady()
    end)

    -- ATT performs its own login refresh. If that completion happened before
    -- this handler was attached, use the already-built database without
    -- forcing another refresh or printing additional ATT chat messages.
    C_Timer.After(1, function()
        if self.ATT == att and not self.ATTReady then
            local ok = pcall(att.GetDatabaseRoot, att)
            if ok then MarkReady() end
        end
    end)
end

function ZL:IndexATTInstances()
    local att = self.ATT
    if not att or self.ATTInstancesIndexed then return end
    local ok, container = pcall(function()
        att:GetDatabaseRoot()
        return att.GetRawFieldContainer and att.GetRawFieldContainer("instanceID") or
            (att.SearchForFieldContainer and att.SearchForFieldContainer("instanceID"))
    end)
    if not ok or type(container) ~= "table" then return end
    self.ATTInstancesIndexed = true

    local configuredMaps = {}
    for _, instances in pairs(self.ZoneInstances) do
        for _, instance in ipairs(instances) do configuredMaps[instance.mapID] = true end
    end

    for instanceID, groups in pairs(container) do
        instanceID = tonumber(instanceID)
        local group = instanceID and BestCachedGroup(att, "instanceID", instanceID)
        local unobtainable = group and SafeValue(group, "u")
        if group and unobtainable ~= 1 and unobtainable ~= 2 then
            local parentMapID
            for _, candidate in ipairs(type(groups) == "table" and groups or {}) do
                local current = candidate
                for _ = 1, 8 do
                    if not current then break end
                    local coords = SafeValue(current, "coords")
                    if type(coords) == "table" then
                        for candidateMapID in pairs(coords) do
                            candidateMapID = tonumber(candidateMapID)
                            local routeParentMapID = candidateMapID and (self.HubRouteParents[candidateMapID] or candidateMapID)
                            if routeParentMapID and self:GetMapExpansionKey(routeParentMapID) and
                                not self:IsHubMap(routeParentMapID) then
                                parentMapID = routeParentMapID
                                break
                            end
                        end
                    end
                    if parentMapID then break end
                    current = SafeValue(current, "parent")
                end
                if parentMapID then break end
            end
            local mapID = SafeValue(group, "mapID")
            local maps = SafeValue(group, "maps")
            if not mapID and type(maps) == "table" then mapID = maps[1] end
            if parentMapID and mapID and not configuredMaps[mapID] then
                local childMapIDs = {}
                for _, childMapID in ipairs(type(maps) == "table" and maps or {}) do
                    if childMapID ~= mapID then childMapIDs[#childMapIDs + 1] = childMapID end
                end
                local entry = {
                    instanceID = instanceID,
                    mapID = mapID,
                    childMapIDs = childMapIDs,
                    name = SafeValue(group, "text") or SafeValue(group, "name") or ("Instance " .. instanceID),
                    kind = SafeValue(group, "isRaid") and "Raid" or "Dungeon",
                }
                self.ZoneInstances[parentMapID] = self.ZoneInstances[parentMapID] or {}
                self.ZoneInstances[parentMapID][#self.ZoneInstances[parentMapID] + 1] = entry
                self.InstanceParents[mapID] = parentMapID
                for _, childMapID in ipairs(childMapIDs) do self.InstanceParents[childMapID] = parentMapID end
                configuredMaps[mapID] = true
            end
        end
    end
end

function ZL:ScanZoneFromATT(mapID)
    local att = self.ATT
    if not att then return nil, false end
    if not self.ATTReady then return nil, true end

    local ok, result = pcall(function()
        att:GetDatabaseRoot()
        local mapGroup = BestCachedGroup(att, "mapID", mapID)
        if not mapGroup then return nil end
        local linkedGroups = {}
        for _, instance in ipairs(self.ZoneInstances[mapID] or {}) do
            local group = instance.instanceID and BestCachedGroup(att, "instanceID", instance.instanceID) or
                BestCachedGroup(att, "mapID", instance.mapID)
            if group then
                linkedGroups[#linkedGroups + 1] = {
                    group = group,
                    mapID = instance.mapID,
                    name = instance.name or self:GetZoneDefinition(instance.mapID).name,
                    kind = instance.kind,
                }
            end
        end
        return BuildFromMap(att, mapGroup, mapID, self:GetZoneDefinition(mapID).name, linkedGroups)
    end)
    if not ok then
        if not self._attErrorReported then
            self._attErrorReported = true
            self:Print("ATT provider failed; using the built-in catalog for this session.")
        end
        self.ATT = nil
        return nil, false
    end
    if not result then
        self.ATT = nil
        return nil, false
    end
    return result, true
end
