-- ============================================================================
-- Vamoose's Companion - Collectors
-- Registers WoW events and normalizes them into the internal event format
-- ============================================================================

VC = VC or {}
VC.Collectors = {}

local INTENSITY = VC.Constants.INTENSITY

-- Secret value helpers (12.0+): chat event args may be opaque secret values
local function SafeStr(val)
    if val == nil then return nil end
    local ok, str = pcall(tostring, val)
    if not ok then return nil end
    -- Verify string is not secret/tainted (pcall catches indexing errors)
    local usable = pcall(string.len, str)
    return usable and str or nil
end

local function SafeNum(val)
    if val == nil then return nil end
    local ok = pcall(function() return val > 0 end)
    if ok then return val end
    return nil
end

-- ============================================================================
-- INITIALIZATION
-- ============================================================================

function VC.Collectors:Initialize()
    self.frame = CreateFrame("Frame")

    -- Register WoW events
    self.frame:RegisterEvent("CHAT_MSG_LOOT")
    self.frame:RegisterEvent("PLAYER_MONEY")
    self.frame:RegisterEvent("ACHIEVEMENT_EARNED")
    self.frame:RegisterEvent("PLAYER_DEAD")
    self.frame:RegisterEvent("QUEST_TURNED_IN")
    self.frame:RegisterEvent("ENCOUNTER_END")
    self.frame:RegisterEvent("PLAYER_LEVEL_UP")
    self.frame:RegisterEvent("CHAT_MSG_COMBAT_FACTION_CHANGE")
    self.frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    self.frame:RegisterEvent("HOUSE_LEVEL_CHANGED")
    self.frame:RegisterEvent("INITIATIVE_TASK_COMPLETED")
    self.frame:RegisterEvent("INITIATIVE_COMPLETED")
    self.frame:RegisterEvent("CHALLENGE_MODE_COMPLETED")
    self.frame:RegisterEvent("PET_BATTLE_OPENING_START")
    self.frame:RegisterEvent("PET_BATTLE_FINAL_ROUND")
    self.frame:RegisterEvent("PET_BATTLE_CLOSE")
    self.frame:RegisterEvent("UPDATE_SUMMONPETS_ACTION")
    self.frame:RegisterEvent("GROUP_ROSTER_UPDATE")
    -- Tier 1 collectors
    self.frame:RegisterEvent("TRADE_SKILL_ITEM_CRAFTED_RESULT")
    self.frame:RegisterEvent("PLAYER_UNGHOST")
    self.frame:RegisterEvent("RESURRECT_REQUEST")
    self.frame:RegisterEvent("PERKS_ACTIVITY_COMPLETED")
    self.frame:RegisterEvent("SCENARIO_COMPLETED")
    self.frame:RegisterEvent("TRANSMOGRIFY_SUCCESS")
    self.frame:RegisterEvent("BARBER_SHOP_RESULT")
    -- Tier 2 collectors
    self.frame:RegisterEvent("TRANSMOG_COLLECTION_SOURCE_ADDED")
    self.frame:RegisterEvent("LOOT_OPENED")
    self.frame:RegisterEvent("PLAYER_FLAGS_CHANGED")
    self.frame:RegisterEvent("UPDATE_FACTION")
    self.frame:RegisterEvent("GARRISON_MISSION_FINISHED")
    self.frame:RegisterEvent("PLAYER_CAMPING")
    self.frame:RegisterEvent("CRAFTINGORDERS_DISPLAY_CRAFTER_FULFILLED_MSG")
    -- Tier 3 collectors
    self.frame:RegisterEvent("CRITERIA_EARNED")
    -- CHAT_MSG_MONSTER_YELL disabled: args are tainted ("secret") during combat.
    -- Re-enable when _notableNPCs is populated and a taint-safe lookup is in place.
    -- self.frame:RegisterEvent("CHAT_MSG_MONSTER_YELL")
    self.frame:RegisterEvent("PLAYER_REGEN_ENABLED")
    self.frame:RegisterEvent("BN_FRIEND_ACCOUNT_ONLINE")
    self.frame:RegisterEvent("PLAYER_XP_UPDATE")
    self.frame:RegisterEvent("UPDATE_INVENTORY_DURABILITY")
    -- Fishing cast detection (unit event, player only)
    self.frame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
    -- COMBAT_LOG_EVENT_UNFILTERED intentionally disabled: fires every combat frame
    -- and caused UI taint in combat. Death context (killedBy) is a nice-to-have;
    -- not worth the taint risk. OnDeath() will simply report killedBy as "".
    -- self.frame:RegisterEvent("COMBAT_LOG_EVENT_UNFILTERED")

    -- Track gold for delta calculation
    self._lastGold = GetMoney()
    -- Track pet battle state
    self._inPetBattle = false
    self._petBattleFinalRound = false
    -- Track group state for join detection
    self._wasInGroup = IsInGroup()
    -- Track death time for unghost pairing
    self._lastDeathTime = 0
    -- Track fishing state
    self._isFishingCast = false
    self._lastFishingTime = 0
    -- Track AFK state
    self._wasAFK = false
    -- Track faction standings for tier change detection
    self._factionStandings = {}
    -- Debounce trackers for high-frequency events
    self._lastTransmogCollectionTime = 0
    self._lastCriteriaTime = 0
    self._lastCombatEndTime = 0
    self._lastXPCommentTime = 0

    self.frame:SetScript("OnEvent", function(_, event, ...)
        self:OnWoWEvent(event, ...)
    end)

    -- Start ambient timer
    self:ScheduleAmbient()

    -- Fire login greeting (deferred so engine is ready)
    C_Timer.After(2, function()
        self:OnLogin()
    end)
