-- ============================================================================
-- ERT (Enhanced Rare Tracker) — Observer
-- ============================================================================
-- The universal observation layer. It records ANY rare you encounter in the
-- open world, across every expansion, with no per-rare data required, because
-- both of its signals are derived, not hardcoded:
--
--   1. GUID spawn-time decode (technique from NPCTime): a creature GUID's spawn
--      segment encodes the exact spawn timestamp. The moment a rare is on your
--      mouseover / target / nameplate we know precisely how long it has been up
--      — no estimation. Works on any Creature in any expansion.
--   2. Combat-log UNIT_DIED: the death timestamp that starts a projected respawn
--      window for when the rare is NOT visible.
--
-- "Is this a rare?" is answered live by UnitClassification ("rare"/"rareelite"),
-- so the tracker auto-discovers rares with zero roster. The seed RareData only
-- adds windows / model class / confidence for ones we already know; everything
-- else is learned empirically (observed interval = next spawn − last death).
--
-- Persistence: ETB_RareDB, realm-scoped and versioned. Phase 1 has NO UI; a
-- /etbrare command dumps status for verification.
-- ============================================================================

local ADDON_NAME, NS = ...
local ETB = NS.ETB
local ERT = ETB.ERT or {}
ETB.ERT = ERT

local band = bit.band
local strsplit, strsub = strsplit, strsub
local GetServerTime = GetServerTime
local UnitClassification, UnitGUID = UnitClassification, UnitGUID

local DB_VERSION = 1
local MAX_INTERVALS = 10   -- per rare, for empirical window calibration

-- ---------------------------------------------------------------------------
-- GUID helpers
-- ---------------------------------------------------------------------------

local function GetNPCID(guid)
    if not guid then return nil end
    local t, _, _, _, _, npcID = strsplit("-", guid)
    if t == "Creature" or t == "Vehicle" then return tonumber(npcID) end
    return nil
end

-- Decode the spawn timestamp from a creature GUID. The low 23 bits of the last
-- segment are a truncated unix time; anchor them to the current ~97-day epoch
-- and correct a forward-wrap. Returns nil for non-creatures / malformed GUIDs.
local function DecodeSpawnTime(guid)
    if not guid then return nil end
    local t = strsplit("-", guid)
    if t ~= "Creature" and t ~= "Vehicle" then return nil end
    local timeRaw = tonumber(strsub(guid, -6), 16)
    if not timeRaw then return nil end
    local now = GetServerTime()
    local spawn = (now - (now % 2^23)) + band(timeRaw, 0x7fffff)
    if spawn > now then spawn = spawn - ((2^23) - 1) end
    return spawn
end

-- ---------------------------------------------------------------------------
-- DB (realm-scoped, versioned)
-- ---------------------------------------------------------------------------

local realmKey  -- resolved on login
local realmDB   -- ETB_RareDB.realms[realmKey]

local function EnsureDB()
    if type(ETB_RareDB) ~= "table" then ETB_RareDB = {} end
    if ETB_RareDB.version ~= DB_VERSION then
        ETB_RareDB.version = DB_VERSION
        ETB_RareDB.realms = ETB_RareDB.realms or {}
    end
    if ETB_RareDB.enabled == nil and ETB.IsLowMemoryMode and ETB:IsLowMemoryMode() then
        ETB_RareDB.enabled = false
    end
    ETB_RareDB.realms = ETB_RareDB.realms or {}
    local rk = (GetRealmName and GetRealmName()) or nil
    if rk == "" then rk = nil end
    realmKey = rk or "Unknown"
    ETB_RareDB.realms[realmKey] = ETB_RareDB.realms[realmKey] or {}
    realmDB = ETB_RareDB.realms[realmKey]
    -- Recovery: if the realm name wasn't available when some tracking was first
    -- recorded, it landed in an "Unknown" bucket. Now that we have a real realm,
    -- fold that orphaned data in (without clobbering anything already here) so a
    -- mistimed init never looks like lost data.
    if rk and ETB_RareDB.realms["Unknown"] and ETB_RareDB.realms["Unknown"] ~= realmDB then
        for npcID, e in pairs(ETB_RareDB.realms["Unknown"]) do
            if realmDB[npcID] == nil then realmDB[npcID] = e end
        end
        ETB_RareDB.realms["Unknown"] = nil
    end
end

local function ObserverEnabled()
    if ETB_RareDB and ETB_RareDB.enabled ~= nil then
        return ETB_RareDB.enabled == true
    end
    return not (ETB.IsLowMemoryMode and ETB:IsLowMemoryMode())
end

local function Entry(npcID)
    local e = realmDB[npcID]
    if not e then e = {}; realmDB[npcID] = e end
    return e
end

local function EnsureSeed()
    if ERT._RareDataLoader then ERT._RareDataLoader() end
end

