local addonName, privateDB = ...
local HDG = _G.HousingDecorGuide
local Log = privateDB.Log
local LOG_LEVEL = privateDB.LOG_LEVEL

-- ============================================================================
-- Recent Activity Tracker
-- Captures placement and removal events into HDG_DB.recentActivity, keyed by
-- houseGUID, grouped by edit session.
--
-- Architecture (taint-safe):
--   * StartPlacingNewDecor hook captures entryID/decorID for pending placement
--   * PLACE_SUCCESS marks the next CUSTOMIZATION_CHANGED as the new GUID
--   * CUSTOMIZATION_CHANGED maintains knownGUIDs set (also bulk-fills on edit-mode entry)
--   * DECOR_REMOVED gives us the GUID directly
--   * HOUSE_DECOR_ADDED_TO_CHEST opportunistically caches decorGUID->decorID
--
-- See: docs/superpowers/specs/2026-04-19-hdg-recently-placed-design.md
-- ============================================================================

local MODULE_NAME = "RecentActivity"

HDG.RecentActivity = HDG.RecentActivity or {}
local RA = HDG.RecentActivity

-- In-memory state (not persisted)
RA.knownGUIDs = {}                  -- [decorGUID] = true
RA.knownGUIDsCount = 0
RA.decorGUIDToDecorID = {}          -- opportunistic cache from CHEST events / hooks (legacy)
RA.decorMetaCache = {}              -- [decorGUID] = { decorID, name, icon }
RA.pendingRemovedEvents = {}        -- [decorGUID] = { sessionID, eventID } -- patched on CHEST
RA.pendingPlacement = nil           -- { entryID, decorID, name, icon, at }
RA.awaitingNewPlacementGUID = nil   -- { entryID, decorID, name, icon, at }
RA.pendingSessionStartedAt = nil    -- nil = no editor session in progress

-- Frame for event handling
RA.frame = CreateFrame("Frame")

-- ============================================================================
-- SavedVariables shape (per design spec):
--
-- HDG_DB.recentActivity = {
--     [houseGUID] = {
--         sessions = {
--             [sessionID] = {
--                 sessionID  = number,    -- == startedAt
--                 startedAt  = number,
--                 endedAt    = number?,   -- nil if active
--                 events = {
--                     [eventID] = {
--                         eventID   = number,    -- monotonic within session
--                         eventType = "placed" | "removed",
--                         decorID   = number,
--                         entryID   = HousingCatalogEntryID,  -- table; for StartPlacingNewDecor
--                         decorGUID = string,
--                         name      = string,
--                         icon      = number,
--                         timestamp = number,
--                         isLive    = boolean,   -- placed events only
--                     },
--                 },
--                 eventOrder = { eventID, ... },
--                 nextEventID = number,  -- counter for new events
--             },
--         },
--         sessionOrder = { sessionID, ... },  -- newest first
--     },
-- }
--
-- HDG_Settings.recentActivity = {
--     sessionCap = 20,  -- +-10 stepper, floor 10, default 20
-- }
-- ============================================================================

local function ensureDB()
    HDG_DB = HDG_DB or {}
    HDG_DB.recentActivity = HDG_DB.recentActivity or {}
    HDG_Settings = HDG_Settings or {}
    HDG_Settings.recentActivity = HDG_Settings.recentActivity or {}
    if type(HDG_Settings.recentActivity.sessionCap) ~= "number"
       or HDG_Settings.recentActivity.sessionCap < 10 then
        HDG_Settings.recentActivity.sessionCap = 20
    end
end

local function getCurrentHouseGUID()
    -- C_Housing.GetCurrentHouseInfo() returns a HouseInfo with .houseGUID when
    -- the player is inside their own house. Fall back to nil otherwise --
    -- caller must guard.
    if C_Housing and C_Housing.GetCurrentHouseInfo then
        local info = C_Housing.GetCurrentHouseInfo()
        return info and info.houseGUID
    end
    return nil
end

