local ADDON_NAME, RPAL = ...
RPAL = RPAL or _G.ODYN or _G.RPAL
if type(RPAL) ~= "table" then return end

-- ODYN 1.1 runtime foundation ------------------------------------------------
--
-- Keep cross-cutting behavior here instead of growing one-off fixes in every
-- feature module.  This file intentionally owns only transient runtime state;
-- no inspect payload, timer handle or UI reference is written to SavedVariables.

RPAL.VERSION = "1.8.1"
RPAL.BUILD = RPAL.BUILD or "Stable"
RPAL.DATA_SCHEMA_VERSION = 20

local unpack = unpack or table.unpack
local runtime = type(RPAL.Runtime) == "table" and RPAL.Runtime or {}
RPAL.Runtime = runtime
runtime.errors = type(runtime.errors) == "table" and runtime.errors or {}
runtime.scheduled = type(runtime.scheduled) == "table" and runtime.scheduled or {}
runtime.scheduleSerial = tonumber(runtime.scheduleSerial) or 0
runtime.inspectCache = type(runtime.inspectCache) == "table" and runtime.inspectCache or {}
runtime.inspectCacheSerial = tonumber(runtime.inspectCacheSerial) or 0
runtime.transient = type(runtime.transient) == "table" and runtime.transient or {}
runtime.dataMigrations = type(runtime.dataMigrations) == "table" and runtime.dataMigrations or {}

local function Now()
    if type(_G.GetTimePreciseSec) == "function" then
        local ok, value = pcall(_G.GetTimePreciseSec)
        if ok and tonumber(value) then return tonumber(value) end
    end
    if type(_G.GetTime) == "function" then
        local ok, value = pcall(_G.GetTime)
        if ok and tonumber(value) then return tonumber(value) end
    end
    if type(_G.time) == "function" then
        local ok, value = pcall(_G.time)
        if ok and tonumber(value) then return tonumber(value) end
    end
    return 0
end
RPAL.Now = Now

local function Pack(...)
    return { n = select("#", ...), ... }
end

local function RecordRuntimeError(label, message)
    label = tostring(label or "runtime")
    message = tostring(message or "unknown error")
    local now = Now()
    local key = label .. "\031" .. message
    local existing
    for _, entry in ipairs(runtime.errors) do
        if entry.key == key then existing = entry break end
    end
    if existing then
        existing.count = (tonumber(existing.count) or 1) + 1
        existing.lastAt = now
    else
        runtime.errors[#runtime.errors + 1] = {
            key = key,
            label = label,
            message = message,
            count = 1,
            firstAt = now,
            lastAt = now,
        }
        while #runtime.errors > 40 do table.remove(runtime.errors, 1) end
    end
end

function RPAL.Guard(label, callback, ...)
    if type(callback) ~= "function" then return false, "callback unavailable" end
    local args = Pack(...)
    local result = Pack(xpcall(function()
        return callback(unpack(args, 1, args.n))
    end, function(err)
        local message = tostring(err or "unknown error")
        if type(_G.debugstack) == "function" then
            local ok, stack = pcall(_G.debugstack, 2, 8, 8)
            if ok and stack and stack ~= "" then message = message .. "\n" .. tostring(stack) end
        end
        RecordRuntimeError(label, message)
        return message
    end))
    if not result[1] then return false, result[2] end
    return true, unpack(result, 2, result.n)
end

-- Versioned SavedVariables migration gate ----------------------------------
-- Future releases can register only the data delta they introduce. A schema
-- number is committed after its callback succeeds, so a failed migration is
-- retried on the next login instead of silently marking partial data current.
function RPAL.RegisterDataMigration(version, callback)
    version = math.floor(tonumber(version) or 0)
    if version < 1 or type(callback) ~= "function" then return false end
    runtime.dataMigrations[version] = callback
    return true
end