end

-- ============================================================================
-- WOW EVENT HANDLER
-- ============================================================================

function VC.Collectors:OnWoWEvent(event, ...)
    if event == "CHAT_MSG_LOOT" then
        self:OnLoot(...)
    elseif event == "PLAYER_MONEY" then
        self:OnMoneyChange()
    elseif event == "ACHIEVEMENT_EARNED" then
        self:OnAchievement(...)
    elseif event == "PLAYER_DEAD" then
        self:OnDeath()
    elseif event == "QUEST_TURNED_IN" then
        self:OnQuestTurnedIn(...)
    elseif event == "ENCOUNTER_END" then
        self:OnEncounterEnd(...)
    elseif event == "PLAYER_LEVEL_UP" then
        self:OnLevelUp(...)
    elseif event == "CHAT_MSG_COMBAT_FACTION_CHANGE" then
        self:OnRepChange(...)
    elseif event == "ZONE_CHANGED_NEW_AREA" then
        self:OnZoneChange()
    elseif event == "HOUSE_LEVEL_CHANGED" then
        self:OnHouseLevelUp(...)
    elseif event == "INITIATIVE_TASK_COMPLETED" then
        self:OnInitiativeTask(...)
    elseif event == "INITIATIVE_COMPLETED" then
        self:OnInitiativeCompleted(...)
    elseif event == "CHALLENGE_MODE_COMPLETED" then
        self:OnMythicPlus(...)
    elseif event == "PET_BATTLE_OPENING_START" then
        self._inPetBattle = true
        self._petBattleFinalRound = false
    elseif event == "PET_BATTLE_FINAL_ROUND" then
        self._petBattleFinalRound = true
    elseif event == "PET_BATTLE_CLOSE" then
        self:OnPetBattleClose()
    elseif event == "UPDATE_SUMMONPETS_ACTION" then
        self:OnPetSummoned()
    elseif event == "GROUP_ROSTER_UPDATE" then
        self:OnGroupRosterUpdate()
    -- Tier 1
    elseif event == "TRADE_SKILL_ITEM_CRAFTED_RESULT" then
        self:OnCraftItem(...)
    elseif event == "PLAYER_UNGHOST" then
        self:OnUnghost()
    elseif event == "RESURRECT_REQUEST" then
        self:OnResurrectRequest(...)
    elseif event == "PERKS_ACTIVITY_COMPLETED" then
        self:OnPerksActivity(...)
    elseif event == "SCENARIO_COMPLETED" then
        self:OnScenarioComplete()
    elseif event == "TRANSMOGRIFY_SUCCESS" then
        self:OnTransmogSuccess()
    elseif event == "BARBER_SHOP_RESULT" then
        self:OnBarberShopResult(...)
    -- Tier 2
    elseif event == "TRANSMOG_COLLECTION_SOURCE_ADDED" then
        self:OnTransmogCollectionAdded(...)
    elseif event == "LOOT_OPENED" then
        self:OnLootOpened()
    elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
        self:OnUnitSpellcastSucceeded(...)
    elseif event == "PLAYER_FLAGS_CHANGED" then
        self:OnPlayerFlagsChanged(...)
    elseif event == "UPDATE_FACTION" then
        self:OnFactionUpdate()
    elseif event == "GARRISON_MISSION_FINISHED" then
        self:OnGarrisonMissionFinished(...)
    elseif event == "PLAYER_CAMPING" then
        self:OnPlayerCamping()
    elseif event == "CRAFTINGORDERS_DISPLAY_CRAFTER_FULFILLED_MSG" then
        self:OnCraftingOrderFulfilled(...)
    -- Tier 3
    elseif event == "CRITERIA_EARNED" then
        self:OnCriteriaEarned(...)
    elseif event == "PLAYER_REGEN_ENABLED" then
        self:OnCombatEnd()
    elseif event == "BN_FRIEND_ACCOUNT_ONLINE" then
        self:OnBNetFriendOnline(...)
    elseif event == "PLAYER_XP_UPDATE" then
        self:OnXPUpdate()
    elseif event == "UPDATE_INVENTORY_DURABILITY" then
        self:OnDurabilityUpdate()
    -- "COMBAT_LOG_EVENT_UNFILTERED" handler removed (taint risk)
    end
end

-- ============================================================================
-- LOOT COLLECTOR
-- ============================================================================

function VC.Collectors:OnLoot(message)
    message = SafeStr(message)
    if not message then return end
    local nameOk, playerName = pcall(UnitName, "player")
    if not nameOk or not playerName then return end
    local findOk, found = pcall(message.find, message, playerName)
    if not findOk or not found then return end

    local itemLink = message:match("|c.-|Hitem:.-|h%[.-%]|h|r")
    if not itemLink then return end

    local itemName, _, itemQuality = C_Item.GetItemInfo(itemLink)
    if not itemName or not itemQuality then return end

    local intensity = INTENSITY.LOW
    if itemQuality == 2 then
        intensity = INTENSITY.MEDIUM
    elseif itemQuality == 3 then
        intensity = INTENSITY.HIGH
    elseif itemQuality >= 4 then
        intensity = INTENSITY.EPIC
    end

    self:Emit({
        eventType = "farming",
        intensity = intensity,
        context = {
            itemName = itemName,
            itemQuality = itemQuality,
            itemLink = itemLink,
        },
        timestamp = GetTime(),
        source = "CHAT_MSG_LOOT",
    })

    -- Mood: epic loot
    if itemQuality >= 4 and VC.Mood then VC.Mood:ApplySignal("LOOT_EPIC") end
