local _, private = ...

-- Lua Globals --
-- luacheck: globals next

-- Libs --
local LibWin = _G.LibStub("LibWindow-1.1")

-- RealUI --
local RealUI = private.RealUI

local MODNAME = "FramePoint"
local FramePoint = RealUI:NewModule(MODNAME)

local modules = {}

---------------------------------------------------------------------------
-- Unit-frame anchoring
--
-- LibWindow stores positions relative to UIParent, which is right for a
-- free-floating HuD element but wrong when the user wants something pinned
-- to a unit frame (class power under the player frame, a cast bar on its
-- unit). When a frame's config carries `anchorTo`, we bypass LibWindow's
-- restore and anchor the dragFrame to that unit frame instead, keeping
-- `point`/`x`/`y` as an offset from it.
--
-- `anchorTo` is nil/"screen" by default, so existing profiles are unchanged.
---------------------------------------------------------------------------
local ANCHOR_FRAMES = {
    player = "RealUIPlayerFrame",
    target = "RealUITargetFrame",
    focus  = "RealUIFocusFrame",
}
FramePoint.ANCHOR_FRAMES = ANCHOR_FRAMES

--- Resolve a config's `anchorTo` to a live frame, or nil for screen-anchored.
local function GetAnchorFrame(config)
    if not config then return end

    local key = config.anchorTo
    if not key or key == "screen" then return end

    local globalName = ANCHOR_FRAMES[key]
    return globalName and _G[globalName]
end
FramePoint.GetAnchorFrame = GetAnchorFrame

--- Screen-space coordinates of a named anchor point on a frame, in that
--- frame's own units. Callers multiply by effective scale to compare frames.
local function GetPointCoords(frame, point)
    local left, bottom, width, height = frame:GetRect()
    if not left then return end

    local x
    if point:find("LEFT") then
        x = left
    elseif point:find("RIGHT") then
        x = left + width
    else
        x = left + (width / 2)
    end

    local y
    if point:find("BOTTOM") then
        y = bottom
    elseif point:find("TOP") then
        y = bottom + height
    else
        y = bottom + (height / 2)
    end

    return x, y
end

