-- ============================================================================
-- ETB - Core/Lockouts.lua
-- ============================================================================
-- A defensive, event-driven adapter over the saved-instance / world-boss
-- lockout APIs. It answers "what is this character saved to right now, and when
-- does it reset" — the raw facts the AvailabilityService turns into per-target
-- answers.
--
-- Design rules (from the Farm Availability spec):
--   * EVENT-DRIVEN, NO POLLING. We rebuild the cache only when the game tells us
--     instance info changed (UPDATE_INSTANCE_INFO), and we ASK the game to send
--     that event (RequestRaidInfo) on login, zone-in, boss kills and difficulty
--     changes — coalesced through a single one-shot timer, never an OnUpdate.
--   * NEVER PRESENT STALE/UNKNOWN AS CONFIRMED. Until the first successful
--     refresh, callers get UNKNOWN. Reset countdowns are stored as ABSOLUTE
--     times so they stay correct between refreshes.
--   * DEFENSIVE. Every Blizzard call is presence-checked and pcall'd, every
--     return validated. The exact GetSavedInstanceInfo return signature differs
--     between client builds, so RawDump() exists to confirm it in-game on 5.5.4
--     before any of this drives UI.
-- ============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB

ETB.Lockouts = ETB.Lockouts or {}
local L = ETB.Lockouts

local function now()
    if GetServerTime then return GetServerTime() end
    return time()
end

-- Match key for instance/boss names. ETB's source data and the live lockout API
-- don't always agree on spacing/punctuation — notably the ATT compact source
-- stores names as space-stripped identifiers ("SiegeofOrgrimmar"), which a
-- humaniser renders as "Siegeof Orgrimmar" while the API returns "Siege of
-- Orgrimmar". Comparing on lowercased, alphanumeric-only keys makes the match
-- immune to that: both collapse to "siegeoforgrimmar".
local function nkey(name)
    if type(name) ~= "string" then return nil end
    return (name:lower():gsub("[^%w]", ""))
end
L._nkey = nkey

-- Cache. Absolute reset times (resetAt) so countdowns survive between refreshes.
local state = {
    instances   = {},   -- array of instance records (see Rebuild)
    worldBosses = {},   -- array of { name, id, resetAt }
    byInstance  = {},   -- lowercase instance name -> { record, ... } (all difficulties)
    wbByName    = {},   -- lowercase boss name -> record
    wbById      = {},   -- worldBossID -> record
    lastUpdated = 0,    -- epoch of last successful Rebuild (0 = never)
    apiInstances = nil, -- saved-instance API present?
    apiWorldBoss = nil, -- saved-world-boss API present?
}
L._state = state

-- ---------------------------------------------------------------------------
-- API presence probes (resolved once, lazily)
-- ---------------------------------------------------------------------------
local function instanceApiPresent()
    return type(GetNumSavedInstances) == "function"
       and type(GetSavedInstanceInfo) == "function"
end
local function worldBossApiPresent()
    return type(GetNumSavedWorldBosses) == "function"
       and type(GetSavedWorldBossInfo) == "function"
end