end

-- ============================================================================
-- GOLD COLLECTOR
-- ============================================================================

-- Debounce gold: accumulate changes over 2s window, then emit once
function VC.Collectors:OnMoneyChange()
    local currentGold = GetMoney()
    local delta = currentGold - self._lastGold
    self._lastGold = currentGold

    if delta == 0 then return end

    -- Accumulate delta
    self._goldAccum = (self._goldAccum or 0) + delta

    -- Cancel existing timer
    if self._goldTimer then self._goldTimer:Cancel() end

    -- Fire after 2s of quiet
    self._goldTimer = C_Timer.NewTimer(2.0, function()
        local accum = self._goldAccum or 0
        self._goldAccum = 0
        self._goldTimer = nil
        if accum == 0 then return end

        local absDelta = math.abs(accum)
        if absDelta < 100000 then return end -- ignore gains under 10g (mob drops)
        local intensity = INTENSITY.LOW
        if absDelta >= 1000000 then       -- 100g+
            intensity = INTENSITY.MEDIUM
        end
        if absDelta >= 10000000 then      -- 1,000g+
            intensity = INTENSITY.HIGH
        end
        if absDelta >= 100000000 then     -- 10,000g+
            intensity = INTENSITY.EPIC
        end

        self:Emit({
            eventType = "gold",
            intensity = intensity,
            context = {
                goldDelta = accum,
                isGain = accum > 0,
            },
            timestamp = GetTime(),
            source = "PLAYER_MONEY",
        })
        if accum > 0 and VC.Memory then VC.Memory:IncrementCount("goldEarned", absDelta) end
    end)
end

-- ============================================================================
-- ACHIEVEMENT COLLECTOR
-- ============================================================================

function VC.Collectors:OnAchievement(achievementID)
    local function fire()
        local _, name = GetAchievementInfo(achievementID)
        self:Emit({
            eventType = "achieve",
            intensity = INTENSITY.HIGH,
            context = {
                achievementId = achievementID,
                achieveName = name or "Unknown",
            },
            timestamp = GetTime(),
            source = "ACHIEVEMENT_EARNED",
        })
        if VC.Mood then VC.Mood:ApplySignal("ACHIEVEMENT") end
        if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "achievement") end
        if VC.Memory then VC.Memory:IncrementCount("achievementsEarned") end
    end

    if InCombatLockdown() then
        VC.Collectors._deferredAchievements = VC.Collectors._deferredAchievements or {}
        VC.Collectors._deferredAchievements[#VC.Collectors._deferredAchievements + 1] = fire
    else
        fire()
    end
end

-- ============================================================================
-- DEATH COLLECTOR
-- ============================================================================

function VC.Collectors:OnDeath()
    self:Emit({
        eventType = "death",
        intensity = INTENSITY.MEDIUM,
        context = {},
        timestamp = GetTime(),
        source = "PLAYER_DEAD",
    })
    self._lastDeathTime = GetTime()

    if VC.Mood then VC.Mood:RecordDeath() end
    if VC.Memory then VC.Memory:IncrementCount("deaths") end
end

-- ============================================================================
-- QUEST COLLECTOR
-- ============================================================================

function VC.Collectors:OnQuestTurnedIn(questID, xpReward, moneyReward)
    local questName = C_QuestLog.GetTitleForQuestID(questID) or "Unknown"

    self:Emit({
        eventType = "quest",
        intensity = INTENSITY.MEDIUM,
        context = {
            questId = questID,
            questName = questName,
        },
        timestamp = GetTime(),
        source = "QUEST_TURNED_IN",
    })

    if VC.Mood then VC.Mood:ApplySignal("QUEST_COMPLETE") end
    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "quest") end
    if VC.Memory then VC.Memory:IncrementCount("questsCompleted") end
end

-- ============================================================================
-- BOSS/ENCOUNTER COLLECTOR
-- ============================================================================

function VC.Collectors:OnEncounterEnd(encounterID, encounterName, difficultyID, groupSize, success)
    success = SafeNum(success)
    if not success then return end -- secret in M+, can't determine outcome
    difficultyID = SafeNum(difficultyID)
    encounterName = SafeStr(encounterName)

    -- Mood signal for both kills and wipes
    if VC.Mood then
        if success == 1 then
            VC.Mood:ApplySignal("BOSS_KILL")
        else
            VC.Mood:ApplySignal("BOSS_WIPE")
        end
    end

    if success ~= 1 then return end -- only celebrate kills for quotes

    local intensity = INTENSITY.HIGH
    local isMythicRaid = difficultyID and difficultyID >= 16
    if isMythicRaid then
        intensity = INTENSITY.EPIC
    end

    self:Emit({
        eventType = "boss",
        intensity = intensity,
        context = {
            encounterId = encounterID,
            encounterName = encounterName or "Unknown",
            difficultyId = difficultyID,
        },
        timestamp = GetTime(),
        source = "ENCOUNTER_END",
    })

    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "boss") end
    if VC.Memory then
        VC.Memory:RecordBossKill({
            name = encounterName or "Unknown",
            difficultyId = difficultyID,
            ts = GetTime(),
        })
    end
