local pluginName, pluginShared = ...
local Lockouts = pluginShared.Lockouts
local L = LibStub("AceLocale-3.0"):GetLocale("AppearanceMatic")

-- Access gates: what a player must do before a raid opens (Data/Gates.lua), read live from the game
-- and shown as one "Access:" line under the raid, on guided rows, in the wardrobe tooltip and in the
-- route title. The guide never hides a pin over a gate; it tells the player what to do first.

local RED, GREEN, GREY = { 1, 0.35, 0.35 }, { 0.4, 1, 0.4 }, { 0.7, 0.7, 0.7 }
-- every gate line draws in legendary orange; the state colours above only tag the state record
local GATE_TEXT = { 1, 0.5, 0 }
local GATE_OPEN_TEXT = { 1, 0.97, 0.86 } -- the panel's own cream white for a gate that stands open

local function mapName(mapID)
    if mapID and type(C_Map) == "table" and type(C_Map.GetMapInfo) == "function" then
        local ok, info = pcall(C_Map.GetMapInfo, mapID)
        if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then
            return info.name
        end
    end
    return nil
end

local function factionLabel(tag)
    if tag == "Alliance" then
        return FACTION_ALLIANCE or tag
    elseif tag == "Horde" then
        return FACTION_HORDE or tag
    end
    return tostring(tag)
end

local function questDone(questID)
    if type(C_QuestLog) ~= "table" then
        return false
    end
    if type(C_QuestLog.IsQuestFlaggedCompleted) == "function" then
        local ok, done = pcall(C_QuestLog.IsQuestFlaggedCompleted, questID)
        if ok and done == true then
            return true
        end
    end
    if type(C_QuestLog.IsQuestFlaggedCompletedOnAccount) == "function" then
        local ok, done = pcall(C_QuestLog.IsQuestFlaggedCompletedOnAccount, questID)
        if ok and done == true then
            return true
        end
    end
    return false
end

-- The client's own (localized) quest title; asks the server for it once and answers with the ID until
-- QUEST_DATA_LOAD_RESULT refreshes the gate.
local function questTitle(questID)
    if type(C_QuestLog) == "table" and type(C_QuestLog.GetTitleForQuestID) == "function" then
        local ok, title = pcall(C_QuestLog.GetTitleForQuestID, questID)
        if ok and type(title) == "string" and title ~= "" then
            return title
        end
        -- once a session, through Acquisition: asking on every draw looped through the answer event
        local acquisition = pluginShared.Acquisition
        if acquisition and acquisition.LoadQuestOnce then
            acquisition:LoadQuestOnce(questID)
        elseif type(C_QuestLog.RequestLoadQuestByID) == "function" then
            pcall(C_QuestLog.RequestLoadQuestByID, questID)
        end
    end
    return string.format(L["gate_quest_id_fmt"], questID)
end

-- This character's own completion only: an unlock that is per character (Legion world quests and
-- kin) is not opened by another character's account flag.
local function charDone(questID)
    if not (type(C_QuestLog) == "table" and type(C_QuestLog.IsQuestFlaggedCompleted) == "function") then
        return false
    end
    local ok, done = pcall(C_QuestLog.IsQuestFlaggedCompleted, questID)
    return ok and done == true
end

local function anyDone(quests, perCharacter)
    for _, questID in ipairs(quests or {}) do
        if perCharacter and charDone(questID) or (not perCharacter and questDone(questID)) then
            return true
        end
    end
    return false
end

function Lockouts:FormatWait(seconds)
    local minutes = math.floor(seconds / 60 + 0.5)
    if minutes >= 60 then
        return string.format(L["gate_wait_hours_fmt"], math.floor(minutes / 60), minutes % 60)
    end
    return string.format(L["gate_wait_minutes_fmt"], math.max(minutes, 1))
end

local function battleWaitSeconds(mapID)
    if not (mapID and type(C_PvP) == "table" and type(C_PvP.GetOutdoorPvPWaitTime) == "function") then
        return nil
    end
    local ok, seconds = pcall(C_PvP.GetOutdoorPvPWaitTime, mapID)
    if ok and type(seconds) == "number" and seconds > 0 then
        return seconds
    end
    return nil