local function ensureHouseBucket(houseGUID)
    if not houseGUID then return nil end
    ensureDB()  -- defensive: events may fire before PLAYER_LOGIN on /reload
    HDG_DB.recentActivity[houseGUID] = HDG_DB.recentActivity[houseGUID] or {
        sessions = {},
        sessionOrder = {},
    }
    return HDG_DB.recentActivity[houseGUID]
end

RA._ensureDB = ensureDB
RA._getCurrentHouseGUID = getCurrentHouseGUID
RA._ensureHouseBucket = ensureHouseBucket

-- Get-or-create the active session. If a session is already active (newest
-- session has no endedAt), return it. Otherwise promote a pending one.
-- Returns the session table, or nil if no houseGUID / no pending stamp.
local function promoteSession()
    local houseGUID = getCurrentHouseGUID()
    if not houseGUID then return nil end

    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]

    -- Already-active session (first placement already promoted it): reuse.
    if bucket and #bucket.sessionOrder > 0 then
        local newest = bucket.sessions[bucket.sessionOrder[1]]
        if newest and not newest.endedAt then
            return newest
        end
    end

    -- No active session -- need a pending stamp to create one.
    if not RA.pendingSessionStartedAt then return nil end

    bucket = ensureHouseBucket(houseGUID)
    local sessionID = RA.pendingSessionStartedAt
    if bucket.sessions[sessionID] then return bucket.sessions[sessionID] end

    bucket.sessions[sessionID] = {
        sessionID    = sessionID,
        startedAt    = sessionID,
        endedAt      = nil,
        events       = {},
        eventOrder   = {},
        nextEventID  = 1,
    }
    table.insert(bucket.sessionOrder, 1, sessionID) -- newest first
    RA.pendingSessionStartedAt = nil

    -- Cap enforcement: drop oldest sessions over the limit.
    local cap = HDG_Settings.recentActivity.sessionCap or 10
    while #bucket.sessionOrder > cap do
        local dropID = table.remove(bucket.sessionOrder)
        bucket.sessions[dropID] = nil
    end

    Log(LOG_LEVEL.INFO, MODULE_NAME, "Session promoted: " .. sessionID)
    return bucket.sessions[sessionID]
end

local function sealActiveSession()
    local houseGUID = getCurrentHouseGUID()
    if not houseGUID then return end
    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]
    if not bucket or #bucket.sessionOrder == 0 then return end
    local newest = bucket.sessions[bucket.sessionOrder[1]]
    if newest and not newest.endedAt then
        newest.endedAt = time()
    end
end

RA._promoteSession = promoteSession
RA._sealActiveSession = sealActiveSession

-- Resolve human-readable name + icon fileID for a decorID.
-- C_HousingDecor.GetDecorName + GetDecorIcon are verified taint-safe.
local function resolveDecorMeta(decorID)
    if not decorID then return "Unknown decor", nil end
    local name = "Unknown decor"
    local icon = nil
    if C_HousingDecor and C_HousingDecor.GetDecorName then
        name = C_HousingDecor.GetDecorName(decorID) or name
    end
    if C_HousingDecor and C_HousingDecor.GetDecorIcon then
        icon = C_HousingDecor.GetDecorIcon(decorID)
    end
    return name, icon
end

-- Pull decorID out of the HousingCatalogEntryID table that StartPlacingNewDecor receives.
local function entryIDToDecorID(entryID)
    if type(entryID) == "table" and entryID.recordID then
        return entryID.recordID
    end
    if type(entryID) == "number" then
        return entryID  -- defensive: in case the API ever returns a bare number
    end
    return nil
end

RA._resolveDecorMeta = resolveDecorMeta
RA._entryIDToDecorID = entryIDToDecorID