end

-- ============================================================================
-- LEVEL UP COLLECTOR
-- ============================================================================

function VC.Collectors:OnLevelUp(level)
    self:Emit({
        eventType = "levelup",
        intensity = INTENSITY.EPIC,
        context = {
            newLevel = level,
        },
        timestamp = GetTime(),
        source = "PLAYER_LEVEL_UP",
    })

    if VC.Mood then VC.Mood:ApplySignal("LEVEL_UP") end
end

-- ============================================================================
-- REPUTATION COLLECTOR
-- ============================================================================

function VC.Collectors:OnRepChange(message)
    message = SafeStr(message)
    if not message or message == "" then return end

    -- Use Blizzard's global format strings for locale-safe parsing
    local gainPattern = FACTION_STANDING_INCREASED and FACTION_STANDING_INCREASED:gsub("%%[sd]", "(.+)") or nil
    local lossPattern = FACTION_STANDING_DECREASED and FACTION_STANDING_DECREASED:gsub("%%[sd]", "(.+)") or nil

    local faction, isGain
    if gainPattern then
        faction = message:match(gainPattern)
        isGain = faction ~= nil
    end
    if not faction and lossPattern then
        faction = message:match(lossPattern)
        isGain = false
    end

    self:Emit({
        eventType = "rep",
        intensity = INTENSITY.LOW,
        context = {
            faction = faction or "",
            isGain = isGain or false,
        },
        timestamp = GetTime(),
        source = "CHAT_MSG_COMBAT_FACTION_CHANGE",
    })
end

-- ============================================================================
-- ZONE CHANGE COLLECTOR
-- ============================================================================

function VC.Collectors:OnZoneChange()
    -- Clear expansion cache so it re-resolves
    if VC.Engine then VC.Engine._cachedExpansion = nil end

    -- Delay to avoid firing behind loading screen
    C_Timer.After(10, function()
        local zoneName = GetRealZoneText() or ""
        if zoneName == "" then return end

        self:Emit({
            eventType = "zone",
            intensity = INTENSITY.LOW,
            context = {
                zoneName = zoneName,
            },
            timestamp = GetTime(),
            source = "ZONE_CHANGED_NEW_AREA",
        })
        if VC.Memory then VC.Memory:IncrementCount("zonesVisited") end
    end)
end

-- ============================================================================
-- LOGIN GREETING
-- ============================================================================

function VC.Collectors:OnLogin()
    self:Emit({
        eventType = "login",
        intensity = INTENSITY.HIGH,
        context = {
            playerName = UnitName("player") or "",
        },
        timestamp = GetTime(),
        source = "LOGIN_GREETING",
    })
end

-- ============================================================================
-- AMBIENT TIMER
-- ============================================================================

function VC.Collectors:ScheduleAmbient()
    local base = math.random(VC.Constants.AMBIENT_TIMER.MIN, VC.Constants.AMBIENT_TIMER.MAX)

    local chattiness = VC.Store:GetState().config.chattiness
    local tier = VC.Constants.CHATTINESS[chattiness]
    local delay = base * (tier and tier.categoryMult or 1.0)

    self._ambientTimer = C_Timer.NewTimer(delay, function()
        self:Emit({
            eventType = "ambient",
            intensity = INTENSITY.LOW,
            context = {},
            timestamp = GetTime(),
            source = "AMBIENT_TIMER",
        })
        self:ScheduleAmbient()
    end)
end

-- ============================================================================
-- COMBAT LOG (disabled — taint risk)
-- ============================================================================

-- OnCombatLog was used to track _lastKillerName for death quotes, but
-- COMBAT_LOG_EVENT_UNFILTERED fires every combat frame and caused UI taint.
-- Disabled permanently; OnDeath() reports killedBy="" instead.
--
-- function VC.Collectors:OnCombatLog()
--     local _, subevent, _, srcGUID, srcName, _, _, dstGUID = CombatLogGetCurrentEventInfo()
--     if subevent ~= "UNIT_DIED" then return end
--     if not self._playerGUID then self._playerGUID = UnitGUID("player") end
--     if dstGUID == self._playerGUID then
--         self._lastKillerName = srcName
--     end
-- end

-- ============================================================================
-- HOUSING COLLECTORS
-- ============================================================================

function VC.Collectors:OnHouseLevelUp(houseLevelInfo)
    if not houseLevelInfo then return end
    local level = houseLevelInfo.level or 0

    self:Emit({
        eventType = "housing",
        intensity = INTENSITY.EPIC,
        context = { subtype = "levelup", houseLevel = level },
        timestamp = GetTime(),
        source = "HOUSE_LEVEL_CHANGED",
    })

    if VC.Mood then VC.Mood:ApplySignal("HOUSING_LEVELUP") end
    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "housing") end
end

function VC.Collectors:OnInitiativeTask(taskName)
    self:Emit({
        eventType = "housing",
        intensity = INTENSITY.MEDIUM,
        context = { subtype = "task", taskName = taskName or "" },
        timestamp = GetTime(),
        source = "INITIATIVE_TASK_COMPLETED",
    })

    if VC.Mood then VC.Mood:ApplySignal("INITIATIVE_DONE") end