function RPAL.ApplyDataMigrations(accountDB, characterDB)
    if type(accountDB) ~= "table" or type(characterDB) ~= "table" then
        return false, "SavedVariables unavailable"
    end
    local target = math.max(1, math.floor(tonumber(RPAL.DATA_SCHEMA_VERSION) or 1))
    local accountVersion = math.max(0, math.floor(tonumber(accountDB.odynSchemaVersion) or 0))
    local characterVersion = math.max(0, math.floor(tonumber(characterDB.odynSchemaVersion) or 0))
    for version = 1, target do
        local needsAccount = accountVersion < version
        local needsCharacter = characterVersion < version
        if needsAccount or needsCharacter then
            local migration = runtime.dataMigrations[version]
            if migration then
                local ok, err = RPAL.Guard("data-migration:" .. tostring(version), migration, accountDB, characterDB, {
                    version = version,
                    needsAccount = needsAccount,
                    needsCharacter = needsCharacter,
                })
                if not ok then return false, err end
            end
            if needsAccount then accountDB.odynSchemaVersion = version; accountVersion = version end
            if needsCharacter then characterDB.odynSchemaVersion = version; characterVersion = version end
        end
    end
    return true, target
end

-- A token-based scheduler. Re-scheduling the same key makes older timer
-- callbacks harmless, which removes a large class of stale-window refreshes.
function RPAL.ScheduleUnique(key, delay, callback)
    key = tostring(key or "")
    if key == "" or type(callback) ~= "function" then return false end
    -- Use a process-wide monotonic token rather than a per-key generation that
    -- resets after completion. Otherwise a very old long-delay callback can
    -- collide with a later generation (the classic ABA timer race).
    runtime.scheduleSerial = runtime.scheduleSerial + 1
    local state = runtime.scheduled[key] or {}
    state.token = runtime.scheduleSerial
    state.dueAt = Now() + math.max(0, tonumber(delay) or 0)
    runtime.scheduled[key] = state
    local token = state.token
    local function Run()
        local current = runtime.scheduled[key]
        if not current or current.token ~= token then return end
        runtime.scheduled[key] = nil
        local ok, err = RPAL.Guard("scheduled:" .. key, callback)
        if not ok and type(_G.geterrorhandler) == "function" then
            local handler = _G.geterrorhandler()
            if type(handler) == "function" then pcall(handler, "ODYN " .. key .. ": " .. tostring(err)) end
        end
    end
    if _G.C_Timer and type(_G.C_Timer.After) == "function" then
        _G.C_Timer.After(math.max(0, tonumber(delay) or 0), Run)
    else
        Run()
    end
    return true, token
end

function RPAL.CancelScheduled(key)
    key = tostring(key or "")
    local state = runtime.scheduled[key]
    if not state then return false end
    runtime.scheduleSerial = runtime.scheduleSerial + 1
    state.token = runtime.scheduleSerial
    runtime.scheduled[key] = nil
    return true
end

-- Inspect cache: stale-while-revalidate -------------------------------------
local INSPECT_CACHE_LIMIT = 48

local function NormalizePeer(value)
    value = tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", ""):gsub("%s+", "")
    -- Keep inspect-cache identity aligned with UI.lua's SocialNormalizeName().
    -- CHAT_MSG_ADDON normally supplies Name-Realm, but UnitFullName may return a
    -- short name for a same-realm target. Canonicalising both forms prevents
    -- duplicate/missing cache entries and fixes one-way inspect windows.
    if value ~= "" and not value:find("-", 1, true) and UnitFullName then
        local _, realm = UnitFullName("player")
        if realm and realm ~= "" then value = value .. "-" .. realm:gsub("%s+", "") end
    end
    return value:lower()
end
RPAL.NormalizePeerName = RPAL.NormalizePeerName or NormalizePeer

local function InspectKey(name, intent)
    local peer = NormalizePeer(name)
    intent = intent == "health" and "health" or "profile"
    if peer == "" then return nil end
    return peer .. "\031" .. intent, peer, intent
end

local function PruneInspectCache()
    local count = 0
    for _ in pairs(runtime.inspectCache) do count = count + 1 end
    while count > INSPECT_CACHE_LIMIT do
        local oldestKey, oldestAccess
        for key, entry in pairs(runtime.inspectCache) do
            local access = tonumber(entry.lastAccessAt) or tonumber(entry.cachedAt) or 0
            if oldestAccess == nil or access < oldestAccess then
                oldestKey, oldestAccess = key, access
            end
        end
        if not oldestKey then break end
        runtime.inspectCache[oldestKey] = nil
        count = count - 1
    end
