-- ============================================================
-- FILE: MoverRegistry.lua
-- MODULE: MidnightUI_MoverRegistry  (_G.MidnightUI_MoverRegistry, local MR)
--
-- PURPOSE:
--   Central registry for every draggable frame in MidnightUI.
--   Tracks which frames can be moved by the player, saves their
--   positions to SavedVars, and restores positions on login.
--   When Messenger is unlocked (Edit Mode), registered frames get
--   a visible drag overlay that the player can grab. When locked,
--   overlays are hidden and frames are stationary.
--
-- HOW IT WORKS:
--   1. Each module calls MR.RegisterMover(key, frame, defaultPos, opts)
--      or MR.DefineMover(key, def) to declare a movable frame.
--   2. On drag-stop, MR.SavePosition(key) writes the frame's current
--      GetPoint() to MidnightUISettings.movers[key] as a string.
--   3. On PLAYER_ENTERING_WORLD, MR.ApplyPosition(key) reads the
--      saved string back and calls SetPoint. If no saved pos, uses
--      defaultPosition from the registration.
--   4. SetAllLocked(false) shows all drag overlays (Edit Mode on).
--      SetAllLocked(true) hides them (Edit Mode off).
--
-- DEPENDS ON:
--   Core.lua      — MidnightUI_StyleOverlay, MidnightUI_AttachOverlaySettings,
--                   MidnightUI_BeginFrameSnap, MidnightUI_EndFrameSnap,
--                   Core.OverlayHandles (writes frame references here)
--   Settings.lua  — MidnightUISettings.movers SavedVar
--
-- EXPORTS (functions other files call):
--   MR.RegisterMover(key, frame, defaultPos, opts) — register a frame
--   MR.DefineMover(key, def)                       — deferred registration
--   MR.ApplyPosition(key)        — restore saved/default position
--   MR.ApplyAllPositions()       — restore all registered frames
--   MR.SavePosition(key)         — write current position to SavedVars
--   MR.GetPosition(key)          — read position string from SavedVars
--   MR.HasBeenMoved(key)         — true if player has moved this frame
--   MR.ResetPosition(key)        — clear saved pos, apply default
--   MR.ResetAllPositions()       — clear all saved positions
--   MR.SetAllLocked(locked)      — show/hide all drag overlays
--   MR.SetCategoryLocked(cat,lock)— lock/unlock one category of frames
--   MR.GetAllKeys()              — sorted list of registered frame keys
--   MR.GetEntry(key)             — returns the Registry entry table
--   MR.PointToString(...)        — serializes a SetPoint call to string
--   MR.StringToPoint(str)        — parses a position string back to args
--   MR.UnregisterMover(key)      — removes a frame from the registry
--
-- OWNED SAVEDVARIABLES:
--   MidnightUISettings.movers    — table of key → position strings
--                                  e.g. "BOTTOM,UIParent,BOTTOM,0,240"
--
-- EVENTS REGISTERED:
--   PLAYER_ENTERING_WORLD  — triggers AutoDiscoverAll (0.15s delay)
--   PLAYER_REGEN_ENABLED   — flushes combat-deferred position applies
--
-- GOTCHAS:
--   • SetPoint is blocked during combat lockdown — any ApplyPosition
--     call while InCombatLockdown() is deferred to PLAYER_REGEN_ENABLED
--     via _pendingApplyPosition.
--   • MR.DefineMover is lazy — frames aren't created until first
--     unlock (SetAllLocked(false)) or AutoDiscoverAll fires.
--   • externalOverlay = true means the frame manages its own drag
--     handle; MoverRegistry tracks position only, no overlay created.
-- ============================================================

