local addonName, ZL = ...

-- ZoneLocked is intentionally data-driven. Retail's API can check known IDs, but
-- it does not provide a complete "everything obtainable on this map" index.
-- Add zone definitions here (or from a separate data pack) using this schema:
--
-- ZL.Catalog[mapID] = {
--     name = "Zone name", -- optional; C_Map supplies it when omitted
--     catalogComplete = true, -- assert that every required category was curated
--     achievements = { 12345 },
--     quests = { 123, 456 },
--     mounts = { 789 }, -- Mount Journal mount IDs, not spell IDs
--     pets = { 1234 }, -- Battle Pet species IDs
--     decor = { 1234 }, -- Housing decor record IDs
--     transmogs = { 123456 }, -- itemModifiedAppearance/source IDs
--     rares = {
--         { npcID = 1234, itemID = 5678, sourceID = 9012, name = "Rare: Drop" },
--     },
-- }

ZL.Catalog = ZL.Catalog or {}
ZL.ZoneData = ZL.ZoneData or {}

-- Capital and sanctuary maps are neutral travel spaces for the challenge. They
-- never black out and cannot become rollable route entries.
ZL.HubMaps = {
    [84] = true,   -- Stormwind City
    [85] = true,   -- Orgrimmar
    [87] = true,   -- Ironforge
    [88] = true,   -- Thunder Bluff
    [89] = true,   -- Darnassus
    [90] = true,   -- Undercity
    [103] = true,  -- The Exodar
    [110] = true,  -- Silvermoon City
    [111] = true,  -- Shattrath City
    [125] = true,  -- Dalaran (Northrend)
    [627] = true,  -- Dalaran (Broken Isles)
    [1161] = true, -- Boralus
    [1165] = true, -- Dazar'alor
    [1670] = true, -- Oribos
    [2112] = true, -- Valdrakken
    [2339] = true, -- Dornogal
}

-- Instances whose ATT entrance is inside a capital inherit the nearest
-- outdoor challenge zone instead of making the capital routeable.
ZL.HubRouteParents = {
    [84] = 37,  -- Stormwind -> Elwynn Forest
    [85] = 1,   -- Orgrimmar -> Durotar
    [87] = 27,  -- Ironforge -> Dun Morogh
    [88] = 7,   -- Thunder Bluff -> Mulgore
    [89] = 57,  -- Darnassus -> Teldrassil
    [90] = 18,  -- Undercity -> Tirisfal Glades
    [103] = 97, -- Exodar -> Azuremyst Isle
    [110] = 94, -- Silvermoon -> Eversong Woods
}

-- Instances are owned by an outdoor route zone. Their ATT objectives are
-- folded into that zone instead of creating a separate route stop.
ZL.ZoneInstances = {
    [27] = {
        { instanceID = 231, mapID = 226, childMapIDs = { 227, 228, 229 }, name = "Gnomeregan", kind = "Dungeon" },
    },
    [42] = {
        {
            instanceID = 745,
            mapID = 350,
            childMapIDs = { 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366 },
            name = "Karazhan",
            kind = "Raid",
        },
    },
}
ZL.InstanceParents = {}
for parentMapID, instances in pairs(ZL.ZoneInstances) do
    for _, instance in ipairs(instances) do
        ZL.InstanceParents[instance.mapID] = parentMapID
        for _, childMapID in ipairs(instance.childMapIDs or {}) do
            ZL.InstanceParents[childMapID] = parentMapID
        end
    end
end

