-- MSUF_A2_Reminder.lua  Buff Reminder — Ghost icons for missing/expiring buffs
--
-- Own container (entry.reminder) with independent X/Y offset + Edit Mode mover.
-- Edit Mode popup with X, Y, Size, and Spacing.
--
-- Default: all reminders ON (nil = enabled). User sets key = false to disable.
-- Secret-safe: direct spell-id lookups first, cache fallback for older clients.

local addonName, ns = ...
ns = (rawget(_G, "MSUF_NS") or ns) or {}
ns.MSUF_Auras2 = (type(ns.MSUF_Auras2) == "table") and ns.MSUF_Auras2 or {}
local API = ns.MSUF_Auras2

if ns.__MSUF_A2_REMINDER_LOADED then return end
ns.__MSUF_A2_REMINDER_LOADED = true

API.Reminder = (type(API.Reminder) == "table") and API.Reminder or {}
local Reminder = API.Reminder

-- Hot locals
local type, next = type, next
local wipe = table.wipe or function(t) for k in next, t do t[k] = nil end return t end
local CreateFrame, GetTime = CreateFrame, GetTime
local UnitClass, UnitExists, UnitIsDeadOrGhost = UnitClass, UnitExists, UnitIsDeadOrGhost
local IsInGroup, IsInRaid, GetNumGroupMembers = IsInGroup, IsInRaid, GetNumGroupMembers
local InCombatLockdown = InCombatLockdown
local GetCursorPosition = GetCursorPosition
local floor, format, max, min = math.floor, string.format, math.max, math.min
local C_UnitAuras = C_UnitAuras
local C_Spell_GetSpellTexture = C_Spell and C_Spell.GetSpellTexture
local GetSpellTexture_fn = GetSpellTexture
local tonumber = tonumber
local canaccessvalue = _G.canaccessvalue
local issecretvalue = _G.issecretvalue
local _hasCanaccessvalue = (type(canaccessvalue) == "function")

local function Tr(text)
    if type(text) ~= "string" then return text end
    if type(ns) == "table" and type(ns.Translate) == "function" then
        return ns.Translate(text)
    end
    local locale = (type(ns) == "table" and ns.L) or _G.MSUF_L
    if type(locale) == "table" then
        local translated = rawget(locale, text)
        if translated ~= nil then return translated end
    end
    return text
end

local function _GetIcon(spellId)
    if C_Spell_GetSpellTexture then
        local ok, tex = pcall(C_Spell_GetSpellTexture, spellId)
        if ok and tex then return tex end
    end
    if GetSpellTexture_fn then
        local ok, tex = pcall(GetSpellTexture_fn, spellId)
        if ok and tex then return tex end
    end
    return 134400
end

-- BUFF PROVIDERS
local _PROVIDERS = {
    { key="FORTITUDE",       label="Power Word: Fortitude",  providerClass="PRIEST",     satisfiedBy={[21562]=true},  iconSpell=21562  },
    { key="ARCANE_INTELLECT",label="Arcane Intellect",       providerClass="MAGE",       satisfiedBy={[1459]=true},   iconSpell=1459   },
    { key="MARK_OF_WILD",    label="Mark of the Wild",       providerClass="DRUID",      satisfiedBy={[1126]=true},   iconSpell=1126   },
    { key="BATTLE_SHOUT",    label="Battle Shout",           providerClass="WARRIOR",    satisfiedBy={[6673]=true},   iconSpell=6673   },
    { key="SKYFURY",         label="Skyfury",                providerClass="SHAMAN",     satisfiedBy={[462854]=true}, iconSpell=462854 },
    { key="SOURCE_OF_MAGIC", label="Source of Magic",        providerClass="EVOKER",     satisfiedBy={[369459]=true}, iconSpell=369459 },
    { key="BLESSING_BRONZE", label="Blessing of the Bronze", providerClass="EVOKER",
        satisfiedBy={[381732]=true,[381741]=true,[381746]=true,[381748]=true,[381749]=true,[381750]=true,
                     [381751]=true,[381752]=true,[381753]=true,[381754]=true,[381756]=true,[381757]=true,[381758]=true},
        iconSpell=381732 },
    { key="ROGUE_LETHAL",    label="Lethal Poison",          providerClass="ROGUE_SELF", satisfiedBy={[2823]=true,[8679]=true,[315584]=true,[381664]=true}, iconSpell=2823 },
    { key="ROGUE_NONLETHAL", label="Non-Lethal Poison",      providerClass="ROGUE_SELF", satisfiedBy={[3408]=true,[5761]=true,[381637]=true},               iconSpell=3408 },
}
Reminder.PROVIDERS = _PROVIDERS

-- Group roster scan
local _presentClasses = {}
local _playerClass = nil

-- Forward-declare (used by _ScanRoster, fully initialised in computation section)
local _rosterGen = 0
local _anyProviderInGroup = false
local _providerCount = #_PROVIDERS