-- ============================================================
-- SECTION: Module Initialization + Core State
-- Localizes globals, creates the MR module table, and defines
-- the three internal state tables. All mover data flows through
-- Registry (live entries), Definitions (deferred entries), and
-- GetSavedPositions() (SavedVar accessor).
--
-- TABLES:
--   Registry           — key → live entry {frame, dragOverlay, ...}
--   Definitions        — key → deferred def {resolveFrame, defaultPosition, ...}
--   KNOWN_FRAMES       — static list for auto-discovery of Blizzard frames
--
-- FUNCTIONS:
--   GetSavedPositions()  — returns MidnightUISettings.movers, creating
--                          it if missing; always safe to read/write
--   Log(msg)             — sends a message to Diagnostics console
--                          tagged "MoverRegistry"
-- ============================================================
local _G = _G
local CreateFrame = CreateFrame
local pairs = pairs
local ipairs = ipairs
local type = type
local tostring = tostring
local tonumber = tonumber
local table = table
local string = string
local UIParent = UIParent

local MR = {}
_G.MidnightUI_MoverRegistry = MR


local Registry = {}
local Definitions = {}
local autoDiscoveryDone = false

local function GetSavedPositions()
    if not _G.MidnightUISettings then _G.MidnightUISettings = {} end
    if not _G.MidnightUISettings.movers then _G.MidnightUISettings.movers = {} end
    return _G.MidnightUISettings.movers
end

local function Log(msg)
    if _G.MidnightUI_Diagnostics and _G.MidnightUI_Diagnostics.LogDebugSource then
        _G.MidnightUI_Diagnostics.LogDebugSource("MoverRegistry", msg)
    end
end


-- ============================================================
-- SECTION: Position String Serialization
-- Positions are stored in SavedVars as compact strings rather than
-- tables, e.g. "BOTTOM,UIParent,BOTTOM,0.00,240.00". This avoids
-- table overhead and makes positions readable in the settings dump.
--
-- FUNCTIONS:
--   MR.PointToString(point, anchorName, relPoint, x, y)
--       — serializes SetPoint arguments to a comma-separated string;
--         x/y are formatted to 2 decimal places
--   MR.StringToPoint(str)
--       — parses a position string back to (point, anchorName,
--         relPoint, x, y); returns nil if string is malformed
-- ============================================================
function MR.PointToString(point, anchorName, relPoint, x, y)
    if not point or not relPoint then return nil end
    anchorName = anchorName or "UIParent"
    x = x or 0
    y = y or 0
    return point .. "," .. anchorName .. "," .. relPoint .. ","
        .. string.format("%.2f", x) .. "," .. string.format("%.2f", y)
end

function MR.StringToPoint(str)
    if type(str) ~= "string" or str == "" then return nil end
    local point, anchorName, relPoint, xStr, yStr = str:match(
        "^([%a]+),([%a%d_]+),([%a]+),(%-?[%d%.]+),(%-?[%d%.]+)$"
    )
    if not point then return nil end
    local x = tonumber(xStr)
    local y = tonumber(yStr)
    if not x or not y then return nil end
    return point, anchorName, relPoint, x, y
end


-- ============================================================
-- SECTION: Combat-Deferred Position Apply System
-- WoW blocks SetPoint during combat lockdown. Any ApplyPosition
-- call while in combat is queued in _pendingApplyPosition and
-- replayed on PLAYER_REGEN_ENABLED via FlushPendingApplyPositions.
--
-- FUNCTIONS:
--   FlushPendingApplyPositions()  — runs all deferred _DoApplyPosition
--                                   calls; skipped if still in combat
--   EnsureFlushHook()             — creates the PLAYER_REGEN_ENABLED
--                                   listener frame once on first defer
--   _DoApplyPosition(key)         — core logic: resolves frame from
--                                   Registry or Definitions, reads saved
--                                   pos or default, calls SetPoint
--   MR.ApplyPosition(key)         — public entry point; defers if in
--                                   combat, otherwise runs immediately
--   MR.ApplyAllPositions()        — calls ApplyPosition for every
--                                   registered key
-- ============================================================
local _pendingApplyPosition = {}
local _flushHooked = false
local _DoApplyPosition