end

function VC.Collectors:OnInitiativeCompleted(name)
    self:Emit({
        eventType = "housing",
        intensity = INTENSITY.HIGH,
        context = { subtype = "initiative", initiativeName = name or "" },
        timestamp = GetTime(),
        source = "INITIATIVE_COMPLETED",
    })

    if VC.Mood then VC.Mood:ApplySignal("INITIATIVE_DONE") end
    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "initiative") end
end

-- ============================================================================
-- MYTHIC+ COLLECTOR
-- ============================================================================

function VC.Collectors:OnMythicPlus(mapID, level, durationMs, onTime)
    level = SafeNum(level) or 0
    durationMs = SafeNum(durationMs) or 0
    local durationSec = math.floor(durationMs / 1000)
    local upgradeLevels = 0
    if C_ChallengeMode and C_ChallengeMode.GetChallengeCompletionInfo then
        local info = C_ChallengeMode.GetChallengeCompletionInfo()
        if info then upgradeLevels = info.keystoneUpgradeLevels or 0 end
    end

    local okOnTime, isOnTime = pcall(function() return onTime == true end)
    if not okOnTime then isOnTime = false end
    local intensity = isOnTime and INTENSITY.HIGH or INTENSITY.MEDIUM
    if upgradeLevels >= 3 then intensity = INTENSITY.EPIC end

    self:Emit({
        eventType = "mythicplus",
        intensity = intensity,
        context = {
            mapID = mapID, level = level,
            durationSec = durationSec, onTime = isOnTime,
            keystoneUpgradeLevels = upgradeLevels,
        },
        timestamp = GetTime(),
        source = "CHALLENGE_MODE_COMPLETED",
    })

    if VC.Mood then
        if isOnTime then VC.Mood:ApplySignal("MYTHICPLUS_TIMED")
        else VC.Mood:ApplySignal("MYTHICPLUS_DEPLETED") end
    end
    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "mythicplus") end
    if VC.Memory then VC.Memory:IncrementCount("mythicPlusCompleted") end
end

-- ============================================================================
-- PET BATTLE COLLECTOR
-- ============================================================================

function VC.Collectors:OnPetBattleClose()
    if not self._inPetBattle or not self._petBattleFinalRound then
        self._inPetBattle = false
        self._petBattleFinalRound = false
        return
    end
    self._inPetBattle = false
    self._petBattleFinalRound = false

    self:Emit({
        eventType = "petbattle",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "PET_BATTLE_CLOSE",
    })

    if VC.Mood then VC.Mood:ApplySignal("PET_WIN") end
end

-- ============================================================================
-- PET SUMMON COLLECTOR
-- ============================================================================

function VC.Collectors:OnPetSummoned()
    if not C_PetJournal then return end
    local petGUID = C_PetJournal.GetSummonedPetGUID()
    if not petGUID then return end -- pet was dismissed, not summoned
    -- Only fire if this is the linked pet for the active companion (not manual summons)
    local state = VC.Store:GetState()
    local linkedPet = state.config.linkedPets and state.config.linkedPets[state.config.activeCompanion]
    if not linkedPet or petGUID ~= linkedPet then return end

    self:Emit({
        eventType = "petsummon",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "UPDATE_SUMMONPETS_ACTION",
    })
end

-- ============================================================================
-- GROUP ROSTER COLLECTOR
-- ============================================================================

function VC.Collectors:OnGroupRosterUpdate()
    local nowInGroup = IsInGroup()
    if nowInGroup and not self._wasInGroup then
        if self._groupTimer then self._groupTimer:Cancel() end
        self._groupTimer = C_Timer.NewTimer(1.5, function()
            local groupSize = GetNumGroupMembers()
            self:Emit({
                eventType = "group",
                intensity = INTENSITY.LOW,
                context = { groupSize = groupSize },
                timestamp = GetTime(),
                source = "GROUP_ROSTER_UPDATE",
            })
        end)
    end
    self._wasInGroup = nowInGroup
end

-- ============================================================================
-- CRAFTING COLLECTOR
-- ============================================================================

function VC.Collectors:OnCraftItem(data)
    if type(data) ~= "table" then return end
    local craftingQuality = SafeNum(data.craftingQuality)
    if not craftingQuality then return end
    local isCritOk, isCritVal = pcall(function() return data.isCrit == true end)
    local isCrit = isCritOk and isCritVal
    local itemLink = data.itemLink
    local multicraft = data.multicraft

    -- Gate on quality >= 3 to avoid bulk-craft spam
    if craftingQuality < 3 then return end

    local intensity = INTENSITY.MEDIUM
    if craftingQuality >= 5 then
        intensity = INTENSITY.EPIC
    elseif craftingQuality >= 4 then
        intensity = INTENSITY.HIGH
    end
    if isCrit and intensity < INTENSITY.HIGH then
        intensity = INTENSITY.HIGH
    end

    local itemName = itemLink and C_Item.GetItemInfo(itemLink) or nil
    if not itemName then itemName = "something" end

    self:Emit({
        eventType = "crafting",
        intensity = intensity,
        context = {
            itemName = itemName,
            craftingQuality = craftingQuality,
            isCrit = isCrit,
            multicraft = multicraft or false,
        },
        timestamp = GetTime(),
        source = "TRADE_SKILL_ITEM_CRAFTED_RESULT",
    })

    if isCrit and craftingQuality >= 4 and VC.Mood then
        VC.Mood:ApplySignal("CRAFT_CRIT")
    end
    if isCrit and VC.Memory then VC.Memory:IncrementCount("craftingCrits") end