-- Outdoor adjacency graph. Cities and instances are deliberately omitted.
ZL.Adjacency = {
    -- Kalimdor
    [1] = { 10, 76 },                    -- Durotar
    [7] = { 10, 65, 199 },               -- Mulgore
    [10] = { 1, 7, 63, 65, 199 },        -- Northern Barrens
    [57] = { 62 },                        -- Teldrassil
    [62] = { 57, 63, 77 },                -- Darkshore
    [63] = { 10, 62, 65, 76, 77 },        -- Ashenvale
    [64] = { 69, 70, 71, 78, 199 },       -- Thousand Needles
    [65] = { 7, 10, 63, 66 },             -- Stonetalon Mountains
    [66] = { 65, 69 },                    -- Desolace
    [69] = { 64, 66, 78 },                -- Feralas
    [70] = { 64, 199 },                   -- Dustwallow Marsh
    [71] = { 64, 78, 249 },               -- Tanaris
    [76] = { 1, 63 },                     -- Azshara
    [77] = { 62, 63, 80, 83, 198 },       -- Felwood
    [78] = { 64, 69, 71, 81 },            -- Un'Goro Crater
    [80] = { 77, 83, 198 },                -- Moonglade
    [81] = { 78 },                         -- Silithus
    [83] = { 77, 80, 198 },                -- Winterspring
    [97] = { 106 },                        -- Azuremyst Isle
    [106] = { 97 },                        -- Bloodmyst Isle
    [174] = { 194 },                       -- The Lost Isles
    [194] = { 174 },                       -- Kezan
    [198] = { 77, 80, 83 },                -- Mount Hyjal
    [199] = { 7, 10, 64, 70 },             -- Southern Barrens
    [249] = { 71 },                        -- Uldum

    -- Eastern Kingdoms
    [37] = { 52, 47, 49 },       -- Elwynn Forest
    [52] = { 37, 47 },           -- Westfall
    [47] = { 37, 52, 49, 42 },   -- Duskwood
    [49] = { 37, 47, 36 },       -- Redridge Mountains
    [42] = { 47 },               -- Deadwind Pass
    [36] = { 49, 32 },           -- Burning Steppes
    [27] = { 48 },               -- Dun Morogh
    [48] = { 27, 56, 15 },       -- Loch Modan
    [56] = { 48, 22, 14 },       -- Wetlands
    [15] = { 48, 32 },           -- Badlands
    [32] = { 15, 36 },           -- Searing Gorge
    [18] = { 21, 22 },           -- Tirisfal Glades
    [21] = { 18, 22, 25 },       -- Silverpine Forest
    [22] = { 18, 21, 56 },       -- Western Plaguelands
    [25] = { 21, 14 },           -- Hillsbrad Foothills
    [14] = { 25, 56 },           -- Arathi Highlands
}

function ZL:GetZoneDefinition(mapID)
    local definition = self.Catalog[mapID] or {}
    local mapInfo = C_Map.GetMapInfo(mapID)
    definition.name = definition.name or (mapInfo and mapInfo.name) or ("Map " .. mapID)
    return definition
end

function ZL:GetRoutePosition(mapID)
    for index, routeMapID in ipairs(ZL_Saved.route or {}) do
        if routeMapID == mapID then
            return index
        end
    end
end

function ZL:IsHubMap(mapID)
    return mapID and self.HubMaps[mapID] == true or false
end

function ZL:GetInstanceRouteZone(mapID)
    local candidate = mapID
    while candidate do
        local parentRouteMapID = self.InstanceParents[candidate]
        if parentRouteMapID then return parentRouteMapID, candidate end
        local info = C_Map.GetMapInfo(candidate)
        candidate = info and info.parentMapID
    end
end

function ZL:GetRouteContextZone(mapID)
    return self:GetInstanceRouteZone(mapID) or mapID
end

function ZL:ResetZoneRunState(mapID)
    if not mapID then return end
    ZL_Saved.completedZones[mapID] = nil
    ZL_Saved.zoneSummaries[mapID] = nil
    ZL_Saved.historyKnown["zone:" .. mapID] = nil
    self.ZoneData[mapID] = nil
end