local function FlushPendingApplyPositions()
    if InCombatLockdown and InCombatLockdown() then return end
    local pending = _pendingApplyPosition
    _pendingApplyPosition = {}
    for key in pairs(pending) do
        pcall(_DoApplyPosition, key)
    end
end

local function EnsureFlushHook()
    if _flushHooked then return end
    _flushHooked = true
    local f = CreateFrame("Frame")
    f:RegisterEvent("PLAYER_REGEN_ENABLED")
    f:SetScript("OnEvent", FlushPendingApplyPositions)
end

_DoApplyPosition = function(key)
    local entry = Registry[key]
    local frame, defaultPos

    if entry and entry.frame then
        frame = entry.frame
        defaultPos = entry.defaultPosition
    else
        local def = Definitions[key]
        if def and def.resolveFrame then
            local ok, result = pcall(def.resolveFrame)
            if ok and result and result.SetPoint then
                frame = result
                defaultPos = def.defaultPosition
            end
        end
    end

    if not frame then return end

    local saved = GetSavedPositions()
    local posStr = saved[key] or defaultPos
    if not posStr then return end

    local point, anchorName, relPoint, x, y = MR.StringToPoint(posStr)
    if not point then
        if saved[key] and defaultPos then
            point, anchorName, relPoint, x, y = MR.StringToPoint(defaultPos)
        end
        if not point then return end
    end

    local anchor = _G[anchorName] or UIParent
    pcall(function()
        frame:ClearAllPoints()
        frame:SetPoint(point, anchor, relPoint, x, y)
    end)
end

function MR.ApplyPosition(key)
    if InCombatLockdown and InCombatLockdown() then
        _pendingApplyPosition[key] = true
        EnsureFlushHook()
        return
    end
    return _DoApplyPosition(key)
end

function MR.ApplyAllPositions()
    for key in pairs(Registry) do
        MR.ApplyPosition(key)
    end
end

-- ============================================================
-- SECTION: Position Save / Reset / Query
-- Provides the CRUD operations on saved positions. SavePosition
-- reads the frame's current GetPoint() and writes it to SavedVars.
-- Reset clears the SavedVar entry so the default position is used
-- next time ApplyPosition runs.
--
-- FUNCTIONS:
--   MR.SavePosition(key)       — reads frame:GetPoint() and writes
--                                to MidnightUISettings.movers[key];
--                                fires onPositionChanged callback if set
--   MR.GetPosition(key)        — returns the raw position string from
--                                SavedVars (nil if not yet moved)
--   MR.HasBeenMoved(key)       — true if player has saved a custom pos
--   MR.ResetPosition(key)      — clears saved pos and re-applies default
--   MR.ResetAllPositions()     — wipes entire movers table, re-applies
--                                all default positions
--   MR.GetAllKeys()            — returns sorted list of all Registry keys
--   MR.GetEntry(key)           — returns the raw Registry entry table
-- ============================================================
function MR.SavePosition(key)
    local entry = Registry[key]
    if not entry or not entry.frame then return end

    local frame = entry.frame
    local point, _, relPoint, xOfs, yOfs = frame:GetPoint()
    if not point or not relPoint or not xOfs or not yOfs then return end

    local posStr = MR.PointToString(point, "UIParent", relPoint, xOfs, yOfs)
    if posStr then
        GetSavedPositions()[key] = posStr
    end

    if entry.onPositionChanged then
        pcall(entry.onPositionChanged, key, posStr)
    end
end

function MR.GetPosition(key) return GetSavedPositions()[key] end
function MR.HasBeenMoved(key) return GetSavedPositions()[key] ~= nil end

function MR.ResetPosition(key)
    GetSavedPositions()[key] = nil
    MR.ApplyPosition(key)
end

function MR.ResetAllPositions()
    local saved = GetSavedPositions()
    if wipe then wipe(saved) else for k in pairs(saved) do saved[k] = nil end end
    for key in pairs(Registry) do MR.ApplyPosition(key) end
