-- ============================================================================
-- Vamoose's Companion - ActivitySense
-- State machine detecting 13 player activities; routes to 3 reaction tiers.
-- Tier A: Collectors:Emit (quote pipeline)
-- Tier B: EventBus:Trigger VC_ACTIVITY_CHANGED (lighting)
-- Tier C: Mood:ApplySignal only
-- ============================================================================

VC = VC or {}
VC.ActivitySense = {}

local GetTime   = GetTime
local INTENSITY = VC.Constants.INTENSITY

-- ============================================================================
-- HEARTHSTONE SPELL IDS
-- ============================================================================

local HEARTH_SPELLS = {
    [8690]   = "Hearthstone",
    [222695] = "Dalaran Hearthstone",
    [171253] = "Garrison Hearthstone",
}

-- ============================================================================
-- STATE TABLE
-- ============================================================================

local state = {
    eating       = { active = false, lastTransition = 0 },
    drinking     = { active = false, lastTransition = 0 },
    hearthing    = { active = false, lastTransition = 0 },
    onTaxi       = { active = false, lastTransition = 0 },
    resting      = { active = false, lastTransition = 0 },
    swimming     = { active = false, lastTransition = 0 },
    lowHealth    = { active = false, lastTransition = 0 },
    mounted      = { active = false, lastTransition = 0 },
    flying       = { active = false, lastTransition = 0 },
    dead         = { active = false, lastTransition = 0 },
}

-- Debounce timestamps
local lastAuraCheck = 0

-- Idle timer handle
local idleTimer       = nil

-- Farming loot timestamps buffer
local lootTimestamps  = {}

-- Zone tracking
local currentZone     = nil
local zoneEnteredTime = 0


-- ============================================================================
-- HELPERS
-- ============================================================================

local function transition(activity, active)
    -- Single choke point for the Midnight secret-value crash class: every
    -- poll path (IsMounted/IsSwimming/IsFlying/...) funnels its raw API
    -- return here, and `s.active == active` errors on a secret. Treat a
    -- secret as "state unknowable right now" and skip the transition.
    if issecretvalue(active) then return false end -- exception(boundary): unit-state APIs return secrets in protected contexts
    local s = state[activity]
    if not s or s.active == active then return false end
    s.active = active
    s.lastTransition = GetTime()
    return true
end

local function emitQuote(eventType, intensity, context, skipCooldown)
    if VC.Collectors and VC.Collectors.Emit then
        VC.Collectors:Emit({
            eventType    = eventType,
            intensity    = intensity,
            context      = context or {},
            timestamp    = GetTime(),
            source       = "VC_ActivitySense",
            skipCooldown = skipCooldown,
        })
    end
end

local function triggerActivity(activity, active)
    if VC.EventBus and VC.EventBus.Trigger then
        VC.EventBus:Trigger("VC_ACTIVITY_CHANGED", { activity = activity, active = active })
    end
end

local function applyMood(signal)
    if VC.Mood and VC.Mood.ApplySignal then
        VC.Mood:ApplySignal(signal)
    end
end

-- ============================================================================
-- TIER A: EATING / DRINKING (UNIT_AURA)
-- ============================================================================

local function handleAura()
    local now = GetTime()
    if now - lastAuraCheck < 0.3 then return end
    lastAuraCheck = now

    -- Detect food/drink by scanning buff names. Tier-1 secret guards
    -- (canaccesstable/issecretvalue) per MIDNIGHT_SECRET_VALUES.md; the outer
    -- pcall stays as a last-resort absorb for anything the guards miss.
    local isEating, isDrinking = false, false
    local ok = pcall(function() -- exception(fire-forget): aura scan in protected contexts, miss is fine
        for i = 1, 40 do
            local aura = C_UnitAuras.GetAuraDataByIndex("player", i, "HELPFUL")
            if not aura then break end
            if not canaccesstable(aura) then break end -- exception(boundary): aura tables secret in combat
            local n = aura.name
            if issecretvalue(n) then break end -- exception(boundary): aura names secret in combat
            if n == "Food" or n == "Food & Drink" then isEating = true end
            if n == "Drink" or n == "Food & Drink" then isDrinking = true end
            if isEating and isDrinking then break end
        end
    end)
    if not ok then return end -- taint in combat, skip silently

    -- Eating
    if transition("eating", isEating) and isEating then
        emitQuote("eating", INTENSITY.MEDIUM, { alsoDrinking = isDrinking }, true)
        applyMood("EATING")
    end

    -- Drinking: only fire if food is NOT active (prevent double-fire)
    if transition("drinking", isDrinking) and isDrinking and not isEating then
        emitQuote("drinking", INTENSITY.MEDIUM, {}, true)
        applyMood("DRINKING")
    end

    -- Mounted state check on aura update. UNIT_AURA fires in combat, where
    -- IsMounted() can return a secret boolean -- transition() guards it.
    local isMounted = IsMounted()
    if transition("mounted", isMounted) then
        triggerActivity("mounted", isMounted)
    end
end