end

-- ============================================================================
-- RESURRECTION COLLECTOR (corpse run complete)
-- ============================================================================

function VC.Collectors:OnUnghost()
    local timeSinceDeath = GetTime() - (self._lastDeathTime or 0)
    if timeSinceDeath > 600 then return end -- ignore spurious unghost (>10min)

    self:Emit({
        eventType = "resurrection",
        intensity = INTENSITY.MEDIUM,
        context = {
            corpseRunDuration = math.floor(timeSinceDeath),
        },
        timestamp = GetTime(),
        source = "PLAYER_UNGHOST",
    })

    if VC.Mood then VC.Mood:ApplySignal("UNGHOST") end
end

-- ============================================================================
-- BATTLE REZ COLLECTOR
-- ============================================================================

function VC.Collectors:OnResurrectRequest(inviter)
    self:Emit({
        eventType = "battlerez",
        intensity = INTENSITY.HIGH,
        context = {
            rezzerName = inviter or "Someone",
        },
        timestamp = GetTime(),
        source = "RESURRECT_REQUEST",
    })

    if VC.Mood then VC.Mood:ApplySignal("BATTLE_REZ") end
end

-- ============================================================================
-- TRAVELER'S LOG COLLECTOR
-- ============================================================================

function VC.Collectors:OnPerksActivity(perksActivityID)
    local activityName = "Unknown Activity"
    if C_PerksActivities and C_PerksActivities.GetPerksActivityInfo then
        local info = C_PerksActivities.GetPerksActivityInfo(perksActivityID)
        if info then activityName = info.activityName or activityName end
    end

    self:Emit({
        eventType = "travelerslog",
        intensity = INTENSITY.MEDIUM,
        context = {
            activityName = activityName,
        },
        timestamp = GetTime(),
        source = "PERKS_ACTIVITY_COMPLETED",
    })

    if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "travelerslog") end
end

-- ============================================================================
-- SCENARIO / DELVE COLLECTOR
-- ============================================================================

function VC.Collectors:OnScenarioComplete()
    -- Defer slightly to ensure scenario data is available post-combat
    C_Timer.After(0.5, function()
        if InCombatLockdown() then return end
        local scenarioName = "Unknown"
        if C_Scenario and C_Scenario.GetInfo then
            local name = C_Scenario.GetInfo()
            scenarioName = (name and name ~= "") and name or "Unknown"
        end

        local intensity = INTENSITY.MEDIUM
        if scenarioName:find("Delve", 1, true) then
            intensity = INTENSITY.HIGH
        end

        self:Emit({
            eventType = "scenario",
            intensity = intensity,
            context = {
                scenarioName = scenarioName,
            },
            timestamp = GetTime(),
            source = "SCENARIO_COMPLETED",
        })

        if VC.Bonding then VC.Bonding:AddRapport(VC.Constants.BONDING.GAINS.CONTENT_COMPLETE, "scenario") end
    end)
end

-- ============================================================================
-- TRANSMOG APPLIED COLLECTOR
-- ============================================================================

function VC.Collectors:OnTransmogSuccess()
    self:Emit({
        eventType = "transmog",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "TRANSMOGRIFY_SUCCESS",
    })
end

-- ============================================================================
-- BARBER SHOP COLLECTOR
-- ============================================================================

function VC.Collectors:OnBarberShopResult(result)
    if result ~= 1 then return end

    self:Emit({
        eventType = "appearance",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "BARBER_SHOP_RESULT",
    })
end

-- ============================================================================
-- TRANSMOG COLLECTION COLLECTOR (new appearance unlocked)
-- ============================================================================

function VC.Collectors:OnTransmogCollectionAdded(itemModifiedAppearanceID)
    local now = GetTime()
    if (now - self._lastTransmogCollectionTime) < 120 then return end
    self._lastTransmogCollectionTime = now

    self:Emit({
        eventType = "transmogcollection",
        intensity = INTENSITY.MEDIUM,
        context = {},
        timestamp = GetTime(),
        source = "TRANSMOG_COLLECTION_SOURCE_ADDED",
    })
end

-- ============================================================================
-- FISHING COLLECTORS (cast detection + catch)
-- ============================================================================

function VC.Collectors:OnUnitSpellcastSucceeded(unitID, castGUID, spellID)
    if unitID ~= "player" then return end
    if InCombatLockdown() then return end -- can't fish in combat; avoid taint from spellID
    spellID = SafeNum(spellID)
    if spellID == 131474 then -- Fishing spell
        self._isFishingCast = true
        C_Timer.After(5, function() self._isFishingCast = false end)
    end
end

function VC.Collectors:OnLootOpened()
    if not self._isFishingCast then return end
    if not IsFishingLoot or not IsFishingLoot() then return end

    local now = GetTime()
    if (now - self._lastFishingTime) < 120 then return end
    self._lastFishingTime = now
    self._isFishingCast = false

    self:Emit({
        eventType = "fishing",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "LOOT_OPENED",
    })
    if VC.Memory then VC.Memory:IncrementCount("fishCaught") end
end

-- ============================================================================
-- AFK RETURN COLLECTOR
-- ============================================================================