end


function MR.GetAllKeys()
    local keys = {}
    for key in pairs(Registry) do keys[#keys + 1] = key end
    table.sort(keys)
    return keys
end

function MR.GetEntry(key) return Registry[key] end


-- ============================================================
-- SECTION: Deferred Mover Definitions
-- Some frames don't exist at module-load time (e.g. Blizzard
-- frames that load later). DefineMover stores a definition with
-- a resolveFrame() function; the actual RegisterMover call is
-- deferred until the frame is first needed (unlock or auto-discovery).
--
-- DEFINITION FIELDS:
--   resolveFrame()      — function that returns the live frame object
--   defaultPosition     — fallback position string
--   category            — overlay category for color coding
--   label               — display name on the drag overlay
--   externalOverlay     — true if frame manages its own drag handle
--   onLock / onUnlock   — callbacks for lock/unlock state changes
--   shouldShowOverlay() — optional function; return false to hide overlay
--   onPositionChanged() — callback fired after SavePosition writes
--
-- FUNCTIONS:
--   MR.DefineMover(key, def)   — stores def in Definitions table for
--                                lazy registration later
--   EnsureRegistered(key)      — resolves the frame via def.resolveFrame
--                                and calls RegisterMover if successful
-- ============================================================
function MR.DefineMover(key, def)
    if not key or type(def) ~= "table" then return end
    Definitions[key] = def
end

local function EnsureRegistered(key)
    if Registry[key] then return true end
    local def = Definitions[key]
    if not def then return false end

    local frame
    if def.resolveFrame then
        local ok, result = pcall(def.resolveFrame)
        if ok then frame = result end
    end
    if not frame then return false end

    if not def.externalOverlay then
        if frame.dragOverlay and frame.dragOverlay.EnableMouse then
            frame.dragOverlay:EnableMouse(false)
            if frame.dragOverlay.RegisterForDrag then
                frame.dragOverlay:RegisterForDrag()
            end
        end
    end

    MR.RegisterMover(key, frame, def.defaultPosition, {
        category = def.category,
        label = def.label,
        onLock = def.onLock,
        onUnlock = def.onUnlock,
        shouldShowOverlay = def.shouldShowOverlay,
        externalOverlay = def.externalOverlay,
        onPositionChanged = def.onPositionChanged,
    })
    return true
end


-- ============================================================
-- SECTION: Lock / Unlock (Edit Mode Toggle)
-- Called by Settings_UI when the player toggles Messenger's lock
-- button. Locked = normal play, overlays hidden. Unlocked = Edit
-- Mode, drag overlays visible, frames movable.
-- On unlock, lazy-registered Definitions are resolved first so
-- every registered frame gets an overlay even if it wasn't live
-- at registration time.
--
-- FUNCTIONS:
--   MR.SetAllLocked(locked)           — locks or unlocks every
--                                       registered frame; on unlock,
--                                       forces EnsureRegistered for
--                                       all pending Definitions
--   MR.SetCategoryLocked(cat, locked) — same but only for frames
--                                       in the given category string
--                                       (e.g. "unit", "cast", "bars")
-- ============================================================
function MR.SetAllLocked(locked)
    if not locked then
        for key in pairs(Definitions) do
            if not Registry[key] then
                EnsureRegistered(key)
            end
        end
    end

    for key, entry in pairs(Registry) do
        if locked then
            if entry.dragOverlay then entry.dragOverlay:Hide() end
            if entry.onLock then
                pcall(entry.onLock, entry.frame)
            end
        else
            local showOverlay = true
            if entry.shouldShowOverlay then
                local ok, result = pcall(entry.shouldShowOverlay, entry.frame)
                if ok and result == false then showOverlay = false end
            end
            if entry.onUnlock then
                pcall(entry.onUnlock, entry.frame)
            end
            if showOverlay and entry.dragOverlay then
                entry.dragOverlay:Show()
            end
        end
    end