end

function RPAL.PutInspectSnapshot(name, intent, snapshot)
    local key, peer, normalizedIntent = InspectKey(name, intent)
    if not key or type(snapshot) ~= "table" or not snapshot.finished or snapshot.private then return false end
    runtime.inspectCacheSerial = runtime.inspectCacheSerial + 1
    local now = Now()
    runtime.inspectCache[key] = {
        peer = peer,
        displayName = tostring(name or ""),
        intent = normalizedIntent,
        snapshot = snapshot,
        cachedAt = now,
        lastAccessAt = now,
        serial = runtime.inspectCacheSerial,
    }
    PruneInspectCache()
    return true
end

function RPAL.GetInspectSnapshot(name, intent, freshFor)
    local key = InspectKey(name, intent)
    local entry = key and runtime.inspectCache[key]
    if not entry or type(entry.snapshot) ~= "table" then return nil end
    local now = Now()
    entry.lastAccessAt = now
    local age = math.max(0, now - (tonumber(entry.cachedAt) or now))
    local fresh = age <= math.max(0, tonumber(freshFor) or 0)
    return entry.snapshot, age, fresh, entry
end

function RPAL.InvalidateInspectSnapshot(name, intent)
    local key = InspectKey(name, intent)
    if not key or not runtime.inspectCache[key] then return false end
    runtime.inspectCache[key] = nil
    return true
end

function RPAL.GetInspectCacheStats()
    local count, oldest, newest = 0, nil, nil
    for _, entry in pairs(runtime.inspectCache) do
        count = count + 1
        local at = tonumber(entry.cachedAt) or 0
        if oldest == nil or at < oldest then oldest = at end
        if newest == nil or at > newest then newest = at end
    end
    return { count = count, limit = INSPECT_CACHE_LIMIT, oldestAt = oldest, newestAt = newest }
end

-- Deterministic UI layers ----------------------------------------------------
RPAL.UI_LAYER = RPAL.UI_LAYER or {
    MAIN = { strata = "DIALOG", level = 100 },
    WORKSPACE = { strata = "DIALOG", level = 320 },
    WINDOW = { strata = "FULLSCREEN_DIALOG", level = 3000 },
    OVERLAY = { strata = "FULLSCREEN_DIALOG", level = 6000 },
    MODAL = { strata = "FULLSCREEN_DIALOG", level = 8000 },
    POPOVER = { strata = "TOOLTIP", level = 12000 },
    TOAST = { strata = "TOOLTIP", level = 14000 },
}

function RPAL.ApplyUILayer(frame, role, offset)
    if not frame then return false end
    local definition = type(role) == "table" and role or RPAL.UI_LAYER[tostring(role or "WINDOW")]
    if type(definition) ~= "table" then return false end
    if type(frame.SetFrameStrata) == "function" then pcall(frame.SetFrameStrata, frame, definition.strata or "DIALOG") end
    if type(frame.SetFrameLevel) == "function" then pcall(frame.SetFrameLevel, frame, (tonumber(definition.level) or 1) + (tonumber(offset) or 0)) end
    if type(frame.SetToplevel) == "function" then pcall(frame.SetToplevel, frame, true) end
    return true
end

function RPAL.CloseOpenDropdown(except)
    local current = runtime.transient.dropdown
    if not current or current == except then return false end
    runtime.transient.dropdown = nil
    if type(current.Hide) == "function" then pcall(current.Hide, current) end
    return true
end

local dropdownDismissDriver

local function IsRegionWithin(region,root)
    local node=region
    for _=1,20 do
        if not node then return false end
        if node==root then return true end
        if type(node.GetParent)~="function" then return false end
        local ok,parent=pcall(node.GetParent,node)
        if not ok or parent==node then return false end
        node=parent
    end
    return false
end

