-- ============================================================================
-- ETB - Core/AvailabilityService.lua
-- ============================================================================
-- The ONE public surface for "can this character still obtain this target this
-- reset?". Planner, wishlist, item details, rare tracker, zone scan and the
-- Central Plaza all query this — nothing else talks to the lockout adapter
-- directly.
--
-- It composes three things: the player's COLLECTION state (CollectionAPI), the
-- item's SOURCE (CollectionPlanner's boss graph, then SourceLookup), and the
-- character's current LOCKOUTS (ETB.Lockouts). It returns a status plus a
-- CONFIDENCE, and it never reports a confirmed status from stale/missing data —
-- when the lockout cache hasn't refreshed yet it returns UNKNOWN and quietly
-- asks for a refresh, so consumers re-query on ETB_LOCKOUTS_UPDATED.
--
-- MVP scope: current-character instance + world-boss lockouts, reset
-- countdowns. No quest-reset inference, no cross-character snapshots yet.
-- ============================================================================

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

ETB.AvailabilityService = ETB.AvailabilityService or {}
local AS = ETB.AvailabilityService

AS.Status = {
    COLLECTED          = "COLLECTED",           -- already have the appearance
    AVAILABLE          = "AVAILABLE",            -- obtainable this reset
    ENCOUNTER_DEFEATED = "ENCOUNTER_DEFEATED",   -- this boss already killed this lockout
    INSTANCE_COMPLETED = "INSTANCE_COMPLETED",   -- whole instance locked
    NOT_ELIGIBLE       = "NOT_ELIGIBLE",         -- can't obtain (reserved; not inferred in MVP)
    UNKNOWN            = "UNKNOWN",
}
AS.Confidence = {
    API     = "API",        -- the game told us directly
    OBSERVED = "OBSERVED",  -- ETB saw it happen this session
    INFERRED = "INFERRED",  -- derived from stored facts + reset cycles
    UNKNOWN  = "UNKNOWN",
}

local S, C = AS.Status, AS.Confidence

-- ---------------------------------------------------------------------------
-- Source resolution: itemID -> { instName, encName, isRaid }. Boss graph first
-- (authoritative), SourceLookup as fallback. nil if the item has no instance
-- source we can resolve (vendor/world-drop/crafted/quest in MVP -> UNKNOWN).
-- ---------------------------------------------------------------------------
local function resolveSource(itemID)
    local CP = ETB.CollectionPlanner
    if CP and CP.GetItemLocation then
        local loc = CP:GetItemLocation(itemID)
        if loc and loc.instName then
            return { instName = loc.instName, encName = loc.encName, isRaid = loc.isRaid }
        end
    end
    if ETB.SourceLookup and ETB.SourceLookup.GetItemSources then
        local srcs = ETB.SourceLookup:GetItemSources(itemID)
        if srcs and srcs[1] and srcs[1].instanceName then
            local s = srcs[1]
            return { instName = s.instanceName, encName = s.bossName, isRaid = s.isRaid }
        end
    end
    return nil
end

-- Already-collected? Appearance-wide: any "known" state means no need to farm.
local function isCollected(itemID)
    local CAPI = ETB.CollectionAPI
    if not CAPI or not CAPI.GetState then return nil end
    local st = CAPI:GetState(itemID)
    if st == nil then return nil end  -- not loaded yet
    return st == "COLLECTED" or st == "KNOWN_OTHER" or st == "KNOWN_OTHER_DIFFERENT_ARMOR"
end

local function fmtReset(sec)
    if not sec then return nil end
    sec = math.max(0, math.floor(sec))
    local d = math.floor(sec / 86400); sec = sec - d * 86400
    local h = math.floor(sec / 3600);  sec = sec - h * 3600
    local m = math.floor(sec / 60)
    if d > 0 then return string.format("%dd %dh", d, h) end
    if h > 0 then return string.format("%dh %dm", h, m) end
    return string.format("%dm", m)
end