function RA:Initialize()
    self.frame:RegisterEvent("PLAYER_LOGIN")
    self.frame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED")
    self.frame:RegisterEvent("HOUSING_DECOR_PLACE_SUCCESS")
    self.frame:RegisterEvent("HOUSING_DECOR_CUSTOMIZATION_CHANGED")
    self.frame:RegisterEvent("HOUSING_DECOR_REMOVED")
    self.frame:RegisterEvent("HOUSE_DECOR_ADDED_TO_CHEST")
    self.frame:RegisterEvent("PLAYER_HOUSE_LIST_UPDATED")
    self.frame:SetScript("OnEvent", function(_, event, ...)
        self:OnEvent(event, ...)
    end)

    -- Hook StartPlacingNewDecor non-tainting via hooksecurefunc.
    -- Captures the entryID being placed so PLACE_SUCCESS can pair it.
    if C_HousingBasicMode and C_HousingBasicMode.StartPlacingNewDecor then
        hooksecurefunc(C_HousingBasicMode, "StartPlacingNewDecor", function(entryID)
            local decorID = entryIDToDecorID(entryID)
            local name, icon = resolveDecorMeta(decorID)
            RA.pendingPlacement = {
                entryID = entryID,
                decorID = decorID,
                name    = name,
                icon    = icon,
                at      = GetTime(),
            }
        end)
    end

    -- Also hook the expert-mode placement path in case Blizzard routes through
    -- it for some interactions.
    if C_HousingExpertMode and C_HousingExpertMode.StartPlacingNewDecor then
        hooksecurefunc(C_HousingExpertMode, "StartPlacingNewDecor", function(entryID)
            local decorID = entryIDToDecorID(entryID)
            local name, icon = resolveDecorMeta(decorID)
            RA.pendingPlacement = {
                entryID = entryID, decorID = decorID, name = name, icon = icon, at = GetTime(),
            }
        end)
    end

    -- Hook SetPlacedDecorEntrySelected — fires whenever Blizzard's UI selects a
    -- placed decor (user click in world or in the placed-decor list). At that
    -- moment GetSelectedDecorInfo() is taint-safe and returns the full struct
    -- including decorGUID + decorID + name. Cache it so subsequent removals can
    -- resolve metadata even for items we never recorded a placement for.
    if C_HousingDecor and C_HousingDecor.SetPlacedDecorEntrySelected then
        hooksecurefunc(C_HousingDecor, "SetPlacedDecorEntrySelected", function(decorGUID, selected)
            if not selected or decorGUID == nil then return end
            local gt = type(decorGUID)
            if gt ~= "string" and gt ~= "userdata" then return end
            local info = C_HousingDecor.GetSelectedDecorInfo and C_HousingDecor.GetSelectedDecorInfo()
            -- WOWGUID equality: userdata compares by identity in Midnight. Two calls
            -- returning "the same" GUID may not be ==. tostring normalises both.
            if info and tostring(info.decorGUID) == tostring(decorGUID) and info.decorID then
                local _, icon = resolveDecorMeta(info.decorID)
                RA.decorMetaCache[decorGUID] = {
                    decorID = info.decorID,
                    name    = info.name or resolveDecorMeta(info.decorID),
                    icon    = icon,
                }
                RA.decorGUIDToDecorID[decorGUID] = info.decorID
            end
        end)
    end
end

-- Best-effort metadata lookup for a decorGUID. Tries:
--   1. our rich cache (populated by hooks above + CHEST events)
--   2. C_HousingDecor.GetDecorInstanceInfoForGUID (may or may not be taint-safe;
--      pcall-guarded so a future taint regression can't crash us)
-- Returns { decorID, name, icon } or nil.
local function lookupDecorMeta(decorGUID)
    if not decorGUID then return nil end
    local cached = RA.decorMetaCache[decorGUID]
    if cached then return cached end

    if C_HousingDecor and C_HousingDecor.GetDecorInstanceInfoForGUID then
        local ok, info = pcall(C_HousingDecor.GetDecorInstanceInfoForGUID, decorGUID)
        if ok and info and info.decorID then
            local _, icon = resolveDecorMeta(info.decorID)
            local meta = {
                decorID = info.decorID,
                name    = info.name or resolveDecorMeta(info.decorID),
                icon    = icon,
            }
            RA.decorMetaCache[decorGUID] = meta
            RA.decorGUIDToDecorID[decorGUID] = info.decorID
            return meta
        end
    end

    -- Last-ditch: legacy decorGUIDToDecorID may have an entry from CHEST events
    -- without name/icon — synthesize from decorID.
    local decorID = RA.decorGUIDToDecorID[decorGUID]
    if decorID then
        local name, icon = resolveDecorMeta(decorID)
        return { decorID = decorID, name = name, icon = icon }
    end

    return nil
end

RA._lookupDecorMeta = lookupDecorMeta

-- Walk persisted sessions newest-first and mark the matching placement isLive=false.
-- Returns (placementEvent, sessionID) so the caller can detect a same-session
-- place-then-remove (which should NOT produce a separate "removed" card; the
-- dead placement event already renders with the stacked +/- overlay).
-- Returns (nil, nil) if no live placement matches this GUID.
local function markPlacementDead(houseGUID, decorGUID)
    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]
    if not bucket then return nil, nil end
    for _, sessionID in ipairs(bucket.sessionOrder) do
        local session = bucket.sessions[sessionID]
        if session then
            for _, eid in ipairs(session.eventOrder) do
                local ev = session.events[eid]
                if ev and ev.eventType == "placed" and tostring(ev.decorGUID) == tostring(decorGUID) and ev.isLive then
                    ev.isLive = false
                    return ev, sessionID
                end
            end
        end
    end
    return nil, nil