local function PointerInsideDropdown(frame)
    if not frame then return false end
    local owner=frame._odynDropdownOwner
    local focuses={}
    if type(_G.GetMouseFoci)=="function" then
        local ok,a,b,c,d,e=pcall(_G.GetMouseFoci)
        if ok then
            local values={a,b,c,d,e}
            while #values>0 and values[#values]==nil do values[#values]=nil end
            if #values==1 and type(values[1])=="table" and type(values[1].GetParent)~="function" then
                for _,focus in ipairs(values[1]) do focuses[#focuses+1]=focus end
            else
                focuses=values
            end
        end
    elseif type(_G.GetMouseFocus)=="function" then
        local ok,focus=pcall(_G.GetMouseFocus)
        if ok and focus then focuses[1]=focus end
    end
    for _,focus in ipairs(focuses) do
        if IsRegionWithin(focus,frame) or (owner and IsRegionWithin(focus,owner)) then return true end
    end
    if type(frame.IsMouseOver)=="function" then
        local ok,over=pcall(frame.IsMouseOver,frame)
        if ok and over then return true end
    end
    if owner and type(owner.IsMouseOver)=="function" then
        local ok,over=pcall(owner.IsMouseOver,owner)
        if ok and over then return true end
    end
    return false
end

local function AnyMouseButtonDown()
    if type(_G.IsMouseButtonDown)~="function" then return false end
    return _G.IsMouseButtonDown("LeftButton") or _G.IsMouseButtonDown("RightButton") or _G.IsMouseButtonDown("MiddleButton")
end

local function EnsureDropdownDismissDriver()
    if dropdownDismissDriver or type(_G.CreateFrame)~="function" then return dropdownDismissDriver end
    dropdownDismissDriver=_G.CreateFrame("Frame")
    dropdownDismissDriver:Hide()
    dropdownDismissDriver:SetScript("OnUpdate",function(self,elapsed)
        self._tick=(self._tick or 0)+(elapsed or 0)
        if self._tick<0.02 then return end
        self._tick=0
        local current=runtime.transient.dropdown
        if not current or (type(current.IsShown)=="function" and not current:IsShown()) then self:Hide();return end
        local down=AnyMouseButtonDown()
        -- PopupDropdown uses a dedicated full-screen click catcher underneath
        -- the menu. Do not race its row buttons with the legacy polling
        -- dismiss path: on some clients GetMouseFoci() still reports the
        -- covered settings/modal frame during the initial mouse-down, which
        -- used to hide the menu before the row could receive LeftButtonUp.
        if current._odynUsesClickCatcher or current._odynOwnsDismiss then self._wasDown=down;return end
        local openedAt=tonumber(current._odynDropdownOpenedAt) or 0
        local now=(type(_G.GetTime)=="function" and _G.GetTime()) or 0
        local dismissArmed=(openedAt<=0) or ((now-openedAt)>=0.15)
        if dismissArmed and down and not self._wasDown and not PointerInsideDropdown(current) then RPAL.CloseOpenDropdown() end
        self._wasDown=down
    end)
    return dropdownDismissDriver
end

function RPAL.SetOpenDropdown(frame)
    if not frame then return RPAL.CloseOpenDropdown() end
    RPAL.CloseOpenDropdown(frame)
    runtime.transient.dropdown = frame
    frame._odynDropdownOpenedAt=(type(_G.GetTime)=="function" and _G.GetTime()) or 0
    local driver=EnsureDropdownDismissDriver()
    if driver then driver._wasDown=AnyMouseButtonDown();driver._tick=0;driver:Show() end
    if not frame._odynDropdownManaged and type(frame.HookScript) == "function" then
        frame._odynDropdownManaged = true
        frame:HookScript("OnHide", function(self)
            if runtime.transient.dropdown == self then runtime.transient.dropdown = nil end
            if not runtime.transient.dropdown and dropdownDismissDriver then dropdownDismissDriver:Hide() end
        end)
    end
    return true
end

function RPAL.CloseTransientUI(except)
    RPAL.CloseOpenDropdown(except)
    if type(RPAL.HideCursorTooltip) == "function" then pcall(RPAL.HideCursorTooltip) end
    if _G.GameTooltip and type(_G.GameTooltip.Hide) == "function" then pcall(_G.GameTooltip.Hide, _G.GameTooltip) end
end

-- Small animation drivers live on child frames, so they never replace a
-- feature frame's own OnUpdate handler.
function RPAL.AnimateFrameIn(frame, duration, fromAlpha)
    if not frame or type(frame.SetAlpha) ~= "function" or type(_G.CreateFrame) ~= "function" then return false end
    duration = math.max(0.01, tonumber(duration) or 0.16)
    fromAlpha = math.max(0, math.min(1, tonumber(fromAlpha) or 0))
    local driver = frame._odynEntranceDriver
    if not driver then
        driver = _G.CreateFrame("Frame", nil, frame)
        frame._odynEntranceDriver = driver
    end
    driver:SetScript("OnUpdate", nil)
    driver._elapsed = 0
    frame:SetAlpha(fromAlpha)
    driver:SetScript("OnUpdate", function(self, elapsed)
        self._elapsed = (tonumber(self._elapsed) or 0) + (tonumber(elapsed) or 0)
        local progress = math.min(1, self._elapsed / duration)
        local eased = 1 - ((1 - progress) * (1 - progress) * (1 - progress))
        frame:SetAlpha(fromAlpha + ((1 - fromAlpha) * eased))
        if progress >= 1 then self:SetScript("OnUpdate", nil); frame:SetAlpha(1) end
    end)
    if type(driver.Show) == "function" then driver:Show() end
    return true
end

function RPAL.AnimateFrameOut(frame, duration, toAlpha, after)
    if not frame or type(frame.SetAlpha) ~= "function" or type(_G.CreateFrame) ~= "function" then
        if type(after)=="function" then pcall(after) end
        return false
    end
    duration = math.max(0.01, tonumber(duration) or 0.14)
    toAlpha = math.max(0, math.min(1, tonumber(toAlpha) or 0))
    local driver = frame._odynExitDriver
    if not driver then
        driver = _G.CreateFrame("Frame", nil, frame)
        frame._odynExitDriver = driver
    end
    if frame._odynEntranceDriver then frame._odynEntranceDriver:SetScript("OnUpdate", nil) end
    driver:SetScript("OnUpdate", nil)
    driver._elapsed = 0
    local fromAlpha = tonumber(frame:GetAlpha()) or 1
    driver:SetScript("OnUpdate", function(self, elapsed)
        self._elapsed = (tonumber(self._elapsed) or 0) + (tonumber(elapsed) or 0)
        local progress = math.min(1, self._elapsed / duration)
        local eased = progress * progress * (3 - (2 * progress))
        frame:SetAlpha(fromAlpha + ((toAlpha - fromAlpha) * eased))
        if progress >= 1 then
            self:SetScript("OnUpdate", nil)
            if type(after)=="function" then pcall(after) end
        end
    end)
    if type(driver.Show)=="function" then driver:Show() end
    return true
end

-- Restyle Blizzard UIPanelScrollFrameTemplate widgets without changing their
-- scrolling semantics. Future pages can call this once immediately after
-- creating a stock scroll frame.
function RPAL.SkinScrollFrame(scrollFrame)
    if not scrollFrame or scrollFrame._odynScrollSkinned then return false end
    scrollFrame._odynScrollSkinned = true
    local function Apply()
        if not scrollFrame then return end
        local p = type(RPAL.GetCurrentThemePalette)=="function" and RPAL.GetCurrentThemePalette() or nil
        local accent = p and p.gold or {0.86,0.63,0.28,1}
        local panel = p and (p.panel2 or p.panel) or {0.08,0.05,0.04,1}
        local bar = scrollFrame.ScrollBar
        if not bar and type(scrollFrame.GetName)=="function" then
            local name=scrollFrame:GetName()
            if name then bar=_G[name.."ScrollBar"] end
        end
        if not bar then return end
        if bar.SetWidth then pcall(bar.SetWidth,bar,10) end
        if not bar._odynRail and bar.CreateTexture then
            local rail=bar:CreateTexture(nil,"BACKGROUND",nil,-7)
            bar._odynRail=rail
            rail:SetTexture("Interface\\Buttons\\WHITE8X8")
            rail:SetPoint("TOPLEFT",1,-1);rail:SetPoint("BOTTOMRIGHT",-1,1)
        end
        if bar._odynRail then bar._odynRail:SetVertexColor(panel[1]*0.72,panel[2]*0.72,panel[3]*0.72,0.94) end
        local thumb=bar.ThumbTexture or bar.thumbTexture
        if not thumb and bar.GetThumbTexture then local ok,value=pcall(bar.GetThumbTexture,bar);if ok then thumb=value end end
        if thumb then
            if thumb.SetTexture then pcall(thumb.SetTexture,thumb,"Interface\\Buttons\\WHITE8X8") end
            if thumb.SetVertexColor then pcall(thumb.SetVertexColor,thumb,accent[1],accent[2],accent[3],0.82) end
            if thumb.SetWidth then pcall(thumb.SetWidth,thumb,6) end
        end
        for _,button in ipairs({bar.ScrollUpButton,bar.ScrollDownButton,bar.Back,bar.Forward}) do
            if button then
                if button.SetAlpha then button:SetAlpha(0.32) end
                if button.GetRegions then
                    for _,region in ipairs({button:GetRegions()}) do
                        if region and region.SetVertexColor then pcall(region.SetVertexColor,region,accent[1],accent[2],accent[3],0.60) end
                    end
                end
            end
        end
        if bar.GetRegions then
            for _,region in ipairs({bar:GetRegions()}) do
                if region and region~=bar._odynRail and region~=thumb and region.SetVertexColor then
                    pcall(region.SetVertexColor,region,accent[1],accent[2],accent[3],0.24)
                end
            end
        end
    end
    Apply()
    if scrollFrame.HookScript then scrollFrame:HookScript("OnShow",Apply) end
    if _G.C_Timer and _G.C_Timer.After then _G.C_Timer.After(0,Apply) end
    return true
end

function RPAL.PrepareWindow(frame, role, animate)
    if not frame then return false end
    RPAL.CloseTransientUI(frame)
    if role then RPAL.ApplyUILayer(frame, role) end
    local wasShown = type(frame.IsShown) == "function" and frame:IsShown() or false
    if type(frame.Show) == "function" then frame:Show() end
    if type(frame.Raise) == "function" then pcall(frame.Raise, frame) end
    if animate and not wasShown then RPAL.AnimateFrameIn(frame, tonumber(animate) or 0.16, 0) end
    return true
end

function RPAL.AttachButtonMotion(button, amount)
    if not button or button._odynButtonMotion or type(button.HookScript) ~= "function" or type(_G.CreateFrame) ~= "function" then return false end
    button._odynButtonMotion = true
    amount = math.max(0.006, math.min(0.04, tonumber(amount) or 0.018))
    local baseScale = type(button.GetScale) == "function" and (tonumber(button:GetScale()) or 1) or 1
    local current, target = 0, 0
    local driver = _G.CreateFrame("Frame", nil, button)
    driver:Hide()
    driver:SetScript("OnUpdate", function(self, elapsed)
        local difference = target - current
        if math.abs(difference) < 0.01 then current = target
        else current = current + (difference * math.min(1, (tonumber(elapsed) or 0) * 18)) end
        if type(button.SetScale) == "function" then button:SetScale(baseScale * (1 + (amount * current))) end
        if current == target then self:Hide() end
    end)
    button:HookScript("OnEnter", function(self)
        if type(self.IsEnabled) ~= "function" or self:IsEnabled() then target = 1; driver:Show() end
    end)
    button:HookScript("OnLeave", function() target = 0; driver:Show() end)
    button:HookScript("OnMouseDown", function(_, mouseButton)
        if mouseButton == "LeftButton" then target = -0.45; driver:Show() end
    end)
    button:HookScript("OnMouseUp", function(self)
        target = (type(self.IsMouseOver) == "function" and self:IsMouseOver()) and 1 or 0
        driver:Show()
    end)
    button:HookScript("OnHide", function()
        target, current = 0, 0
        driver:Hide()
        if type(button.SetScale) == "function" then button:SetScale(baseScale) end
    end)
    return true
end