function ZL:AddCurrentZoneToRoute()
    local mapID = self:GetCurrentZoneID()
    if not mapID then return false, "The current map could not be identified." end
    local expansionName, expansionKey = self:GetExpansionNameForMap(mapID)
    if not expansionKey then return false, "This map is not part of a configured expansion pack." end
    if not self:IsExpansionEnabled(expansionKey) then
        return false, expansionName .. " is disabled in Zone Locked settings."
    end
    if self:IsHubMap(mapID) then return false, "Capital cities and sanctuary hubs cannot be added to a route." end
    if not self:IsZoneEligibleForCharacter(mapID) then return false, "This zone is not eligible for this character." end
    local instanceParent = self:GetInstanceRouteZone(mapID)
    local mapInfo = C_Map.GetMapInfo(mapID)
    local isDungeon = Enum and Enum.UIMapType and mapInfo and mapInfo.mapType == Enum.UIMapType.Dungeon
    if instanceParent or isDungeon then
        local parentName = instanceParent and self:GetZoneDefinition(instanceParent).name
        return false, parentName and ("This instance is tracked as part of " .. parentName .. ".") or
            "Instances are tracked through their linked outdoor route zone."
    end
    if self:GetRoutePosition(mapID) then return false, "This zone is already in the route." end

    table.insert(ZL_Saved.route, mapID)
    ZL_Saved.rolledZones[mapID] = true
    self:ResetZoneRunState(mapID)
    self:ScanZone(mapID)
    return true, self:GetZoneDefinition(mapID).name .. " added to the route."
end

function ZL:SetStartingZone(mapID)
    local expansionName, expansionKey = self:GetExpansionNameForMap(mapID)
    if not expansionKey then return false, "That map is not part of a configured expansion pack." end
    if not self:IsExpansionEnabled(expansionKey) then
        return false, expansionName .. " is disabled in Zone Locked settings."
    end
    if self:IsHubMap(mapID) then return false, "Capital cities and sanctuary hubs cannot be starting zones." end
    if not self:IsZoneEligibleForCharacter(mapID) then return false, "That starting zone is not eligible for this character." end
    if self:GetInstanceRouteZone(mapID) then return false, "Choose the linked outdoor zone instead of the instance." end
    if not self.Catalog[mapID] and not self.ATT then
        return false, "That zone requires All The Things for objective data."
    end
    if self:GetRoutePosition(mapID) then return false, "This zone is already in the route." end

    table.insert(ZL_Saved.route, 1, mapID)
    ZL_Saved.rolledZones[mapID] = true
    self:ResetZoneRunState(mapID)
    ZL_Saved.currentJourneyStartIndex = 1
    ZL_Saved.currentJourneyStartedAt = ZL_Saved.currentJourneyStartedAt or time()
    self:ScanZone(mapID)
    self:CheckZoneState()
    self._uiSignature = nil
    self:RefreshUI()
    return true, self:GetZoneDefinition(mapID).name .. " selected as your starting zone."
end

local function ShuffleAndLimit(values, limit)
    for index = #values, 2, -1 do
        local swap = math.random(index)
        values[index], values[swap] = values[swap], values[index]
    end
    while #values > (limit or 6) do table.remove(values) end
    return values
end