end

RA._markPlacementDead = markPlacementDead

local function appendEvent(session, eventType, meta)
    local id = session.nextEventID
    session.nextEventID = id + 1
    -- NOTE: entryID is deliberately NOT persisted. HousingCatalogEntryID is a
    -- table with a metatable; storing it in SavedVariables silently drops the
    -- containing branch on reload (see Reference/HOUSING_API.md gotcha). Click
    -- handlers reconstruct via C_HousingCatalog.GetCatalogEntryInfoByRecordID
    -- from decorID on demand.
    session.events[id] = {
        eventID   = id,
        eventType = eventType,
        decorID   = meta.decorID,
        decorGUID = meta.decorGUID,
        name      = meta.name,
        icon      = meta.icon,
        timestamp = time(),
        isLive    = (eventType == "placed"),
    }
    table.insert(session.eventOrder, id)
end

RA._appendEvent = appendEvent

function RA:OnEvent(event, ...)
    if event == "PLAYER_LOGIN" then
        ensureDB()
        Log(LOG_LEVEL.INFO, MODULE_NAME, string.format(
            "Initialized; sessionCap=%d", HDG_Settings.recentActivity.sessionCap))

        -- Resume or seal any session that was active when the previous client
        -- shut down. If the editor is currently active and the newest session
        -- is unsealed, resume it (subsequent events append). Otherwise seal it
        -- with a synthetic endedAt so it stops claiming "Now" status.
        local houseGUID = getCurrentHouseGUID()
        local bucket = houseGUID and HDG_DB.recentActivity[houseGUID]
        if bucket and #bucket.sessionOrder > 0 then
            local newest = bucket.sessions[bucket.sessionOrder[1]]
            if newest and not newest.endedAt then
                local editorActive = C_HouseEditor and C_HouseEditor.IsHouseEditorActive
                    and C_HouseEditor.IsHouseEditorActive()
                if editorActive then
                    Log(LOG_LEVEL.DEBUG, MODULE_NAME,
                        "Resuming session " .. newest.sessionID .. " (editor still active)")
                else
                    newest.endedAt = newest.startedAt + 1  -- minimal seal
                    Log(LOG_LEVEL.DEBUG, MODULE_NAME,
                        "Sealed orphaned session " .. newest.sessionID)
                end
            end
        end

    elseif event == "PLAYER_HOUSE_LIST_UPDATED" then
        -- PLAYER_HOUSE_LIST_UPDATED fires 3-5x on /reload (spammy). If we wipe
        -- unconditionally, a post-burst fire blows away the knownGUIDs set that
        -- CUSTOMIZATION_CHANGED just populated. Only wipe when the editor is
        -- NOT active -- bulk enumeration trumps house-list refresh.
        local editorActive = C_HouseEditor and C_HouseEditor.IsHouseEditorActive
            and C_HouseEditor.IsHouseEditorActive()
        if not editorActive then
            RA.knownGUIDs = {}
            RA.knownGUIDsCount = 0
            RA.pendingPlacement = nil
            RA.awaitingNewPlacementGUID = nil
        end

    elseif event == "HOUSE_EDITOR_MODE_CHANGED" then
        local active = C_HouseEditor and C_HouseEditor.IsHouseEditorActive
            and C_HouseEditor.IsHouseEditorActive()
        if active then
            -- Begin a pending session in memory only. Wipe knownGUIDs so the
            -- bulk-fill from CUSTOMIZATION_CHANGED on entry repopulates them.
            RA.pendingSessionStartedAt = time()
            RA.knownGUIDs = {}
            RA.knownGUIDsCount = 0
            Log(LOG_LEVEL.DEBUG, MODULE_NAME, "Editor opened; pending session = " .. RA.pendingSessionStartedAt)
        else
            -- Editor closed.
            if RA.pendingSessionStartedAt then
                -- No qualifying event happened; discard pending stamp silently.
                RA.pendingSessionStartedAt = nil
            else
                sealActiveSession()
            end
            -- Clear placement-pairing state so leftover hooks don't pollute later.
            RA.pendingPlacement = nil
            RA.awaitingNewPlacementGUID = nil
        end

    elseif event == "HOUSING_DECOR_PLACE_SUCCESS" then
        -- Args per the docs: (decorGUID, size, isNew, isPreview).
        -- decorGUID is always nil in practice (verified). We use this event
        -- only as a marker: the next CUSTOMIZATION_CHANGED with an unknown
        -- GUID belongs to pendingPlacement.
        --
        -- Blizzard keeps placement mode active across multiple commits (one
        -- StartPlacingNewDecor call -> many PLACE_SUCCESS events), so we DO NOT
        -- clear pendingPlacement here. It's overwritten by the next
        -- StartPlacingNewDecor or cleared on editor close.
        local _, _, _, isPreview = ...
        if isPreview then return end
        if not RA.pendingPlacement then return end  -- placement initiated outside our hook; nothing to pair
        RA.awaitingNewPlacementGUID = RA.pendingPlacement

    elseif event == "HOUSING_DECOR_CUSTOMIZATION_CHANGED" then
        local decorGUID = ...
        -- WOWGUIDs are userdata in Midnight (tostring = "Opaque-N"), not strings.
        -- Accept either so the bulk enumeration populates knownGUIDs.
        if decorGUID == nil then return end
        local gt = type(decorGUID)
        if gt ~= "string" and gt ~= "userdata" then return end

        local isNew = (RA.knownGUIDs[decorGUID] == nil)
        if isNew then
            RA.knownGUIDs[decorGUID] = true
            RA.knownGUIDsCount = RA.knownGUIDsCount + 1
        end

        if isNew and RA.awaitingNewPlacementGUID then
            -- This is the freshly-placed instance.
            local meta = RA.awaitingNewPlacementGUID
            RA.awaitingNewPlacementGUID = nil

            local session = promoteSession()
            if not session then
                Log(LOG_LEVEL.WARN, MODULE_NAME,
                    "Cannot promote session (no houseGUID); dropping placement event for " .. tostring(decorGUID))
                return
            end

            meta.decorGUID = decorGUID
            -- Refresh name/icon in case decorID resolution arrived late.
            local name, icon = resolveDecorMeta(meta.decorID)
            meta.name = meta.name ~= "Unknown decor" and meta.name or name
            meta.icon = meta.icon or icon

            appendEvent(session, "placed", meta)
            Log(LOG_LEVEL.DEBUG, MODULE_NAME, string.format(
                "Captured placement: decorID=%s name=%s guid=%s",
                tostring(meta.decorID), meta.name, decorGUID))

            if HDG.EventBus and HDG.EventBus.Trigger then
                HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "placed" })
            end
        end
        -- else: known GUID OR no pending placement = silent (bulk fill path)

    elseif event == "HOUSING_DECOR_REMOVED" then
        local decorGUID = ...
        if decorGUID == nil then return end
        local gt = type(decorGUID)
        if gt ~= "string" and gt ~= "userdata" then return end

        RA.knownGUIDs[decorGUID] = nil
        if RA.knownGUIDsCount > 0 then RA.knownGUIDsCount = RA.knownGUIDsCount - 1 end

        local houseGUID = getCurrentHouseGUID()
        if not houseGUID then return end

        local prior, priorSessionID = markPlacementDead(houseGUID, decorGUID)

        -- Resolve metadata: prior placement > rich cache (hooks/InstanceInfo) >
        -- legacy CHEST cache > "Unknown decor" placeholder.
        local decorID, name, icon
        if prior then
            decorID, name, icon = prior.decorID, prior.name, prior.icon
        else
            local meta = lookupDecorMeta(decorGUID)
            if meta then
                decorID, name, icon = meta.decorID, meta.name, meta.icon
            else
                decorID = nil
                name, icon = resolveDecorMeta(nil)  -- "Unknown decor", nil
            end
        end

        local session = promoteSession()
        if not session then
            -- We can't persist without a session bucket; bail (rare).
            return
        end

        -- Same-session place-then-remove: the placement event's stacked +/-
        -- overlay already represents this. Don't append a duplicate "removed"
        -- event that would render as a separate card. Still fire the update
        -- event so UIs re-render.
        if prior and priorSessionID == session.sessionID then
            if HDG.EventBus and HDG.EventBus.Trigger then
                HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "removed" })
            end
            return
        end

        appendEvent(session, "removed", {
            decorID   = decorID,
            decorGUID = decorGUID,
            name      = name,
            icon      = icon,
        })

        -- If we couldn't resolve the metadata, mark this event for patching by
        -- the next HOUSE_DECOR_ADDED_TO_CHEST that names this decorGUID.
        if not decorID then
            RA.pendingRemovedEvents[decorGUID] = {
                houseGUID = houseGUID,
                sessionID = session.sessionID,
                eventID   = session.nextEventID - 1,  -- the event we just appended
            }
        end

        Log(LOG_LEVEL.DEBUG, MODULE_NAME, string.format(
            "Captured removal: name=%s guid=%s resolved=%s",
            tostring(name), decorGUID, tostring(decorID ~= nil)))

        if HDG.EventBus and HDG.EventBus.Trigger then
            HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "removed" })
        end

    elseif event == "HOUSE_DECOR_ADDED_TO_CHEST" then
        local decorGUID, decorID = ...
        local gt = type(decorGUID)
        if decorGUID ~= nil and (gt == "string" or gt == "userdata") and type(decorID) == "number" then
            -- Maintain both caches.
            RA.decorGUIDToDecorID[decorGUID] = decorID
            local name, icon = resolveDecorMeta(decorID)
            RA.decorMetaCache[decorGUID] = { decorID = decorID, name = name, icon = icon }

            -- Patch any unresolved removal event for this decorGUID.
            local pending = RA.pendingRemovedEvents[decorGUID]
            if pending then
                RA.pendingRemovedEvents[decorGUID] = nil
                local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[pending.houseGUID]
                local session = bucket and bucket.sessions[pending.sessionID]
                local ev = session and session.events[pending.eventID]
                if ev and ev.eventType == "removed" and tostring(ev.decorGUID) == tostring(decorGUID) then
                    ev.decorID = decorID
                    ev.name    = name
                    ev.icon    = icon
                    Log(LOG_LEVEL.DEBUG, MODULE_NAME, string.format(
                        "Patched pending removal via CHEST: name=%s guid=%s", name, decorGUID))
                    if HDG.EventBus and HDG.EventBus.Trigger then
                        HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "patched" })
                    end
                end
            end
        end
    end