function VC.Collectors:OnPlayerFlagsChanged(unitID)
    if unitID ~= "player" then return end

    -- UnitIsAFK returns secret boolean in protected contexts — test via pcall wrapper
    local ok, isAFK = pcall(function() if UnitIsAFK("player") then return true end return false end)
    if not ok then isAFK = false end
    if self._wasAFK and not isAFK then
        self:Emit({
            eventType = "afkreturn",
            intensity = INTENSITY.LOW,
            context = {},
            timestamp = GetTime(),
            source = "PLAYER_FLAGS_CHANGED",
        })
    end
    self._wasAFK = isAFK
end

-- ============================================================================
-- REPUTATION RANK-UP COLLECTOR (tier transitions only)
-- ============================================================================

function VC.Collectors:OnFactionUpdate()
    -- Defer to avoid scanning during heavy event batches
    if self._factionUpdateTimer then return end
    self._factionUpdateTimer = C_Timer.NewTimer(0.5, function()
        self._factionUpdateTimer = nil
        self:ScanFactionStandings()
    end)
end

function VC.Collectors:ScanFactionStandings()
    if not C_Reputation or not C_Reputation.GetNumFactions then return end

    for i = 1, C_Reputation.GetNumFactions() do
        local data = C_Reputation.GetFactionDataByIndex(i)
        if data and not data.isHeader and data.factionID then
            local newStanding = SafeNum(data.reaction)
            if newStanding then
                local oldStanding = self._factionStandings[data.factionID]
                local advanced = oldStanding and newStanding and newStanding > oldStanding
                if advanced then
                    self._factionStandings[data.factionID] = newStanding
                    self:Emit({
                        eventType = "reprank",
                        intensity = INTENSITY.MEDIUM,
                        context = {
                            factionName = data.name or "",
                            standingLabel = _G["FACTION_STANDING_LABEL" .. newStanding] or "",
                        },
                        timestamp = GetTime(),
                        source = "UPDATE_FACTION",
                    })
                elseif not oldStanding then
                    self._factionStandings[data.factionID] = newStanding
                end
            end
        end
    end
end

-- ============================================================================
-- GARRISON MISSION COLLECTOR
-- ============================================================================

function VC.Collectors:OnGarrisonMissionFinished(followerTypeID, missionID)
    if not C_Garrison or not C_Garrison.GetBasicMissionInfo then return end
    local mission = C_Garrison.GetBasicMissionInfo(missionID)
    if not mission or not mission.succeeded then return end

    self:Emit({
        eventType = "mission",
        intensity = INTENSITY.LOW,
        context = {
            missionName = mission.name or "Mission",
        },
        timestamp = GetTime(),
        source = "GARRISON_MISSION_FINISHED",
    })
end

-- ============================================================================
-- LOGOUT COLLECTOR
-- ============================================================================

function VC.Collectors:OnPlayerCamping()
    self:Emit({
        eventType = "logout",
        intensity = INTENSITY.MEDIUM,
        context = {},
        timestamp = GetTime(),
        source = "PLAYER_CAMPING",
    })
end

-- ============================================================================
-- CRAFTING ORDER FULFILLED COLLECTOR
-- ============================================================================

function VC.Collectors:OnCraftingOrderFulfilled(orderType, itemNameString, crafter, tipAmount)
    tipAmount = SafeNum(tipAmount) or 0
    local itemName = SafeStr(itemNameString) or "something"

    local intensity = INTENSITY.MEDIUM
    if tipAmount >= 50000 then intensity = INTENSITY.HIGH end

    self:Emit({
        eventType = "craftingorder",
        intensity = intensity,
        context = {
            itemName = itemName,
            tipAmount = tipAmount,
        },
        timestamp = GetTime(),
        source = "CRAFTINGORDERS_DISPLAY_CRAFTER_FULFILLED_MSG",
    })

    if tipAmount >= 100000 and VC.Mood then VC.Mood:ApplySignal("BIG_TIP") end
end

-- ============================================================================
-- ACHIEVEMENT CRITERIA COLLECTOR (gated)
-- CRITERIA_EARNED fires with (achievementID, description) when a criteria step completes
-- ============================================================================

function VC.Collectors:OnCriteriaEarned(achievementID, description)
    if not achievementID then return end

    local now = GetTime()
    if (now - self._lastCriteriaTime) < 180 then return end

    -- Resolve achievementID to name — skip if already earned (full achievement fires ACHIEVEMENT_EARNED)
    if not GetAchievementInfo then return end
    local achieveName, completed
    local ok, _ = pcall(function()
        local _, name, _, comp = GetAchievementInfo(achievementID)
        achieveName, completed = name, comp
    end)
    if not ok or not achieveName or completed then return end

    self._lastCriteriaTime = now

    self:Emit({
        eventType = "achieveprogress",
        intensity = INTENSITY.LOW,
        context = {
            achieveName = achieveName or "Unknown",
            description = description,
        },
        timestamp = GetTime(),
        source = "CRITERIA_EARNED",
    })
end

-- ============================================================================
-- MONSTER YELL COLLECTOR (rare spawn allowlist)
-- ============================================================================

-- Start with empty allowlist; expand based on player feedback
-- ============================================================================
-- COMBAT END COLLECTOR
-- ============================================================================