end

local function nextBattle(mapID)
    local seconds = battleWaitSeconds(mapID)
    return seconds and string.format(L["gate_next_battle_fmt"], Lockouts:FormatWait(seconds)) or ""
end

-- Sightings of who holds a PvP zone, account-wide: the client serves the control pins and the battle
-- timer only near the zone itself, so the last observation carries the answer everywhere else.
-- Control flips only when a battle ends, making a sighting fact until its battle time passes.
-- A battle runs at most 30 minutes in both zones (Tol Barad 15 + 5 per tower, three towers;
-- Wintergrasp 30, shortened when towers fall), so past its start time the battle is on for
-- this long before the holder may have flipped.
local BATTLE_WINDOW = 30 * 60

local function controlMemory()
    local db = pluginShared.db
    local store = db and db.global
    if not store then
        return nil
    end
    store.zoneControl = store.zoneControl or {}
    return store.zoneControl
end

-- Encounter Journal instance ID for a raid name: the journal index first, then the shipped entrances.
function Lockouts:JournalIDOf(raidName)
    if type(raidName) ~= "string" then
        return nil
    end
    local wanted = raidName:lower()
    local bossEntry = self.BuildBossOrdinalIndex and self:BuildBossOrdinalIndex()[wanted]
    if bossEntry and bossEntry.originalInstanceID then
        return bossEntry.originalInstanceID
    end
    local shipped = pluginShared.RouteData and pluginShared.RouteData.ENTRANCES
    for id, row in pairs(type(shipped) == "table" and shipped or {}) do
        if type(row.name) == "string" and row.name:lower() == wanted then
            return id
        end
    end
    return nil
end

function Lockouts:GateRow(raidName)
    local gates = pluginShared.RouteData and pluginShared.RouteData.GATES
    if type(gates) ~= "table" then
        return nil
    end
    local id = self:JournalIDOf(raidName)
    return id and gates[id] or nil
end

-- Who holds a PvP zone: Blizzard's map pins (one per controlling faction), else the zone's own PvP
-- status while standing in it. nil when neither can answer from here.
function Lockouts:ZoneController(row)
    if row.controlPOI and type(C_AreaPoiInfo) == "table" and type(C_AreaPoiInfo.GetAreaPOIForMap) == "function" then
        local ok, ids = pcall(C_AreaPoiInfo.GetAreaPOIForMap, row.zoneMapID)
        if ok and type(ids) == "table" then
            for _, id in ipairs(ids) do
                if row.controlPOI[id] then
                    return row.controlPOI[id], id
                end
            end
        end
    end
    if self.InShippedZone and self:InShippedZone(row.zoneMapID) and type(C_PvP) == "table" and type(C_PvP.GetZonePVPInfo) == "function" then
        local ok, _, _, factionName = pcall(C_PvP.GetZonePVPInfo)
        if ok and type(factionName) == "string" and factionName ~= "" then
            for _, tag in ipairs({ "Alliance", "Horde" }) do
                if factionName == tag or factionName == factionLabel(tag) then
                    return tag
                end
            end
        end
    end
    return nil
end

-- Faction colours as Blizzard paints them; the client's own table when it is there, the same
-- values shipped otherwise (SharedColorConstants PLAYER_FACTION_COLORS).
local FACTION_RGB = { Horde = { 0.90, 0.05, 0.07 }, Alliance = { 0.29, 0.33, 0.91 } }
local function factionHex(tag)
    local group = tag == "Horde" and 0 or 1
    local color = type(PLAYER_FACTION_COLORS) == "table" and PLAYER_FACTION_COLORS[group] or nil
    local r, g, b
    if type(color) == "table" and type(color.GetRGB) == "function" then
        local ok, cr, cg, cb = pcall(color.GetRGB, color)
        if ok then r, g, b = cr, cg, cb end
    end
    if not r then
        local fallback = FACTION_RGB[tag] or FACTION_RGB.Alliance
        r, g, b = fallback[1], fallback[2], fallback[3]
    end
    return string.format("ff%02x%02x%02x", math.floor(r * 255 + 0.5), math.floor(g * 255 + 0.5), math.floor(b * 255 + 0.5))
end