-- Creature displayID harvest. No reference addon (ATT/RareScanner/SilverDragon)
-- ships per-npc creature displayIDs, so we learn them empirically: while a known
-- rare is in line of sight we read its model's displayID off a hidden model and
-- cache it account-wide (displayIDs are realm-independent). This makes the
-- tracker portrait reliable on the next view, replacing the unreliable
-- SetCreature(npcID) fallback. Fully guarded: any failure is a silent no-op and
-- the displayID is simply re-attempted on the next sighting (progressive fill).
local _harvester
local function HarvestDisplayID(unit, npcID)
    if not (unit and npcID and ETB_RareDB) then return end
    ETB_RareDB.displayIDs = ETB_RareDB.displayIDs or {}
    if ETB_RareDB.displayIDs[npcID] then return end
    pcall(function()
        if not _harvester then
            _harvester = CreateFrame("DressUpModel", nil, UIParent)
            _harvester:SetSize(1, 1)
            _harvester:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -32, 32)
            _harvester:SetAlpha(0)
        end
        if _harvester.SetUnit then _harvester:SetUnit(unit) end
        local d = _harvester.GetDisplayInfo and _harvester:GetDisplayInfo()
        if d and d > 0 then ETB_RareDB.displayIDs[npcID] = d end
    end)
end

-- ---------------------------------------------------------------------------
-- Recording
-- ---------------------------------------------------------------------------

-- Is this npc something we should track? Either the game flags it rare right now,
-- or we already know it (seed roster or prior observation).
local function ClassIsRare(unit)
    local c = UnitClassification(unit)
    return c == "rare" or c == "rareelite"
end

function ERT:IsKnownRare(npcID)
    EnsureSeed()
    if ERT.RareData and ERT.RareData[npcID] then return true end
    if ERT.RareRoster and ERT.RareRoster[npcID] then return true end
    if realmDB and realmDB[npcID] then return true end
    return false
end