end

-- ============================================================================
-- PUBLIC API
-- ============================================================================

-- Returns array of sessions for the given houseGUID, newest first.
-- Each entry: { sessionID, startedAt, endedAt, eventCount, isActive }
function RA:GetSessions(houseGUID)
    local out = {}
    if not houseGUID then return out end
    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]
    if not bucket then return out end
    for _, sessionID in ipairs(bucket.sessionOrder) do
        local s = bucket.sessions[sessionID]
        if s then
            table.insert(out, {
                sessionID  = s.sessionID,
                startedAt  = s.startedAt,
                endedAt    = s.endedAt,
                eventCount = #s.eventOrder,
                isActive   = (s.endedAt == nil),
            })
        end
    end
    return out
end

-- Returns array of EventGroup display structs for the session, newest first.
-- Groups events by (decorID, eventType). See spec for shape.
function RA:GetEventGroups(houseGUID, sessionID)
    local out = {}
    if not (houseGUID and sessionID) then return out end
    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]
    if not bucket or not bucket.sessions[sessionID] then return out end
    local session = bucket.sessions[sessionID]

    -- Build groups keyed by (decorID, eventType).
    local groups = {}     -- [key] = group
    local order = {}      -- preserve newest-event-first display order
    for i = #session.eventOrder, 1, -1 do
        local ev = session.events[session.eventOrder[i]]
        if ev then
            -- Late-resolution: if a placement was captured before the catalog
            -- was warm and got "Unknown decor", retry now (catalog APIs are
            -- taint-safe and fast). Patches the persisted record so the next
            -- read hits the cached name directly.
            if ev.decorID and (ev.name == nil or ev.name == "Unknown decor" or not ev.icon) then
                local name, icon = resolveDecorMeta(ev.decorID)
                if name and name ~= "Unknown decor" then ev.name = name end
                if icon then ev.icon = icon end
            end

            local key = (ev.decorID or "?") .. "|" .. ev.eventType
            local g = groups[key]
            if not g then
                g = {
                    decorID    = ev.decorID,
                    name       = ev.name,
                    icon       = ev.icon,
                    eventType  = ev.eventType,
                    liveCount  = 0,
                    deadCount  = 0,
                    eventCount = 0,
                    sessionID  = sessionID,
                    mostRecent = ev.timestamp,
                }
                groups[key] = g
                table.insert(order, g)
            end
            g.eventCount = g.eventCount + 1
            if ev.eventType == "placed" then
                if ev.isLive then g.liveCount = g.liveCount + 1
                else g.deadCount = g.deadCount + 1 end
            end
            if ev.timestamp > g.mostRecent then g.mostRecent = ev.timestamp end
        end
    end

    -- Sort groups newest-event-first
    table.sort(order, function(a, b) return a.mostRecent > b.mostRecent end)
    return order
