-- [ STRATA ENGINE ] ---------------------------------------------------------------------------------
-- One profile-persisted layer stack per scope. Plugins self-register with an applier (only the plugin knows its internal level fan-out) and tag their draggable roots with frame.orbitStrataEntity; Ctrl+scroll in Edit Mode reorders via Move. absoluteLevel = base + persistedIndex × StrataBlockReserve, so intra-plugin offsets must stay below the reserve (50).
local _, Orbit = ...
local C = Orbit.Constants

Orbit.StrataEngine = {}
local Engine = Orbit.StrataEngine

-- Session-only: appliers/labels re-register each boot; persisted order lives in profile Layouts.
Engine._volatileBase = {}
Engine._appliers = {}
Engine._labels = {}

-- Canonical bottom→top order — data only (fresh-profile seeding via rank-walk inserts, ResetOrder target). The persisted per-profile array is the live truth; entities unknown here append on top.
local DEFAULT_ORDER = {
    "Orbit_PlayerPetFrame",
    "Orbit_PlayerFrame",
    "Orbit_TargetFrame",
    "Orbit_FocusFrame",
    "Orbit_PlayerPower",
    "Orbit_PlayerResources",
    "Orbit_GroupFrames",
    "Orbit_BossFrames",
    "Orbit_ActionBars",
    "Orbit_CooldownViewer",
    "Orbit_Tracked",
    "Orbit_DamageMeter",
    "Orbit_Minimap",
    "Orbit_Datatexts",
    "Orbit_Objectives",
    "Orbit_StatusWidget",
    "Orbit_RaidPanel",
    "Orbit_MicroMenu",
    "Orbit_BagBar",
    "Orbit_QueueStatus",
    "Orbit_TalkingHead",
}
local DEFAULT_RANK = {}
for i, id in ipairs(DEFAULT_ORDER) do DEFAULT_RANK[id] = i end

-- [ SCOPE DATA ] ------------------------------------------------------------------------------------
function Engine:GetScopeData(scopeID)
    local layouts = Orbit.runtime and Orbit.runtime.Layouts
    assert(layouts, "StrataEngine: Profile Layouts not yet initialized!")
    layouts.Orbit = layouts.Orbit or {}
    layouts.Orbit.Orbit_StrataEngine = layouts.Orbit.Orbit_StrataEngine or {}
    layouts.Orbit.Orbit_StrataEngine[scopeID] = layouts.Orbit.Orbit_StrataEngine[scopeID] or { entities = {} }
    return layouts.Orbit.Orbit_StrataEngine[scopeID]
end

-- [ INITIALIZATION ] --------------------------------------------------------------------------------
function Engine:InitializeScope(scopeID, baseFrameLevel)
    self._volatileBase[scopeID] = baseFrameLevel or 1
    self._appliers[scopeID] = self._appliers[scopeID] or {}
    self._labels[scopeID] = self._labels[scopeID] or {}
    self:GetScopeData(scopeID)
end

-- Registration is idempotent per session; an entity already in the persisted array keeps the user's slot. New entities insert after the last persisted entity ranked below them in DEFAULT_ORDER, so Tier-2 systems join customized profiles at sensible positions instead of on top.
function Engine:Register(scopeID, entityID, applier, label)
    assert(self._volatileBase[scopeID], "StrataEngine: Register into uninitialized scope: " .. tostring(scopeID))
    self._appliers[scopeID][entityID] = applier
    self._labels[scopeID][entityID] = label or entityID
    local scope = self:GetScopeData(scopeID)
    local present = false
    for _, id in ipairs(scope.entities) do
        if id == entityID then present = true break end
    end
    if not present then
        local rank = DEFAULT_RANK[entityID]
        local at = #scope.entities + 1
        if rank then
            at = 1
            for i, id in ipairs(scope.entities) do
                local r = DEFAULT_RANK[id]
                if r and r < rank then at = i + 1 end
            end
        end
        table.insert(scope.entities, at, entityID)
    end
    -- Inserting below already-registered entities shifted their indexes, so re-assert everyone.
    self:ReapplyAll(scopeID)
end

-- [ QUERY ] -----------------------------------------------------------------------------------------
-- absoluteLevel = baseFrameLevel + (persistedIndex × StrataBlockReserve).
function Engine:GetFrameLevel(scopeID, entityID)
    assert(self._volatileBase[scopeID], "StrataEngine: Uninitialized scope: " .. tostring(scopeID))
    local scope = self:GetScopeData(scopeID)
    for i, id in ipairs(scope.entities) do
        if id == entityID then
            return self._volatileBase[scopeID] + (i * C.Levels.StrataBlockReserve)
        end
    end
    error("StrataEngine: Unregistered entity: " .. tostring(entityID) .. " in scope: " .. tostring(scopeID))