function ZL:GetContinentRollCandidates(continentID, expansionKey, useEntryZones)
    local candidates, allowed, expansion = {}, nil, self.ExpansionByKey and self.ExpansionByKey[expansionKey]
    if not self:IsExpansionEnabled(expansionKey) then return candidates end
    if useEntryZones and expansionKey == "vanilla" then
        allowed = {}
        for _, starter in ipairs(self.StartingZones or {}) do
            if self:IsStartingZoneEligible(starter, continentID, expansionKey) then allowed[starter.mapID] = true end
        end
    elseif useEntryZones and expansion and #(expansion.entryZones or {}) > 0 then
        allowed = {}
        for _, mapID in ipairs(expansion.entryZones) do allowed[mapID] = true end
    end
    for _, mapID in ipairs(self:GetContinentZoneIDs(continentID, expansionKey)) do
        local hasProvider = self.ATT or self.Catalog[mapID]
        if C_Map.GetMapInfo(mapID) and hasProvider and self:IsZoneEligibleForCharacter(mapID) and
            (not allowed or allowed[mapID]) and not self:GetRoutePosition(mapID) and
            not ZL_Saved.rolledZones[mapID] then
            candidates[#candidates + 1] = mapID
        end
    end
    return ShuffleAndLimit(candidates, 6)
end

function ZL:GetInitialRollCandidates(continentID, expansionKey)
    if #(ZL_Saved.route or {}) > 0 then return nil, "A journey is already in progress." end
    if self.ATT and not self.ATTReady then return nil, "ATT is synchronizing collection and instance data. Try again in a moment." end
    local candidates = self:GetContinentRollCandidates(continentID, expansionKey, true)
    if #candidates == 0 then return nil, "No routeable zones were found on that continent." end
    return candidates, {
        mode = "start", continentID = continentID, expansionKey = expansionKey,
        label = "Beginning in " .. self:GetContinentName(continentID, expansionKey),
    }
end

function ZL:GetNextJourneyContinent(currentContinentID, currentExpansionKey)
    local currentExpansionIndex = 0
    for index, expansion in ipairs(self.Expansions) do
        if expansion.key == currentExpansionKey then currentExpansionIndex = index; break end
    end

    -- Finish every continent in the current expansion before advancing.
    for _, continentID in ipairs(self:GetExpansionContinents(currentExpansionKey)) do
        if continentID ~= currentContinentID then
            local candidates = self:GetContinentRollCandidates(continentID, currentExpansionKey, true)
            if #candidates > 0 then
                return continentID, currentExpansionKey, candidates, "continent",
                    "Crossing to " .. self:GetContinentName(continentID, currentExpansionKey)
            end
        end
    end

    -- Expansion order is deterministic even when the user enables only a
    -- subset: Vanilla -> TBC -> Wrath -> ... -> current content.
    for index = currentExpansionIndex + 1, #self.Expansions do
        local expansion = self.Expansions[index]
        if self:IsExpansionEnabled(expansion.key) then
            for _, continentID in ipairs(self:GetExpansionContinents(expansion.key)) do
                local candidates = self:GetContinentRollCandidates(continentID, expansion.key, true)
                if #candidates > 0 then
                    return continentID, expansion.key, candidates, "expansion",
                        "Entering " .. expansion.name
                end
            end
        end
    end
end

function ZL:RemoveZoneFromRoute(mapID)
    local position = self:GetRoutePosition(mapID)
    if not position then return false, "That zone is not in the route." end

    local name = self:GetZoneDefinition(mapID).name
    table.remove(ZL_Saved.route, position)
    ZL_Saved.rolledZones = {}
    for _, routeMapID in ipairs(ZL_Saved.route) do ZL_Saved.rolledZones[routeMapID] = true end
    ZL_Saved.transition = nil
    ZL_Saved.currentJourneyStartIndex = math.min(ZL_Saved.currentJourneyStartIndex or 1, #ZL_Saved.route + 1)
    if #ZL_Saved.route == 0 then
        ZL_Saved.currentJourneyStartIndex, ZL_Saved.currentJourneyStartedAt = 1, nil
    end
    self.ZoneData[mapID] = nil
    if self.selectedMapID == mapID then self.selectedMapID = nil end
    self._uiSignature = nil
    self:CheckZoneState()
    self:RefreshUI()
    return true, name .. " removed from the route."
end

function ZL:GetRollCandidates()
    local route = ZL_Saved.route or {}
    if self.ATT and not self.ATTReady then return nil, nil, "ATT is synchronizing collection and instance data. Try again in a moment." end
    local activeMapID = route[self:GetUnlockedIndex()]
    if not activeMapID then return nil, nil, "No active zone is configured." end
    if not self:IsZoneComplete(activeMapID) then return nil, nil, "Reach 100% before rolling the next zone." end
    if route[self:GetUnlockedIndex() + 1] then return nil, nil, "The next zone has already been selected." end

    local continentID, expansionKey = self:GetMapContinentID(activeMapID)
    local candidates, candidateSet = {}, {}
    local function AddCandidate(mapID)
        local candidateContinentID = self:GetMapContinentID(mapID)
        local hasProvider = self.ATT or self.Catalog[mapID]
        if candidateContinentID == continentID and hasProvider and self:IsMapExpansionEnabled(mapID) and
            not self:GetRoutePosition(mapID) and not ZL_Saved.rolledZones[mapID] and not candidateSet[mapID] then
            candidateSet[mapID] = true
            candidates[#candidates + 1] = mapID
        end
    end
    for _, mapID in ipairs(self:GetAdjacentZoneIDs(activeMapID)) do
        AddCandidate(mapID)
    end

    local context = { mode = "adjacent", continentID = continentID, expansionKey = expansionKey,
        gatewayMapID = activeMapID, label = "Adjacent to " .. self:GetZoneDefinition(activeMapID).name }

    -- A walk can end at a cul-de-sac before the whole continent is covered.
    -- Continue from any already-cleared border on the connected frontier; the
    -- player can travel back through those permanently unlocked zones.
    if #candidates == 0 then
        for index = #route, 1, -1 do
            local gatewayMapID = route[index]
            local gatewayContinentID = self:GetMapContinentID(gatewayMapID)
            if gatewayContinentID == continentID and self:IsZoneComplete(gatewayMapID) then
                for _, mapID in ipairs(self:GetAdjacentZoneIDs(gatewayMapID)) do AddCandidate(mapID) end
                if #candidates > 0 then
                    context.mode = "frontier"
                    context.gatewayMapID = gatewayMapID
                    context.label = "Continuing from the completed frontier at " .. self:GetZoneDefinition(gatewayMapID).name
                    break
                end
            end
        end
    end

    -- Islands and portal-only zones have no land border on the continent map.
    if #candidates == 0 then
        candidates = self:GetContinentRollCandidates(continentID, expansionKey, false)
        if #candidates > 0 then
            context.mode = "crossing"
            context.label = "Crossing to another landmass in " .. self:GetContinentName(continentID, expansionKey)
        end
    end

    if #candidates == 0 then
        local nextContinentID, nextExpansionKey, nextCandidates, mode, label =
            self:GetNextJourneyContinent(continentID, expansionKey)
        if nextCandidates then
            candidates = nextCandidates
            context = {
                mode = mode, continentID = nextContinentID, expansionKey = nextExpansionKey,
                gatewayMapID = activeMapID, label = label,
            }
        end
    end

    if #candidates == 0 then
        return nil, nil, "This journey has cleared every available zone. Enable the next expansion to continue."
    end
    return activeMapID, ShuffleAndLimit(candidates, 6), nil, context
end

function ZL:CommitRolledZone(activeMapID, nextMapID, context)
    if self:GetActiveZone() ~= activeMapID or not self:IsZoneComplete(activeMapID) then
        return false, "The active route changed before the roll completed."
    end
    if self:GetRoutePosition(nextMapID) then return false, "That zone is already in the route." end
    local route = ZL_Saved.route or {}
    local currentContinentID = self:GetMapContinentID(activeMapID)
    local nextContinentID = self:GetMapContinentID(nextMapID)
    if currentContinentID ~= nextContinentID and self.ArchiveJourneyLeg then
        self:ArchiveJourneyLeg(activeMapID, context)
    end
    ZL_Saved.rolledZones[nextMapID] = true
    table.insert(route, nextMapID)
    self:ResetZoneRunState(nextMapID)
    ZL_Saved.transition = { from = (context and context.gatewayMapID) or activeMapID, to = nextMapID,
        mode = context and context.mode }
    self:ScanZone(nextMapID)
    self.selectedMapID = nil
    self._uiSignature = nil
    self:RefreshUI()
    return true, self:GetZoneDefinition(nextMapID).name .. " has been unlocked!"
end

-- Retained as a non-animated fallback for integrations which call this directly.
function ZL:RollNextZone()
    local activeMapID, candidates, errorMessage, context = self:GetRollCandidates()
    if not activeMapID then return false, errorMessage end
    return self:CommitRolledZone(activeMapID, candidates[math.random(#candidates)], context)
end
