-- ============================================================================
-- Vamoose's Companion - Portrait
-- Draggable 3D companion portrait frame with click interactions
-- Supports "compact" (48x48) and "standard" (80x100) sizes
-- ============================================================================

VC = VC or {}
VC.Portrait = {}

-- ============================================================================
-- COMPANION SOUND LOOKUP
-- Reads intents directly from modelGroups. Each intent has { anim, sound }.
-- ============================================================================

local function FindModelGroup(companion, displayID)
    if not companion or not companion.modelGroups then return nil end
    for _, g in ipairs(companion.modelGroups) do
        for _, id in ipairs(g.ids) do
            if id == displayID then return g end
        end
    end
    return nil
end

local function ResolveCompanionSound(companion, displayID, intent)
    local group = FindModelGroup(companion, displayID)
    if not group or not group.intents then return nil end

    local entry = group.intents[intent]
    if not entry or not entry.sound or #entry.sound == 0 then return nil end

    return entry.sound[math.random(#entry.sound)]
end

-- Reverse lookup: anim slot name → intent key (for debug cycle)
local function IntentForAnimName(group, animName)
    if not group or not group.intents then return nil end
    for intentKey, entry in pairs(group.intents) do
        if entry.anim == animName then return intentKey end
    end
    return nil
end

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

function VC.Portrait:Initialize()
    self.frame = VC.UI:CreatePortraitFrame(UIParent)

    -- Restore saved position or use default
    local state = VC.Store:GetState()
    if state.ui.portraitPoint then
        local p = state.ui.portraitPoint
        self.frame:ClearAllPoints()
        self.frame:SetPoint(p.point, UIParent, p.relPoint, p.x, p.y)
    else
        self.frame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 20, -200)
    end

    -- Restore scale, fade in on first load
    local portraitScale = state.config.portraitScale or 1.0
    self.frame:SetScale(portraitScale)
    if state.ui.portraitShown ~= false then
        self.frame:SetAlpha(0)
        self.frame:Show()
        local fadeIn = self.frame:CreateAnimationGroup()
        fadeIn:SetToFinalAlpha(true)
        local alpha = fadeIn:CreateAnimation("Alpha")
        alpha:SetFromAlpha(0)
        alpha:SetToAlpha(1)
        alpha:SetDuration(1.0)
        alpha:SetSmoothing("OUT")
        fadeIn:Play()
    end

    -- Load companion 3D model (immediate, no swap animation on first load)
    self:UpdateModel(true)

    -- Initial mood wash + bonding pips
    VC.UI:UpdatePortraitMood()
    self:RefreshInfo()

    -- Update portrait when a quote fires (random mode may change companion)
    VC.EventBus:Register("VC_QUOTE_READY", function(data)
        if data.companion then
            local current = self:GetActiveCompanion()
            if not current or data.companion.key ~= current.key then
                self:UpdateModelForCompanion(data.companion)
            end
        end
    end)

    -- Update bonding bar + name when state changes
    VC.EventBus:Register("VC_STATE_CHANGED", function()
        self:RefreshInfo()
    end)

    -- Start animation state machine
    self:InitAnimState()

    -- Initialize Pepe perch (after portrait frame exists)
    if VC.Pepe and VC.Pepe.Initialize then
        VC.Pepe:Initialize()
    end

    -- Click handlers (left-click opens settings, right-click triggers quote)
    self.frame:SetScript("OnMouseUp", function(_, button)
        if button == "LeftButton" then
            if VC.Settings and VC.Settings.Toggle then
                VC.Settings:Toggle()
            end
        elseif button == "RightButton" then
            self:OnRightClick()
        end
    end)

    -- Tooltip
    self.frame:SetScript("OnEnter", function(f)
        local companion = self:GetActiveCompanion()
        GameTooltip:SetOwner(f, "ANCHOR_RIGHT")
        local name = companion and companion.name or "Companion"
        local title = companion and companion.title or ""
        local c = VC.Theme:GetScheme()
        GameTooltip:AddLine(name, c.text_header.r, c.text_header.g, c.text_header.b)
        if title ~= "" then
            GameTooltip:AddLine(title, c.text_dim.r, c.text_dim.g, c.text_dim.b)
        end
        -- Bonding level
        if VC.Bonding then
            local s = VC.Store:GetState()
            local info = VC.Bonding:GetLevelInfo(s.config.activeCompanion)
            if info then
                local next = info.nextThreshold and tostring(info.nextThreshold) or "Max"
                GameTooltip:AddLine("Bond: " .. info.name .. " (" .. info.rapport .. "/" .. next .. ")",
                    c.bonding_glow and c.bonding_glow.r or 0.302,
                    c.bonding_glow and c.bonding_glow.g or 0.788,
                    c.bonding_glow and c.bonding_glow.b or 0.965)
            end
        end
        -- Mood
        if VC.Mood then
            local mood = VC.Mood:GetMood()
            local mc = VC.Constants.MOOD_COLORS[mood] or VC.Constants.MOOD_COLORS.NEUTRAL
            GameTooltip:AddLine("Mood: " .. mood:sub(1,1) .. mood:sub(2):lower(), mc.r, mc.g, mc.b)
        end
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("Left-click to open settings", 0.4, 0.8, 0.4)
        GameTooltip:AddLine("Right-click for a quote", 0.4, 0.8, 0.4)
        GameTooltip:Show()
    end)

    self.frame:SetScript("OnLeave", function()
        GameTooltip:Hide()
    end)

end

-- ============================================================================
-- MODEL
-- ============================================================================

-- Debug overlay: /vc debug to toggle, state saved to SavedVariables
function VC.Portrait:UpdateDebugText(model, settings, source)
    if not self.frame then return end
    local debugOn = VC.Store and VC.Store:GetState().config.debug
    if self._debugText then self._debugText:SetShown(debugOn) end
    if self._debugBtnFrame then self._debugBtnFrame:SetShown(debugOn) end
    if not debugOn then return end
    if not self._debugText then
        local dt = self.frame:CreateFontString(nil, "OVERLAY")
        dt:SetFont("Fonts\\ARIALN.TTF", 10, "OUTLINE")
        dt:SetPoint("TOPLEFT", self.frame, "TOPRIGHT", 4, 0)
        dt:SetJustifyH("LEFT")
        dt:SetTextColor(0.5, 1, 0.5, 1)
        self._debugText = dt

        -- Anim cycle buttons (in a container for easy show/hide)
        local btnFrame = CreateFrame("Frame", nil, self.frame)
        btnFrame:SetSize(100, 16)
        btnFrame:SetPoint("TOPLEFT", self.frame, "TOPRIGHT", 4, -110)
        self._debugBtnFrame = btnFrame
        local prevBtn = CreateFrame("Button", nil, btnFrame, "UIPanelButtonTemplate")
        prevBtn:SetSize(20, 16)
        prevBtn:SetPoint("LEFT", 0, 0)
        prevBtn:SetText("<")
        prevBtn:SetScript("OnClick", function() VC.Portrait:CycleDebugAnim(-1) end)
        local nextBtn = CreateFrame("Button", nil, btnFrame, "UIPanelButtonTemplate")
        nextBtn:SetSize(20, 16)
        nextBtn:SetPoint("LEFT", prevBtn, "RIGHT", 2, 0)
        nextBtn:SetText(">")
        nextBtn:SetScript("OnClick", function() VC.Portrait:CycleDebugAnim(1) end)
        local resetBtn = CreateFrame("Button", nil, btnFrame, "UIPanelButtonTemplate")
        resetBtn:SetSize(36, 16)
        resetBtn:SetPoint("LEFT", nextBtn, "RIGHT", 2, 0)
        resetBtn:SetText("Live")
        resetBtn:SetScript("OnClick", function()
            VC.Portrait._debugAnimIndex = nil
            VC.Portrait:EvaluateState()
        end)
    end
    local intent = self._currentIntent or "?"
    local animID = self._currentAnimID or "?"
    local animName = "?"
    local comp = self:GetActiveCompanion()
    if comp and comp.animations then
        for name, id in pairs(comp.animations) do
            if id == animID then animName = name; break end
        end
    end
    local actor = self.frame.model
    local posInfo, scaleInfo, bboxInfo = "", "", ""
    if actor then
        local ok, px, py, pz = pcall(actor.GetPosition, actor)
        if ok then posInfo = format("pos: %.2f, %.2f, %.2f", px or 0, py or 0, pz or 0) end
        local s = actor:GetScale() or 0
        local yaw = pcall(actor.GetYaw, actor) and actor:GetYaw() or 0
        local vis = actor:IsShown() and "shown" or "HIDDEN"
        scaleInfo = format("scale: %.3f  yaw: %.2f  %s", s, yaw, vis)
        local ba, bb, bc, bd, be, bf = actor:GetActiveBoundingBox()
        if ba then
            bboxInfo = format("bbox: h%.2f w%.2f d%.2f", bf - bc, be - bb, bd - ba)
        else
            bboxInfo = "bbox: nil"
        end
    end
    local sw, sh = 0, 0
    local camInfo = ""
    if self.frame.scene then
        sw, sh = self.frame.scene:GetSize()
        local ok, cx, cy, cz = pcall(self.frame.scene.GetCameraPosition, self.frame.scene)
        if ok then camInfo = format("cam: %.2f, %.2f, %.2f", cx or 0, cy or 0, cz or 0) end
        local ok2, yaw, pitch, roll = pcall(self.frame.scene.GetCameraOrientationByYawPitchRoll, self.frame.scene)
        if ok2 then camInfo = camInfo .. format("\nypr: %.2f, %.2f, %.2f", yaw or 0, pitch or 0, roll or 0) end
        local ok3, fov = pcall(self.frame.scene.GetCameraFieldOfView, self.frame.scene)
        if ok3 then camInfo = camInfo .. format(" fov:%.2f", fov or 0) end
    end
    self._debugText:SetText(
        (source or "") .. "\n" ..
        "intent: " .. tostring(intent) .. " [" .. animName .. " #" .. tostring(animID) .. "]\n" ..
        posInfo .. "\n" .. scaleInfo .. "\n" .. bboxInfo .. "\n" ..
        camInfo .. "\n" ..
        format("scene: %dx%d", sw, sh)
    )
end

function VC.Portrait:CycleDebugAnim(dir)
    local comp = self:GetActiveCompanion()
    if not comp or not comp.animations then return end
    -- Build sorted anim list
    if not self._debugAnimList or self._debugAnimComp ~= comp.key then
        self._debugAnimList = {}
        for name, id in pairs(comp.animations) do
            self._debugAnimList[#self._debugAnimList + 1] = { name = name, id = id }
        end
        table.sort(self._debugAnimList, function(a, b) return a.id < b.id end)
        self._debugAnimComp = comp.key
    end
    local list = self._debugAnimList
    if #list == 0 then return end
    self._debugAnimIndex = ((self._debugAnimIndex or 1) - 1 + dir) % #list + 1
    local entry = list[self._debugAnimIndex]
    local displayID = self:GetDisplayIDForCompanion(comp)
    local group = FindModelGroup(comp, displayID)
    local intent = IntentForAnimName(group, entry.name) or "GREETING"
    self:SetPortraitAnimation(intent, entry.id)
end

-- Calculate facing yaw so companion looks toward screen center
function VC.Portrait:CalculateFacing(companion)
    local baseFacing = companion and companion.portraitBaseFacing or 0
    if not self.frame then return baseFacing end
    local maxRange = 0.5
    if VC_AnimDB and VC_AnimDB.companionArchetype and companion then
        local arch = VC_AnimDB.companionArchetype[companion.key]
        if arch == "critter" then maxRange = 0.6
        elseif arch == "bird" then maxRange = 0.3
        elseif arch == "serpent" or arch == "demon" or arch == "phoenix" then maxRange = 0.3
        end
    end
    local frameX = self.frame:GetCenter()
    local screenX = UIParent:GetCenter()
    if not frameX or not screenX then return baseFacing end
    local screenW = UIParent:GetWidth()
    if screenW == 0 then return baseFacing end
    local dx = (screenX - frameX) / (screenW * 0.5)
    return baseFacing + dx * maxRange
end

-- Update facing on the current actor (for drag repositioning)
function VC.Portrait:UpdateFacingForPosition(actor, companion)
    if not actor then return end
    actor:SetYaw(self:CalculateFacing(companion))
end

function VC.Portrait:UpdateModel(immediate)
    local companion = self:GetActiveCompanion()
    if immediate then
        self:LoadActorModel(companion)
    else
        self:UpdateModelForCompanion(companion)
    end
    self:RefreshInfo()
end

function VC.Portrait:LoadActorModel(companion)
    local scene = self.frame.scene
    if not scene then return end
    local displayID = self:GetDisplayIDForCompanion(companion)
    local pY = companion and companion.portraitY or 0
    local pZ = companion and companion.portraitZ or 0
    local scale = companion and companion.portraitScale or 0.5
    local yaw = self:CalculateFacing(companion)
    local actor = VC.UI:SetCreatureOnScene(scene, displayID, {
        scale = scale, posY = pY, posZ = pZ, yaw = yaw,
        onLoaded = function(a) self.frame.model = a end,
    })
    self.frame.model = actor  -- set immediately for animation state machine
end

function VC.Portrait:GetDisplayIDForCompanion(companion)
    if not companion then return 19598 end
    local state = VC.Store:GetState()
    local override = state.config.modelOverrides[companion.key]
    if override then return override end
    return companion.displayID or 19598
end

function VC.Portrait:UpdateModelForCompanion(companion)
    local scene = self.frame.scene
    if not scene then self:LoadActorModel(companion); return end

    -- Build swap AG once on the scene frame (actors don't support AnimationGroups)
    if not scene._swapAG then
        local ag = scene:CreateAnimationGroup()
        ag:SetToFinalAlpha(true)
        local fadeOut = ag:CreateAnimation("Alpha")
        fadeOut:SetFromAlpha(1)
        fadeOut:SetToAlpha(0)
        fadeOut:SetDuration(0.15)
        fadeOut:SetSmoothing("IN")
        scene._swapAG = ag
        scene._swapFadeIn = scene:CreateAnimationGroup()
        scene._swapFadeIn:SetToFinalAlpha(true)
        local fadeIn = scene._swapFadeIn:CreateAnimation("Alpha")
        fadeIn:SetFromAlpha(0)
        fadeIn:SetToAlpha(1)
        fadeIn:SetDuration(0.2)
        fadeIn:SetSmoothing("OUT")
    end

    local pendingCompanion = companion
    scene._swapAG:SetScript("OnFinished", function()
        VC.Portrait:LoadActorModel(pendingCompanion)
        scene._swapFadeIn:Play()
    end)

    if scene._swapFadeIn:IsPlaying() then scene._swapFadeIn:Stop() end
    scene._swapAG:Play()
end

function VC.Portrait:GetActiveCompanion()
    local state = VC.Store:GetState()
    return VC_CompanionProfiles and VC_CompanionProfiles[state.config.activeCompanion]
end

function VC.Portrait:RefreshInfo()
    local state = VC.Store:GetState()
    VC.UI:RefreshPortraitBonding(self.frame, state.config.activeCompanion)
end

-- ============================================================================
-- CLICK HANDLERS
-- ============================================================================

function VC.Portrait:OnRightClick()
    if VC.Engine and VC.Engine.ProcessEvent then
        VC.Engine:ProcessEvent({
            eventType = "ambient",
            intensity = VC.Constants.INTENSITY.HIGH,
            context = {},
            timestamp = GetTime(),
            source = "SLASH_TEST",
        })
    end
end

-- ============================================================================
-- ANIMATION STATE MACHINE
-- Two layers: sustained states (priority-evaluated) and one-shot reactions.
-- ============================================================================

local IDLE_TICKER_INTERVAL = 8
local FAST_POLL_INTERVAL = 2  -- faster poll for states without landing events
local FAST_POLL_INTENTS = { SWIMMING = true }
local FIDGET_CHANCE = 0.15
local FIDGET_DURATION = 3.0
local IDLE_TO_SEATED_SECS = 30
local IDLE_TO_SLEEP_SECS = 60
local ONESHOT_DURATION = 2.5

-- Interaction oneshot durations (brief reaction, not sustained state)
local INTERACTION_DURATIONS = {
    LOOTING = 3,
    CRAFTING = 5,
    INTERACTING = 4,
    TALKING = 4,
}


function VC.Portrait:InitAnimState()
    self._currentIntent = "IDLE"
    self._currentAnimID = 0
    self._lastActivity = GetTime()
    self._oneshotTimer = nil              -- quote reactions
    self._interactionOneshotTimer = nil    -- interaction reactions (separate track)
    self._idleTickerHandle = nil
    self._animEventFrame = nil

    -- Sustained state flags (only for states with no frame/duration equivalent)
    self._inCombat = false
    self._controlLost = false
    self._fastPolling = false

    self:StartIdleTicker()
    self:RegisterAnimEvents()
end

function VC.Portrait:SetPortraitAnimation(intent, overrideAnimID)
    if not self.frame or not self.frame.model then return end
    local companion = self:GetActiveCompanion()
    if not VC.AnimResolver and not overrideAnimID then return end
    local animID = overrideAnimID or VC.AnimResolver:Resolve(intent, companion)
    if not overrideAnimID and animID == self._currentAnimID and intent == self._currentIntent then return end
    self._currentIntent = intent
    self._currentAnimID = animID
    self.frame.model:SetAnimation(animID)

    -- Companion sound: play alongside animation changes, gated by chattiness cooldown
    if self._soundHandle then StopSound(self._soundHandle); self._soundHandle = nil end
    self._lastSoundID = nil
    local state = VC.Store and VC.Store:GetState()
    local compKey = companion and companion.key
    local soundEnabled = state and compKey and state.config.companionSound[compKey] ~= false -- nil = on (new companions default on until Store defaults updated)
    if soundEnabled then
        -- Chattiness cooldown: respect globalCD between companion sounds
        local now = GetTime()
        local tier = state and VC.Constants and VC.Constants.CHATTINESS[state.config.chattiness]
        local cd = tier and tier.globalCD or 30
        local soundReady = not self._lastSoundTime or (now - self._lastSoundTime >= cd)

        -- Suppress during quote oneshot if TTS is actively speaking
        local ttsSpeaking = self._oneshotTimer and state.config.ttsEnabled
            and VamoosesTTS and VamoosesTTS.Queue and VamoosesTTS.Queue:IsActive()
        if soundReady and not ttsSpeaking then
            local displayID = self:GetDisplayIDForCompanion(companion)
            local fileID = ResolveCompanionSound(companion, displayID, intent)
            if fileID then
                local ok, handle = PlaySoundFile(fileID, "Master")
                if ok then
                    self._soundHandle = handle
                    self._lastSoundID = fileID
                    self._lastSoundTime = now
                end
            end
        end
    end

    -- Flip Scurry to face camera during death/sleep (animation plays away from viewer)
    if compKey == "scurry" and (intent == "DYING" or intent == "MOURNING" or intent == "SLEEP") then
        self.frame.model:SetYaw(self:CalculateFacing(companion) + math.pi + 1)
    else
        self.frame.model:SetYaw(self:CalculateFacing(companion))
    end

    -- Zzz flipbook overlay when sleeping (any sleep intent, not just AFK)
    self:UpdateSleepOverlay(intent == "SLEEP")

    if state and state.config.debug then
        local aName = "?"
        if companion and companion.animations then
            for name, id in pairs(companion.animations) do
                if id == animID then aName = name; break end
            end
        end
        local sndPart = not soundEnabled and "snd:off" or self._lastSoundID and ("snd:" .. self._lastSoundID) or "snd:--"
        DEFAULT_CHAT_FRAME:AddMessage("|cFF80FF80[VC Anim]|r " .. intent .. " [" .. aName .. " #" .. animID .. "] " .. sndPart)
        self:UpdateDebugText(self.frame.model, nil, "Debug")
    end
end

-- ============================================================================
-- SLEEP ZZZ OVERLAY (flipbook animation from UI-HUD-UnitFrame-Player-Rest-Flipbook)
-- ============================================================================

local ZZZ_COLS, ZZZ_ROWS = 6, 7
local ZZZ_FRAMES = ZZZ_COLS * ZZZ_ROWS  -- 42 frames
local ZZZ_FPS = 12
local ZZZ_TEX_W, ZZZ_TEX_H = 512, 512  -- power-of-2 atlas texture size
local ZZZ_FRAME_W = 360 / ZZZ_COLS / ZZZ_TEX_W  -- UV width per frame
local ZZZ_FRAME_H = 420 / ZZZ_ROWS / ZZZ_TEX_H  -- UV height per frame
local ZZZ_U_BASE = 1 / ZZZ_TEX_W  -- small offset from atlas padding
local ZZZ_V_BASE = 1 / ZZZ_TEX_H

function VC.Portrait:UpdateSleepOverlay(show)
    if not self.frame then return end
    if show then
        if not self._zzzTex then
            local tex = self.frame:CreateTexture(nil, "OVERLAY")
            tex:SetSize(40, 40)
            tex:SetPoint("CENTER", self.frame, "CENTER", 5, 15)
            tex:SetTexture("Interface/HUD/UIUnitFrameRestingFlipbook")
            tex:SetAlpha(0.8)
            self._zzzTex = tex
            self._zzzFrame = 0
            self._zzzElapsed = 0
            -- Animator
            local anim = CreateFrame("Frame")
            anim:SetScript("OnUpdate", function(_, elapsed)
                if not self._zzzTex or not self._zzzTex:IsShown() then return end
                self._zzzElapsed = self._zzzElapsed + elapsed
                if self._zzzElapsed >= 1 / ZZZ_FPS then
                    self._zzzElapsed = 0
                    self._zzzFrame = (self._zzzFrame + 1) % ZZZ_FRAMES
                    local col = self._zzzFrame % ZZZ_COLS
                    local row = math.floor(self._zzzFrame / ZZZ_COLS)
                    local u1 = ZZZ_U_BASE + col * ZZZ_FRAME_W
                    local u2 = u1 + ZZZ_FRAME_W
                    local v1 = ZZZ_V_BASE + row * ZZZ_FRAME_H
                    local v2 = v1 + ZZZ_FRAME_H
                    self._zzzTex:SetTexCoord(u1, u2, v1, v2)
                end
            end)
            self._zzzAnimFrame = anim
        end
        self._zzzTex:Show()
        self._zzzAnimFrame:Show()  -- resume OnUpdate
    elseif self._zzzTex then
        self._zzzTex:Hide()
        if self._zzzAnimFrame then self._zzzAnimFrame:Hide() end  -- stop OnUpdate
    end
end


-- Evaluate sustained state from top priority down
function VC.Portrait:EvaluateState()
    if not self.frame or not self.frame:IsShown() then return end
    if not VC.AnimResolver then return end

    -- Midnight secret values: APIs like IsFlying/IsMounted/UnitIsAFK return opaque
    -- secret booleans in protected contexts (M+, encounters, PvP, dungeon combat).
    -- Two layers of protection:
    --   1. Context guard: skip entirely when secrets are known to be active
    --   2. pcall wrapper: catches any secret value errors the guard misses
    -- Pattern: context guard from Reference/MIDNIGHT_SECRET_VALUES.md MayHaveSecretValues()
    local inSecretContext = false
    if C_ChallengeMode and C_ChallengeMode.IsChallengeModeActive
       and C_ChallengeMode.IsChallengeModeActive() then inSecretContext = true end
    if not inSecretContext and IsEncounterInProgress
       and IsEncounterInProgress() then inSecretContext = true end
    if not inSecretContext then
        local _, instanceType = IsInInstance()
        if instanceType == "pvp" or instanceType == "arena" then inSecretContext = true end
        if not inSecretContext and InCombatLockdown()
           and (instanceType == "party" or instanceType == "raid") then inSecretContext = true end
    end
    if inSecretContext then
        if self._inCombat then self:SetPortraitAnimation("COMBAT_READY") end
        return
    end

    -- Read speed (pcall: GetUnitSpeed can return secret numeric in instances)
    local speed = 0
    local sok, sval = pcall(GetUnitSpeed, "player")
    if sok and type(sval) == "number" then speed = sval end

    -- Priority evaluation — pcall wraps all secret-returning boolean APIs.
    -- If any API returns a secret value the guard missed, pcall catches it
    -- and we degrade to COMBAT_READY rather than crashing.
    local ok, intent = pcall(function()
        if UnitIsGhost("player") then return "MOURNING" end
        if UnitIsDead("player") then return "DYING" end
        if UnitOnTaxi("player") then return "FLYING" end -- before controlLost: taxi fires PLAYER_CONTROL_LOST
        if self._controlLost then return "DISTRESSED" end
        if C_PlayerInfo and C_PlayerInfo.GetGlidingInfo and C_PlayerInfo.GetGlidingInfo() then return "FLYING" end
        if IsFlying() then return "FLYING" end
        if IsSwimming() then return "SWIMMING" end
        if IsFalling() then return "FALLING" end
        if IsMounted() then return speed > 0 and "MOUNTED" or "IDLE" end
        if self._inCombat then return "COMBAT_READY" end
        if UnitIsAFK("player") then return "SLEEP" end
        return nil
    end)
    if not ok then intent = "COMBAT_READY" end

    if not intent then
        -- Movement / idle
        if speed > 2.6 then
            intent = "RUNNING"
        elseif speed > 0 then
            intent = "WALKING"
        else
            local idle = GetTime() - (self._lastActivity or GetTime())
            if idle > IDLE_TO_SLEEP_SECS then
                intent = "SLEEP"
            elseif idle > IDLE_TO_SEATED_SECS then
                intent = "IDLE_SEATED"
            else
                intent = "IDLE"
            end
        end
    end

    self:SetPortraitAnimation(intent)

    -- Adaptive ticker: poll faster for states without WoW events
    local needsFast = FAST_POLL_INTENTS[intent]
    if needsFast and not self._fastPolling then
        self:StartIdleTicker(FAST_POLL_INTERVAL)
        self._fastPolling = true
    elseif not needsFast and self._fastPolling then
        self:StartIdleTicker(IDLE_TICKER_INTERVAL)
        self._fastPolling = false
    end
end

-- One-shot reaction: play briefly, then return to sustained state
function VC.Portrait:PlayOneshot(intent, duration)
    if self._oneshotTimer then self._oneshotTimer:Cancel() end
    self._lastActivity = GetTime()
    self:SetPortraitAnimation(intent)
    self._oneshotTimer = C_Timer.NewTimer(duration or ONESHOT_DURATION, function()
        self._oneshotTimer = nil
        self:EvaluateState()
    end)
end

-- Interaction oneshot: separate timer track from quote oneshots
-- Re-triggerable: same intent resets the timer without restarting the animation
function VC.Portrait:PlayInteractionOneshot(intent)
    local duration = INTERACTION_DURATIONS[intent] or 4
    self._lastActivity = GetTime()
    self:SetPortraitAnimation(intent) -- early-returns if same intent+anim (no visual glitch on re-trigger)
    if self._interactionOneshotTimer then self._interactionOneshotTimer:Cancel() end
    self._interactionOneshotTimer = C_Timer.NewTimer(duration, function()
        self._interactionOneshotTimer = nil
        self:EvaluateState()
    end)
end

-- Debounced EvaluateState for noisy events (movement, mount)
function VC.Portrait:DebouncedEvaluate(delay)
    if self._oneshotTimer or self._interactionOneshotTimer then return end
    if self._evalDebounce then self._evalDebounce:Cancel() end
    self._evalDebounce = C_Timer.NewTimer(delay or 0.15, function()
        self._evalDebounce = nil
        self:EvaluateState()
    end)
end

function VC.Portrait:StartIdleTicker(interval)
    if self._idleTickerHandle then self._idleTickerHandle:Cancel() end
    self._idleTickerHandle = C_Timer.NewTicker(interval or IDLE_TICKER_INTERVAL, function()
        if not self.frame or not self.frame:IsShown() then return end
        if self._oneshotTimer or self._interactionOneshotTimer then return end
        if (self._currentIntent == "IDLE" or self._currentIntent == "IDLE_SEATED") and math.random() < FIDGET_CHANCE then
            self:PlayOneshot("FIDGET", FIDGET_DURATION)
            return
        end
        self:EvaluateState()
    end)
end


-- High-priority events that always bypass one-shot guard
local PRIORITY_EVENTS = {
    PLAYER_DEAD = true,
    PLAYER_CONTROL_LOST = true, PLAYER_CONTROL_GAINED = true,
    CINEMATIC_START = true, CINEMATIC_STOP = true,
    PLAY_MOVIE = true, STOP_MOVIE = true,
}

-- Movement events: debounce to absorb stutter-stepping
local DEBOUNCE_EVENTS = {
    PLAYER_STARTED_MOVING = 0.15,
    PLAYER_STOPPED_MOVING = 0.15,
    PLAYER_MOUNT_DISPLAY_CHANGED = 0.15,
}

function VC.Portrait:RegisterAnimEvents()
    if self._animEventFrame then return end
    local f = CreateFrame("Frame")
    self._animEventFrame = f

    -- Combat
    f:RegisterEvent("PLAYER_REGEN_DISABLED")
    f:RegisterEvent("PLAYER_REGEN_ENABLED")
    -- Death / ghost
    f:RegisterEvent("PLAYER_DEAD")
    f:RegisterEvent("PLAYER_ALIVE")
    f:RegisterEvent("PLAYER_UNGHOST")
    -- Movement / travel
    f:RegisterEvent("PLAYER_STARTED_MOVING")
    f:RegisterEvent("PLAYER_STOPPED_MOVING")
    f:RegisterEvent("PLAYER_MOUNT_DISPLAY_CHANGED")
    f:RegisterEvent("ZONE_CHANGED_NEW_AREA")
    -- Skyriding (instant glide state detection)
    f:RegisterEvent("PLAYER_IS_GLIDING_CHANGED")
    -- AFK
    f:RegisterEvent("PLAYER_FLAGS_CHANGED")
    -- Loss of control
    f:RegisterEvent("PLAYER_CONTROL_LOST")
    f:RegisterEvent("PLAYER_CONTROL_GAINED")
    -- Interaction windows (open events only — oneshot model, no close events needed)
    f:RegisterEvent("LOOT_OPENED")
    f:RegisterEvent("TRADE_SKILL_SHOW")
    f:RegisterEvent("TRADE_SKILL_ITEM_CRAFTED_RESULT") -- re-trigger crafting on each item
    f:RegisterEvent("MERCHANT_SHOW")
    f:RegisterEvent("BANKFRAME_OPENED")
    f:RegisterEvent("AUCTION_HOUSE_SHOW")
    f:RegisterEvent("MAIL_SHOW")
    f:RegisterEvent("BARBER_SHOP_OPEN")
    f:RegisterEvent("GOSSIP_SHOW")
    -- Cinematics + movies (hide portrait)
    f:RegisterEvent("CINEMATIC_START")
    f:RegisterEvent("CINEMATIC_STOP")
    f:RegisterEvent("PLAY_MOVIE")
    f:RegisterEvent("STOP_MOVIE")

    f:SetScript("OnEvent", function(_, event)
        -- State flag updates (only for sustained flags without frame/duration equivalent)
        if event == "PLAYER_REGEN_DISABLED" then self._inCombat = true
        elseif event == "PLAYER_REGEN_ENABLED" then
            self._inCombat = false
            if self._deferCinematicHide then
                self._deferCinematicHide = nil
                if self.frame then self.frame:Hide() end
            end
            if self._deferCinematicShow then
                self._deferCinematicShow = nil
                local state = VC.Store:GetState()
                if self.frame and state.ui.portraitShown ~= false then self.frame:Show() end
            end
        elseif event == "PLAYER_CONTROL_LOST" then self._controlLost = true
        elseif event == "PLAYER_CONTROL_GAINED" then self._controlLost = false
        -- Interaction oneshots (brief reaction, re-triggerable)
        elseif event == "LOOT_OPENED" then self:PlayInteractionOneshot("LOOTING"); return
        elseif event == "TRADE_SKILL_SHOW" or event == "TRADE_SKILL_ITEM_CRAFTED_RESULT" then
            self:PlayInteractionOneshot("CRAFTING"); return
        elseif event == "MERCHANT_SHOW" or event == "BANKFRAME_OPENED"
            or event == "AUCTION_HOUSE_SHOW" or event == "MAIL_SHOW"
            or event == "BARBER_SHOP_OPEN" then
            self:PlayInteractionOneshot("INTERACTING"); return
        elseif event == "GOSSIP_SHOW" then self:PlayInteractionOneshot("TALKING"); return
        elseif event == "PLAYER_FLAGS_CHANGED" then
            -- Just trigger a re-evaluate (handled by DebouncedEvaluate below)
        -- Cinematics/movies: hide/show portrait (defer if in combat)
        elseif event == "CINEMATIC_START" or event == "PLAY_MOVIE" then
            if self.frame then
                if not InCombatLockdown() then
                    self.frame:Hide()
                else
                    self._deferCinematicHide = true
                end
            end
            if self._quoteDelayTimer then self._quoteDelayTimer:Cancel(); self._quoteDelayTimer = nil end
            if self._oneshotTimer then self._oneshotTimer:Cancel(); self._oneshotTimer = nil end
            if self._interactionOneshotTimer then self._interactionOneshotTimer:Cancel(); self._interactionOneshotTimer = nil end
            if self._evalDebounce then self._evalDebounce:Cancel(); self._evalDebounce = nil end
            if self._idleTickerHandle then self._idleTickerHandle:Cancel(); self._idleTickerHandle = nil end
        elseif event == "CINEMATIC_STOP" or event == "STOP_MOVIE" then
            self._deferCinematicHide = nil
            local state = VC.Store:GetState()
            if self.frame and state.ui.portraitShown ~= false then
                if not InCombatLockdown() then
                    self.frame:Show()
                else
                    self._deferCinematicShow = true
                end
            end
            if not self._idleTickerHandle then self:StartIdleTicker() end
            self:EvaluateState()
        end

        -- Activity tracking (immediate, not debounced)
        if event == "PLAYER_STARTED_MOVING" or event == "PLAYER_REGEN_DISABLED"
            or event == "PLAYER_DEAD" or event == "PLAYER_ALIVE" or event == "PLAYER_UNGHOST"
            or event == "LOOT_OPENED" or event == "TRADE_SKILL_SHOW"
            or event == "TRADE_SKILL_ITEM_CRAFTED_RESULT"
            or event == "MERCHANT_SHOW" or event == "BANKFRAME_OPENED"
            or event == "AUCTION_HOUSE_SHOW" or event == "MAIL_SHOW"
            or event == "BARBER_SHOP_OPEN" or event == "GOSSIP_SHOW" then
            self._lastActivity = GetTime()
        end

        -- Determine evaluation strategy
        if PRIORITY_EVENTS[event] then
            -- High-priority: always evaluate immediately, bypass one-shot guard
            if self._oneshotTimer then self._oneshotTimer:Cancel(); self._oneshotTimer = nil end
            if self._interactionOneshotTimer then self._interactionOneshotTimer:Cancel(); self._interactionOneshotTimer = nil end
            self:EvaluateState()
        elseif self._oneshotTimer or self._interactionOneshotTimer then
            -- One-shot active + non-priority event: skip (timer will re-evaluate)
            return
        elseif DEBOUNCE_EVENTS[event] then
            -- Noisy events: debounce to absorb rapid fire
            self:DebouncedEvaluate(DEBOUNCE_EVENTS[event])
        else
            -- Normal events: evaluate immediately
            self:EvaluateState()
        end
    end)

    -- Wire into EventBus for quote reactions (deferred until model swap completes)
    VC.EventBus:Register("VC_QUOTE_READY", function(data)
        -- Kill any playing companion sound immediately so it doesn't overlap TTS
        if self._soundHandle then StopSound(self._soundHandle); self._soundHandle = nil end
        self._lastSoundID = nil
        if not VC.AnimResolver or not data.event then return end
        local intent = VC.AnimResolver:IntentForQuote(data.event, data.quote)
        -- Cancel any pending timer before overwriting (rapid quotes)
        if self._quoteDelayTimer then self._quoteDelayTimer:Cancel() end
        -- 400ms delay: lets talking head entry animation start before portrait reacts (intentional desync)
        self._quoteDelayTimer = C_Timer.NewTimer(0.4, function()
            self._quoteDelayTimer = nil
            if InCombatLockdown() then return end
            if not self.frame or not self.frame:IsShown() then return end
            VC.Portrait:PlayOneshot(intent, ONESHOT_DURATION)
        end)
    end)
end