end

-- Returns the active session's sessionID for the current house, or nil if no
-- session is currently active (editor closed, or no qualifying event yet).
function RA:GetCurrentSessionID()
    local houseGUID = getCurrentHouseGUID()
    if not houseGUID then return nil end
    local bucket = HDG_DB and HDG_DB.recentActivity and HDG_DB.recentActivity[houseGUID]
    if not bucket or #bucket.sessionOrder == 0 then return nil end
    local newest = bucket.sessions[bucket.sessionOrder[1]]
    if newest and not newest.endedAt then
        return newest.sessionID
    end
    return nil
end

-- Wipe a house's history (called from "/hdg recent clear" or settings UI).
function RA:Clear(houseGUID)
    if not houseGUID or not HDG_DB or not HDG_DB.recentActivity then return end
    HDG_DB.recentActivity[houseGUID] = nil
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "cleared" })
    end
end

-- Apply a new session cap immediately (used by the +/- stepper). Trims
-- oldest sessions across all houses if needed.
function RA:SetSessionCap(newCap)
    newCap = math.max(10, math.floor(newCap or 10))
    HDG_Settings.recentActivity.sessionCap = newCap
    if not HDG_DB or not HDG_DB.recentActivity then return end
    for _, bucket in pairs(HDG_DB.recentActivity) do
        while #bucket.sessionOrder > newCap do
            local dropID = table.remove(bucket.sessionOrder)
            bucket.sessions[dropID] = nil
        end
    end
    if HDG.EventBus and HDG.EventBus.Trigger then
        HDG.EventBus:Trigger("HDG_RECENT_ACTIVITY_UPDATED", { eventType = "capChanged" })
    end
end