-- The controlling faction as the map itself tells it: the live control pin's own icon (its atlas
-- when the client hands one, its cell on the POIIcons sheet otherwise, Wintergrasp's shipped keep
-- indexes when no pin exists) and the holder's name in faction colour.
function Lockouts:ControlBadge(row, holder, holderPOI)
    local icon = ""
    local info
    if holderPOI and type(C_AreaPoiInfo) == "table" and type(C_AreaPoiInfo.GetAreaPOIInfo) == "function" then
        local ok, live = pcall(C_AreaPoiInfo.GetAreaPOIInfo, row.zoneMapID, holderPOI)
        if ok and type(live) == "table" then
            info = live
        end
    end
    if info and type(info.atlasName) == "string" and info.atlasName ~= "" then
        icon = "|A:" .. info.atlasName .. ":16:16|a"
    else
        local index = info and info.textureIndex or (row.controlIcon and row.controlIcon[holder])
        if type(index) == "number" and type(C_Minimap) == "table" and type(C_Minimap.GetPOITextureCoords) == "function" then
            local ok, x1, x2, y1, y2 = pcall(C_Minimap.GetPOITextureCoords, index)
            if ok and type(x1) == "number" then
                icon = string.format("|TInterface\\Minimap\\POIIcons:16:16:0:0:1024:1024:%d:%d:%d:%d|t",
                    math.floor(x1 * 1024 + 0.5), math.floor(x2 * 1024 + 0.5), math.floor(y1 * 1024 + 0.5), math.floor(y2 * 1024 + 0.5))
            end
        end
    end
    return icon .. "|c" .. factionHex(holder) .. factionLabel(holder) .. "|r"
end

-- { state = "open" | "blocked" | "unknown", text, textRich, color } for a gated raid, nil for an
-- ungated one. text stays free of texture escapes (route titles carry it); textRich adds the control
-- pin's icon and faction colour for the panel and tooltips.
-- Cached until the next rebuild or a gate event (quest turned in, map pins or PvP state changed).
function Lockouts:GateState(raidName)
    local row = self:GateRow(raidName)
    if not row then
        return nil
    end
    self.gateStates = self.gateStates or {}
    local cached = self.gateStates[raidName]
    if cached then
        return cached
    end
    local zone = mapName(row.zoneMapID) or raidName
    local state
    if row.kind == "control" then
        local route = pluginShared.Route
        local me = route and route.PlayerFaction and route:PlayerFaction() or "Neutral"
        local holder, holderPOI = self:ZoneController(row)
        local wait = nextBattle(row.zoneMapID)
        local lastSeen, battling
        local memory = controlMemory()
        if holder then
            if memory then
                local seconds = battleWaitSeconds(row.zoneMapID)
                memory[row.zoneMapID] = { holder = holder, seenAt = time(), battleAt = seconds and (time() + seconds) or nil }
            end
        else
            local seen = memory and memory[row.zoneMapID]
            if seen and seen.holder then
                if seen.battleAt and seen.battleAt > time() then
                    holder = seen.holder
                    wait = string.format(L["gate_next_battle_fmt"], self:FormatWait(seen.battleAt - time()))
                elseif seen.battleAt and time() < seen.battleAt + BATTLE_WINDOW then
                    battling = seen.holder
                else
                    lastSeen = seen.holder
                end
            end
        end
        if holder and holder == me then
            state = { state = "open", text = string.format(L["gate_control_open_fmt"], zone, factionLabel(holder), raidName) .. wait, color = GREEN }
            state.textRich = string.format(L["gate_control_open_fmt"], zone, self:ControlBadge(row, holder, holderPOI), raidName) .. wait
        elseif holder then
            state = { state = "blocked", text = string.format(L["gate_control_blocked_fmt"], zone, factionLabel(holder), factionLabel(me), wait, raidName), color = RED }
            state.textRich = string.format(L["gate_control_blocked_fmt"], zone, self:ControlBadge(row, holder, holderPOI), factionLabel(me), wait, raidName)
        elseif battling then
            state = { state = "unknown", text = string.format(L["gate_control_battle_fmt"], zone, factionLabel(battling)), color = GREY }
            state.textRich = string.format(L["gate_control_battle_fmt"], zone, self:ControlBadge(row, battling, nil))
        elseif lastSeen then
            state = { state = "unknown", text = string.format(L["gate_control_last_seen_fmt"], zone, factionLabel(lastSeen), wait), color = GREY }
            state.textRich = string.format(L["gate_control_last_seen_fmt"], zone, self:ControlBadge(row, lastSeen, nil), wait)
        else
            -- the client answers only near the zone: say where the line starts filling in
            state = { state = "unknown", text = string.format(L["gate_control_unknown_fmt"], zone, wait) .. string.format(L["gate_control_hint_fmt"], zone), color = GREY }
        end
    elseif row.kind == "worldquest" then
        -- the group's weekly boss is a world quest; without the unlock quest this character cannot
        -- even see it, so the guide's active-boss scan comes up empty
        if anyDone(row.quests, row.perCharacter) then
            -- world-boss groups sit in the panel for good; an unlocked gate stays silent there
            -- instead of decorating them with a permanent access line
            state = { state = "open", text = string.format(L["gate_worldquest_open_fmt"], zone), color = GREEN, quietOpen = true }
        else
            state = { state = "blocked", text = string.format(L["gate_worldquest_blocked_fmt"], zone, questTitle(row.quests[1])), color = RED }
        end
    elseif row.kind == "travel" or row.kind == "quest" then
        -- travel: the zone's portal quest first; then, when known, the quest that opens the raid door
        if row.kind == "travel" and not anyDone(row.quests) then
            state = { state = "blocked", text = string.format(L["gate_travel_blocked_fmt"], zone, row.campaign and L[row.campaign] or ""), color = RED }
        elseif row.doorQuests and #row.doorQuests > 0 and not anyDone(row.doorQuests) then
            state = { state = "blocked", text = string.format(L["gate_quest_blocked_fmt"], zone, questTitle(row.doorQuests[1]), raidName), color = RED }
        elseif row.kind == "quest" then
            state = { state = "open", text = string.format(L["gate_quest_open_fmt"], zone, raidName), color = GREEN }
        else
            state = { state = "open", text = string.format(L["gate_travel_open_fmt"], zone, raidName), color = GREEN }
        end
    else
        return nil
    end
    self.gateStates[raidName] = state
    return state