local function _ScanRoster()
    wipe(_presentClasses)
    local _, cls = UnitClass("player")
    _playerClass = cls
    if cls then _presentClasses[cls] = true end
    if IsInGroup() or IsInRaid() then
        local n = GetNumGroupMembers() or 0
        local prefix = IsInRaid() and "raid" or "party"
        for i = 1, n do
            local u = prefix .. i
            if UnitExists(u) then
                local _, c = UnitClass(u)
                if c then _presentClasses[c] = true end
            end
        end
    end
    -- Bump roster generation → triggers rescan in _ComputeMissing
    _rosterGen = _rosterGen + 1
    -- Pre-compute: is ANY provider class present? If not, skip all work.
    _anyProviderInGroup = false
    for i = 1, _providerCount do
        local pc = _PROVIDERS[i].providerClass
        if pc == "ROGUE_SELF" then
            if _playerClass == "ROGUE" then _anyProviderInGroup = true; break end
        elseif _presentClasses[pc] then
            _anyProviderInGroup = true; break
        end
    end
end

-- Missing / expiring computation
--
-- PERF STRATEGY (near-zero overhead when ON):
--   1. Epoch-gate: _ScanPlayerAuras only runs when Cache epoch changes
--      (= player auras actually added/removed/updated by UNIT_AURA).
--      Typical raid: epoch changes 1-5× per second, Render called 60×.
--      → 95%+ of calls skip the scan entirely.
--   2. Provider-presence gate: if no provider class in group → skip scan.
--   3. Result-cache: ghost frames only re-rendered when computed output
--      actually changes (different providers missing, or timer bucket shift).
--   4. Reverse spellId lookup: O(1) per aura instead of O(9) inner loop.
--   5. All provider spells are whitelisted → direct arithmetic, no secret checks.
local _results = {}
local _resultCount = 0
for i = 1, 9 do _results[i] = { provider = nil, state = nil, remaining = nil } end

-- Reverse spellId → provider index (built once at load, O(1) per aura)
local _spellToProvider = {}
for i = 1, #_PROVIDERS do
    for sid in next, _PROVIDERS[i].satisfiedBy do
        _spellToProvider[sid] = i
    end
end

local _providerHasBuff = {}
local _providerMinRem  = {}
local _wantedProviders = {}
local _wantedRosterGen = -1
local _wantedReminderSig = nil
local _wantedAny = false
local _lastWantedSig = nil

local _getPlayerAuraBySpellID, _getUnitAuraBySpellID, _auraLookupBound

local function _ProviderWanted(idx, reminders)
    local p = _PROVIDERS[idx]
    if not p then return false end
    if reminders and reminders[p.key] == false then return false end
    if p.providerClass == "ROGUE_SELF" then
        return _playerClass == "ROGUE"
    end
    return _presentClasses[p.providerClass] == true
end

local function _BuildReminderWantedSig(reminders)
    if not reminders then return "" end
    local sig = ""
    for i = 1, _providerCount do
        local p = _PROVIDERS[i]
        if reminders[p.key] == false then
            sig = sig .. i .. ","
        end
    end
    return sig
end

local function _RefreshWantedProviders(reminders)
    local sig = _BuildReminderWantedSig(reminders)
    if _wantedRosterGen == _rosterGen and _wantedReminderSig == sig then
        return _wantedProviders, _wantedAny, sig
    end

    _wantedRosterGen = _rosterGen
    _wantedReminderSig = sig
    _wantedAny = false
    for i = 1, _providerCount do
        local wanted = _ProviderWanted(i, reminders)
        _wantedProviders[i] = wanted and true or false
        if wanted then _wantedAny = true end
    end
    return _wantedProviders, _wantedAny, sig
end

local function _BindAuraLookups()
    if _auraLookupBound then return end
    local CUA = C_UnitAuras or _G.C_UnitAuras
    if not CUA then return end
    _auraLookupBound = true
    C_UnitAuras = CUA
    if CUA then
        _getPlayerAuraBySpellID = CUA.GetPlayerAuraBySpellID
        _getUnitAuraBySpellID = CUA.GetUnitAuraBySpellID
    end
end

local function _DecodeSpellId(data)
    local sid = data and (data.spellId or data.spellID)
    if _hasCanaccessvalue then
        if canaccessvalue(sid) ~= true then return 0 end
    elseif issecretvalue and issecretvalue(sid) == true then
        return 0
    end
    return tonumber(sid) or 0
end

local function _AddProviderAura(idx, data, threshold)
    _providerHasBuff[idx] = true
    if threshold > 0 then
        local exp = data and data.expirationTime
        if exp and exp ~= 0 then
            local rem = exp - GetTime()
            if rem < 0 then rem = 0 end
            local prev = _providerMinRem[idx]
            if not prev or rem < prev then
                _providerMinRem[idx] = rem
            end
        else
            if not _providerMinRem[idx] then
                _providerMinRem[idx] = 999999
            end
        end
    end
end