local function result(status, confidence, resetIn, detail, extra)
    local r = { status = status, confidence = confidence, resetIn = resetIn, detail = detail }
    if extra then for k, v in pairs(extra) do r[k] = v end end
    return r
end

-- ---------------------------------------------------------------------------
-- World-boss branch: the saved-world-boss list is keyed by boss NAME, so a
-- source whose encounter/instance name matches a saved world boss is handled
-- here without needing an item->npc map.
-- ---------------------------------------------------------------------------
local function queryWorldBoss(name)
    local L = ETB.Lockouts
    local rec = L:GetWorldBoss(name)
    if rec then
        local resetIn = L:ResetIn(rec)
        local rs = fmtReset(resetIn)
        return result(S.ENCOUNTER_DEFEATED, C.API, resetIn,
            "Defeated this week" .. (rs and (" · resets in " .. rs) or ""), { worldBoss = true })
    end
    return result(S.AVAILABLE, C.API, nil, "Available — world boss is up", { worldBoss = true })
end

-- ---------------------------------------------------------------------------
-- Instance branch.
-- ---------------------------------------------------------------------------
local function queryInstance(instName, encName)
    local L = ETB.Lockouts
    local recs = L:GetInstanceRecords(instName)
    if not recs or #recs == 0 then
        return result(S.AVAILABLE, C.API, nil, "Available — not saved to " .. instName)
    end

    -- A literal "Unknown" boss (ETB has the instance but not the encounter for
    -- this item) is treated as "no encounter".
    if encName and (encName == "" or encName:lower() == "unknown") then
        encName = nil
    end

    -- ----- Unknown-boss branch -----------------------------------------------
    -- We're saved to this instance but don't know which of its bosses drops the
    -- item. We must NOT claim a confident "Available": if you've already killed
    -- some bosses, this item's boss might be one of them. Be honest:
    --   * nothing killed on a saved difficulty -> every boss alive -> AVAILABLE
    --   * fully cleared everywhere            -> INSTANCE_COMPLETED (inferred)
    --   * partially cleared                   -> UNKNOWN (can't tell)
    if not encName then
        local savedSummary, soonestReset = {}, nil
        local anyFresh, allCleared = false, true
        for _, rec in ipairs(recs) do
            local label = rec.difficultyName or ("diff " .. tostring(rec.difficulty))
            local legacy = (rec.numEncounters or 0) == 0 and rec.locked
            if legacy then
                savedSummary[#savedSummary + 1] = label .. " (locked)"
            else
                savedSummary[#savedSummary + 1] = string.format("%s %d/%d", label, rec.progress or 0, rec.numEncounters or 0)
            end
            -- A legacy lockout (no per-boss data) is NOT "fresh" — you're locked
            -- to the raid even though progress reads 0.
            if not legacy and (rec.progress or 0) == 0 then anyFresh = true end
            local full = (rec.numEncounters > 0 and rec.progress >= rec.numEncounters)
            if not full then allCleared = false end
            if full or legacy then
                local ri = L:ResetIn(rec)
                if ri and (not soonestReset or ri < soonestReset) then soonestReset = ri end
            end
        end
        local summary = table.concat(savedSummary, ", ")
        if anyFresh then
            return result(S.AVAILABLE, C.API, nil, "Saved (" .. summary .. ") — no bosses killed yet")
        elseif allCleared then
            return result(S.INSTANCE_COMPLETED, C.INFERRED, soonestReset, "Instance cleared (" .. summary .. ")")
        else
            return result(S.UNKNOWN, C.UNKNOWN, soonestReset,
                "Saved: " .. summary .. " · can't confirm — the game doesn't report per-boss kills here",
                { ambiguous = true })
        end
    end

    -- ----- Known-boss branch -------------------------------------------------
    -- We are saved to this instance on >=1 difficulty.
    local soonestReset, anyOpenPath, anyLegacyLock = nil, false, false
    local apiOpen, inferredOpen, observedKill = false, false, false
    local blockedDiffs, openDiffs, legacyDiffs = {}, {}, {}

    for _, rec in ipairs(recs) do
        local killed = L:IsEncounterKilled(rec, encName)
        local label = rec.difficultyName or ("diff " .. tostring(rec.difficulty))

        if killed == true then
            blockedDiffs[#blockedDiffs + 1] = label
            local ri = L:ResetIn(rec)
            if ri and (not soonestReset or ri < soonestReset) then soonestReset = ri end
        elseif killed == false then
            anyOpenPath, apiOpen = true, true
            openDiffs[#openDiffs + 1] = label   -- saved, boss not yet killed -> farmable
        elseif rec.numEncounters > 0 then
            -- Per-instance progress available (Cata/MoP raids): full clear blocks.
            if rec.progress >= rec.numEncounters then
                blockedDiffs[#blockedDiffs + 1] = label
                local ri = L:ResetIn(rec)
                if ri and (not soonestReset or ri < soonestReset) then soonestReset = ri end
            else
                anyOpenPath, apiOpen = true, true
                openDiffs[#openDiffs + 1] = label
            end
        elseif rec.locked then
            -- LEGACY raid (TBC/Wrath): no per-boss API data. Consult ETB's own
            -- kill log — the only way to know what we've cleared in these raids.
            local logged = ETB.KillLog and encName
                and ETB.KillLog:WasKilledThisLockout(instName, encName)
            if logged == true then
                -- We witnessed this boss die this lockout.
                blockedDiffs[#blockedDiffs + 1] = label
                observedKill = true
                local ri = L:ResetIn(rec)
                if ri and (not soonestReset or ri < soonestReset) then soonestReset = ri end
            elseif logged == false then
                -- We were tracking this raid this lockout and did NOT log this
                -- boss dying — most likely still up (we may have missed a kill).
                anyOpenPath, inferredOpen = true, true
                openDiffs[#openDiffs + 1] = label
            else
                -- No log at all: genuinely can't tell.
                anyLegacyLock = true
                legacyDiffs[#legacyDiffs + 1] = label
                local ri = L:ResetIn(rec)
                if ri and (not soonestReset or ri < soonestReset) then soonestReset = ri end
            end
        else
            anyOpenPath, apiOpen = true, true   -- saved record but not locked -> open
            openDiffs[#openDiffs + 1] = label
        end
    end

    if anyOpenPath then
        local note, conf = nil, (apiOpen and C.API or C.INFERRED)
        if #blockedDiffs > 0 then
            note = "Available on " .. table.concat(openDiffs, ", ")
                 .. " · done: " .. table.concat(blockedDiffs, ", ")
        elseif inferredOpen and not apiOpen then
            note = "Likely available (" .. table.concat(openDiffs, ", ")
                 .. ") — no ETB-logged kill this reset"
        else
            note = "Available — boss not yet killed (" .. table.concat(openDiffs, ", ") .. ")"
        end
        return result(S.AVAILABLE, conf, nil, note)
    end

    -- Only legacy lockouts apply and nothing was confirmed killed: we genuinely
    -- can't tell whether THIS boss is down. Be honest (ambiguous), with context.
    if anyLegacyLock and #blockedDiffs == 0 then
        local rs = fmtReset(soonestReset)
        return result(S.UNKNOWN, C.UNKNOWN, soonestReset,
            "Locked to " .. instName .. " (" .. table.concat(legacyDiffs, ", ") .. ") this reset"
            .. (rs and (" · resets in " .. rs) or "")
            .. " · legacy raid — kill it with ETB loaded and it'll track from then on",
            { ambiguous = true })
    end

    -- A logged legacy kill gives us an OBSERVED-confidence defeat.
    if observedKill and #blockedDiffs > 0 then
        local rs = fmtReset(soonestReset)
        return result(S.ENCOUNTER_DEFEATED, C.OBSERVED, soonestReset,
            "You killed " .. encName .. " this reset (logged by ETB)"
            .. (rs and (" · resets in " .. rs) or ""))
    end

    -- Every saved difficulty is blocked for this target. We can't see difficulties
    -- the character ISN'T saved to (and could still run), so this is an INFERENCE
    -- from the saved subset, not an API-confirmed "globally unavailable".
    local status = S.ENCOUNTER_DEFEATED
    local rs = fmtReset(soonestReset)
    return result(status, C.INFERRED, soonestReset,
        "Boss defeated on " .. table.concat(blockedDiffs, ", ")
        .. (rs and (" · resets in " .. rs) or "")
        .. " · other difficulties may still drop it")
end

-- ---------------------------------------------------------------------------
-- Public: Query(target). target = { itemID = N } (MVP), or { worldBossName = s }
-- / { instName = s, encName = s }. Always returns a {status,confidence,...}.
-- ---------------------------------------------------------------------------
function AS:Query(target)
    if type(target) == "number" then target = { itemID = target } end
    if type(target) ~= "table" then
        return result(S.UNKNOWN, C.UNKNOWN, nil, "No target")
    end

    local L = ETB.Lockouts
    if not L then return result(S.UNKNOWN, C.UNKNOWN, nil, "Lockout data unavailable") end

    -- Collection short-circuit (itemID targets only).
    if target.itemID then
        local got = isCollected(target.itemID)
        if got == true then
            return result(S.COLLECTED, C.API, nil, "Already collected")
        end
        -- got == nil just means "item info not cached yet"; continue.
    end

    -- Until the first successful lockout refresh we must not assert anything.
    if not L:IsFresh() then
        L:Refresh()
        return result(S.UNKNOWN, C.UNKNOWN, nil, "Checking lockouts…")
    end

    -- Resolve the source.
    local instName, encName = target.instName, target.encName
    if target.worldBossName then
        return queryWorldBoss(target.worldBossName)
    end
    if not instName and target.itemID then
        local src = resolveSource(target.itemID)
        if src then instName, encName = src.instName, src.encName end
    end
    if not instName then
        return result(S.UNKNOWN, C.UNKNOWN, nil, "Source not an instance drop")
    end

    -- A world boss may surface as an instance/encounter name — check first.
    if encName and L:GetWorldBoss(encName) then return queryWorldBoss(encName) end
    if L:GetWorldBoss(instName) then return queryWorldBoss(instName) end

    return queryInstance(instName, encName)
end

-- Short badge text + colour for a result (for wishlist/planner badges later).
function AS:Format(r)
    if not r then return "", "ffffffff" end
    local map = {
        [S.COLLECTED]          = { "Collected",  "ff888888" },
        [S.AVAILABLE]          = { "Available",  "ff40ff40" },
        [S.ENCOUNTER_DEFEATED] = { "Defeated",   "ffff8800" },
        [S.INSTANCE_COMPLETED] = { "Locked",     "ffff8800" },
        [S.NOT_ELIGIBLE]       = { "N/A",        "ff888888" },
        [S.UNKNOWN]            = { "—",          "ff888888" },
    }
    local m = map[r.status] or map[S.UNKNOWN]
    local text = m[1]
    -- A saved-but-unresolved-boss result is genuinely ambiguous: show it as a
    -- distinct "?" rather than blank or a misleading "Available".
    if r.ambiguous then return "?", "ffd9a441" end
    if r.resetIn and (r.status == S.ENCOUNTER_DEFEATED or r.status == S.INSTANCE_COMPLETED) then
        local rs = fmtReset(r.resetIn)
        if rs then text = text .. " (" .. rs .. ")" end
    end
    -- Confidence cues: UNKNOWN is fully muted; INFERRED gets a ~ prefix so an
    -- un-confirmed status never reads as a confirmed one.
    if r.confidence == C.UNKNOWN then return text, "ff888888" end
    if r.confidence == C.INFERRED then return "~" .. text, m[2] end
    return text, m[2]
end

ETB:Debug("AvailabilityService", "Availability service loaded")