end

-- "Access: ..." in legendary orange, nil when the raid has no gate.
function Lockouts:GateLine(raidName)
    local gate = self:GateState(raidName)
    if not gate or (gate.quietOpen and gate.state == "open") then
        return nil
    end
    local color = gate.state == "open" and GATE_OPEN_TEXT or GATE_TEXT
    return string.format(L["lock_access_label"], gate.textRich or gate.text), color[1], color[2], color[3]
end

-- A red requirement line for one gated boss inside a free group (Ordos and kin), nil when the
-- boss is open to this character or carries no gate.
function Lockouts:BossGateText(raidName, bossName)
    local gates = pluginShared.RouteData and pluginShared.RouteData.BOSS_GATES
    if type(gates) ~= "table" or not (raidName and bossName) then
        return nil
    end
    local entry = self.BuildBossOrdinalIndex and self:BuildBossOrdinalIndex()[raidName:lower()]
    local encounterID = entry and entry.bossEncounters and entry.bossEncounters[bossName:lower()]
    local row = encounterID and gates[encounterID]
    if not row or anyDone(row.quests) then
        return nil
    end
    return string.format(L["gate_boss_quest_fmt"], bossName, questTitle(row.quests[1])), RED[1], RED[2], RED[3]
end

function Lockouts:InvalidateGates()
    self.gateStates = nil
end

-- Recomputes every cached gate answer and says whether any actually moved. Gate events fire
-- constantly while flying (every streamed zone's map pins), and a full panel draw costs a visible
-- frame, so the caller redraws only on a true answer change.
function Lockouts:RefreshGates()
    local old = self.gateStates
    if not old then
        return false
    end
    self.gateStates = nil
    local changed = false
    for raidName, was in pairs(old) do
        local fresh = self:GateState(raidName)
        if not fresh or fresh.state ~= was.state or fresh.text ~= was.text or fresh.textRich ~= was.textRich then
            changed = true
        end
    end
    return changed
end