--- Apply a config's anchor to its dragFrame. Returns true when the frame was
--- anchored to a unit frame (so callers know to skip LibWindow's restore).
local function ApplyAnchor(dragFrame, config)
    local anchorFrame = GetAnchorFrame(config)
    if not anchorFrame then return false end

    local point = config.point or "CENTER"
    dragFrame:ClearAllPoints()
    dragFrame:SetPoint(point, anchorFrame, point, config.x or 0, config.y or 0)

    return true
end
FramePoint.ApplyAnchor = ApplyAnchor

function FramePoint:LockMod(mod)
    local module = modules[mod]

    for frame, meta in next, module.frames do
        meta.dragFrame:Hide()
    end

    if mod.ToggleConfigMode then
        mod:ToggleConfigMode(false)
    end

    module.isLocked = true
end

function FramePoint:UnlockMod(mod)
    local module = modules[mod]

    for frame, meta in next, module.frames do
        meta.dragFrame:Show()
    end

    if mod.ToggleConfigMode then
        mod:ToggleConfigMode(true)
    end

    module.isLocked = false
end

function FramePoint:IsModLocked(mod)
    if not modules[mod] then
        return true
    end
    return modules[mod].isLocked
end

function FramePoint:ToggleMod(mod, setLocked)
    if self:IsModLocked(mod) then
        FramePoint:UnlockMod(mod)
    else
        FramePoint:LockMod(mod)
    end
end

function FramePoint:ToggleAll(setLocked)
    for mod, module in next, modules do
        if setLocked then
            FramePoint:LockMod(mod)
        else
            FramePoint:UnlockMod(mod)
        end
    end
end

--- Switch a managed frame between screen- and unit-frame anchoring, keeping
--- it visually in place.
---
--- `point`/`x`/`y` mean different things in each mode (UIParent-relative vs
--- offset-from-anchor), so simply writing `anchorTo` would teleport the frame.
--- This captures where it currently sits and rewrites the offsets into the
--- new coordinate space before re-applying.
---@param mod table          the module that owns the frame
---@param optionPath table   same path passed to PositionFrame
---@param anchorTo string    "screen" | key of ANCHOR_FRAMES
function FramePoint:SetAnchorTo(mod, optionPath, anchorTo)
    local module = modules[mod]
    if not module then return end

    -- Option paths are built fresh at each call site, so compare by content.
    -- Several frames can share one path (ClassResource registers both the
    -- holder and the rune frame under "class.points.position"); matching the
    -- first is enough because RestorePosition below re-applies to all of them.
    local function PathsMatch(a, b)
        if not a or not b or #a ~= #b then return false end
        for i = 1, #a do
            if a[i] ~= b[i] then return false end
        end
        return true
    end

    for _, meta in next, module.frames do
        if PathsMatch(meta.optionPath, optionPath) then
            local dragFrame = meta.dragFrame
            local config = RealUI.GetOptions(mod.moduleName, meta.optionPath)
            if not config then return end

            local point = config.point or "CENTER"
            local scale = dragFrame:GetEffectiveScale()
            local fx, fy = GetPointCoords(dragFrame, point)

            config.anchorTo = anchorTo

            local anchorFrame = GetAnchorFrame(config)
            if fx then
                if anchorFrame then
                    -- screen -> anchored: offsets become relative to the unit frame
                    local ax, ay = GetPointCoords(anchorFrame, point)
                    local anchorScale = anchorFrame:GetEffectiveScale()
                    if ax then
                        config.x = RealUI.Round((fx * scale - ax * anchorScale) / scale, 1)
                        config.y = RealUI.Round((fy * scale - ay * anchorScale) / scale, 1)
                    end
                else
                    -- anchored -> screen: offsets become UIParent-relative, which
                    -- is what LibWindow expects on the next restore.
                    local parentScale = _G.UIParent:GetEffectiveScale()
                    local px, py = GetPointCoords(_G.UIParent, point)
                    if px then
                        config.x = RealUI.Round((fx * scale - px * parentScale) / scale, 1)
                        config.y = RealUI.Round((fy * scale - py * parentScale) / scale, 1)
                    end
                end
            end

            self:RestorePosition(mod)
            return
        end
    end
end

function FramePoint:RestorePosition(mod)
    local module = modules[mod]
    if not module then return end
    for frame, meta in next, module.frames do
        local config = RealUI.GetOptions(mod.moduleName, meta.optionPath)
        if config and config.x then
            frame:ClearAllPoints()
            frame:SetPoint("CENTER", meta.dragFrame)

            -- Profile/layout switches swap the options table out from under
            -- LibWindow: the dragFrame was registered against the OLD
            -- profile's framePoint table at spawn, so RestorePosition would
            -- re-apply the old profile's coordinates and strand the frame
            -- (B43: focus/focustarget orphaned after a layout switch).
            -- Re-register against the table the CURRENT profile resolves to
            -- before restoring.
            LibWin.RegisterConfig(meta.dragFrame, config)
            meta.dragFrame._framePointConfig = config

            if not ApplyAnchor(meta.dragFrame, config) then
                LibWin.RestorePosition(meta.dragFrame)
            end
        end
    end
end

--- Was this frame originally hung off a RealUI positioner frame?
--
-- `_framePointDefault` records the anchor the frame's own unit/module file gave
-- it before FramePoint took over, so it is an exact record of which elements
-- the HuD position sliders were ever meant to drive. Party/raid holders anchor
-- to UIParent and must NOT move with HuDY; player/target/boss/cast bars anchor
-- to RealUIPositioners* and must.
local function WasPositionerAnchored(dragFrame)
    local default = dragFrame and dragFrame._framePointDefault
    local rel = default and default.relativeTo
    if not rel then return false end

    local name = rel
    if type(rel) ~= "string" then
        name = rel.GetName and rel:GetName()
    end
    return name and name:find("RealUIPositioners", 1, true) == 1 or false
end

--- Shift dragged, screen-anchored HuD elements by (dx, dy).
--
-- A managed frame follows its positioner only while it has NEVER been dragged.
-- The moment LibWindow has saved coordinates for it, `RestorePosition` anchors
-- the dragFrame to UIParent and the positioner drops out of the chain — so
-- `RealUI:UpdatePositioners()` moves a frame nothing is attached to any more
-- and the HuD Vertical slider silently does nothing.
--
-- That is what a beta 8 tester hit: the slider moved the HuD in one spec and
-- was dead in the other. It is per-PROFILE, not per-spec — each layout has its
-- own profile, and their long-lived Healing profile carried saved positions
-- while the DPS one did not. Neither spec is "dominant"; whichever profile has
-- saved drag coordinates is the one where the slider had nothing to move.
--
-- Skipped: frames pinned to a unit frame (they already ride their anchor) and
-- frames that never belonged to a positioner in the first place.
function FramePoint:ShiftScreenAnchored(dx, dy)
    dx, dy = dx or 0, dy or 0
    if dx == 0 and dy == 0 then return end

    for mod, module in next, modules do
        local moved = false
        for _, meta in next, module.frames do
            local config = RealUI.GetOptions(mod.moduleName, meta.optionPath)
            if config and config.x and config.y
                and not GetAnchorFrame(config)
                and WasPositionerAnchored(meta.dragFrame) then
                config.x = config.x + dx
                config.y = config.y + dy
                moved = true
            end
        end
        if moved then
            self:RestorePosition(mod)
        end
    end
end

--- Hand positioner-owned elements back to the HuD sliders (B93).
--
-- Once an element has been dragged, LibWindow has saved coordinates for it and
-- `RestorePosition` anchors its dragFrame to UIParent — the positioner drops
-- out of the chain permanently. From then on the HuD Vertical and Anchor Width
-- sliders are decorative for that element: Vertical only nudges it (B87), and
-- Width does nothing at all, because width is a property of the positioner
-- frame the element no longer touches.
--
-- That is per-profile, and a profile is a spec, which is why a tester saw
-- `UFHorizontal = 200` in both specs render at different widths while both were
-- on the same HuD size: in one profile the frames still hung off the positioner,
-- in the other they carried saved drag coordinates (`player.framePoint =
-- {point="CENTER", x=-360, y=-148}`).
--
-- Clearing the saved coordinates re-attaches them, and the sliders become
-- authoritative again. Deliberately narrow, same tests as ShiftScreenAnchored:
-- unit-pinned elements keep their pin, and anything that never belonged to a
-- positioner (party/raid holders) is left alone.
-- Undo buffer for the action below. Session-only and deliberately so: this is
-- a safety net for a mis-click, not a second persistence layer competing with
-- the profile. Cleared on reload, which the tooltip says.
local detachedBackup = {}

--- How many hand-placed positions the last re-attach is holding.
function FramePoint:GetReattachUndoCount()
    return #detachedBackup
end

--- Put back what the last re-attach cleared.
---@return number count restored
function FramePoint:UndoReattach()
    local count = 0
    for _, entry in next, detachedBackup do
        local config = RealUI.GetOptions(entry.mod.moduleName, entry.optionPath)
        if config then
            config.x, config.y = entry.x, entry.y
            config.point, config.scale = entry.point, entry.scale
            count = count + 1
        end
    end
    _G.wipe(detachedBackup)

    if count > 0 then
        self:RefreshMod()
        if RealUI.UpdatePositioners then
            RealUI:UpdatePositioners()
        end
    end
    return count
end

---@return number count of elements re-attached
function FramePoint:ReattachToPositioners()
    local count = 0
    _G.wipe(detachedBackup)
    for mod, module in next, modules do
        for _, meta in next, module.frames do
            local config = RealUI.GetOptions(mod.moduleName, meta.optionPath)
            if config and config.x
                and not GetAnchorFrame(config)
                and WasPositionerAnchored(meta.dragFrame) then
                -- Remembered BEFORE the wipe: a tester who has spent real time
                -- hand-placing their HuD should be able to try this and take it
                -- back, rather than gambling that they prefer the result.
                detachedBackup[#detachedBackup + 1] = {
                    mod = mod,
                    optionPath = meta.optionPath,
                    x = config.x, y = config.y,
                    point = config.point, scale = config.scale,
                }
                -- The LibWindow keys, and only those: anything else under this
                -- config belongs to the module, not to the saved position.
                config.x, config.y = nil, nil
                config.point, config.scale = nil, nil
                count = count + 1
            end
        end
    end

    if count > 0 then
        -- Restores each frame to its `_framePointDefault`, which for these is
        -- the positioner anchor, then lets the sliders place them.
        self:RefreshMod()
        if RealUI.UpdatePositioners then
            RealUI:UpdatePositioners()
        end
    end
    return count
end

local function FixCollectionJournal(point, anchor, relPoint, x, y)
    local CollectionsJournal = _G.CollectionsJournal
    local mover = _G.CollectionsJournalMover

    CollectionsJournal:ClearAllPoints()
    CollectionsJournal:SetPoint(point, _G.UIParent, relPoint, x, y)
    mover:Show()
end
FramePoint.FixCollectionJournal = FixCollectionJournal

local function FixCommunitiesFrame(point, anchor, relPoint, x, y)
    local CommunitiesFrame = _G.CommunitiesFrame
    local mover = _G.CommunitiesFrameMover

    CommunitiesFrame:ClearAllPoints()
    CommunitiesFrame:SetPoint(point, _G.UIParent, relPoint, x, y)
    mover:Show()
end
FramePoint.FixCommunitiesFrame = FixCommunitiesFrame

local function FixHouseEditorStoragePanel(point, anchor, relPoint, x, y)
    local HouseEditorFrame = _G.HouseEditorFrame
    local mover = _G.HouseEditorStoragePanelMover

    if HouseEditorFrame.StoragePanel then
        HouseEditorFrame.StoragePanel:ClearAllPoints()
        HouseEditorFrame.StoragePanel:SetPoint(point, _G.UIParent, relPoint, x, y)
    end

    if HouseEditorFrame.StorageButton then
        HouseEditorFrame.StorageButton:ClearAllPoints()
        HouseEditorFrame.StorageButton:SetPoint(point, _G.UIParent, relPoint, x, y)
    end

    mover:Show()
end
FramePoint.FixHouseEditorStoragePanel = FixHouseEditorStoragePanel

function FramePoint.OnDragStart(frame)
    LibWin.OnDragStart(frame)
    if frame.dragBG then
        frame.dragBG:Show()
    end
end
function FramePoint.OnDragStop(frame, retries)
    -- Stop the move IMMEDIATELY and unconditionally. The anchored branch
    -- below never called StopMovingOrSizing at all (LibWin.OnDragStop only
    -- ran for screen-anchored frames), so dragging a PINNED frame could stay
    -- glued to the cursor with mouse capture held — hit live 2026-08-21 on
    -- the player and focus cast bars right after the B47 nudge pinned them.
    -- The nil-x retry path had the same hole, and mid-drag GetPoint can also
    -- hand back SECRET coords, where a bare `not x` test throws.
    frame:StopMovingOrSizing()

    local point, anchor, relPoint, x, y = frame:GetPoint()
    if x == nil or _G.issecretvalue(x) or _G.issecretvalue(y) then
        retries = retries or 0
        if retries < 10 then
            _G.C_Timer.After(0, function ()
                FramePoint.OnDragStop(frame, retries + 1)
            end)
        elseif frame.dragBG then
            -- Give up on saving this drag; the frame is already released.
            frame.dragBG:Hide()
        end
        return
    end

    if frame:GetName() == "CollectionsJournalMover" then
        FixCollectionJournal(point, anchor, relPoint, x, y)
    end
    if frame:GetName() == "CommunitiesFrameMover" then
        FixCommunitiesFrame(point, anchor, relPoint, x, y)
    end
    if frame:GetName() == "HouseEditorStoragePanelMover" then
        FixHouseEditorStoragePanel(point, anchor, relPoint, x, y)
    end

    RealUI.SetPixelPoint(frame)

    -- Anchored frames store offsets from their unit frame, not from
    -- UIParent, so LibWindow's save would write the wrong numbers.
    -- Convert the dragged position into anchor-relative offsets and
    -- re-apply, keeping drag working the same as for screen-anchored
    -- frames.
    local config = frame._framePointConfig
    local anchorFrame = GetAnchorFrame(config)
    if anchorFrame then
        local anchorPoint = config.point or "CENTER"
        local scale = frame:GetEffectiveScale()
        local anchorScale = anchorFrame:GetEffectiveScale()

        local fx, fy = GetPointCoords(frame, anchorPoint)
        local ax, ay = GetPointCoords(anchorFrame, anchorPoint)
        if fx and ax and not (_G.issecretvalue(fx) or _G.issecretvalue(fy)
            or _G.issecretvalue(ax) or _G.issecretvalue(ay)) then
            config.x = RealUI.Round((fx * scale - ax * anchorScale) / scale, 1)
            config.y = RealUI.Round((fy * scale - ay * anchorScale) / scale, 1)
            ApplyAnchor(frame, config)
        end
    else
        LibWin.OnDragStop(frame)
    end

    if frame.dragBG then
        frame.dragBG:Hide()
    end
end
function FramePoint:PositionFrame(mod, frame, optionPath)
    local dragFrame = _G.CreateFrame("Frame", nil, _G.UIParent)
    _G.Aurora.Base.SetBackdrop(dragFrame, _G.Aurora.Color.white, 0.2)

    local module = modules[mod]
    dragFrame:SetSize(frame:GetSize())
    dragFrame:SetHitRectInsets(-5, -5, -5, -5)
    dragFrame:SetClampedToScreen(true)
    dragFrame:SetMovable(true)
    dragFrame:EnableMouse(true)
    dragFrame:RegisterForDrag("LeftButton")
    dragFrame:SetScript("OnDragStart", module.OnDragStart)
    dragFrame:SetScript("OnDragStop", module.OnDragStop)
    dragFrame:Hide()

    local name = dragFrame:CreateFontString(nil, "BACKGROUND", "Game12Font")
    -- Anonymous frames render GetDebugName() as a parent chain ending in a
    -- hex address (the class points mover read "RealUIPlayerFr...cfe0").
    -- Build a readable label from the module + option path instead; named
    -- frames keep their real name.
    local label = frame:GetName()
    if not label then
        local structuralKeys = {
            profile = true, class = true, char = true, global = true,
            units = true, position = true, framePoint = true,
        }
        local parts = {mod.moduleName}
        if type(optionPath) == "table" then
            for i = 1, #optionPath do
                if not structuralKeys[optionPath[i]] then
                    parts[#parts + 1] = optionPath[i]
                end
            end
        end
        label = _G.table.concat(parts, " ")
    end
    name:SetText(label)
    name:SetPoint("CENTER")

    -- Copy the frame's original anchor onto the dragFrame as the default position.
    -- This ensures the dragFrame starts where the frame was placed by its unit file.
    -- LibWin.RestorePosition will override this only if saved data exists.
    local numPoints = frame:GetNumPoints()
    if numPoints > 0 then
        local point, relativeTo, relativePoint, xOfs, yOfs = frame:GetPoint(1)
        if point and relativeTo then
            dragFrame:SetPoint(point, relativeTo, relativePoint, xOfs or 0, yOfs or 0)
            -- Remember it: RefreshMod needs somewhere to put the frame back to
            -- when the profile it is switching INTO has no saved position for
            -- it. Without this the only fallback is LibWindow's, which centres.
            dragFrame._framePointDefault = {
                point = point,
                relativeTo = relativeTo,
                relativePoint = relativePoint,
                x = xOfs or 0,
                y = yOfs or 0,
            }
        end
    end

    frame:ClearAllPoints()
    frame:SetPoint("CENTER", dragFrame)

    local config = RealUI.GetOptions(mod.moduleName, optionPath)
    LibWin.RegisterConfig(dragFrame, config)

    -- OnDragStop needs the config to convert a drag into anchor-relative
    -- offsets; LibWindow keeps its own reference privately.
    dragFrame._framePointConfig = config

    -- Unit-frame anchoring wins over LibWindow's UIParent-relative position.
    if not ApplyAnchor(dragFrame, config) then
        -- Only call RestorePosition if LibWindow has actual saved data.
        -- An empty table means the user never moved the frame, so keep the inherited anchor.
        if config and _G.next(config) ~= nil then
            LibWin.RestorePosition(dragFrame)
        end
    end

    modules[mod].frames[frame] = {
        optionPath = optionPath,
        dragFrame = dragFrame,
    }
end

--- Re-apply every registered frame's position after a profile switch.
--
-- This MUST mirror the placement logic in PositionFrame. It used to call
-- LibWin.RestorePosition unconditionally, which broke two ways whenever the
-- incoming profile had not saved a position for a frame:
--
--   1. LibWindow's RestorePosition centres a frame when its config has no
--      x/y ("nothing stored in config yet, smack it in the center" —
--      LibWindow-1.1.lua:183). Switching to the Healing layout therefore
--      threw the party/raid anchors into the middle of the screen; a
--      /reload put them right again, because the spawn path guards for it.
--   2. ApplyAnchor was never called, so elements pinned to a unit frame
--      (cast bars, class resource — the "Anchor To" option) lost their pin
--      on every layout switch and got LibWindow-positioned instead.
--
-- Order matters and matches PositionFrame: unit-frame anchoring wins, then
-- real saved data, then the frame's original default anchor. Never centre.
function FramePoint:RefreshMod()
    for mod, module in next, modules do
        for frame, meta in next, module.frames do
            local dragFrame = meta.dragFrame
            local config = RealUI.GetOptions(mod.moduleName, meta.optionPath)
            LibWin.RegisterConfig(dragFrame, config)
            dragFrame._framePointConfig = config

            if not ApplyAnchor(dragFrame, config) then
                if config and next(config) ~= nil then
                    LibWin.RestorePosition(dragFrame)
                else
                    -- Unmoved in this profile: return to where the unit file
                    -- originally put it rather than keeping the outgoing
                    -- profile's position (or centring).
                    local default = dragFrame._framePointDefault
                    if default then
                        dragFrame:ClearAllPoints()
                        dragFrame:SetPoint(default.point, default.relativeTo,
                            default.relativePoint, default.x, default.y)
                    end
                end
            end
        end
    end
end

function FramePoint:RegisterMod(mod, OnDragStart, OnDragStop)
    modules[mod] = {
        frames = {},
        isLocked = true,
        OnDragStart = OnDragStart or LibWin.OnDragStart,
        OnDragStop = OnDragStop or LibWin.OnDragStop,
    }
end