function VC.Collectors:OnCombatEnd()
    -- Fire any deferred achievement callbacks
    if self._deferredAchievements then
        local queue = self._deferredAchievements
        self._deferredAchievements = nil
        for _, fn in ipairs(queue) do fn() end
    end

    local now = GetTime()
    if (now - self._lastCombatEndTime) < 180 then return end
    self._lastCombatEndTime = now

    self:Emit({
        eventType = "combatend",
        intensity = INTENSITY.LOW,
        context = {},
        timestamp = GetTime(),
        source = "PLAYER_REGEN_ENABLED",
    })
end

-- ============================================================================
-- FRIEND ONLINE COLLECTOR
-- ============================================================================

function VC.Collectors:OnBNetFriendOnline(bnetID)
    if not C_BattleNet or not C_BattleNet.GetAccountInfoByID then return end
    local accountInfo = C_BattleNet.GetAccountInfoByID(bnetID)
    local friendName = accountInfo and accountInfo.accountName or "a friend"

    self:Emit({
        eventType = "friendonline",
        intensity = INTENSITY.LOW,
        context = {
            friendName = friendName,
        },
        timestamp = GetTime(),
        source = "BN_FRIEND_ACCOUNT_ONLINE",
    })
end

-- ============================================================================
-- ALMOST LEVEL COLLECTOR
-- ============================================================================

function VC.Collectors:OnXPUpdate()
    if InCombatLockdown() then return end -- avoid consuming cooldown during combat
    local currentXP = SafeNum(UnitXP("player"))
    local maxXP = SafeNum(UnitXPMax("player"))
    if not currentXP or not maxXP or maxXP == 0 then return end

    local pct = (currentXP / maxXP) * 100
    if pct < 95 then return end

    local now = GetTime()
    if (now - self._lastXPCommentTime) < 600 then return end
    self._lastXPCommentTime = now

    self:Emit({
        eventType = "almostlevel",
        intensity = INTENSITY.LOW,
        context = {
            percentToLevel = math.floor(pct),
        },
        timestamp = GetTime(),
        source = "PLAYER_XP_UPDATE",
    })
end

-- ============================================================================
-- DURABILITY COLLECTOR (gear wear tracking)
-- ============================================================================
-- State transitions (fires once per crossing, not every UPDATE_INVENTORY_DURABILITY tick):
--   any -> broken    : a piece reaches 0% durability (brokenCount just increased)
--   fine -> degrading: min piece drops from >30% to <=30% (not yet broken)
--   anything -> full : everything just returned to 100% (repair bell)
-- Shirt (slot 4) and Tabard (slot 19) have no durability; nil/0 max skips them.

local DURABILITY_SLOTS = { 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16, 17, 18 } -- head..ranged
local DURABILITY_DEGRADE_PCT = 30
local DURABILITY_DEBOUNCE = 0.5 -- seconds; UPDATE_INVENTORY_DURABILITY can burst during combat

local function ComputeDurabilityState()
    local minPct = 100
    local brokenCount = 0
    local equippedWithDur = 0
    for _, slot in ipairs(DURABILITY_SLOTS) do
        local cur, maxD = GetInventoryItemDurability(slot)
        if cur and maxD and maxD > 0 then
            equippedWithDur = equippedWithDur + 1
            local pct = (cur / maxD) * 100
            if pct < minPct then minPct = pct end
            if cur == 0 then brokenCount = brokenCount + 1 end
        end
    end
    if equippedWithDur == 0 then return nil end
    return { minPct = minPct, broken = brokenCount }
end

function VC.Collectors:OnDurabilityUpdate()
    local now = GetTime()
    if self._lastDurEvalTime and (now - self._lastDurEvalTime) < DURABILITY_DEBOUNCE then return end
    self._lastDurEvalTime = now

    local cur = ComputeDurabilityState()
    if not cur then return end

    -- First evaluation after login/reload: seed baseline, don't fire (avoids
    -- announcing "gear broken" on login when user just hasn't repaired yet)
    if not self._lastDurState then
        self._lastDurState = cur
        return
    end

    local prev = self._lastDurState
    self._lastDurState = cur

    -- Broken: a NEW piece just hit 0% (not pre-existing broken pieces)
    if cur.broken > prev.broken then
        self:Emit({
            eventType = "gear_broken",
            intensity = INTENSITY.HIGH,
            context = { brokenCount = cur.broken },
            timestamp = now,
            source = "UPDATE_INVENTORY_DURABILITY",
        })
        return
    end

    -- Repaired: everything just returned to 100% from a damaged state
    if prev.minPct < 100 and cur.minPct >= 100 then
        self:Emit({
            eventType = "gear_repaired",
            intensity = INTENSITY.LOW,
            context = {},
            timestamp = now,
            source = "UPDATE_INVENTORY_DURABILITY",
        })
        return
    end

    -- Degrading: min piece crossed the 30% threshold downward (and isn't broken)
    if prev.minPct > DURABILITY_DEGRADE_PCT and cur.minPct <= DURABILITY_DEGRADE_PCT and cur.broken == 0 then
        self:Emit({
            eventType = "gear_degrading",
            intensity = INTENSITY.LOW,
            context = { minPct = math.floor(cur.minPct) },
            timestamp = now,
            source = "UPDATE_INVENTORY_DURABILITY",
        })
    end
end

-- ============================================================================
-- EMIT: Send normalized event to engine
-- ============================================================================

function VC.Collectors:Emit(event)
    if VC.Engine and VC.Engine.ProcessEvent then
        VC.Engine:ProcessEvent(event)
    end
end