end

function MR.SetCategoryLocked(category, locked)
    if not locked then
        for key, def in pairs(Definitions) do
            if def.category == category and not Registry[key] then
                EnsureRegistered(key)
            end
        end
    end
    for key, entry in pairs(Registry) do
        if entry.category == category then
            if locked then
                if entry.dragOverlay then entry.dragOverlay:Hide() end
                if entry.onLock then pcall(entry.onLock, entry.frame) end
            else
                local showOverlay = true
                if entry.shouldShowOverlay then
                    local ok, result = pcall(entry.shouldShowOverlay, entry.frame)
                    if ok and result == false then showOverlay = false end
                end
                if entry.onUnlock then pcall(entry.onUnlock, entry.frame) end
                if showOverlay and entry.dragOverlay then entry.dragOverlay:Show() end
            end
        end
    end
end


-- ============================================================
-- SECTION: Mover Registration (Drag Overlay Creation)
-- The core registration function. Creates a transparent drag overlay
-- frame covering the target frame, hooks drag events to StartMoving/
-- StopMovingOrSizing + snap system, and records everything in Registry.
-- If opts.externalOverlay is true, no overlay is created — the caller
-- supplies its own drag handle.
--
-- OVERLAY BEHAVIOR:
--   OnDragStart  → frame:StartMoving() + BeginFrameSnap
--   OnDragStop   → StopMovingOrSizing + EndFrameSnap + SavePosition
--   OnEnter/Leave → forwards to frame's own OnEnter/OnLeave scripts
--   Right-click  → opens overlay settings panel (via AttachOverlaySettings)
--
-- FUNCTIONS:
--   MR.RegisterMover(key, frame, defaultPos, opts)
--       — creates drag overlay, registers entry in Registry and
--         Core.OverlayHandles, calls ApplyPosition
--   MR.UnregisterMover(key)
--       — hides and detaches overlay, removes from Registry
--         and Core.OverlayHandles
-- ============================================================
function MR.RegisterMover(key, frame, defaultPosition, opts)
    if not key or not frame then return end
    opts = opts or {}

    if Registry[key] and Registry[key].frame == frame then
        MR.ApplyPosition(key)
        return
    end

    if Registry[key] and Registry[key].dragOverlay then
        Registry[key].dragOverlay:Hide()
        Registry[key].dragOverlay:SetParent(nil)
    end

    frame:SetMovable(true)
    frame:SetClampedToScreen(true)

    if opts.externalOverlay then
        Registry[key] = {
            key = key, frame = frame, dragOverlay = nil,
            defaultPosition = defaultPosition,
            category = opts.category, label = opts.label or key,
            onPositionChanged = opts.onPositionChanged,
            onLock = opts.onLock, onUnlock = opts.onUnlock,
            shouldShowOverlay = opts.shouldShowOverlay,
            externalOverlay = true,
        }
        local Core = _G.MidnightUI_Core
        if Core and Core.OverlayHandles then Core.OverlayHandles[key] = frame end
        MR.ApplyPosition(key)
        return
    end

    local overlay = CreateFrame("Frame", nil, frame, "BackdropTemplate")
    overlay:SetAllPoints()
    overlay:SetFrameStrata("HIGH")
    overlay:SetFrameLevel((frame.GetFrameLevel and frame:GetFrameLevel() or 0) + 10)
    overlay:EnableMouse(true)
    overlay:RegisterForDrag("LeftButton")
    overlay:Hide()

    if _G.MidnightUI_StyleOverlay then
        _G.MidnightUI_StyleOverlay(overlay, opts.label or key, opts.labelFont, opts.category)
    else
        overlay:SetBackdrop({
            bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = true, tileSize = 16, edgeSize = 16,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        overlay:SetBackdropColor(0.05, 0.08, 0.11, 0.30)
        overlay:SetBackdropBorderColor(0.30, 0.46, 0.58, 0.78)
        local lbl = overlay:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        lbl:SetPoint("CENTER")
        lbl:SetText(opts.label or key)
        lbl:SetTextColor(1, 1, 1)
    end

    overlay:SetScript("OnDragStart", function()
        frame:StartMoving()
        if _G.MidnightUI_BeginFrameSnap then _G.MidnightUI_BeginFrameSnap(frame) end
    end)
    overlay:SetScript("OnDragStop", function()
        frame:StopMovingOrSizing()
        if _G.MidnightUI_EndFrameSnap then _G.MidnightUI_EndFrameSnap(frame) end
        MR.SavePosition(key)
    end)
    overlay:SetScript("OnEnter", function()
        local fn = frame:GetScript("OnEnter")
        if fn then pcall(fn, frame) end
    end)
    overlay:SetScript("OnLeave", function()
        local fn = frame:GetScript("OnLeave")
        if fn then pcall(fn, frame) end
    end)

    if _G.MidnightUI_AttachOverlaySettings then
        _G.MidnightUI_AttachOverlaySettings(overlay, key)
    end

    Registry[key] = {
        key = key, frame = frame, dragOverlay = overlay,
        defaultPosition = defaultPosition,
        category = opts.category, label = opts.label or key,
        onPositionChanged = opts.onPositionChanged,
        onLock = opts.onLock, onUnlock = opts.onUnlock,
        shouldShowOverlay = opts.shouldShowOverlay,
    }

    frame.dragOverlay = overlay

    local Core = _G.MidnightUI_Core
    if Core and Core.OverlayHandles then Core.OverlayHandles[key] = overlay end

    MR.ApplyPosition(key)
end

function MR.UnregisterMover(key)
    local entry = Registry[key]
    if not entry then return end
    if entry.dragOverlay then entry.dragOverlay:Hide() end
    Registry[key] = nil
    local Core = _G.MidnightUI_Core
    if Core and Core.OverlayHandles then Core.OverlayHandles[key] = nil end
end


-- ============================================================
-- SECTION: Auto Discovery
-- Scans a static KNOWN_FRAMES list on PLAYER_ENTERING_WORLD
-- (with a 0.15s delay to let other modules finish creating their
-- frames). Registers any frame in KNOWN_FRAMES that hasn't already
-- been registered via RegisterMover or DefineMover.
-- Currently KNOWN_FRAMES is empty — all frames self-register.
--
-- FUNCTIONS:
--   ResolveKnownFrame(def)   — resolves a frame from def.globalName
--                              or def.parentGlobal + def.field
--   AutoDiscoverAll()        — iterates KNOWN_FRAMES, resolves each
--                              frame, registers if not already known;
--                              runs once per session (guarded by flag)
-- ============================================================
local KNOWN_FRAMES = {}

local function ResolveKnownFrame(def)
    if def.globalName then return _G[def.globalName] end
    if def.parentGlobal and def.field then
        local parent = _G[def.parentGlobal]
        if parent then return parent[def.field] end
    end
    return nil
end

local function AutoDiscoverAll()
    if autoDiscoveryDone then return end
    autoDiscoveryDone = true

    for _, def in ipairs(KNOWN_FRAMES) do
        if not Registry[def.key] and not Definitions[def.key] then
            local frame = ResolveKnownFrame(def)
            if frame and frame.SetPoint then
                if frame.dragOverlay and frame.dragOverlay.EnableMouse then
                    frame.dragOverlay:EnableMouse(false)
                    if frame.dragOverlay.RegisterForDrag then frame.dragOverlay:RegisterForDrag() end
                end
                MR.RegisterMover(def.key, frame, def.defaultPos, {
                    category = def.category,
                    label = def.label,
                })
            end
        end
    end
end


local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_ENTERING_WORLD" then
        C_Timer.After(0.15, AutoDiscoverAll)
    end
end)