local function _ScanPlayerAurasDirect(threshold, wanted)
    _BindAuraLookups()
    if not _getPlayerAuraBySpellID and not _getUnitAuraBySpellID then return false end
    for i = 1, _providerCount do
        if wanted[i] then
            local spells = _PROVIDERS[i].satisfiedBy
            for sid in next, spells do
                local data
                if _getPlayerAuraBySpellID then
                    data = _getPlayerAuraBySpellID(sid)
                end
                if not data and _getUnitAuraBySpellID then
                    data = _getUnitAuraBySpellID("player", sid)
                end
                if data then
                    _AddProviderAura(i, data, threshold)
                end
            end
        end
    end
    return true
end

-- ---- Epoch + dirty tracking ----
local _lastEpoch       = -1   -- last Cache.GetEpoch("player") we scanned at
local _lastRosterGen   = -1   -- last roster gen we scanned at

-- Result signature: compact fingerprint of last _ComputeMissing output.
-- Format: per result entry "providerKey:state:timerBucket" joined.
-- When sig unchanged → ghost frame update skipped entirely.
local _lastResultSig    = ""
-- Layout cache: track last ghost sizing to avoid SetSize/ClearAllPoints/SetPoint
local _lastRenderSize   = -1
local _lastRenderGap    = -1
local _lastRenderGrow   = ""
local _lastRenderEdit   = -1  -- -1 = never rendered

-- Called from Options when user toggles reminder checkboxes / threshold
function Reminder.MarkDirty()
    _lastEpoch = -1      -- force rescan
    _lastWantedSig = nil -- force rescan if reminder toggles changed without aura changes
    _lastResultSig = ""  -- force re-render
end

local function _ScanPlayerAurasCached(threshold, wanted)
    local Cache = API.Cache
    local s = Cache._units and Cache._units.player
    if not s or not s.all then return false end
    local thr = threshold
    local lookup = _spellToProvider
    for _, data in next, s.all do
        local idx = data._msufA2_remProvider
        if idx == nil then
            local sid = data._msufA2_sid
            if sid == nil then
                sid = _DecodeSpellId(data)
                data._msufA2_sid = sid or 0
            end
            if sid and sid ~= 0 then
                idx = lookup[sid] or false
            else
                idx = false
            end
            data._msufA2_remProvider = idx
        end
        if idx and wanted[idx] then
            _AddProviderAura(idx, data, thr)
        end
    end
    return true
end

local function _ScanPlayerAuras(threshold, wanted)
    if _ScanPlayerAurasCached(threshold, wanted) then return true end
    return _ScanPlayerAurasDirect(threshold, wanted)
end

-- Build compact result signature (zero-alloc via reusable buffer)
local _sigParts = {}
local function _BuildResultSig()
    local n = _resultCount
    if n == 0 then return "" end
    for i = 1, n do
        local r = _results[i]
        -- Timer bucket at 10s granularity to avoid churn on trivial tick changes
        local bucket = r.remaining and floor(r.remaining * 0.1) or -1
        _sigParts[i] = r.provider.key .. (r.state == "EXPIRING" and bucket or "M")
    end
    for i = n + 1, #_sigParts do _sigParts[i] = nil end
    return table.concat(_sigParts, ",")
end

local function _ComputeMissing(reminders, threshold, isPreview)
    local thr = (type(threshold) == "number" and threshold > 0) and threshold or 0

    if isPreview then
        _resultCount = 0
        for i = 1, _providerCount do
            local p = _PROVIDERS[i]
            if not reminders or reminders[p.key] ~= false then
                _resultCount = _resultCount + 1
                local r = _results[_resultCount]
                r.provider = p; r.state = "MISSING"; r.remaining = nil
            end
        end
        return true -- always "changed" in preview
    end

    -- PERF: No provider class in group → nothing can be missing, skip scan
    local wanted, wantedAny, wantedSig = _RefreshWantedProviders(reminders)

    if not _anyProviderInGroup or not wantedAny then
        local changed = (_resultCount ~= 0)
        _resultCount = 0
        _lastWantedSig = wantedSig
        return changed
    end

    -- PERF: Epoch-gate — only rescan when player auras actually changed
    local Cache = API.Cache
    local epoch = Cache and Cache.GetEpoch and Cache.GetEpoch("player") or 0
    local rGen = _rosterGen
    if epoch == _lastEpoch and rGen == _lastRosterGen and wantedSig == _lastWantedSig then
        -- Nothing changed since last scan → _results still valid
        return false
    end
    _lastEpoch = epoch
    _lastRosterGen = rGen
    _lastWantedSig = wantedSig

    -- Reset (fixed-size, no wipe)
    _resultCount = 0
    for i = 1, _providerCount do
        _providerHasBuff[i] = false
        _providerMinRem[i] = nil
    end

    if not _ScanPlayerAuras(thr, wanted) then
        _lastEpoch = -1
        return true
    end

    for i = 1, _providerCount do
        local p = _PROVIDERS[i]
        if wanted[i] then
            if not _providerHasBuff[i] then
                _resultCount = _resultCount + 1
                local r = _results[_resultCount]
                r.provider = p; r.state = "MISSING"; r.remaining = nil
            elseif thr > 0 then
                local rem = _providerMinRem[i]
                if rem and rem < thr then
                    _resultCount = _resultCount + 1
                    local r = _results[_resultCount]
                    r.provider = p; r.state = "EXPIRING"; r.remaining = rem
                end
            end
        end
    end
    return true -- data was rescanned