end

-- Position among REGISTERED entities only (disabled plugins keep their persisted slot but don't count as layers). Returns index, count — or nil for an unregistered entity.
function Engine:GetIndex(scopeID, entityID)
    local appliers = self._appliers[scopeID]
    if not (appliers and appliers[entityID]) then return nil end
    local index, count = nil, 0
    for _, id in ipairs(self:GetScopeData(scopeID).entities) do
        if appliers[id] then
            count = count + 1
            if id == entityID then index = count end
        end
    end
    return index, count
end

-- Nearest registered entity above (+1) or below (-1) in the stack, or nil at the ends.
function Engine:GetNeighbor(scopeID, entityID, direction)
    local appliers = self._appliers[scopeID]
    if not appliers then return nil end
    local entities = self:GetScopeData(scopeID).entities
    local from
    for i, id in ipairs(entities) do
        if id == entityID then from = i break end
    end
    if not from then return nil end
    local step = direction > 0 and 1 or -1
    for i = from + step, step > 0 and #entities or 1, step do
        if appliers[entities[i]] then return entities[i] end
    end
    return nil
end

function Engine:GetLabel(scopeID, entityID)
    local labels = self._labels[scopeID]
    return labels and labels[entityID] or entityID
end

-- [ REORDER ] ---------------------------------------------------------------------------------------
-- delta ±1 = one step (swap with nearest registered neighbor — unregistered slots stay put); |delta| > 1 = send to top/bottom (remove+insert so the others keep their relative order). Returns false when already clamped at the end.
function Engine:Move(scopeID, entityID, delta)
    local appliers = self._appliers[scopeID]
    if not (appliers and appliers[entityID]) then return false end
    local entities = self:GetScopeData(scopeID).entities
    local from
    for i, id in ipairs(entities) do
        if id == entityID then from = i break end
    end
    if not from then return false end
    local to
    if delta > 0 then
        for i = from + 1, #entities do
            if appliers[entities[i]] then to = i; if delta == 1 then break end end
        end
    else
        for i = from - 1, 1, -1 do
            if appliers[entities[i]] then to = i; if delta == -1 then break end end
        end
    end
    if not to then return false end
    if delta == 1 or delta == -1 then
        entities[from], entities[to] = entities[to], entities[from]
    else
        table.remove(entities, from)
        if to > from then to = to - 1 end
        table.insert(entities, delta > 0 and to + 1 or to, entityID)
    end
    self:ReapplyAll(scopeID)
    Orbit.EventBus:Fire("ORBIT_STRATA_CHANGED", scopeID)
    return true
end

function Engine:ResetOrder(scopeID)
    local entities = self:GetScopeData(scopeID).entities
    table.sort(entities, function(a, b) return (DEFAULT_RANK[a] or math.huge) < (DEFAULT_RANK[b] or math.huge) end)
    self:ReapplyAll(scopeID)
    Orbit.EventBus:Fire("ORBIT_STRATA_CHANGED", scopeID)
end

-- [ APPLY ] -----------------------------------------------------------------------------------------
-- Appliers are plugin code crossing an infrastructure boundary — one broken applier must not strand the rest of the stack.
function Engine:ReapplyAll(scopeID)
    -- SetFrameLevel is render-only, but several roots are secure unit buttons/state handlers — defer the reapply a mid-combat /reload or profile switch would trigger (Edit Mode moves are OOC by definition).
    if InCombatLockdown() then
        Orbit.CombatManager:QueueUpdate(function() self:ReapplyAll(scopeID) end)
        return
    end
    local appliers = self._appliers[scopeID]
    if not appliers then return end
    local base = self._volatileBase[scopeID]
    for i, id in ipairs(self:GetScopeData(scopeID).entities) do
        local applier = appliers[id]
        if applier then
            Orbit.ErrorHandler:Wrap(applier, "StrataEngine:" .. id)(base + i * C.Levels.StrataBlockReserve)
        end
    end
end

-- [ STARTUP ] ---------------------------------------------------------------------------------------
-- Called once during Orbit:OnLoad before any plugin initializes; plugins then self-register in their OnLoad.
function Engine:PopulateDefaults()
    self:InitializeScope("Global_HUD", 1)
    -- A profile switch swaps the persisted array (empty for a brand-new profile): re-seat every registered entity there and re-assert levels. Rank-walk insertion is order-independent, so pairs() order is fine.
    Orbit.EventBus:On("ORBIT_PROFILE_CHANGED", function()
        for entityID, applier in pairs(self._appliers.Global_HUD or {}) do
            self:Register("Global_HUD", entityID, applier, self._labels.Global_HUD[entityID])
        end
    end, self)
end