-- Core sighting recorder, driven by a GUID. Used both by line-of-sight events
-- (mouseover/target/nameplate) and by vignettes (which hand us the rare's
-- objectGUID with no line of sight). `confirmed` marks a game-authoritative
-- "this rare is up right now" signal (a vignette), as opposed to us merely
-- having it on screen.
function ERT:RecordSightingByGUID(guid, name, confirmed)
    if not realmDB then return end
    local npcID = GetNPCID(guid)
    if not npcID then return end

    local now = GetServerTime()
    local spawn = DecodeSpawnTime(guid)
    local e = Entry(npcID)
    e.name = e.name or name or nil
    e.firstTracked = e.firstTracked or now
    e.lastSeen = now
    if confirmed then e.lastConfirmedUp = now end

    local newSpawn = spawn and spawn ~= e.lastSpawn
    if newSpawn then
        -- A genuinely new spawn. Three things can corroborate the next respawn:
        --   (a) witnessed death → this spawn = an EXACT respawn interval. A death
        --       anchors exactly one interval, so consume it afterwards (otherwise
        --       a second spawn would measure against a stale, multi-cycle-old death).
        --   (b) no usable death but a previous spawn → spawn-to-spawn CYCLE, which
        --       is (lifespan + respawn) and therefore an upper bound on respawn;
        --       for farmed rares (lifespan ~ 0) it is ~ the respawn itself.
        if e.lastDeath and spawn > e.lastDeath then
            local gap = spawn - e.lastDeath
            if gap > 30 and gap < 7 * 24 * 3600 then
                e.intervals = e.intervals or {}
                e.intervals[#e.intervals + 1] = gap
                while #e.intervals > MAX_INTERVALS do table.remove(e.intervals, 1) end
            end
            e.lastDeath = nil  -- consumed
        elseif e.lastSpawn and spawn > e.lastSpawn then
            local cyc = spawn - e.lastSpawn
            if cyc > 30 and cyc < 7 * 24 * 3600 then
                e.cycles = e.cycles or {}
                e.cycles[#e.cycles + 1] = cyc
                while #e.cycles > MAX_INTERVALS do table.remove(e.cycles, 1) end
            end
        end
        e.lastSpawn = spawn
        e.spawnsSeen = (e.spawnsSeen or 0) + 1  -- lifetime tally of distinct spawns seen
    end

    -- Notify status-driven consumers (alerts, countdown bar) only when the rare's
    -- standing actually changed: a game-authoritative "up now" (confirmed) or a
    -- genuinely new spawn. A bare line-of-sight re-touch (repeated mouseover) does
    -- not move status, so it stays silent — keeping consumers idle-cost-free.
    if (confirmed or newSpawn) and ETB.EventBus and ETB.EventBus.Fire then
        ETB.EventBus:Fire("ETB_RARE_STATUS_CHANGED", npcID)
    end
end

-- Seen alive via line of sight (mouseover/target/nameplate). Only records things
-- the game currently flags rare, or ones we already know.
function ERT:RecordSighting(unit)
    if not unit or not realmDB then return end
    local guid = UnitGUID(unit)
    local npcID = GetNPCID(guid)
    if not npcID then return end
    if not (ClassIsRare(unit) or self:IsKnownRare(npcID)) then return end
    HarvestDisplayID(unit, npcID)
    self:RecordSightingByGUID(guid, (UnitName and UnitName(unit)) or nil, false)
end

-- Death (combat-log UNIT_DIED). Starts the projected respawn window. The combat
-- log hands us the dead unit's name, so we capture it here too — that fills in
-- rares you killed without ever mousing over them.
function ERT:RecordDeath(npcID, name)
    if not npcID or not realmDB then return end
    if not self:IsKnownRare(npcID) and not (realmDB[npcID]) then
        -- Only record deaths for things we treat as rares. (Sightings promote an
        -- npc into realmDB via classification; deaths of unknown trash are ignored.)
        return
    end
    local e = Entry(npcID)
    local now = GetServerTime()
    e.firstTracked = e.firstTracked or now
    if not e.name then
        e.name = name or (ERT.RareData and ERT.RareData[npcID] and ERT.RareData[npcID].name) or nil
    end

    -- Dedupe the SAME kill. A corpse throws a lingering "dead" vignette and the
    -- combat log fires too, so RecordDeath can be called repeatedly for one kill.
    -- It is only a NEW kill if the rare has been seen alive again since the last
    -- death (a new spawn). Otherwise ignore it — counting it would inflate the
    -- tally and, worse, keep pushing the death time to "now", wrecking the window.
    if e.lastDeath and (not e.lastSpawn or e.lastSpawn <= e.lastDeath) then
        return
    end

    -- Corroborate against the spawn we're holding: a valid death comes AFTER the
    -- spawn, and the gap is this generation's lifespan (a stat + a sanity check).
    if e.lastSpawn and now > e.lastSpawn and (now - e.lastSpawn) < 7 * 24 * 3600 then
        e.lifespans = e.lifespans or {}
        e.lifespans[#e.lifespans + 1] = now - e.lastSpawn
        while #e.lifespans > MAX_INTERVALS do table.remove(e.lifespans, 1) end
    end
    e.lastDeath = now
    e.kills = (e.kills or 0) + 1       -- lifetime tally of witnessed kills

    -- A death starts a fresh respawn window, which is exactly when the countdown
    -- bar wants to (re)appear and tick. Wake status-driven consumers.
    if ETB.EventBus and ETB.EventBus.Fire then
        ETB.EventBus:Fire("ETB_RARE_STATUS_CHANGED", npcID)
    end
end

-- Resolve a display name: live observation → seed roster → ETB's runtime NPC
-- name cache (shared with the tooltip system) → bare id.
function ERT:GetName(npcID)
    EnsureSeed()
    local e = realmDB and realmDB[npcID]
    if e and e.name then return e.name end
    if ERT.RareData and ERT.RareData[npcID] and ERT.RareData[npcID].name then return ERT.RareData[npcID].name end
    if ERT.RareRoster and ERT.RareRoster[npcID] and ERT.RareRoster[npcID].n then return ERT.RareRoster[npcID].n end
    local rn = ETB_RuntimeNPCNames and ETB_RuntimeNPCNames[npcID]
    if rn then return (type(rn) == "table" and rn.n) or (type(rn) == "string" and rn) or ("npc " .. npcID) end
    return "npc " .. npcID
end

-- List of npcIDs we are currently tracking on this realm.
function ERT:GetTrackedNPCs()
    local t = {}
    if realmDB then for id in pairs(realmDB) do t[#t + 1] = id end end
    return t
end

-- Last known map location (from a vignette sighting), for waypoints.
function ERT:SetLocation(npcID, mapID, x, y)
    if not realmDB or not mapID or not x or not y then return end
    local e = Entry(npcID)
    e.mapID, e.x, e.y = mapID, x, y
end
function ERT:GetLocation(npcID)
    local e = realmDB and realmDB[npcID]
    if e and e.mapID and e.x and e.y then return e.mapID, e.x, e.y, "seen" end
    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    if r and r.L and r.L[1] then return r.L[1][1], r.L[1][2], r.L[1][3], "roster" end
    return nil
end

-- All known spawn points for a rare: array of {uiMapID, x, y} (0-1 coords).
function ERT:GetSpawnPoints(npcID)
    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    return r and r.L or nil
end

-- Creature display ID for a model preview (nil if we don't have one).
function ERT:GetDisplayID(npcID)
    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    if r and r.d then return r.d end
    return ETB_RareDB and ETB_RareDB.displayIDs and ETB_RareDB.displayIDs[npcID] or nil
end

-- ---------------------------------------------------------------------------
-- Zone + expansion classification (for filtering / display). Derived from the
-- rare's canonical (roster-primary) spawn zone. Expansion is resolved by walking
-- the map tree up to its continent, with an override set for the Cataclysm zones
-- that hang off the old continents. Both are cached per-rare/per-zone since the
-- map tree never changes within a session.
-- ---------------------------------------------------------------------------
local CONTINENT_EXP = {
    [13]="Classic", [12]="Classic",   -- Eastern Kingdoms, Kalimdor
    [101]="BC",                        -- Outland
    [113]="WotLK",                     -- Northrend
    [948]="Cata",                      -- The Maelstrom (Deepholm, Vashj'ir, Kezan)
    [424]="MoP",                       -- Pandaria
}
-- Cataclysm zones parented under the classic continents (revamped or 4.x-added).
local CATA_ZONES = {
    [198]=true,[199]=true,[201]=true,[203]=true,[204]=true,[205]=true,[207]=true,
    [217]=true,[241]=true,[244]=true,[245]=true,[249]=true,[338]=true,
}
local expCache, zoneNameCache = {}, {}

local function primaryZone(npcID)
    local r = ERT.RareRoster and ERT.RareRoster[npcID]
    if r and r.L and r.L[1] then return r.L[1][1] end
    return (ERT:GetLocation(npcID))
end

function ERT:GetExpansion(npcID)
    if expCache[npcID] ~= nil then return expCache[npcID] end
    local mapID = primaryZone(npcID)
    local exp = "Other"
    if mapID then
        if CATA_ZONES[mapID] then
            exp = "Cata"
        elseif C_Map and C_Map.GetMapInfo then
            local id, hops = mapID, 0
            while id and hops < 10 do
                local info = C_Map.GetMapInfo(id)
                if not info then break end
                local key = info.mapID or id
                if CONTINENT_EXP[key] then exp = CONTINENT_EXP[key]; break end
                local parent = info.parentMapID
                if not parent or parent == 0 or parent == id then break end
                id, hops = parent, hops + 1
            end
        end
    end
    expCache[npcID] = exp
    return exp
end

function ERT:GetZoneName(npcID)
    local mapID = primaryZone(npcID)
    if not mapID then return nil end
    if zoneNameCache[mapID] ~= nil then return zoneNameCache[mapID] or nil end
    local name
    if C_Map and C_Map.GetMapInfo then
        local info = C_Map.GetMapInfo(mapID)
        name = info and info.name
    end
    zoneNameCache[mapID] = name or false
    return name
end

-- ---------------------------------------------------------------------------
-- Vignettes — "it's up RIGHT NOW", no line of sight required (the SilverDragon
-- signal). The game surfaces active rares on the minimap; each vignette gives
-- us the rare's objectGUID (→ exact spawn time, npcID, name) and an isDead flag
-- (a kill we can record even when we didn't land it — closing the
-- "killed out of your sight" gap that made predictions go stale).
-- ---------------------------------------------------------------------------

local function ScanVignettes()
    if not realmDB or not C_VignetteInfo or not C_VignetteInfo.GetVignettes then return end
    local list = C_VignetteInfo.GetVignettes()
    if not list then return end
    for _, vGUID in ipairs(list) do
        local info = C_VignetteInfo.GetVignetteInfo and C_VignetteInfo.GetVignetteInfo(vGUID)
        if info and info.objectGUID then
            local npcID = GetNPCID(info.objectGUID)
            if npcID then
                if info.isDead then
                    -- Lootable-corpse vignette: the rare was just killed (possibly by
                    -- someone else). Recording it restarts our window honestly.
                    if ERT:IsKnownRare(npcID) or realmDB[npcID] then
                        ERT:RecordDeath(npcID, info.name)
                    end
                else
                    -- Confirmed up. The objectGUID gives spawn time + npcID + name.
                    ERT:RecordSightingByGUID(info.objectGUID, info.name, true)
                    -- Capture where, for the waypoint button.
                    if C_Map and C_Map.GetBestMapForUnit and C_VignetteInfo.GetVignettePosition then
                        local mapID = C_Map.GetBestMapForUnit("player")
                        if mapID then
                            local pos = C_VignetteInfo.GetVignettePosition(vGUID, mapID)
                            if pos and pos.x and pos.y then ERT:SetLocation(npcID, mapID, pos.x, pos.y) end
                        end
                    end
                end
            end
        end
    end
end
ERT.ScanVignettes = ScanVignettes

-- ---------------------------------------------------------------------------
-- Query / projection
-- ---------------------------------------------------------------------------

local function Median(t)
    local n = #t; if n == 0 then return nil end
    local s = {}; for i = 1, n do s[i] = t[i] end; table.sort(s)
    local m = math.floor((n + 1) / 2)
    if n % 2 == 1 then return s[m] else return math.floor((s[m] + s[m + 1]) / 2) end
end

-- Blended window. The seed acts as a weak prior; each witnessed respawn pulls
-- the estimate toward measured reality; spawn-to-spawn cycles cap how long the
-- window can claim to be (respawn <= shortest full cycle). Returns
-- earliest, modal, latest, source, confidence.
function ERT:GetWindow(npcID)
    EnsureSeed()
    local e = realmDB and realmDB[npcID]
    local seed = ERT.RareData and ERT.RareData[npcID]

    local priorLo, priorModal, priorHi
    if seed and seed.window then
        priorLo, priorModal, priorHi = seed.window[1], seed.window[2], seed.window[3]
    else
        local d = ERT.DEFAULT_WINDOW; priorLo, priorModal, priorHi = d[1], d[2], d[3]
    end

    local samples = (e and e.intervals) or {}      -- exact respawn measurements
    local cycles  = (e and e.cycles) or {}         -- spawn-to-spawn upper bounds
    local nS = #samples

    -- Blended modal from the prior + exact respawn samples (a continuous update,
    -- not a hard switch). Cycles do NOT feed the modal directly — they only cap
    -- the ceiling below — which keeps the math honest for un-farmed rares.
    local priorWeight = (seed and seed.window) and 2 or 1
    local sum, w = priorModal * priorWeight, priorWeight
    for _, s in ipairs(samples) do sum = sum + s; w = w + 1 end
    local modal = math.floor(sum / w)

    -- Spread from the observation pool, else scale the prior's shape around modal.
    local pool = {}
    for _, s in ipairs(samples) do pool[#pool + 1] = s end
    if nS < 2 then for _, c in ipairs(cycles) do pool[#pool + 1] = c end end
    local lo, hi
    if #pool >= 2 then
        lo, hi = pool[1], pool[1]
        for _, v in ipairs(pool) do if v < lo then lo = v end; if v > hi then hi = v end end
    else
        lo = math.floor(modal * (priorLo / priorModal))
        hi = math.floor(modal * (priorHi / priorModal))
    end

    -- A full cycle is a hard ceiling on the respawn: never claim "latest" longer
    -- than the shortest spawn-to-spawn gap we've actually seen.
    if #cycles > 0 then
        local minCyc = cycles[1]
        for _, c in ipairs(cycles) do if c < minCyc then minCyc = c end end
        if hi > minCyc then hi = minCyc end
    end

    -- Keep the modal inside the bounds it's describing.
    if lo > hi then lo = hi end
    if modal > hi then modal = hi end
    if modal < lo then modal = lo end

    -- Confidence grows with corroborating evidence; conflicting spread lowers it.
    local evidence = nS + 0.5 * #cycles
    local conf
    if evidence >= 4 then conf = "high"
    elseif evidence >= 2 then conf = "medium"
    else conf = (seed and seed.confidence) or "low" end

    local src
    if nS >= 1 then src = "observed"
    elseif #cycles > 0 then src = "estimated"   -- cycle-derived only
    elseif seed and seed.window then src = "seed"
    else src = "default" end

    return lo, modal, hi, src, conf, nS
end

-- Status for a rare right now. state: alive | too_early | window_open | overdue
-- | summoned | phased | unknown. For shared_pool rares the death of any pool
-- member resolves the window.
function ERT:GetStatus(npcID)
    EnsureSeed()
    local seed = ERT.RareData and ERT.RareData[npcID]
    local e = realmDB and realmDB[npcID]
    local now = GetServerTime()

    local model = seed and seed.model or "fixed_window"
    if model == "summoned" or model == "event_gated" then
        return { state = "summoned", trigger = seed and seed.trigger, model = model }
    end

    -- Up RIGHT NOW — confirmed by the game (a recent vignette) or by line of
    -- sight, as opposed to merely predicted. A death after the confirming signal
    -- means it's a corpse now, so guard against that.
    local confirmedUp = e and e.lastConfirmedUp and (now - e.lastConfirmedUp) < 120
        and (not e.lastDeath or e.lastDeath < e.lastConfirmedUp)
    local losUp = e and e.lastSeen and (now - e.lastSeen) < 60 and e.lastSpawn
        and (not e.lastDeath or e.lastDeath < e.lastSpawn)
    if confirmedUp or losUp then
        return { state = "alive", confirmed = true,
                 spawnTime = e.lastSpawn, aliveFor = e.lastSpawn and (now - e.lastSpawn) or nil,
                 model = model, kills = e.kills or 0, spawnsSeen = e.spawnsSeen or 0 }
    end

    -- For shared pools, use the most recent death across the whole pool.
    local lastDeath = e and e.lastDeath
    if model == "shared_pool" and seed and seed.poolID and ERT.RarePools then
        for _, member in ipairs(ERT.RarePools[seed.poolID] or {}) do
            local me = realmDB and realmDB[member]
            if me and me.lastDeath and (not lastDeath or me.lastDeath > lastDeath) then
                lastDeath = me.lastDeath
            end
        end
    end

    if not lastDeath then
        return { state = (model == "phased") and "phased" or "unknown", model = model }
    end

    local earliest, modal, latest, src, conf, nSamples = self:GetWindow(npcID)
    local since = now - lastDeath
    local state
    if since < earliest then state = "too_early"
    elseif since <= latest then state = "window_open"
    elseif since <= latest * 3 then state = "overdue"
    else state = "stale" end   -- far past the window: we likely missed kills, projection unreliable
    return {
        state = state, model = model, source = src,
        lastDeath = lastDeath, since = since,
        opensIn = math.max(0, earliest - since),
        earliest = earliest, modal = modal, latest = latest,
        -- absolute "when it will exist" clock times (server time)
        opensAt = lastDeath + earliest,
        modalAt = lastDeath + modal,
        latestAt = lastDeath + latest,
        confidence = conf,
        samples = nSamples or 0,
        kills = e and e.kills or 0,
        spawnsSeen = e and e.spawnsSeen or 0,
    }
end

-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------

local f = CreateFrame("Frame")
local lightRegistered = false
local heavyRegistered = false

-- Light events: user-action frequency (mouseover/target only fire when the
-- player acts) or low-frequency (vignette/zone updates). Cheap to keep on
-- whenever rare tracking is enabled.
local LIGHT_EVENTS = {
    "UPDATE_MOUSEOVER_UNIT",
    "PLAYER_TARGET_CHANGED",
    "VIGNETTES_UPDATED",
    "VIGNETTE_MINIMAP_UPDATED",
    "PLAYER_ENTERING_WORLD",
    "ZONE_CHANGED_NEW_AREA",
}

-- Heavy events: gameplay frequency. COMBAT_LOG_EVENT_UNFILTERED fires on EVERY
-- combat-log line (a storm in raids; this handler calls CombatLogGetCurrentEventInfo
-- every time) and NAME_PLATE_UNIT_ADDED on every nameplate (a storm in cities and
-- raids). World rares never spawn inside instances, so tracking them there is pure
-- waste — register these only in the open world. Mirrors KillLog, which registers
-- COMBAT_LOG only inside raids. Re-evaluated on every instance transition below.
local HEAVY_EVENTS = {
    "COMBAT_LOG_EVENT_UNFILTERED",
    "NAME_PLATE_UNIT_ADDED",
}

local function InInstanceNow()
    if not IsInInstance then return false end
    local _, instType = IsInInstance()
    return instType ~= nil and instType ~= "none"
end

local function ApplyEventSet(list, want, isRegistered)
    if want == isRegistered then return isRegistered end
    local op = want and f.RegisterEvent or f.UnregisterEvent
    for _, eventName in ipairs(list) do pcall(op, f, eventName) end
    return want
end

-- Single source of truth: register the light set whenever tracking is enabled,
-- and the heavy set only when enabled AND in the open world.
local function RefreshTrackingEvents()
    local enabled = ObserverEnabled()
    lightRegistered = ApplyEventSet(LIGHT_EVENTS, enabled, lightRegistered)
    heavyRegistered = ApplyEventSet(HEAVY_EVENTS, enabled and not InInstanceNow(), heavyRegistered)
end
f:RegisterEvent("ADDON_LOADED")
f:RegisterEvent("PLAYER_LOGIN")
-- Vignette events (guarded — these only exist on clients that expose them).

f:SetScript("OnEvent", function(_, event, arg1)
    if event == "ADDON_LOADED" then
        if arg1 == ADDON_NAME then EnsureDB(); RefreshTrackingEvents() end
    elseif event == "PLAYER_LOGIN" then
        EnsureDB()
        RefreshTrackingEvents()
    elseif event == "UPDATE_MOUSEOVER_UNIT" then
        if not ObserverEnabled() then return end
        ERT:RecordSighting("mouseover")
    elseif event == "PLAYER_TARGET_CHANGED" then
        if not ObserverEnabled() then return end
        ERT:RecordSighting("target")
    elseif event == "NAME_PLATE_UNIT_ADDED" then
        if not ObserverEnabled() then return end
        ERT:RecordSighting(arg1)
    elseif event == "COMBAT_LOG_EVENT_UNFILTERED" then
        if not ObserverEnabled() then return end
        local _, sub, _, _, _, _, _, destGUID, destName = CombatLogGetCurrentEventInfo()
        if sub == "UNIT_DIED" or sub == "PARTY_KILL" then
            local npcID = GetNPCID(destGUID)
            if npcID then ERT:RecordDeath(npcID, destName) end
        end
    elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then
        -- Re-evaluate heavy-event gating on every instance transition: entering a
        -- dungeon/raid drops COMBAT_LOG + nameplate, leaving restores them.
        RefreshTrackingEvents()
        if not ObserverEnabled() then return end
        ScanVignettes()
    elseif event == "VIGNETTES_UPDATED" or event == "VIGNETTE_MINIMAP_UPDATED" then
        if not ObserverEnabled() then return end
        ScanVignettes()
    end
end)

-- ---------------------------------------------------------------------------
-- /etbrare — Phase 1 verification (no UI yet)
-- ---------------------------------------------------------------------------

local function fmt(sec)
    sec = math.max(0, math.floor(sec or 0))
    local m = math.floor(sec / 60); local h = math.floor(m / 60)
    if h > 0 then return h .. "h" .. (m % 60) .. "m" end
    return m .. "m" .. (sec % 60) .. "s"
end

-- Render a stored server-epoch timestamp as the REALM (server) clock HH:MM, so
-- it matches the in-game clock and reads identically for everyone on the realm
-- regardless of their computer's timezone — which is how players coordinate
-- rares. Anchors to GetGameTime() (the minimap's server clock); falls back to
-- local time only if that API is unavailable.
--
-- When the timestamp lands on a DIFFERENT server day than now (a long window, or
-- an overdue rare from yesterday), the bare clock is ambiguous — "by ~3:00 AM"
-- could be tonight or tomorrow — so we append the date. Today's times stay clean.
-- The date is computed in SERVER terms: we offset the epoch by the server-minus-
-- local timezone difference so date() yields the realm's calendar day, not the
-- player's, keeping it consistent with the HH:MM.
local function ServerClock(ts)
    if not ts then return "?" end
    local now = GetServerTime and GetServerTime()
    local serverSOD  -- server seconds-of-day for "now" (minute precision)
    if GetGameTime then
        local sh, sm = GetGameTime()
        if sh then serverSOD = sh * 3600 + sm * 60 end
    end

    if not serverSOD or not now then
        -- No realm clock available: fall back to the player's local clock, and
        -- append the local date when ts isn't local-today.
        if not date then return "?" end
        local t = date("%I:%M %p", ts):gsub("^0", "")
        if now and date("%x", ts) ~= date("%x", now) then
            t = t .. " " .. (date("%b %d", ts):gsub(" 0", " "))
        end
        return t
    end

    local raw = serverSOD + (ts - now)           -- server seconds-of-day for ts
    local dayOffset = math.floor(raw / 86400)    -- 0=today, 1=tomorrow, -1=yesterday
    local secs = raw - dayOffset * 86400         -- [0, 86400)
    local h = math.floor(secs / 3600)
    local m = math.floor((secs % 3600) / 60)
    local ap = h < 12 and "AM" or "PM"
    local h12 = h % 12; if h12 == 0 then h12 = 12 end
    local tstr = string.format("%d:%02d %s", h12, m, ap)
    if dayOffset == 0 then return tstr end

    -- Off-day: append the realm's calendar date for ts.
    if date then
        local lt = date("*t", now)
        local localSOD = lt.hour * 3600 + lt.min * 60 + lt.sec
        local off = serverSOD - localSOD          -- server minus local, seconds
        if off > 43200 then off = off - 86400 elseif off < -43200 then off = off + 86400 end
        local dstr = date("%b %d", ts + off):gsub(" 0", " ")
        return tstr .. " " .. dstr
    end
    -- date() unavailable: relative day label.
    local rel = (dayOffset == 1 and "tomorrow") or (dayOffset == -1 and "yesterday")
        or (dayOffset > 0 and ("+" .. dayOffset .. "d")) or (dayOffset .. "d")
    return tstr .. " " .. rel
end
ERT.ServerClock = ServerClock
ERT.FmtDuration = fmt   -- shared with the tracker UI

SLASH_ETBRARE1 = "/etbrare"
SlashCmdList["ETBRARE"] = function(msg)
    EnsureDB(); EnsureSeed()
    msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")
    if msg == "on" or msg == "enable" then
        ETB_RareDB.enabled = true
        RefreshTrackingEvents()
        DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r observer ON")
        return
    elseif msg == "off" or msg == "disable" then
        ETB_RareDB.enabled = false
        RefreshTrackingEvents()
        DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r observer OFF")
        return
    elseif msg == "status" then
        DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r observer " .. (ObserverEnabled() and "ON" or "OFF"))
        return
    elseif msg == "ui" or msg == "show" then
        if ERT.ToggleTracker then ERT:ToggleTracker() else
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r tracker UI not loaded") end
        return
    elseif msg == "needed" then
        local s = ERT.AlertSettings and ERT:AlertSettings()
        if s then s.neededOnly = not s.neededOnly
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r needed-only " .. (s.neededOnly and "ON" or "OFF")) end
        return
    elseif msg == "alerts" then
        local s = ERT.AlertSettings and ERT:AlertSettings()
        if s then s.enabled = not s.enabled
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r alerts " .. (s.enabled and "ON" or "OFF")) end
        return
    elseif msg == "minimap" then
        if ERT.Minimap and ERT.Minimap.Toggle then
            ERT.Minimap:Toggle()
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r minimap button toggled")
        end
        return
    elseif msg == "timer" then
        if ERT.ToggleCountdown then
            local on = ERT:ToggleCountdown()
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r countdown timer " .. (on and "ON" or "OFF"))
        end
        return
    elseif msg == "popout" or msg == "timer pop" then
        if ERT.PopOutTimer then
            local on = ERT:PopOutTimer()
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r timer " .. (on and "popped out (pinned)" or "un-pinned"))
        end
        return
    elseif msg == "sound" then
        if ERT.ToggleRareSound then
            local on = ERT:ToggleRareSound()
            DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r up-sound " .. (on and "ON (test played)" or "OFF"))
        end
        return
    elseif msg == "data" then
        local p = function(s) DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r " .. s) end
        local tracked, withWindow = 0, 0
        if realmDB then
            for _, e in pairs(realmDB) do
                tracked = tracked + 1
                if e.lastDeath then withWindow = withWindow + 1 end
            end
        end
        local wanted = 0
        if ETB_RareDB and ETB_RareDB.want then for _ in pairs(ETB_RareDB.want) do wanted = wanted + 1 end end
        local realms = 0
        if ETB_RareDB and ETB_RareDB.realms then for _ in pairs(ETB_RareDB.realms) do realms = realms + 1 end end
        p("realm: |cffFFFFFF" .. tostring(realmKey) .. "|r  (" .. realms .. " realm(s) stored)")
        p("tracked here: |cffFFFFFF" .. tracked .. "|r rares, |cffFFFFFF" .. withWindow .. "|r with a respawn window")
        p("wanted (account-wide): |cffFFFFFF" .. wanted .. "|r")
        p("Note: data is written to disk on /logout or /reload only — /reload after a kill you care about.")
        return
    end
    local p = function(s) DEFAULT_CHAT_FRAME:AddMessage("|cffEEE4AE[ETB Rare]|r " .. s) end
    if not realmDB then p("no data yet"); return end
    local n = 0
    for npcID, e in pairs(realmDB) do
        n = n + 1
        local st = ERT:GetStatus(npcID)
        local line = ERT:GetName(npcID) .. " — " .. (st.state or "?")
        if st.state == "alive" then
            line = line .. (st.confirmed and " — |cff40ff40UP NOW|r" or " (up)")
                .. (st.aliveFor and (" " .. fmt(st.aliveFor)) or "")
        elseif st.state == "too_early" then
            line = line .. " (up ~" .. ServerClock(st.opensAt) .. "–" .. ServerClock(st.latestAt)
                .. ", in ~" .. fmt(st.opensIn) .. ")"
        elseif st.state == "window_open" then
            if st.modalAt and st.modalAt > GetServerTime() then
                line = line .. " (up now → most likely ~" .. ServerClock(st.modalAt) .. ", by ~" .. ServerClock(st.latestAt) .. ")"
            else
                line = line .. " (likely up now → by ~" .. ServerClock(st.latestAt) .. ")"
            end
        elseif st.state == "overdue" then
            line = line .. " (overdue — due by ~" .. ServerClock(st.latestAt) .. ", " .. fmt(st.since) .. " ago)"
        elseif st.state == "stale" then
            line = line .. " (lost track — last kill " .. fmt(st.since) .. " ago, go check)"
        end
        -- evidence: data tier + how much backs it, last-kill time, running tally
        local meta = {}
        if st.source then meta[#meta + 1] = st.source .. "/" .. (st.confidence or "") .. (st.samples and st.samples > 0 and (" n=" .. st.samples) or "") end
        if st.lastDeath then meta[#meta + 1] = "killed " .. ServerClock(st.lastDeath) end
        if (st.kills or 0) > 0 then meta[#meta + 1] = st.kills .. " kills" end
        if (st.spawnsSeen or 0) > 0 then meta[#meta + 1] = st.spawnsSeen .. " seen" end
        if #meta > 0 then line = line .. " |cff808080[" .. table.concat(meta, ", ") .. "]|r" end
        p(line)
    end
    if n == 0 then p("tracking nothing yet — mousing over or killing a rare will populate this") end
    if n > 0 then
        p("|cff808080UP NOW = confirmed alive; other times are predictions. Open-world rares are shard-specific, so timers are approximate across players.|r")
    end
end

ERT.DecodeSpawnTime = DecodeSpawnTime  -- exposed for reuse/tests
ERT.GetNPCID = GetNPCID