end

-- Timer text formatting
local function _FormatTime(sec)
    if not sec or sec <= 0 then return "" end
    sec = floor(sec)
    if sec < 60 then return sec .. "s" end
    local m = floor(sec / 60)
    local s = sec - m * 60
    if s > 0 then return format("%d:%02d", m, s) end
    return m .. "m"
end

-- Ghost icon pool
local _ghostPool = {}
local _ghostActive = 0

local function _AcquireGhost(container, index)
    local ghost = _ghostPool[index]
    if ghost then
        -- PERF: SetParent triggers texture regeneration; skip if unchanged.
        if ghost:GetParent() ~= container then
            ghost:SetParent(container)
        end
        if not ghost:IsShown() then ghost:Show() end
        return ghost
    end

    ghost = CreateFrame("Button", nil, container)
    ghost:SetSize(22, 22)
    ghost:EnableMouse(true)
    ghost:RegisterForClicks("AnyUp")

    local tex = ghost:CreateTexture(nil, "ARTWORK")
    tex:SetAllPoints()
    tex:SetDesaturated(true)
    ghost._tex = tex

    local overlay = ghost:CreateTexture(nil, "OVERLAY")
    overlay:SetAllPoints()
    overlay:SetColorTexture(0, 0, 0, 0.3)
    ghost._overlay = overlay

    local badge = ghost:CreateTexture(nil, "OVERLAY", nil, 2)
    badge:SetSize(10, 10)
    badge:SetPoint("TOPRIGHT", ghost, "TOPRIGHT", 2, 2)
    ghost._badge = badge

    local bang = ghost:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bang:SetPoint("CENTER", badge, "CENTER", 0, 0)
    bang:SetText("!")
    bang:SetTextColor(1, 1, 1, 1)
    ghost._bang = bang

    local timer = ghost:CreateFontString(nil, "OVERLAY")
    timer:SetFont(STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF", 10, "OUTLINE")
    timer:SetPoint("BOTTOM", ghost, "BOTTOM", 0, 1)
    timer:SetTextColor(1, 0.8, 0.2, 1)
    timer:Hide()
    ghost._timer = timer

    ghost:SetScript("OnEnter", function(self)
        if not self._result then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        local r = self._result
        if self._isPreview then
            GameTooltip:AddLine(Tr("Buff Reminder Preview"), 0.4, 0.7, 1)
            GameTooltip:AddLine(r.provider.label, 1, 1, 1)
            GameTooltip:AddLine(Tr("Click the mover to open position settings."), 0.7, 0.7, 0.7, true)
        elseif r.state == "EXPIRING" then
            GameTooltip:AddLine(format(Tr("Expiring: %s"), r.provider.label), 1, 0.6, 0.1)
            GameTooltip:AddLine(format(Tr("%s remaining"), _FormatTime(r.remaining)), 0.9, 0.9, 0.9)
        else
            GameTooltip:AddLine(format(Tr("Missing: %s"), r.provider.label), 1, 0.3, 0.3)
            if r.provider.providerClass == "ROGUE_SELF" then
                GameTooltip:AddLine(Tr("Apply your poison!"), 0.8, 0.8, 0.8, true)
            else
                local cls = r.provider.providerClass
                local color = RAID_CLASS_COLORS and RAID_CLASS_COLORS[cls]
                local cName = color and color.colorStr and ("|c" .. color.colorStr .. cls .. "|r") or cls
                GameTooltip:AddLine(format(Tr("A %s in your group can provide this."), cName), 0.8, 0.8, 0.8, true)
            end
        end
        GameTooltip:Show()
    end)
    ghost:SetScript("OnLeave", function() GameTooltip:Hide() end)

    _ghostPool[index] = ghost
    return ghost
end

local function _HideGhosts(fromIndex)
    for i = fromIndex, _ghostActive do
        local g = _ghostPool[i]
        if g then g:Hide() end
    end
end

-- DB helpers — read/write reminder layout fields
local function _GetCursorScaled()
    local scale = (UIParent and UIParent.GetEffectiveScale) and UIParent:GetEffectiveScale() or 1
    local cx, cy = GetCursorPosition()
    return cx / scale, cy / scale
end

local function _GetDB()
    if not MSUF_DB or not MSUF_DB.auras2 then return nil, nil, nil end
    local a2 = MSUF_DB.auras2
    local shared = a2.shared or {}
    local pu = a2.perUnit and a2.perUnit.player
    local lay = (pu and pu.overrideLayout == true and type(pu.layout) == "table") and pu.layout or nil
    return a2, shared, lay
end

local function _ReadVal(shared, lay, key, def)
    if lay and lay[key] ~= nil then return tonumber(lay[key]) or def end
    if shared and shared[key] ~= nil then return tonumber(shared[key]) or def end
    return def
end

local function _WriteLayout(key, value)
    if not MSUF_DB or not MSUF_DB.auras2 then return end
    local a2 = MSUF_DB.auras2
    a2.perUnit = (type(a2.perUnit) == "table") and a2.perUnit or {}
    local u = a2.perUnit.player
    if type(u) ~= "table" then u = {}; a2.perUnit.player = u end
    u.overrideLayout = true
    u.layout = (type(u.layout) == "table") and u.layout or {}
    u.layout[key] = value
end

local function _ApplyAndRefresh()
    if API.UpdateUnitAnchor then API.UpdateUnitAnchor("player") end
    if API.MarkDirty then API.MarkDirty("player") end
end

-- Edit Mode Popup — Position/Size/Spacing for reminder container
-- Matches existing MSUF popup visual style 1:1.
local _popup = nil

-- Numeric row builder (label + editbox + stepper buttons)
-- Replicates MSUF_EM_CreateNumericRowStored pattern.
local _popupRowCount = 0
local function _CreateNumericRow(parent, labelText, anchorTo, dy, onChanged)
    _popupRowCount = _popupRowCount + 1
    local rowName = "MSUF_ReminderPopupRow" .. _popupRowCount

    -- Label
    local label = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    label:SetPoint("TOPLEFT", anchorTo, "BOTTOMLEFT", 0, dy or -8)
    label:SetText(labelText)
    label:SetTextColor(0.85, 0.85, 0.85, 1)

    -- EditBox
    local box = CreateFrame("EditBox", rowName .. "Box", parent, "InputBoxTemplate")
    box:SetSize(55, 18)
    box:SetPoint("LEFT", label, "RIGHT", 8, 0)
    box:SetAutoFocus(false)
    box:SetNumeric(false)
    box:SetMaxLetters(6)
    box:SetFontObject("GameFontHighlightSmall")
    box:SetScript("OnEnterPressed", function(self)
        self:ClearFocus()
        if onChanged then onChanged() end
    end)
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    -- Minus button
    local minus = CreateFrame("Button", nil, parent)
    minus:SetSize(16, 16)
    minus:SetPoint("LEFT", box, "RIGHT", 2, 0)
    minus:SetNormalFontObject("GameFontNormal")
    local mTex = minus:CreateTexture(nil, "ARTWORK")
    mTex:SetAllPoints()
    mTex:SetColorTexture(0.25, 0.25, 0.25, 0.6)
    local mText = minus:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    mText:SetPoint("CENTER")
    mText:SetText("-")
    minus:SetScript("OnClick", function()
        local v = tonumber(box:GetText()) or 0
        local step = IsShiftKeyDown() and 10 or 1
        box:SetText(tostring(floor(v - step)))
        if onChanged then onChanged() end
    end)

    -- Plus button
    local plus = CreateFrame("Button", nil, parent)
    plus:SetSize(16, 16)
    plus:SetPoint("LEFT", minus, "RIGHT", 1, 0)
    local pTex = plus:CreateTexture(nil, "ARTWORK")
    pTex:SetAllPoints()
    pTex:SetColorTexture(0.25, 0.25, 0.25, 0.6)
    local pText = plus:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    pText:SetPoint("CENTER")
    pText:SetText("+")
    plus:SetScript("OnClick", function()
        local v = tonumber(box:GetText()) or 0
        local step = IsShiftKeyDown() and 10 or 1
        box:SetText(tostring(floor(v + step)))
        if onChanged then onChanged() end
    end)

    return { label = label, box = box, minus = minus, plus = plus }
end

local function _EnsurePopup()
    if _popup then return _popup end

    local pf = CreateFrame("Frame", "MSUF_ReminderPositionPopup", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil)
    pf:SetSize(300, 250)
    pf:SetPoint("CENTER", UIParent, "CENTER", 200, 0)
    pf:SetFrameStrata("FULLSCREEN_DIALOG")
    pf:SetFrameLevel(500)
    pf:SetClampedToScreen(true)
    pf:SetMovable(true)
    pf:EnableMouse(true)
    pf:RegisterForDrag("LeftButton")
    pf:SetScript("OnDragStart", function(self) self:StartMoving() end)
    pf:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)

    -- Backdrop: matches MSUF_POPUP_DEFAULT_BACKDROP
    pf:SetBackdrop({
        bgFile   = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        edgeSize = 16,
        insets   = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    pf:SetBackdropColor(0, 0, 0, 0.9)

    -- Title
    local title = pf:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    title:SetPoint("TOP", 0, -12)
    title:SetText(Tr("MSUF Edit - Player Reminders"))
    pf.title = title

    -- Close button
    local closeBtn = CreateFrame("Button", "$parentClose", pf, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", pf, "TOPRIGHT", -6, -6)
    closeBtn:SetScript("OnClick", function() pf:Hide() end)

    -- Section header
    local header = pf:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    header:SetPoint("TOPLEFT", pf, "TOPLEFT", 16, -36)
    header:SetText(Tr("Reminder Icons"))
    header:SetTextColor(1, 0.82, 0, 1)

    -- Apply callback — reads all boxes, writes to DB, refreshes
    local function Apply()
        if pf._applying then return end
        if _G.MSUF__UndoRestoring then return end
        pf._applying = true

        pcall(function()
            -- Undo
            if _G.MSUF_EM_UndoBeforeChange then
                _G.MSUF_EM_UndoBeforeChange("aura", "player")
            end

            local ox = tonumber(pf._rowX and pf._rowX.box:GetText()) or 0
            local oy = tonumber(pf._rowY and pf._rowY.box:GetText()) or 0
            local sz = tonumber(pf._rowSize and pf._rowSize.box:GetText()) or 22
            local sp = tonumber(pf._rowSpacing and pf._rowSpacing.box:GetText()) or 2
            -- Clamp
            ox = max(-2000, min(2000, floor(ox + 0.5)))
            oy = max(-2000, min(2000, floor(oy + 0.5)))
            sz = max(10, min(80, floor(sz + 0.5)))
            sp = max(0, min(30, floor(sp + 0.5)))

            _WriteLayout("reminderOffsetX", ox)
            _WriteLayout("reminderOffsetY", oy)
            _WriteLayout("reminderIconSize", sz)
            _WriteLayout("reminderSpacing", sp)
            -- Also write to shared as defaults
            local _, shared = _GetDB()
            if shared then
                shared.reminderOffsetX = ox
                shared.reminderOffsetY = oy
                shared.reminderIconSize = sz
                shared.reminderSpacing = sp
            end

            _ApplyAndRefresh()
        end)
        pf._applying = false
    end

    -- Build numeric rows
    pf._rowX = _CreateNumericRow(pf, Tr("Offset X:"), header, -10, Apply)
    pf._rowY = _CreateNumericRow(pf, Tr("Offset Y:"), pf._rowX.label, -8, Apply)
    pf._rowSize = _CreateNumericRow(pf, Tr("Icon Size:"), pf._rowY.label, -8, Apply)
    pf._rowSpacing = _CreateNumericRow(pf, Tr("Spacing:"), pf._rowSize.label, -8, Apply)

    -- Step hint
    local stepHint = pf:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
    stepHint:SetPoint("BOTTOM", pf, "BOTTOM", 0, 14)
    stepHint:SetText(Tr("Hold Shift for +/- 10 steps"))
    stepHint:SetTextColor(0.5, 0.5, 0.5, 0.8)

    pf:Hide()
    _popup = pf

    -- Register with edit mode popup system
    local Edit = _G.MSUF_Edit
    if Edit and Edit.Popups and Edit.Popups.Register then
        Edit.Popups.Register(pf)
    end

    return pf
end

-- Sync popup boxes from DB
function Reminder.SyncPopup()
    if not _popup or not _popup:IsShown() then return end
    if _popup._applying then return end

    local _, shared, lay = _GetDB()
    local ox = _ReadVal(shared, lay, "reminderOffsetX", 0)
    local oy = _ReadVal(shared, lay, "reminderOffsetY", 0)
    local sz = _ReadVal(shared, lay, "reminderIconSize", 22)
    local sp = _ReadVal(shared, lay, "reminderSpacing", 2)
    local function setBox(row, v)
        if row and row.box and not row.box:HasFocus() then
            row.box:SetText(tostring(floor(v + 0.5)))
        end
    end
    setBox(_popup._rowX, ox)
    setBox(_popup._rowY, oy)
    setBox(_popup._rowSize, sz)
    setBox(_popup._rowSpacing, sp)

end

function Reminder.OpenPopup(parent)
    if InCombatLockdown() then return end
    local pf = _EnsurePopup()

    -- Smart positioning: to the right of the mover if possible
    if parent and parent.GetRight then
        local sw = UIParent and UIParent:GetWidth() or 0
        local pr = parent:GetRight() or 0
        local pw = pf:GetWidth() or 300
        pf:ClearAllPoints()
        if (sw - pr) > (pw + 20) then
            pf:SetPoint("TOPLEFT", parent, "TOPRIGHT", 10, 0)
        elseif (parent:GetLeft() or 0) > (pw + 20) then
            pf:SetPoint("TOPRIGHT", parent, "TOPLEFT", -10, 0)
        else
            pf:SetPoint("CENTER", UIParent, "CENTER", 200, 0)
        end
    end

    pf:Show()
    Reminder.SyncPopup()
end

function Reminder.HidePopup()
    if _popup and _popup:IsShown() then _popup:Hide() end
end

-- Expose for sync from mover drag
_G.MSUF_SyncReminderPopup = Reminder.SyncPopup

-- Edit Mode Mover — writes reminderOffsetX/Y, opens popup on click
function Reminder.EnsureMover(entry, unit, shared)
    if not entry or unit ~= "player" then return end
    if entry.editMoverReminder then return entry.editMoverReminder end

    local mover = CreateFrame("Frame", "MSUF_A2_player_Mover_reminder", UIParent, "BackdropTemplate")
    mover:SetFrameStrata("DIALOG")
    mover:SetFrameLevel(500)
    mover:SetClampedToScreen(true)
    mover:EnableMouse(true)
    if mover.SetHitRectInsets then mover:SetHitRectInsets(-2, -2, -22, -2) end

    mover:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8X8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true, tileSize = 8, edgeSize = 12,
        insets = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    mover:SetBackdropColor(1.00, 0.75, 0.20, 0.12)
    mover:SetBackdropBorderColor(1.00, 0.75, 0.20, 0.55)

    local headerH = 18
    local hdr = CreateFrame("Frame", nil, mover, "BackdropTemplate")
    hdr:SetPoint("TOPLEFT", mover, "TOPLEFT", 2, -2)
    hdr:SetPoint("TOPRIGHT", mover, "TOPRIGHT", -2, -2)
    hdr:SetHeight(headerH)
    hdr:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" })
    hdr:SetBackdropColor(1.0, 0.75, 0.20, 0.22)
    hdr:EnableMouse(true)
    hdr:SetScript("OnMouseDown", function(h, btn) local p = h:GetParent(); local fn = p and p:GetScript("OnMouseDown"); if fn then fn(p, btn) end end)
    hdr:SetScript("OnMouseUp",   function(h, btn) local p = h:GetParent(); local fn = p and p:GetScript("OnMouseUp");   if fn then fn(p, btn) end end)

    local ico = hdr:CreateTexture(nil, "OVERLAY")
    ico:SetSize(14, 14)
    ico:SetPoint("LEFT", hdr, "LEFT", 6, 0)
    ico:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark")
    ico:SetTexCoord(0.07, 0.93, 0.07, 0.93)

    local lbl = hdr:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    lbl:SetPoint("LEFT", ico, "RIGHT", 6, 0)
    lbl:SetPoint("RIGHT", hdr, "RIGHT", -6, 0)
    lbl:SetJustifyH("LEFT")
    lbl:SetText(Tr("Player Reminders"))
    lbl:SetTextColor(0.95, 0.95, 0.95, 0.92)

    mover:Hide()
    mover._msufAuraEntry = entry
    mover._msufAuraUnitKey = "player"

    -- Drag state
    local _dragged = false

    local function ApplyDrag(self, dx, dy)
        if InCombatLockdown() then return end
        local sx = self._dragStartOX or 0
        local sy = self._dragStartOY or 0
        local nx = max(-2000, min(2000, floor(sx + dx + 0.5)))
        local ny = max(-2000, min(2000, floor(sy + dy + 0.5)))
        _WriteLayout("reminderOffsetX", nx)
        _WriteLayout("reminderOffsetY", ny)
        _ApplyAndRefresh()
        -- Sync popup if open
        if Reminder.SyncPopup then Reminder.SyncPopup() end
    end

    local function ReminderDragUpdate(self)
        if not self._isDragging then
            self:SetScript("OnUpdate", nil)
            return
        end
        local cx, cy = _GetCursorScaled()
        local dx = cx - (self._dragStartCX or cx)
        local dy = cy - (self._dragStartCY or cy)
        if (dx * dx + dy * dy) > 4 then _dragged = true end
        ApplyDrag(self, dx, dy)
    end

    mover:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" then return end
        if InCombatLockdown() then return end
        _dragged = false

        if not _G.MSUF__UndoRestoring then
            local bc = _G.MSUF_EM_UndoBeforeChange
            if type(bc) == "function" then bc("aura", "player") end
        end

        local _, sh, lay = _GetDB()
        self._dragStartOX = _ReadVal(sh, lay, "reminderOffsetX", 0)
        self._dragStartOY = _ReadVal(sh, lay, "reminderOffsetY", 0)
        local cx, cy = _GetCursorScaled()
        self._dragStartCX = cx
        self._dragStartCY = cy
        self._isDragging = true
        self:SetScript("OnUpdate", ReminderDragUpdate)
    end)

    mover:SetScript("OnMouseUp", function(self, button)
        if button ~= "LeftButton" then return end
        self._isDragging = false
        self:SetScript("OnUpdate", nil)
        if not _G.MSUF__UndoRestoring then
            local ac = _G.MSUF_EM_UndoAfterChange
            if type(ac) == "function" then ac("aura", "player") end
        end
        -- Click without drag: open popup
        if not _dragged then
            Reminder.OpenPopup(self)
        end
    end)

    entry.editMoverReminder = mover
    return mover
end

-- Render: show ghost icons into entry.reminder container
--
-- PERF: Three-layer gating ensures near-zero overhead when ON:
--   Layer 1: _ComputeMissing epoch-gates the aura scan (Cache.GetEpoch)
--            → 95%+ of calls skip _ScanPlayerAuras entirely.
--   Layer 2: Result signature check — if same providers missing with
--            same timer buckets AND same layout → skip ALL ghost updates.
--            Cost: 1 string compare. No SetSize, SetPoint, SetTexture calls.
--   Layer 3: Individual ghost dirty checks — only update properties that
--            actually changed (size, position, texture, state).
function Reminder.Render(entry, unit, shared, iconSize, spacing, growth, isEditMode)
    if unit ~= "player" then
        if _ghostActive > 0 then _HideGhosts(1); _ghostActive = 0 end
        return
    end

    local container = entry and entry.reminder
    if not container then
        if _ghostActive > 0 then _HideGhosts(1); _ghostActive = 0 end
        return
    end

    local remindersOn = (shared and shared.showReminders ~= false)
    if not remindersOn then
        if _ghostActive > 0 then _HideGhosts(1); _ghostActive = 0 end
        container:Hide()
        return
    end

    if not isEditMode and UnitIsDeadOrGhost and UnitIsDeadOrGhost("player") then
        if _ghostActive > 0 then _HideGhosts(1); _ghostActive = 0 end
        container:Hide()
        return
    end

    local reminders = shared and shared.reminders
    local thr = (shared and type(shared.reminderThreshold) == "number") and shared.reminderThreshold or 0

    -- Read per-icon size/spacing/growth from layout
    local _, sh, lay = _GetDB()
    local remSize    = _ReadVal(sh, lay, "reminderIconSize", iconSize or 22)
    local remSpacing = _ReadVal(sh, lay, "reminderSpacing", spacing or 2)
    local remGrowth  = nil
    if lay and type(lay.reminderGrowth) == "string" then remGrowth = lay.reminderGrowth end
    if not remGrowth and sh and type(sh.reminderGrowth) == "string" then remGrowth = sh.reminderGrowth end
    remGrowth = remGrowth or growth or "RIGHT"

    -- Layer 1: Epoch-gated compute (returns true only if data rescanned)
    local rescanned = _ComputeMissing(reminders, thr, isEditMode)

    -- Handle count decrease
    if _resultCount < _ghostActive then
        _HideGhosts(_resultCount + 1)
    end
    _ghostActive = _resultCount

    if _resultCount == 0 then
        container:Hide()
        _lastResultSig = ""
        return
    end
    container:Show()

    -- Layer 2: Result + layout signature check.
    -- If epoch said "no change" AND layout identical → skip ALL ghost updates.
    local editFlag = isEditMode and 1 or 0
    local sig = rescanned and _BuildResultSig() or _lastResultSig
    if sig == _lastResultSig
       and remSize == _lastRenderSize
       and remSpacing == _lastRenderGap
       and remGrowth == _lastRenderGrow
       and editFlag == _lastRenderEdit then
        -- Identical output — zero ghost frame calls.
        return
    end
    _lastResultSig  = sig
    _lastRenderSize = remSize
    _lastRenderGap  = remSpacing
    _lastRenderGrow = remGrowth
    _lastRenderEdit = editFlag

    -- Layer 3: Update ghost frames (only reached when something changed)
    local size = remSize
    local gap = remSpacing
    local growDir = remGrowth

    for i = 1, _resultCount do
        local r = _results[i]
        local p = r.provider
        local ghost = _AcquireGhost(container, i)
        ghost:SetSize(size, size)
        ghost._result = r
        ghost._isPreview = isEditMode or false

        ghost:ClearAllPoints()
        local col = i - 1
        if growDir == "RIGHT" then
            ghost:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", col * (size + gap), 0)
        elseif growDir == "LEFT" then
            ghost:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -(col * (size + gap)), 0)
        elseif growDir == "UP" then
            ghost:SetPoint("BOTTOMLEFT", container, "BOTTOMLEFT", 0, col * (size + gap))
        elseif growDir == "DOWN" then
            ghost:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -(col * (size + gap)))
        end

        if not p._cachedIcon then p._cachedIcon = _GetIcon(p.iconSpell) end
        ghost._tex:SetTexture(p._cachedIcon)
        ghost._tex:SetDesaturated(true)

        if isEditMode then
            ghost:SetAlpha(0.7)
            ghost._badge:SetColorTexture(0.3, 0.5, 0.9, 0.9)
            ghost._bang:SetText("?")
            ghost._timer:Hide()
        elseif r.state == "EXPIRING" then
            ghost:SetAlpha(0.65)
            ghost._badge:SetColorTexture(1.0, 0.6, 0.1, 0.9)
            ghost._bang:SetText("!")
            ghost._timer:SetText(_FormatTime(r.remaining))
            ghost._timer:Show()
        else
            ghost:SetAlpha(0.55)
            ghost._badge:SetColorTexture(0.85, 0.12, 0.12, 0.9)
            ghost._bang:SetText("!")
            ghost._timer:Hide()
        end
    end
end

-- Events
local _eventFrame = CreateFrame("Frame")
_eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
_eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
_eventFrame:SetScript("OnEvent", function()
    _ScanRoster()
    if API.MarkDirty then API.MarkDirty("player") end
end)

if UnitExists("player") then _ScanRoster() end