-- ============================================================================
-- TIER A: HEARTHING (UNIT_SPELLCAST_*)
-- ============================================================================

local function handleSpellCastStart(unitTarget, castGUID, spellID)
    if unitTarget ~= "player" then return end
    if HEARTH_SPELLS[spellID] then
        if transition("hearthing", true) then
            emitQuote("hearthing", INTENSITY.LOW, { hearthName = HEARTH_SPELLS[spellID] })
            applyMood("HEARTHING")
        end
    end
end

local function handleSpellCastEnd(unitTarget, castGUID, spellID)
    if unitTarget ~= "player" then return end
    if HEARTH_SPELLS[spellID] then
        transition("hearthing", false)
    end
end

-- ============================================================================
-- TIER A: FLIGHT PATH (PLAYER_STARTED/STOPPED_MOVING)
-- ============================================================================

local function checkTaxi(started)
    if started then
        local onTaxi = UnitOnTaxi("player")
        if transition("onTaxi", onTaxi) and onTaxi then
            emitQuote("flightpath", INTENSITY.LOW, {})
            applyMood("HEARTHING") -- mild positive for travel
        end
    else
        -- stopped moving: check if taxi ended
        local onTaxi = UnitOnTaxi("player")
        transition("onTaxi", onTaxi)
    end
end

-- ============================================================================
-- TIER A: REST ZONE (PLAYER_UPDATE_RESTING)
-- ============================================================================

local function handleResting()
    local resting = IsResting()
    if transition("resting", resting) then
        if resting then
            emitQuote("restzone", INTENSITY.LOW, {})
            applyMood("RESTING")
            triggerActivity("resting", true)
        else
            triggerActivity("resting", false)
        end
    end
end

-- ============================================================================
-- TIER A: RARE SPAWN (VIGNETTE_MINIMAP_UPDATED)
-- ============================================================================

local function handleVignette(vignetteGUID, onMinimap)
    if not onMinimap then return end
    if not C_VignetteInfo or not C_VignetteInfo.GetVignetteInfo then return end
    local info = C_VignetteInfo.GetVignetteInfo(vignetteGUID)
    if not info then return end
    local atlas = info.atlasName or ""
    if atlas:find("[Vv]ignette[Kk]ill") or atlas:find("[Vv]ignette[Ee]lite") then
        emitQuote("rarespawn", INTENSITY.HIGH, { rareName = info.name }, true)
        applyMood("RARE_SPOTTED")
    end
end

-- ============================================================================
-- TIER A: LONG IDLE (PLAYER_STARTED/STOPPED_MOVING + timer)
-- ============================================================================

local function cancelIdleTimer()
    if idleTimer then
        idleTimer:Cancel()
        idleTimer = nil
    end
end

local function startIdleTimer()
    cancelIdleTimer()
    idleTimer = C_Timer.NewTimer(90, function()
        idleTimer = nil

        -- UnitIsAFK and similar return secret booleans in protected contexts;
        -- skip the entire idle path there (quotes are pointless mid-key anyway).
        if VC.MayHaveSecretValues() then return end

        if UnitIsAFK("player") then return end -- don't talk to an empty chair
        emitQuote("idle", INTENSITY.LOW, { idleSeconds = 90 })
        applyMood("IDLE_LONG")
    end)
end

-- ============================================================================
-- TIER B: DELAYED STATE CHECK (shared pattern for swimming/flying)
-- ============================================================================

local function delayedStateCheck(timerRef, activity, pollFn, mood)
    if timerRef[1] then timerRef[1]:Cancel() end
    timerRef[1] = C_Timer.NewTimer(0.5, function()
        timerRef[1] = nil
        local active = pollFn()
        if transition(activity, active) then
            triggerActivity(activity, active)
            if active and mood then applyMood(mood) end
        end
    end)
end

local swimTimer, flyTimer = {}, {}
local function checkSwimming() delayedStateCheck(swimTimer, "swimming", IsSwimming, "SWIMMING") end
local function checkFlying()   delayedStateCheck(flyTimer,  "flying",   IsFlying,   nil)        end

-- ============================================================================
-- TIER B: LOW HEALTH (1s fast poll, avoids UNIT_HEALTH taint)
-- ============================================================================

local healthTicker = nil

local function startHealthPoll()
    if healthTicker then healthTicker:Cancel() end
    healthTicker = C_Timer.NewTicker(1.0, function()
        local ok, pct = pcall(function()
            local max = UnitHealthMax("player")
            if not max or max == 0 then return nil end
            return UnitHealth("player") / max
        end)
        if not ok or not pct then return end

        if pct < 0.25 then
            if transition("lowHealth", true) then triggerActivity("lowhealth", true) end
        elseif pct > 0.35 then
            if transition("lowHealth", false) then triggerActivity("lowhealth", false) end
        end
    end)
end

-- ============================================================================
-- TIER C: EXTENDED FARMING (CHAT_MSG_LOOT)
-- ============================================================================

local FARM_WINDOW = 1800  -- 30 minutes
local FARM_THRESHOLD = 15