-- ---------------------------------------------------------------------------
-- Rebuild: snapshot the current lockouts. Pure read; safe to call any time.
-- ---------------------------------------------------------------------------
function L:Rebuild()
    state.apiInstances = instanceApiPresent()
    state.apiWorldBoss = worldBossApiPresent()

    local t = now()
    local instances, byInstance = {}, {}
    if state.apiInstances then
        local okN, n = pcall(GetNumSavedInstances)
        n = (okN and type(n) == "number") and n or 0
        for i = 1, n do
            local ok, name, id, reset, difficulty, locked, extended, _, isRaid,
                  maxPlayers, difficultyName, numEncounters, progress = pcall(GetSavedInstanceInfo, i)
            if ok and name then
                -- reset is seconds-until-reset on this engine; store absolute.
                local resetAt = (type(reset) == "number" and reset > 0) and (t + reset) or nil
                local rec = {
                    name = name, id = id, difficulty = difficulty,
                    difficultyName = difficultyName, locked = locked and true or false,
                    extended = extended and true or false,
                    isRaid = isRaid and true or false,
                    maxPlayers = maxPlayers,
                    numEncounters = tonumber(numEncounters) or 0,
                    progress = tonumber(progress) or 0,
                    resetAt = resetAt,
                    encounters = {},   -- lowercase boss name -> killed(bool)
                }
                -- Per-encounter kills (partial lockouts). Signature:
                -- bossName, fileDataID, isKilled = GetSavedInstanceEncounterInfo(i, j)
                if type(GetSavedInstanceEncounterInfo) == "function" and rec.numEncounters > 0 then
                    for j = 1, rec.numEncounters do
                        local eok, bossName, _, isKilled = pcall(GetSavedInstanceEncounterInfo, i, j)
                        if eok and bossName then
                            rec.encounters[nkey(bossName)] = isKilled and true or false
                        end
                    end
                end
                instances[#instances + 1] = rec
                local key = nkey(name)
                byInstance[key] = byInstance[key] or {}
                byInstance[key][#byInstance[key] + 1] = rec
            end
        end
    end

    local worldBosses, wbByName, wbById = {}, {}, {}
    if state.apiWorldBoss then
        local okN, n = pcall(GetNumSavedWorldBosses)
        n = (okN and type(n) == "number") and n or 0
        for i = 1, n do
            local ok, name, wbID, reset = pcall(GetSavedWorldBossInfo, i)
            if ok and name then
                local rec = {
                    name = name, id = wbID,
                    resetAt = (type(reset) == "number" and reset > 0) and (t + reset) or nil,
                }
                worldBosses[#worldBosses + 1] = rec
                wbByName[nkey(name)] = rec
                if wbID then wbById[wbID] = rec end
            end
        end
    end

    state.instances, state.byInstance = instances, byInstance
    state.worldBosses, state.wbByName, state.wbById = worldBosses, wbByName, wbById
    state.lastUpdated = t

    if ETB.EventBus and ETB.EventBus.Fire then
        ETB.EventBus:Fire("ETB_LOCKOUTS_UPDATED")
    end
end

-- ---------------------------------------------------------------------------
-- Freshness / queries
-- ---------------------------------------------------------------------------
function L:IsFresh()
    return state.lastUpdated > 0
end

-- All saved records for an instance by name (normalized). nil if none.
function L:GetInstanceRecords(instName)
    if not instName then return nil end
    return state.byInstance[nkey(instName)]
end

-- Is a named encounter killed in a given record? nil if that record has no
-- per-encounter data (then the caller falls back to full-lockout progress).
function L:IsEncounterKilled(rec, encName)
    if not rec or not encName then return nil end
    local v = rec.encounters[nkey(encName)]
    return v
end

-- World-boss record by id (preferred) or name. nil if not saved.
function L:GetWorldBoss(idOrName)
    if type(idOrName) == "number" then
        return state.wbById[idOrName] or nil
    elseif type(idOrName) == "string" then
        return state.wbByName[nkey(idOrName)] or nil
    end
    return nil
end

-- Seconds until a record resets (>=0), or nil if unknown.
function L:ResetIn(rec)
    if not rec or not rec.resetAt then return nil end
    return math.max(0, rec.resetAt - now())
end

function L:GetAll()
    return state.instances, state.worldBosses
end

-- ---------------------------------------------------------------------------
-- Refresh plumbing — ask the game to resend instance info, coalesced.
-- ---------------------------------------------------------------------------
local refreshQueued = false
local function doRequest()
    refreshQueued = false
    if type(RequestRaidInfo) == "function" then
        pcall(RequestRaidInfo)        -- async -> UPDATE_INSTANCE_INFO -> Rebuild
    else
        L:Rebuild()                   -- no request API: read directly
    end
end

-- Coalesce bursts (e.g. BOSS_KILL fires per boss) into one request via a single
-- one-shot timer. No persistent OnUpdate.
function L:Refresh()
    if refreshQueued then return end
    refreshQueued = true
    if C_Timer and C_Timer.After then
        C_Timer.After(1, doRequest)
    else
        doRequest()
    end
end

-- ---------------------------------------------------------------------------
-- Raw dump for in-game API validation on 5.5.4 (drives /etb availprobe).
-- Prints exactly what each API returns so the assumed signatures can be
-- confirmed before this powers any UI.
-- ---------------------------------------------------------------------------
function L:RawDump(printer)
    local p = printer or function(s) if ETB.Print then ETB:Print(s) else print(s) end end
    p("|cff00ccff=== ETB Lockout API probe ===|r")
    p(string.format("GetNumSavedInstances: %s | GetSavedInstanceInfo: %s | EncounterInfo: %s",
        type(GetNumSavedInstances), type(GetSavedInstanceInfo), type(GetSavedInstanceEncounterInfo)))
    p(string.format("GetNumSavedWorldBosses: %s | GetSavedWorldBossInfo: %s | RequestRaidInfo: %s",
        type(GetNumSavedWorldBosses), type(GetSavedWorldBossInfo), type(RequestRaidInfo)))

    if instanceApiPresent() then
        local okN, n = pcall(GetNumSavedInstances)
        p("Saved instances: " .. tostring(okN and n or "?"))
        for i = 1, (okN and n or 0) do
            local res = { pcall(GetSavedInstanceInfo, i) }
            -- res[1] is the pcall ok flag; the rest are the raw returns
            local parts = {}
            for k = 2, math.min(#res, 14) do parts[#parts + 1] = tostring(res[k]) end
            p(string.format("  [%d] %s", i, table.concat(parts, " | ")))
            if type(GetSavedInstanceEncounterInfo) == "function" then
                local numEnc = tonumber(res[12]) or 0   -- assumed numEncounters slot
                for j = 1, numEnc do
                    local e = { pcall(GetSavedInstanceEncounterInfo, i, j) }
                    p(string.format("      enc[%d]: %s | killed=%s", j, tostring(e[2]), tostring(e[4])))
                end
            end
        end
    else
        p("|cffff6666Saved-instance API NOT present.|r")
    end

    if worldBossApiPresent() then
        local okN, n = pcall(GetNumSavedWorldBosses)
        p("Saved world bosses: " .. tostring(okN and n or "?"))
        for i = 1, (okN and n or 0) do
            local r = { pcall(GetSavedWorldBossInfo, i) }
            p(string.format("  [%d] name=%s id=%s reset=%s", i, tostring(r[2]), tostring(r[3]), tostring(r[4])))
        end
    else
        p("|cffff6666Saved-world-boss API NOT present.|r")
    end
    p("Last Rebuild: " .. (state.lastUpdated > 0 and (now() - state.lastUpdated) .. "s ago" or "never"))
end

-- ---------------------------------------------------------------------------
-- Events — purely reactive. RequestRaidInfo on the moments lockouts can change;
-- Rebuild when the game reports new info. No OnUpdate anywhere.
-- ---------------------------------------------------------------------------
local function register()
    if not (ETB.EventBus and ETB.EventBus.Register) then return end
    ETB.EventBus:Register("UPDATE_INSTANCE_INFO", function() L:Rebuild() end)
    ETB.EventBus:Register("PLAYER_ENTERING_WORLD", function()
        if ETB.RunWhenStartupIdle then
            ETB:RunWhenStartupIdle("lockouts.initialRefresh", function() L:Refresh() end,
                { delay = 2, interval = 2, maxWait = 120 })
        else
            L:Refresh()
        end
    end)
    ETB.EventBus:Register("BOSS_KILL", function() L:Refresh() end)
    ETB.EventBus:Register("ENCOUNTER_END", function() L:Refresh() end)
    -- Difficulty change can change which lockout applies.
    ETB.EventBus:Register("PLAYER_DIFFICULTY_CHANGED", function() L:Refresh() end)
end
register()

ETB:Debug("Lockouts", "Lockout adapter loaded")