local function handleLoot()
    local now = GetTime()
    lootTimestamps[#lootTimestamps + 1] = now

    -- Trim entries older than 30 minutes (in-place from front)
    local cutoff = now - FARM_WINDOW
    while #lootTimestamps > 0 and lootTimestamps[1] < cutoff do
        table.remove(lootTimestamps, 1)
    end

    if #lootTimestamps >= FARM_THRESHOLD then
        applyMood("FARMING_LONG")
        lootTimestamps = {} -- reset after signal
    end
end

-- ============================================================================
-- TIER C: SAME ZONE LONG TIME (ZONE_CHANGED_NEW_AREA)
-- ============================================================================

local ZONE_LONG_THRESHOLD = 1800 -- 30 minutes

local function handleZoneChanged()
    local zone = GetZoneText()
    local now  = GetTime()

    if zone ~= currentZone then
        -- Check if we stayed in the previous zone long enough
        if currentZone and (now - zoneEnteredTime) >= ZONE_LONG_THRESHOLD then
            applyMood("ZONE_LONG")
        end
        currentZone    = zone
        zoneEnteredTime = now
    end
end

-- ============================================================================
-- MOVEMENT HANDLER (shared across multiple activities)
-- ============================================================================

local function handleMovement(started)
    if started then
        cancelIdleTimer()
        checkTaxi(true)
        checkSwimming()
        checkFlying()
    else
        checkTaxi(false)
        checkSwimming()
        checkFlying()
        startIdleTimer()
    end
end

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

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

    self.frame:RegisterEvent("UNIT_AURA")
    self.frame:RegisterEvent("UNIT_SPELLCAST_START")
    self.frame:RegisterEvent("UNIT_SPELLCAST_STOP")
    self.frame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED")
    self.frame:RegisterEvent("UNIT_SPELLCAST_FAILED")
    self.frame:RegisterEvent("PLAYER_UPDATE_RESTING")
    self.frame:RegisterEvent("VIGNETTE_MINIMAP_UPDATED")
    self.frame:RegisterEvent("PLAYER_STARTED_MOVING")
    self.frame:RegisterEvent("PLAYER_STOPPED_MOVING")
    self.frame:RegisterEvent("CHAT_MSG_LOOT")
    self.frame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    self.frame:RegisterEvent("PLAYER_DEAD")
    self.frame:RegisterEvent("PLAYER_ALIVE")
    self.frame:RegisterEvent("PLAYER_UNGHOST")

    -- Seed zone tracking, idle timer, health poll
    currentZone    = GetZoneText()
    zoneEnteredTime = GetTime()
    startIdleTimer()
    startHealthPoll()

    -- Bootstrap current state (handles /reload while swimming, dead, etc.)
    C_Timer.After(1, function()
        -- Capture each raw return and drop it if secret -- a /reload inside
        -- housing or an instance can hand back secrets with no guardable
        -- context (MayHaveSecretValues misses housing by design).
        local function bootState(activity, value)
            if issecretvalue(value) or not value then return end -- exception(boundary): unit-state secrets after /reload in protected contexts
            if transition(activity, true) then triggerActivity(activity, true) end
        end
        if not VC.MayHaveSecretValues() then
            local dead, ghost = UnitIsDead("player"), UnitIsGhost("player")
            if not issecretvalue(dead) and not issecretvalue(ghost) -- exception(boundary): same
               and (dead or ghost) then
                if transition("dead", true) then triggerActivity("dead", true) end
            end
        end
        bootState("swimming", IsSwimming())
        bootState("resting", IsResting())
        bootState("mounted", IsMounted())
        bootState("flying", IsFlying())
    end)

    self.frame:SetScript("OnEvent", function(_, event, ...)
        if event == "UNIT_AURA" then
            local unit = ...
            if unit == "player" then handleAura() end

        elseif event == "UNIT_SPELLCAST_START" then
            handleSpellCastStart(...)

        elseif event == "UNIT_SPELLCAST_STOP"
            or event == "UNIT_SPELLCAST_INTERRUPTED"
            or event == "UNIT_SPELLCAST_FAILED" then
            handleSpellCastEnd(...)

        elseif event == "PLAYER_UPDATE_RESTING" then
            handleResting()

        elseif event == "VIGNETTE_MINIMAP_UPDATED" then
            handleVignette(...)

        elseif event == "PLAYER_STARTED_MOVING" then
            handleMovement(true)

        elseif event == "PLAYER_STOPPED_MOVING" then
            handleMovement(false)

        elseif event == "CHAT_MSG_LOOT" then
            handleLoot()

        elseif event == "ZONE_CHANGED_NEW_AREA" then
            handleZoneChanged()

        elseif event == "PLAYER_DEAD" then
            if transition("dead", true) then
                triggerActivity("dead", true)
            end

        elseif event == "PLAYER_ALIVE" or event == "PLAYER_UNGHOST" then
            if transition("dead", false) then
                triggerActivity("dead", false)
            end
        end
    end)
end
