-- VaultBuddy.lua
-- Alt-wide Great Vault watcher with on-screen icon + minimap button + dropdown list.
-- Addon: VaultBuddy  v0.9.19
-- v0.9.19:
--   • FIX: Prevent progress reset (e.g. 2/9 → 0/9) when logging into a character with a READY vault.
--     Progress is now frozen until collection to handle Blizzard API returning empty data for ready vaults.
-- v0.9.18:
--   • FIX: Reset detection now uses API-based dynamic calculation instead of unreliable stored timestamps.
--     Characters are promoted if their last update was before the calculated previous reset time.
-- v0.9.17:
--   • FIX: Corrected WillHave → HasVault promotion logic. Now executes unconditionally in PLAYER_LOGIN before any UI/state logic.
--     Removed hasVault conditional from promotion loop, moved to guaranteed early execution path. Promotion now reliable and timer-free.

local ADDON_NAME = ...
local L = {} -- reserved

-- ============================================================================
-- Namespaces / State
-- ============================================================================
VB = _G.VB or {}
VB.cfg     = VB.cfg     or {}
VB.state   = VB.state   or { frames={}, lists={lines={}}, anim=nil }
VB.util    = VB.util    or {}
VB.weekly  = VB.weekly  or {}
VB.ui      = VB.ui      or {}
VB.minimap = VB.minimap or {}
VB.logic   = VB.logic   or {}
VB.options = VB.options or {}

-- ---------------------------------------------------------
-- Saved Vars
-- ---------------------------------------------------------
VaultBuddyDB = VaultBuddyDB or nil                   -- account-wide (chars + settings)
VaultBuddyCharacterDB = VaultBuddyCharacterDB or nil -- per-character (pos/size/lock)

-- ============================================================================
-- Config / Constants
-- ============================================================================
do
  local C = VB.cfg
  C.CONTENT_PAD    = 6

  C.CUSTOM_ICON_SINGLE = "Interface\\AddOns\\VaultBuddy\\media\\vaultbuddy_chest.png"
  C.ICON_BASE_FMT      = "Interface\\AddOns\\VaultBuddy\\media\\vaultbuddy_chest_%d.png"
  C.ICON_SIZES         = { 16, 32, 64, 128, 256, 512 }
  C.BLIZZ_ICON         = "Interface\\Icons\\INV_Misc_TreasureChest02"

  C.DEFAULT_SIZE   = 64
  C.MIN_SIZE       = 16
  C.MAX_SIZE       = 512
  C.DEFAULT_STRATA = "LOW"

  -- List config
  C.MIN_LIST_WIDTH = 260
  C.LIST_LINE_H    = 18
  C.LIST_PAD_X     = 10
  C.LIST_PAD_Y     = 8
  C.LIST_MAX_ROWS  = 10
  C.EDGE_MARGIN    = 8

  -- Scroll visuals
  C.SLIDER_W       = 10
  C.SLIDER_GAP     = 4
  C.FRAME_PAD      = 6

  -- Backplate colors (RGBA)
  C.BACK_COLORS = {
    current = { 0.18, 0.90, 0.42, 0.75 }, -- emerald
    others  = { 1.00, 0.79, 0.29, 0.72 }, -- warm gold
    none    = { 0.43, 0.46, 0.50, 0.55 }, -- slate grey
  }

  -- Minimap sizes (Blizzard norms)
  C.MINIMAP_BTN_SIZE  = 31
  C.MINIMAP_ICON_SIZE = 20
  C.MINIMAP_RING_SIZE = 54

  -- Default ring offset from ICON center (nudged via slash)
  C.MINIMAP_RING_DEFAULT_X = 10
  C.MINIMAP_RING_DEFAULT_Y = -10

  -- Status polling
  C.DEFAULT_STATUS_POLL_SEC = 90
end

VB.cfg.COLOR = VB.cfg.COLOR or {
  current={0.20,0.90,0.20,1},
  others ={0.95,0.78,0.18,1},
  none   ={0.35,0.35,0.35,1}
}

-- ============================================================================
-- Utilities / Debug
-- ============================================================================
function VB.util.dprint(...)
  if VaultBuddyDB and VaultBuddyDB.debug then
    print("|cff00d1ff[VaultBuddy]|r", ...)
  end
end

function VB.util.runDebugReport()
  local function bprint(...) print("|cff00d1ff[VaultBuddy Debug]|r", ...) end
  
  bprint("=== VAULTBUDDY DEBUG REPORT ===")
  
  -- Current character info
  local me = VB.util.playerFullName()
  local currentHas = VB.weekly.currentHasClaimableInDB()
  local currentWill = VB.weekly.currentWillHaveInDB()
  local claimable = VB.weekly.currentCharHasVaultClaimable()
  local willHave = VB.weekly.currentCharWillHaveAfterReset()
  
  bprint("Current Character:", me)
  bprint("  - HasVault in DB:", currentHas)
  bprint("  - WillHave in DB:", currentWill)
  bprint("  - Can claim now:", claimable)
  bprint("  - Will have after reset:", willHave)
  
  -- Reset timing
  local now = time()
  local secsUntil = (C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset and C_DateAndTime.GetSecondsUntilWeeklyReset()) or nil
  
  bprint("Reset Timing (Dynamic):")
  bprint("  - Current time:", now)
  bprint("  - Secs until reset:", secsUntil or "nil")
  
  local prevResetTime = nil
  if secsUntil then
    if secsUntil <= 0 then prevResetTime = now else prevResetTime = (now + secsUntil) - 604800 end
    bprint("  - Calc Prev Reset:", prevResetTime, date("%c", prevResetTime))
  else
    bprint("  - Calc Prev Reset: nil")
  end
  
  -- All characters in database
  bprint("Database Characters:")
  local hasVaultCount = 0
  local willHaveCount = 0
  for nameRealm, data in pairs(VaultBuddyDB.chars) do
    local status = ""
    if data.hasVault then
      status = status .. " [HAS VAULT]"
      hasVaultCount = hasVaultCount + 1
    end
    if data.willHave then
      status = status .. " [WILL HAVE]"
      willHaveCount = willHaveCount + 1
    end
    if status == "" then status = " [NONE]" end
    bprint("  -", nameRealm .. ":" .. status)
  end
  bprint("  - Total with vaults:", hasVaultCount)
  bprint("  - Total will have:", willHaveCount)
  
  -- Icon state logic
  local anyAltHas = VB.weekly.anyAltHasClaimable()
  local anyAltWill = VB.weekly.anyAltWillHave()
  bprint("Icon State Logic:")
  bprint("  - Current has vault:", currentHas)
  bprint("  - Any alt has vault:", anyAltHas)
  bprint("  - Current will have:", currentWill)
  bprint("  - Any alt will have:", anyAltWill)
  
  -- Auto-flip check
  bprint("Auto-Flip Check:")
  VB.weekly.autoFlipPreResetIfDue()
  
  bprint("=== END DEBUG REPORT ===")
end
function VB.util.info(...) print("|cff00d1ff[VaultBuddy]|r", ...) end

function VB.util.safe_toboolean(v)
  if type(v) == "boolean" then return v end
  if type(v) == "number" then return v ~= 0 end
  if type(v) == "string" then
    v = v:lower()
    return (v == "true" or v == "on" or v == "1" or v == "yes")
  end
  return false
end

function VB.util.clamp_quantize2(n)
  local C = VB.cfg
  n = tonumber(n) or C.DEFAULT_SIZE
  n = math.max(C.MIN_SIZE, math.min(C.MAX_SIZE, n))
  n = math.floor(n/2 + 0.5) * 2
  if n < C.MIN_SIZE then n = C.MIN_SIZE elseif n > C.MAX_SIZE then n = C.MAX_SIZE end
  return n
end

function VB.util.nearest_icon_size(n)
  local C = VB.cfg
  local nearest, best = C.ICON_SIZES[1], math.huge
  for _, s in ipairs(C.ICON_SIZES) do
    local diff = math.abs((n or C.DEFAULT_SIZE) - s)
    if diff < best then best = diff; nearest = s end
  end
  return nearest
end

function VB.util.playerFullName()
  local name, realm = UnitName("player"), GetRealmName()
  if not name or not realm then return "Unknown-Realm" end
  return name .. "-" .. realm
end
function VB.util.playerClassFile() local _, f = UnitClass("player"); return f or "PRIEST" end
function VB.util.classColoredName(nameRealm, classFileID)
  local c = RAID_CLASS_COLORS[classFileID or "PRIEST"] or NORMAL_FONT_COLOR
  return ("|c%s%s|r"):format(c.colorStr, nameRealm)
end

-- Tooltip helper – anchor at cursor-right to avoid covering mouse
function VB.util.AttachTooltip(frame, title, bodyLines)
  if not frame then return end
  frame:HookScript("OnEnter", function(self)
    GameTooltip:SetOwner(self, "ANCHOR_CURSOR_RIGHT")
    if title then GameTooltip:AddLine(title, 1, 0.82, 0) end
    if type(bodyLines) == "table" then
      for _, ln in ipairs(bodyLines) do GameTooltip:AddLine(ln, .9, .9, .9, true) end
    elseif type(bodyLines) == "string" then
      GameTooltip:AddLine(bodyLines, .9, .9, .9, true)
    end
    GameTooltip:Show()
  end)
  frame:HookScript("OnLeave", function() GameTooltip:Hide() end)
end

function VB.util.ExpandCheckHitToLabel(checkButton, labelFS)
  if not (checkButton and labelFS) then return end
  local tw = labelFS:GetStringWidth() or 0
  checkButton:SetHitRectInsets(0, -math.floor(tw + 10), 0, 0)
end

-- ---------------------------------------------------------
-- DB init
-- ---------------------------------------------------------
function VB.util.ensureDB()
  VaultBuddyDB = VaultBuddyDB or {}
  VaultBuddyDB.chars = VaultBuddyDB.chars or {}
  VaultBuddyDB.settings = VaultBuddyDB.settings or {}
  local s = VaultBuddyDB.settings
  if s.alwaysShow == nil   then s.alwaysShow = false end
  if s.hideInCombat == nil then s.hideInCombat = true end
  if s.preResetShow == nil then s.preResetShow = true end
  if s.pulseEnabled == nil then s.pulseEnabled = true end
  s.minimap = s.minimap or { hide=false, angle=140, ringNudgeX=0, ringNudgeY=0 }
  s.statusPoll = s.statusPoll or VB.cfg.DEFAULT_STATUS_POLL_SEC
  VaultBuddyDB.debug = VaultBuddyDB.debug or false
end
function VB.util.ensureCharDB()
  local C = VB.cfg
  VaultBuddyCharacterDB = VaultBuddyCharacterDB or {}
  if type(VaultBuddyCharacterDB.pos) ~= "table" then
    VaultBuddyCharacterDB.pos = { "CENTER", "CENTER", 0, 0 }
  end
  if type(VaultBuddyCharacterDB.size) ~= "number" then
    VaultBuddyCharacterDB.size = C.DEFAULT_SIZE
  end
  VaultBuddyCharacterDB.size = VB.util.clamp_quantize2(VaultBuddyCharacterDB.size)
  if type(VaultBuddyCharacterDB.locked) ~= "boolean" then
    VaultBuddyCharacterDB.locked = false
  end
end

-- ---------------------------------------------------------
-- Blizzard UI helpers
-- ---------------------------------------------------------
function VB.util.IsBlizzAddonLoaded(name)
  if C_AddOns and C_AddOns.IsAddOnLoaded then return C_AddOns.IsAddOnLoaded(name) end
  if type(IsAddOnLoaded) == "function" then return IsAddOnLoaded(name) end
  if name == "Blizzard_WeeklyRewards" then return _G.WeeklyRewardsFrame ~= nil end
  return false
end
function VB.util.SafeLoadBlizzAddon(name)
  if type(UIParentLoadAddOn) == "function" then return UIParentLoadAddOn(name) and true or false end
  if C_AddOns and C_AddOns.LoadAddOn then return C_AddOns.LoadAddOn(name) and true or false end
  if type(LoadAddOn) == "function" then return LoadAddOn(name) and true or false end
  return false
end

-- ============================================================================
-- Weekly info helpers
-- ============================================================================
function VB.weekly.RequestWeeklyInfo()
  if not C_WeeklyRewards then return end
  if C_WeeklyRewards.RequestInfo then C_WeeklyRewards.RequestInfo() end
  if C_WeeklyRewards.RequestRewardInfo then C_WeeklyRewards.RequestRewardInfo() end
end

local function getWeeklyTypeIds()
  local ids = {}
  if Enum and Enum.WeeklyRewardChestThresholdType then
    for name, val in pairs(Enum.WeeklyRewardChestThresholdType) do
      if type(val) == "number" then
        local n = tostring(name)
        if n ~= "None" and n ~= "AlsoReceive" and n ~= "Concession" then
          if n:find("Mythic") or n:find("Raid") or n:find("PvP") or n:find("World") or n:find("Activities") then
            table.insert(ids, val)
          end
        end
      end
    end
  end
  if #ids == 0 then ids = { 1, 2, 3 } end
  return ids
end

function VB.weekly.debugPrintActivities(tag, list)
  if not (VaultBuddyDB and VaultBuddyDB.debug) then return end
  if type(list) ~= "table" or #list == 0 then VB.util.dprint(tag, "no activities"); return end
  for i, a in ipairs(list) do
    local thr  = a.threshold or a.required or 0
    local prog = a.progress or a.progressAmount or 0
    local lvl  = a.level or a.activityLevel or 0
    VB.util.dprint(tag, ("#%d: progress=%s / threshold=%s level=%s id=%s type=%s"):format(
      i, tostring(prog), tostring(thr), tostring(lvl), tostring(a.id), tostring(a.type)))
  end
end

function VB.weekly.collectActivities()
  if not (C_WeeklyRewards and C_WeeklyRewards.GetActivities) then return nil, "none" end
  local okFlat, flat = pcall(C_WeeklyRewards.GetActivities)
  if okFlat and type(flat) == "table" and #flat > 0 then
    VB.weekly.debugPrintActivities("Activities(flat)", flat)
    return flat, "flat"
  end
  local acc = {}
  for _, tid in ipairs(getWeeklyTypeIds()) do
    local ok, list = pcall(C_WeeklyRewards.GetActivities, tid)
    if ok and type(list) == "table" and #list > 0 then
      VB.weekly.debugPrintActivities(("Activities(type=%s)"):format(tostring(tid)), list)
      for _, a in ipairs(list) do table.insert(acc, a) end
    end
  end
  if #acc > 0 then return acc, "typed" end
  VB.util.dprint("GetActivities had no data; may still be populating.")
  return nil, "none"
end

local function anyActivityThresholdMet(list)
  if not list then return false end
  for _, a in ipairs(list) do
    local thr = a and (a.threshold or a.required) or 0
    local prog = a and (a.progress or a.progressAmount) or 0
    if thr and prog and thr > 0 and prog >= thr then return true end
  end
  return false
end

local function mythicPlusFallbackWillHave()
  if not (C_MythicPlus and C_MythicPlus.GetRunHistory) then return false end
  local ok, runs = pcall(C_MythicPlus.GetRunHistory, false, true)
  if ok and type(runs) == "table" and #runs > 0 then
    VB.util.dprint("M+ fallback: run history count =", #runs)
    return true
  end
  return false
end

function VB.weekly.currentCharHasVaultClaimable()
  local hasAvailable = C_WeeklyRewards and C_WeeklyRewards.HasAvailableRewards and C_WeeklyRewards.HasAvailableRewards() or false
  local canClaim     = C_WeeklyRewards and C_WeeklyRewards.CanClaimRewards and C_WeeklyRewards.CanClaimRewards() or false
  VB.util.dprint("Claimable check: HasAvailable=", hasAvailable, " CanClaim=", canClaim)
  
  -- Trust explicit can-claim
  if canClaim then return true end
  
  -- For HasAvailable without CanClaim, we need to distinguish between:
  -- 1. Current expansion vaults (legitimate)
  -- 2. Legacy expansion vaults (false positives)
  -- 
  -- Current expansion typically has activities with non-zero thresholds,
  -- while legacy expansions may have empty or zero-threshold activities
  if hasAvailable then
    local list = select(1, VB.weekly.collectActivities())
    if not list or #list == 0 then
      -- No activities = likely legacy expansion false positive
      VB.util.dprint("HasAvailable but no activities - likely legacy expansion")
      return false
    end
    
    -- Check if we have any activities with meaningful thresholds (current expansion)
    local hasValidActivities = false
    for _, a in ipairs(list) do
      local threshold = a and (a.threshold or a.required) or 0
      if threshold > 0 then
        hasValidActivities = true
        break
      end
    end
    
    if hasValidActivities then
      -- Current expansion with valid activities - trust HasAvailable
      return true
    else
      -- All activities have zero thresholds - likely legacy expansion
      VB.util.dprint("HasAvailable but all activities have zero thresholds - likely legacy expansion")
      return false
    end
  end
  
  return false
end

function VB.weekly.currentCharWillHaveAfterReset()
  local list, mode = VB.weekly.collectActivities()
  if list and anyActivityThresholdMet(list) then
    VB.util.dprint("Pre-reset via activities (mode:", mode, ") -> true")
    return true
  end
  if mythicPlusFallbackWillHave() then
    VB.util.dprint("Pre-reset via Mythic+ fallback -> true")
    return true
  end
  VB.util.dprint("Pre-reset -> false")
  return false
end

function VB.weekly.setCharVaultStatus(nameRealm, hasClaimable, willHave, classFileID, activitySummary)
  VaultBuddyDB.chars[nameRealm] = VaultBuddyDB.chars[nameRealm] or {}
  local e = VaultBuddyDB.chars[nameRealm]
  e.hasVault  = not not hasClaimable
  e.willHave  = not not willHave
  e.classFile = classFileID or e.classFile or "PRIEST"
  e.activitySummary = activitySummary
  e.lastUpdate = time()
end
function VB.weekly.anyAltHasClaimable() 
  local count = 0
  local now = time()
  local secsUntilReset = (C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset and C_DateAndTime.GetSecondsUntilWeeklyReset()) or nil
  
  -- Calculate timestamp of the *previous* weekly reset.
  -- Next reset is (now + secsUntilReset).
  -- Previous reset was (now + secsUntilReset) - 7 days.
  local prevResetTime = nil
  if secsUntilReset then
    if secsUntilReset <= 0 then 
       prevResetTime = now 
    else
       prevResetTime = (now + secsUntilReset) - 604800
    end
  end

  for nameRealm, d in pairs(VaultBuddyDB.chars) do 
    if d.hasVault then 
      count = count + 1
      VB.util.dprint("anyAltHasClaimable: found", nameRealm, "with hasVault=true")
    elseif d.willHave then
      -- Check if this character's state is from *before* the last reset.
      if prevResetTime and (d.lastUpdate or 0) < prevResetTime then
        count = count + 1
        VB.util.dprint("anyAltHasClaimable: found", nameRealm, "with willHave=true (post-reset verified via API)")
      end
    end 
  end 
  VB.util.dprint("anyAltHasClaimable: total count =", count)
  return count > 0
end
function VB.weekly.anyAltWillHave() 
  local count = 0
  for nameRealm, d in pairs(VaultBuddyDB.chars) do 
    if d.willHave then 
      count = count + 1
      VB.util.dprint("anyAltWillHave: found", nameRealm, "with willHave=true")
    end 
  end 
  VB.util.dprint("anyAltWillHave: total count =", count)
  return count > 0
end
function VB.weekly.currentHasClaimableInDB() local e=VaultBuddyDB.chars[VB.util.playerFullName()] return e and e.hasVault or false end
function VB.weekly.currentWillHaveInDB() local e=VaultBuddyDB.chars[VB.util.playerFullName()] return e and e.willHave or false end

-- Weekly reset timestamp is now dynamic via C_DateAndTime; storage deprecated.
function VB.weekly.computeAndStoreNextWeeklyAbs()
  -- Deprecated: Logic now relies on C_DateAndTime.GetSecondsUntilWeeklyReset() directly.
end

function VB.weekly.autoFlipPreResetIfDue()
  local now = time()
  local secsUntilReset = (C_DateAndTime and C_DateAndTime.GetSecondsUntilWeeklyReset and C_DateAndTime.GetSecondsUntilWeeklyReset()) or nil
  
  if not secsUntilReset then
    VB.util.dprint("Auto-flip: Pre-reset check skipped (no API data)")
    return
  end

  -- Determine the timestamp of the previous weekly reset
  local prevResetTime = 0
  if secsUntilReset <= 0 then
    prevResetTime = now
  else
    prevResetTime = (now + secsUntilReset) - 604800
  end

  VB.util.dprint("Auto-flip: Checking for characters to promote. PrevResetTime:", prevResetTime)

  local flipped = 0
  for nameRealm, v in pairs(VaultBuddyDB.chars) do
    if v.willHave then
      -- If the character was LAST UPDATED before the PREVIOUS RESET, promote it.
      local lastUp = v.lastUpdate or 0
      if lastUp < prevResetTime then
        v.hasVault = true
        v.willHave = false
        v.lastUpdate = now
        flipped = flipped + 1
        VB.util.dprint("Auto-flipped", nameRealm, "from willHave to hasVault")
      end
    end
  end

  if flipped > 0 then
    VB.util.dprint("Auto-flip: flipped", flipped, "characters from willHave to hasVault")
  end
end
-- ============================================================================
-- UI helpers (icon visuals, border, grip/badge, size/position)
-- ============================================================================
function VB.ui.setBackplate(state)
  local f, C = VB.state.frames, VB.cfg
  if not f.plateTex then return end
  local c = (C.BACK_COLORS[state] or C.BACK_COLORS.none)
  f.plateTex:SetColorTexture(c[1], c[2], c[3], c[4])
end

function VB.ui.setChestTexture()
  local f, C = VB.state.frames, VB.cfg
  if not f.iconTex then return false end
  local wz = VB.util.clamp_quantize2(VaultBuddyCharacterDB and VaultBuddyCharacterDB.size or C.DEFAULT_SIZE)
  local pick = VB.util.nearest_icon_size(wz)
  local path = C.ICON_BASE_FMT:format(pick)
  f.iconTex:SetTexture(path)
  local used = f.iconTex:GetTexture()
  if not used or used == "" then
    VB.util.dprint(("Icon pick failed for %s; trying single-file asset."):format(path))
    f.iconTex:SetTexture(C.CUSTOM_ICON_SINGLE)
    used = f.iconTex:GetTexture()
  else
    VB.util.dprint(("Icon pick: savedSize=%d -> nearest=%d -> %s"):format(wz, pick, tostring(path)))
  end
  if not used or used == "" then
    VB.util.dprint("Using Blizzard icon fallback.")
    f.iconTex:SetTexture(C.BLIZZ_ICON)
    used = f.iconTex:GetTexture()
  end
  return used and true or false
end

function VB.ui.useIconWithState(state)
  local f = VB.state.frames
  if not VB.ui.setChestTexture() then return false end
  if f.iconTex.SetDesaturated then f.iconTex:SetDesaturated(false) end
  f.iconTex:SetAlpha(1)
  if state=="current" or state=="others" then
    f.iconTex:SetVertexColor(1,1,1,1)
  else
    if f.iconTex.SetDesaturated then f.iconTex:SetDesaturated(true) end
    f.iconTex:SetAlpha(0.65)
    f.iconTex:SetVertexColor(1,1,1,1)
  end
  f.iconTex:Show()
  return true
end

local function showProgrammaticForState(state)
  VB.state._prog = VB.state._prog or {
    body = VB.state.frames.contentFrame and VB.state.frames.contentFrame:CreateTexture(nil, "OVERLAY"),
  }
  local base = VB.cfg.COLOR[state] or VB.cfg.COLOR.none
  local p = VB.state._prog.body
  if p then
    p:SetAllPoints(VB.state.frames.contentFrame)
    p:SetColorTexture(base[1],base[2],base[3],1)
    p:Show()
  end
  if VB.state.frames.iconTex then VB.state.frames.iconTex:Hide() end
end

function VB.ui.setIconVisual(state)
  VB.ui.setBackplate(state)
  if not VB.ui.useIconWithState(state) then
    showProgrammaticForState(state)
  end
end

function VB.ui.refreshBorder()
  local f = VB.state.frames
  if not (f.borderFrame and f.mainButton) then return end
  f.borderFrame:ClearAllPoints()
  f.borderFrame:SetPoint("TOPLEFT", f.mainButton, "TOPLEFT", 0, 0)
  f.borderFrame:SetPoint("BOTTOMRIGHT", f.mainButton, "BOTTOMRIGHT", 0, 0)
  if f.mainButton:IsShown() then f.borderFrame:Show() else f.borderFrame:Hide() end
end

local function gripSizeForIcon()
  local C = VB.cfg
  local sz = math.max(C.MIN_SIZE, tonumber(VaultBuddyCharacterDB.size) or C.DEFAULT_SIZE)
  return math.max(14, math.min(28, math.floor(sz*0.22 + 0.5)))
end
function VB.ui.layoutGrip()
  local f = VB.state.frames
  if not f.sizeGrip or not f.mainButton then return end
  local g = gripSizeForIcon()
  f.sizeGrip:SetSize(g, g)
  f.sizeGrip:ClearAllPoints()
  f.sizeGrip:SetPoint("BOTTOMRIGHT", f.mainButton, "BOTTOMRIGHT", -1, 1)
end

-- ============================================================================
-- Size/position updates (cont.)
-- ============================================================================
function VB.ui.updateButtonSize()
  local f = VB.state.frames
  if not f.mainButton or not f.contentFrame then return end
  local sz = VB.util.clamp_quantize2(tonumber(VaultBuddyCharacterDB.size) or VB.cfg.DEFAULT_SIZE)
  VaultBuddyCharacterDB.size = sz
  f.mainButton:SetSize(sz, sz)
  f.contentFrame:ClearAllPoints()
  f.contentFrame:SetPoint("TOPLEFT", f.mainButton, "TOPLEFT", VB.cfg.CONTENT_PAD, -VB.cfg.CONTENT_PAD)
  f.contentFrame:SetPoint("BOTTOMRIGHT", f.mainButton, "BOTTOMRIGHT", -VB.cfg.CONTENT_PAD, VB.cfg.CONTENT_PAD)
  if f.plateTex   then f.plateTex:SetAllPoints(f.contentFrame) end
  if f.iconTex    then f.iconTex:SetAllPoints(f.contentFrame) end
  if f.bgDebugTex then f.bgDebugTex:SetAllPoints(f.contentFrame) end
  VB.ui.layoutGrip()
  VB.ui.refreshBorder()
  VB.ui.setChestTexture()
end

function VB.ui.updateButtonPosition()
  local f = VB.state.frames
  if not f.mainButton then return end
  local p = VaultBuddyCharacterDB.pos
  f.mainButton:ClearAllPoints()
  if p and p[1] and p[2] and p[3] and p[4] then
    f.mainButton:SetPoint(p[1], UIParent, p[2], p[3], p[4])
  else
    f.mainButton:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
  end
end

-- ============================================================================
-- List helpers & UI
-- ============================================================================
local function frameRectInUI(frame)
  local s = frame:GetEffectiveScale() or 1
  local u = UIParent:GetEffectiveScale() or 1
  local l, r, t, b = frame:GetLeft(), frame:GetRight(), frame:GetTop(), frame:GetBottom()
  if not (l and r and t and b) then return 0,0,0,0 end
  local k = s / u
  return l*k, r*k, t*k, b*k
end

local function gatherListEntries()
  local entries = {}
  for nameRealm, data in pairs(VaultBuddyDB.chars) do
    if data.hasVault or (VaultBuddyDB.settings.preResetShow and data.willHave) then
      table.insert(entries, {
        nameRealm = nameRealm,
        classFile = data.classFile or "PRIEST",
        has  = data.hasVault,
        will = data.willHave,
        activitySummary = data.activitySummary
      })
    end
  end
  table.sort(entries, function(a,b)
    local ak = (a.has and 0) or (a.will and 1) or 2
    local bk = (b.has and 0) or (b.will and 1) or 2
    return (ak ~= bk) and (ak < bk) or (a.nameRealm < b.nameRealm)
  end)
  return entries
end

local function placeListFrameAround(anchor, listFrame, viewH)
  local screenW, screenH = UIParent:GetWidth(), UIParent:GetHeight()
  local aL, aR, aT, aB = frameRectInUI(anchor)
  local lw, lh = listFrame:GetWidth() or 0, listFrame:GetHeight() or viewH
  local aCX = (aL + aR) / 2
  local desiredLeft  = aCX - lw/2
  local desiredRight = aCX + lw/2
  local xLeft
  if desiredLeft < VB.cfg.EDGE_MARGIN then
    xLeft = VB.cfg.EDGE_MARGIN
  elseif desiredRight > (screenW - VB.cfg.EDGE_MARGIN) then
    xLeft = screenW - lw - VB.cfg.EDGE_MARGIN
  else
    xLeft = desiredLeft
  end
  local spaceDown = aB
  local spaceUp   = screenH - aT
  listFrame:ClearAllPoints()
  if spaceDown >= (lh + VB.cfg.EDGE_MARGIN) then
    local yTop = math.max(lh + VB.cfg.EDGE_MARGIN, aB - 6)
    listFrame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", xLeft, yTop)
  else
    local yBottom = math.min(screenH - lh - VB.cfg.EDGE_MARGIN, aT + 6)
    listFrame:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", xLeft, yBottom)
  end
end
local function showListEntries(entries)
  local f = VB.state.frames
  if not (f.listFrame and f.listScroll and f.listContent) then return end
  for i=1,#VB.state.lists.lines do
    if VB.state.lists.lines[i] then VB.state.lists.lines[i]:Hide() end
  end

  local measure = f.measureFS
  local maxTextW = 0
  for _, e in ipairs(entries) do
    local txt = VB.util.classColoredName(e.nameRealm, e.classFile)
    if e.has then
      txt = txt .. " |cff00ff00[ready]|r"
    elseif e.will then
      txt = txt .. " |cffffff00[next reset]|r"
    end
    if e.activitySummary and e.activitySummary ~= "" then
      txt = txt .. " |cffaaaaaa("..e.activitySummary..")|r"
    end
    if measure then
      measure:SetText(txt)
      local w = measure:GetStringWidth() or 0
      if w > maxTextW then maxTextW = w end
    end
  end

  local C = VB.cfg
  local minInnerNoSlider = math.max(0, C.MIN_LIST_WIDTH - C.FRAME_PAD*2)
  local innerW           = math.max(minInnerNoSlider, maxTextW + C.LIST_PAD_X*2)
  local total = #entries
  local contentH = C.LIST_PAD_Y*2 + math.max(1,total)*C.LIST_LINE_H
  local viewH    = C.LIST_PAD_Y*2 + math.min(C.LIST_MAX_ROWS, math.max(1,total))*C.LIST_LINE_H
  local needsScroll = contentH > viewH + 0.5
  local frameW      = innerW + C.FRAME_PAD*2 + (needsScroll and (C.SLIDER_W + C.SLIDER_GAP) or 0)

  f.listContent:SetWidth(innerW)
  f.listContent:SetHeight(contentH)
  f.listFrame:SetSize(frameW, viewH + C.FRAME_PAD*2)

  -- show/hide slider
  f.listScroll:ClearAllPoints()
  if needsScroll then
    f.listScroll:SetPoint("TOPLEFT",     f.listFrame, "TOPLEFT", C.FRAME_PAD, -C.FRAME_PAD)
    f.listScroll:SetPoint("BOTTOMRIGHT", f.listFrame, "BOTTOMRIGHT", -(C.FRAME_PAD + C.SLIDER_W + C.SLIDER_GAP), C.FRAME_PAD)
    f.listSlider:Show()
    local maxScroll = math.max(0, math.floor(contentH - viewH + 0.5))
    f.listSlider:SetMinMaxValues(0, maxScroll)
    f.listSlider:SetValueStep(C.LIST_LINE_H)
    local cur = f.listSlider:GetValue() or 0
    if cur > maxScroll then cur = maxScroll end
    f.listSlider:SetValue(cur)
  else
    f.listScroll:SetPoint("TOPLEFT",     f.listFrame, "TOPLEFT", C.FRAME_PAD, -C.FRAME_PAD)
    f.listScroll:SetPoint("BOTTOMRIGHT", f.listFrame, "BOTTOMRIGHT", -C.FRAME_PAD, C.FRAME_PAD)
    f.listSlider:Hide()
    f.listScroll:SetVerticalScroll(0)
  end
  f.listFrame:EnableMouseWheel(needsScroll)

  -- populate
  for i, e in ipairs(entries) do
    if not VB.state.lists.lines[i] then
      VB.state.lists.lines[i] = f.listContent:CreateFontString(nil,"ARTWORK","GameFontNormal")
    end
    local fs = VB.state.lists.lines[i]
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", f.listContent, "TOPLEFT", C.LIST_PAD_X, -C.LIST_PAD_Y - (i-1)*C.LIST_LINE_H)
    local suffix = e.has and " |cff00ff00[ready]|r" or (e.will and " |cffffff00[next reset]|r" or "")
    local extra  = (e.activitySummary and e.activitySummary ~= "") and (" |cffaaaaaa("..e.activitySummary..")|r") or ""
    fs:SetText(VB.util.classColoredName(e.nameRealm, e.classFile)..suffix..extra)
    fs:Show()
  end

  placeListFrameAround(f.listAnchorFrame or f.mainButton, f.listFrame, viewH)
  f.listFrame:Show()
end

function VB.ui.ToggleList(anchorFrame)
  local f = VB.state.frames
  if not f.listFrame then return end
  f.listAnchorFrame = anchorFrame or f.mainButton
  if f.listFrame:IsShown() then f.listFrame:Hide(); return end

  local entries = gatherListEntries()
  if #entries == 0 then
    local msg = "|cffaaaaaaNo characters have a vault ready or pre-reset completed.|r"
    local measure = f.measureFS
    local textW = 0
    if measure then measure:SetText(msg); textW = measure:GetStringWidth() or 0 end
    local innerW   = math.max(0, VB.cfg.MIN_LIST_WIDTH - VB.cfg.FRAME_PAD*2)
    innerW         = math.max(innerW, textW + VB.cfg.LIST_PAD_X*2)
    local contentH = VB.cfg.LIST_PAD_Y*2 + VB.cfg.LIST_LINE_H
    local viewH    = contentH
    f.listContent:SetWidth(innerW); f.listContent:SetHeight(contentH)
    f.listFrame:SetSize(innerW + VB.cfg.FRAME_PAD*2, viewH + VB.cfg.FRAME_PAD*2)
    f.listScroll:ClearAllPoints()
    f.listScroll:SetPoint("TOPLEFT",     f.listFrame, "TOPLEFT", VB.cfg.FRAME_PAD, -VB.cfg.FRAME_PAD)
    f.listScroll:SetPoint("BOTTOMRIGHT", f.listFrame, "BOTTOMRIGHT", -VB.cfg.FRAME_PAD, VB.cfg.FRAME_PAD)
    f.listSlider:Hide(); f.listScroll:SetVerticalScroll(0)
    if not VB.state.lists.lines[1] then
      VB.state.lists.lines[1] = f.listContent:CreateFontString(nil,"ARTWORK","GameFontNormal")
    end
    local fs = VB.state.lists.lines[1]
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", f.listContent, "TOPLEFT", VB.cfg.LIST_PAD_X, -VB.cfg.LIST_PAD_Y)
    fs:SetText(msg); fs:Show()
    placeListFrameAround(f.listAnchorFrame or f.mainButton, f.listFrame, viewH)
    f.listFrame:Show()
    return
  end

  showListEntries(entries)
end

function VB.ui.BuildListFrame()
  local f = VB.state.frames
  if f.listFrame then return end

  local lf = CreateFrame("Frame", "VaultBuddyListFrame", UIParent, "BackdropTemplate")
  lf:SetFrameStrata("DIALOG")
  lf:SetBackdrop({ bgFile="Interface\\DialogFrame\\UI-DialogBox-Background", edgeFile="Interface\\Tooltips\\UI-Tooltip-Border",
      tile=true, tileSize=16, edgeSize=16, insets={ left=4, right=4, top=4, bottom=4 }})
  lf:SetClampedToScreen(true); lf:EnableMouse(true); lf:EnableMouseWheel(false); lf:Hide()
  if UISpecialFrames then table.insert(UISpecialFrames, "VaultBuddyListFrame") end

  local scroll = CreateFrame("ScrollFrame", "VaultBuddyListScroll", lf)
  scroll:SetPoint("TOPLEFT", lf, "TOPLEFT", VB.cfg.FRAME_PAD, -VB.cfg.FRAME_PAD)
  scroll:SetPoint("BOTTOMRIGHT", lf, "BOTTOMRIGHT", -VB.cfg.FRAME_PAD, VB.cfg.FRAME_PAD)

  local content = CreateFrame("Frame", nil, scroll)
  content:SetSize(VB.cfg.MIN_LIST_WIDTH - (VB.cfg.FRAME_PAD*2), VB.cfg.LIST_PAD_Y*2 + VB.cfg.LIST_LINE_H)
  scroll:SetScrollChild(content)

  local slider = CreateFrame("Slider", nil, lf)
  slider:SetOrientation("VERTICAL"); slider:SetWidth(VB.cfg.SLIDER_W)
  slider:SetMinMaxValues(0, 0); slider:SetValueStep(VB.cfg.LIST_LINE_H); slider:SetValue(0)
  slider:SetPoint("TOPRIGHT", lf, "TOPRIGHT", -VB.cfg.FRAME_PAD, -VB.cfg.FRAME_PAD)
  slider:SetPoint("BOTTOMRIGHT", lf, "BOTTOMRIGHT", -VB.cfg.FRAME_PAD, VB.cfg.FRAME_PAD)
  local track = slider:CreateTexture(nil, "BACKGROUND")
  track:SetColorTexture(0,0,0,0.35)
  track:SetPoint("TOPLEFT", slider, "TOPLEFT", 1, -1)
  track:SetPoint("BOTTOMRIGHT", slider, "BOTTOMRIGHT", -1, 1)
  local thumb = slider:CreateTexture(nil, "ARTWORK")
  thumb:SetColorTexture(0.85, 0.85, 0.85, 1)
  thumb:SetSize(VB.cfg.SLIDER_W, 24)
  slider:SetThumbTexture(thumb)
  slider:Hide()
  slider:SetScript("OnValueChanged", function(_, val) scroll:SetVerticalScroll(val or 0) end)
  lf:SetScript("OnMouseWheel", function(_, delta)
    if not (slider and slider:IsShown()) then return end
    local step = VB.cfg.LIST_LINE_H * 2
    local cur  = slider:GetValue() or 0
    local minV, maxV = slider:GetMinMaxValues()
    local newV = cur - (delta * step)
    if newV < minV then newV = minV elseif newV > maxV then newV = maxV end
    slider:SetValue(newV)
  end)

  local measureFS = lf:CreateFontString(nil, "OVERLAY", "GameFontNormal"); measureFS:Hide()

  f.listFrame   = lf
  f.listScroll  = scroll
  f.listContent = content
  f.listSlider  = slider
  f.listSliderTrack = track
  f.listSliderThumb = thumb
  f.measureFS   = measureFS
end
-- ============================================================================
-- Main on-screen button
-- ============================================================================
function VB.ui.BuildMainButton()
  local f = VB.state.frames
  if f.mainButton and f.mainButton:GetParent() then f.mainButton:Hide() end

  local btn = CreateFrame("Button", "VaultBuddyButton", UIParent, "BackdropTemplate")
  btn:SetClampedToScreen(true)
  btn:SetFrameStrata(VB.cfg.DEFAULT_STRATA)
  btn:SetFrameLevel(50)
  btn:SetIgnoreParentAlpha(true)
  btn:SetIgnoreParentScale(true)
  btn:EnableMouse(true)
  btn:RegisterForClicks("AnyUp")
  btn:SetHitRectInsets(0,0,0,0)
  btn:SetMovable(true)
  btn:SetResizable(true)
  if btn.SetResizeBounds then
    btn:SetResizeBounds(VB.cfg.MIN_SIZE, VB.cfg.MIN_SIZE, VB.cfg.MAX_SIZE, VB.cfg.MAX_SIZE)
  end

  local cf = CreateFrame("Frame", nil, btn)
  cf:SetFrameLevel(btn:GetFrameLevel()+5)
  cf:SetPoint("TOPLEFT", btn, "TOPLEFT", VB.cfg.CONTENT_PAD, -VB.cfg.CONTENT_PAD)
  cf:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -VB.cfg.CONTENT_PAD, VB.cfg.CONTENT_PAD)

  local plate = cf:CreateTexture(nil, "BACKGROUND")
  plate:SetAllPoints(cf)
  plate:SetColorTexture(0,0,0,0.6)

  local debugTex = cf:CreateTexture(nil, "BACKGROUND")
  debugTex:SetColorTexture(0,1,0,0.18)
  debugTex:Hide()

  local icon = cf:CreateTexture(nil, "ARTWORK")
  icon:SetAllPoints(cf)

  local badgeBg = cf:CreateTexture(nil, "OVERLAY")
  badgeBg:SetColorTexture(0,0,0,0.75)
  badgeBg:Hide()

  local badgeFS = cf:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
  badgeFS:SetJustifyH("CENTER")
  badgeFS:SetJustifyV("MIDDLE")
  badgeFS:SetText("")

  local border = CreateFrame("Frame", nil, btn, "BackdropTemplate")
  border:SetFrameStrata(VB.cfg.DEFAULT_STRATA)
  border:SetFrameLevel(100)
  border:SetBackdrop({
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    tile = true, tileSize = 16, edgeSize = 16,
    insets = {left=4,right=4,top=4,bottom=4}
  })
  border:SetBackdropBorderColor(1,1,1,1)
  border:EnableMouse(false)

  local grip = CreateFrame("Button", nil, btn)
  grip:SetFrameLevel(btn:GetFrameLevel()+200)
  grip:EnableMouse(true)
  grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
  grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
  grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
  -- Smooth resize: no quantize until mouse release
  grip:SetHitRectInsets(-2,-2,-2,-2)
  VB.util.AttachTooltip(grip, "Resize", {"Drag to resize the icon (unlock first)."})

  -- Main icon tooltip
  VB.util.AttachTooltip(btn, "VaultBuddy", {
    (VB.weekly.currentHasClaimableInDB() and "|cff00ff00This character has a Great Vault to open.|r")
      or (VB.weekly.anyAltHasClaimable() and "|cffffff00Other characters have a Great Vault to open.|r")
      or (VaultBuddyDB.settings.preResetShow and (VB.weekly.currentWillHaveInDB() or VB.weekly.anyAltWillHave())
          and "|cffffc000At least one character will have a reward after reset.|r")
      or "|cffff8080No characters have a Great Vault ready.|r",
    " ",
    "Left-click: show character list",
    "Right-click: options",
    "Drag icon to move (unlock first).",
    "Drag corner to resize (unlock first).",
  })

  grip:SetScript("OnMouseDown", function(_, btnName)
    if btnName ~= "LeftButton" or VaultBuddyCharacterDB.locked then return end
    VB.state.resizing = true
    btn:StopMovingOrSizing()
    btn:StartSizing("BOTTOMRIGHT")
  end)

  grip:SetScript("OnMouseUp", function(_, btnName)
    if btnName ~= "LeftButton" then return end
    if VB.state.resizing then
      VB.state.resizing = false
      btn:StopMovingOrSizing()
      local w = math.floor((btn:GetWidth() or VaultBuddyCharacterDB.size) + 0.5)
      VaultBuddyCharacterDB.size = VB.util.clamp_quantize2(w)
      VB.ui.updateButtonSize()
    end
  end)

  grip:SetScript("OnHide", function()
    if VB.state.resizing then
      VB.state.resizing = false
      btn:StopMovingOrSizing()
      local w = math.floor((btn:GetWidth() or VaultBuddyCharacterDB.size) + 0.5)
      VaultBuddyCharacterDB.size = VB.util.clamp_quantize2(w)
      VB.ui.updateButtonSize()
    end
  end)

  btn:SetScript("OnUpdate", function()
    if VB.state.resizing then
      local w = math.floor((btn:GetWidth() or VaultBuddyCharacterDB.size) + 0.5)
      if w ~= VaultBuddyCharacterDB.size then
        VaultBuddyCharacterDB.size = w
        VB.ui.updateButtonSize()
      end
    end
  end)

  local function toggleListFromMain()
    if InCombatLockdown() and VaultBuddyDB.settings.hideInCombat then return end
    VB.ui.ToggleList(btn)
  end

  btn:SetScript("OnMouseDown", function(self, btnName)
    if btnName == "RightButton" then return end
    if btnName ~= "LeftButton" then return end
    if (grip and grip:IsMouseOver()) or VB.state.resizing then return end
    VB.state.pressX, VB.state.pressY = GetCursorPosition()
    VB.state.dragging = false
    if not VaultBuddyCharacterDB.locked then
      VB.state.dragging = true
      self:StartMoving()
    end
  end)

  btn:SetScript("OnMouseUp", function(self, btnName)
    if btnName == "RightButton" then VB.options.Toggle(); return end
    if btnName ~= "LeftButton" then return end
    if VB.state.resizing or (grip and grip:IsMouseOver()) then return end
    local wasDragging = VB.state.dragging
    VB.state.dragging = false
    self:StopMovingOrSizing()
    local point, _, relativePoint, xOfs, yOfs = self:GetPoint(1)
    VaultBuddyCharacterDB.pos = { point or "CENTER", relativePoint or "CENTER", xOfs or 0, yOfs or 0 }
    local cx, cy = GetCursorPosition()
    local dx = (cx or 0) - (VB.state.pressX or 0)
    local dy = (cy or 0) - (VB.state.pressY or 0)
    if (not wasDragging) or (dx*dx + dy*dy) < 25 then toggleListFromMain() end
  end)

  btn:SetScript("OnShow", function() VB.ui.refreshBorder() end)
  btn:SetScript("OnHide", function()
    if VB.state.frames.borderFrame then VB.state.frames.borderFrame:Hide() end
    if VB.state.frames.listFrame and VB.state.frames.listFrame:IsShown() then VB.state.frames.listFrame:Hide() end
  end)

  btn:HookScript("OnSizeChanged", function()
    if not VB.state.resizing then
      local w = math.floor((btn:GetWidth() or VaultBuddyCharacterDB.size) + 0.5)
      if w ~= VaultBuddyCharacterDB.size then
        VaultBuddyCharacterDB.size = VB.util.clamp_quantize2(w)
      end
      VB.ui.updateButtonSize()
    end
  end)

  -- Save refs
  f.mainButton   = btn
  f.contentFrame = cf
  f.plateTex     = plate
  f.bgDebugTex   = debugTex
  f.iconTex      = icon
  f.badgeBg      = badgeBg
  f.badgeFS      = badgeFS
  f.borderFrame  = border
  f.sizeGrip     = grip

  VB.ui.layoutGrip()
  VB.ui.updateButtonSize()
  VB.ui.updateButtonPosition()
  VB.ui.setIconVisual("others")
  btn:Show()
end
-- ============================================================================
-- Minimap button (robust clicks + ring centered on icon)
-- ============================================================================
local function MinimapRingLayout()
  local f = VB.state.frames
  if not (f.mmbRing and f.mmbIcon) then return end
  local dx = (VaultBuddyDB.settings.minimap.ringNudgeX or 0) + (VB.cfg.MINIMAP_RING_DEFAULT_X or 0)
  local dy = (VaultBuddyDB.settings.minimap.ringNudgeY or 0) + (VB.cfg.MINIMAP_RING_DEFAULT_Y or 0)
  f.mmbRing:ClearAllPoints()
  f.mmbRing:SetSize(VB.cfg.MINIMAP_RING_SIZE, VB.cfg.MINIMAP_RING_SIZE)
  f.mmbRing:SetPoint("CENTER", f.mmbIcon, "CENTER", dx, dy)
  f.mmbRing:SetTexCoord(0,1,0,1)
  f.mmbRing:SetDrawLayer("OVERLAY", 1)
  f.mmbRing:Show()
end

local function MinimapUpdateAnchor()
  local f = VB.state.frames
  if not f.mmb then return end
  f.mmb:ClearAllPoints()
  f.mmb:SetPoint("CENTER", Minimap, "CENTER", 0, 0)
  local angle = (VaultBuddyDB.settings.minimap.angle or 140) % 360
  local rad = math.rad(angle)
  local radius = (Minimap:GetWidth() or 140) * 0.38
  f.mmb:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad)*radius, math.sin(rad)*radius)
  MinimapRingLayout()
end

-- Single handler on MouseUp only to avoid double toggles
local function MinimapMouseUp(self, button)
  if button == "RightButton" then
    VB.util.dprint("MMB: RightButtonUp -> Options.Toggle()")
    VB.options.Toggle()
  elseif button == "LeftButton" then
    VB.util.dprint("MMB: LeftButtonUp -> ToggleList()")
    VB.ui.ToggleList(self)
  else
    VB.util.dprint("MMB: Other button up -> ignored")
  end
end

-- Dragging to reposition around minimap
local function MinimapOnDrag(self)
  local mx, my = Minimap:GetCenter()
  local cx, cy = GetCursorPosition()
  local scale = UIParent:GetEffectiveScale()
  cx, cy = cx/scale, cy/scale
  local angle = math.deg(math.atan2(cy - my, cx - mx)) % 360
  VaultBuddyDB.settings.minimap.angle = angle
  MinimapUpdateAnchor()
end

local function MinimapBindClicks()
  local f = VB.state.frames
  if not f.mmb then return end
  f.mmb:EnableMouse(true)
  if f.mmb.RegisterForClicks then
    f.mmb:RegisterForClicks("LeftButtonUp", "RightButtonUp")
  end
  f.mmb:SetScript("OnClick", nil)
  f.mmb:SetScript("OnMouseUp", MinimapMouseUp)
  f.mmb:RegisterForDrag("LeftButton")
  f.mmb:SetScript("OnDragStart", function(self) self:SetScript("OnUpdate", MinimapOnDrag) end)
  f.mmb:SetScript("OnDragStop", function(self) self:SetScript("OnUpdate", nil) end)
end

local function MinimapScheduleRebinds()
  local delays = {0.10, 0.50, 2.00}
  for _, t in ipairs(delays) do
    C_Timer.After(t, function() MinimapBindClicks() end)
  end
end

function VB.minimap.Build()
  if VB.state.frames.mmb then return end

  local mmb = CreateFrame("Button", "VaultBuddy_MinimapButton", Minimap)
  mmb:SetSize(VB.cfg.MINIMAP_BTN_SIZE, VB.cfg.MINIMAP_BTN_SIZE)
  mmb:SetFrameStrata("HIGH")
  mmb:SetFrameLevel((Minimap:GetFrameLevel() or 5) + 10)
  mmb:SetIgnoreParentAlpha(true)
  mmb:SetIgnoreParentScale(true)
  mmb:EnableMouse(true)
  if mmb.RegisterForClicks then
    mmb:RegisterForClicks("LeftButtonUp", "RightButtonUp")
  end
  mmb:SetHitRectInsets(0,0,0,0)
  mmb:SetClampedToScreen(true)
  mmb:SetMovable(false)

  local icon = mmb:CreateTexture(nil, "ARTWORK")
  icon:SetSize(VB.cfg.MINIMAP_ICON_SIZE, VB.cfg.MINIMAP_ICON_SIZE)
  icon:SetPoint("CENTER", mmb, "CENTER", 0, 0)
  icon:SetTexture(VB.cfg.CUSTOM_ICON_SINGLE)
  icon:SetTexCoord(0.08,0.92,0.08,0.92)

  local ring = mmb:CreateTexture(nil, "OVERLAY")
  ring:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")

  VB.util.AttachTooltip(mmb, "VaultBuddy", {
    "Left-click: Toggle character list",
    "Right-click: Toggle Options",
    "Drag (left-hold): Move around minimap",
  })

  VB.state.frames.mmb     = mmb
  VB.state.frames.mmbIcon = icon
  VB.state.frames.mmbRing = ring

  mmb:HookScript("OnShow", function()
    MinimapBindClicks()
    MinimapScheduleRebinds()
  end)

  MinimapBindClicks()
  MinimapUpdateAnchor()
  MinimapRingLayout()
  MinimapScheduleRebinds()

  if VaultBuddyDB.settings.minimap and VaultBuddyDB.settings.minimap.hide then
    mmb:Hide()
  else
    mmb:Show()
  end
end
-- ============================================================================
-- Badge / pulse & visibility
-- ============================================================================
local function setBadgeCount(n)
  local f = VB.state.frames
  if not (f.badgeBg and f.badgeFS) then return end
  n = tonumber(n) or 0
  if n > 0 then
    f.badgeFS:SetText(n <= 99 and tostring(n) or "99+")
    f.badgeBg:Show()
    f.badgeFS:Show()
  else
    f.badgeBg:Hide()
    f.badgeFS:Hide()
  end
end

local function countReady()
  local c = 0
  for _, d in pairs(VaultBuddyDB.chars) do
    if d.hasVault then c = c + 1 end
  end
  return c
end

function VB.logic.updateBadgeAndPulse()
  local f = VB.state.frames
  local readyCount = countReady()
  setBadgeCount(readyCount)

  if not f.iconTex then return end
  if readyCount > 0 and VaultBuddyDB.settings.pulseEnabled then
    VB.state.anim = VB.state.anim or {}
    if not VB.state.anim.pulseAG then
      local ag=f.iconTex:CreateAnimationGroup()
      local a1=ag:CreateAnimation("Alpha"); a1:SetFromAlpha(1); a1:SetToAlpha(0.25); a1:SetDuration(0.4)
      local a2=ag:CreateAnimation("Alpha"); a2:SetFromAlpha(0.25); a2:SetToAlpha(1); a2:SetDuration(0.4)
      ag:SetLooping("BOUNCE")
      VB.state.anim.pulseAG = ag
    end
    if not VB.state.anim.pulseAG:IsPlaying() then VB.state.anim.pulseAG:Play() end
  else
    if VB.state.anim and VB.state.anim.pulseAG and VB.state.anim.pulseAG:IsPlaying() then
      VB.state.anim.pulseAG:Stop()
      f.iconTex:SetAlpha(1)
    end
  end
end

function VB.logic.anyShouldShow()
  if VB.weekly.anyAltHasClaimable() then return true end
  if VaultBuddyDB.settings.preResetShow and VB.weekly.anyAltWillHave() then return true end
  return false
end

function VB.logic.shouldShowButton()
  local inCombat = InCombatLockdown()
  if inCombat and VaultBuddyDB.settings.hideInCombat then return false end
  if VaultBuddyDB.settings.alwaysShow then return true end
  return VB.logic.anyShouldShow()
end

function VB.logic.applyIconStateAccordingToDB()
  local state
  local currentHas = VB.weekly.currentHasClaimableInDB()
  local anyAltHas = VB.weekly.anyAltHasClaimable()
  local currentWill = VB.weekly.currentWillHaveInDB()
  local anyAltWill = VB.weekly.anyAltWillHave()
  
  VB.util.dprint("applyIconState: currentHas=", currentHas, "anyAltHas=", anyAltHas, "currentWill=", currentWill, "anyAltWill=", anyAltWill)
  
  if currentHas then
    state="current"
  elseif anyAltHas or
         (VaultBuddyDB.settings.preResetShow and (currentWill or anyAltWill)) then
    state="others"
  else
    state="none"
  end
  VB.util.dprint("applyIconState: state=", state)
  VB.ui.setIconVisual(state)
  VB.ui.refreshBorder()
  if VB.logic.shouldShowButton() and VB.state.frames.mainButton then
    VB.state.frames.mainButton:Show()
  else
    if VB.state.frames.mainButton then VB.state.frames.mainButton:Hide() end
  end
  VB.logic.updateBadgeAndPulse()
  local lf = VB.state.frames.listFrame
  if lf and lf:IsShown() then
    local entries = (function()
      local out = {}
      for nameRealm, data in pairs(VaultBuddyDB.chars) do
        if data.hasVault or (VaultBuddyDB.settings.preResetShow and data.willHave) then
          table.insert(out, {
            nameRealm = nameRealm,
            classFile = data.classFile or "PRIEST",
            has  = data.hasVault,
            will = data.willHave,
            activitySummary = data.activitySummary
          })
        end
      end
      table.sort(out, function(a,b)
        local ak = (a.has and 0) or (a.will and 1) or 2
        local bk = (b.has and 0) or (b.will and 1) or 2
        return (ak ~= bk) and (ak < bk) or (a.nameRealm < b.nameRealm)
      end)
      return out
    end)()
    local f = VB.state.frames
    if f.listFrame and f.listScroll and f.listContent then
      for i=1,#VB.state.lists.lines do
        if VB.state.lists.lines[i] then VB.state.lists.lines[i]:Hide() end
      end
      for i, e in ipairs(entries) do
        if not VB.state.lists.lines[i] then
          VB.state.lists.lines[i] = f.listContent:CreateFontString(nil,"ARTWORK","GameFontNormal")
        end
        local fs = VB.state.lists.lines[i]
        fs:ClearAllPoints()
        fs:SetPoint("TOPLEFT", f.listContent, "TOPLEFT",
          VB.cfg.LIST_PAD_X, -VB.cfg.LIST_PAD_Y - (i-1)*VB.cfg.LIST_LINE_H)
        local suffix = e.has and " |cff00ff00[ready]|r" or
                       (e.will and " |cffffff00[next reset]|r" or "")
        local extra  = (e.activitySummary and e.activitySummary ~= "") and
                       (" |cffaaaaaa("..e.activitySummary..")|r") or ""
        fs:SetText(VB.util.classColoredName(e.nameRealm, e.classFile)..suffix..extra)
        fs:Show()
      end
    end
  end
end

-- ============================================================================
-- Options (toggle)
-- ============================================================================
VB.options = VB.options or {}

local function options_AddCheck(owner, y, label, getter, setter, tipTitle, tipBody)
  local cb = CreateFrame("CheckButton", nil, owner, "UICheckButtonTemplate")
  cb:SetSize(24, 24)
  cb:SetPoint("TOPLEFT", 16, y)
  local lbl = cb.Text or owner:CreateFontString(nil, "OVERLAY", "GameFontNormal")
  if not cb.Text then
    lbl:SetPoint("LEFT", cb, "RIGHT", 4, 0)
    lbl:SetText(label)
  else
    lbl:ClearAllPoints()
    lbl:SetPoint("LEFT", cb, "RIGHT", 4, 0)
    lbl:SetText(label)
  end
  cb:SetScript("OnClick", function(self) setter(self:GetChecked()) end)
  cb:SetScript("OnShow", function(self)
    self:SetChecked(getter())
    C_Timer.After(0,function() VB.util.ExpandCheckHitToLabel(cb, lbl) end)
  end)
  VB.util.AttachTooltip(cb, tipTitle or label, tipBody)
  return cb
end

local function options_AddButton(owner, x, y, w, text, onclick, tipTitle, tipBody)
  local b = CreateFrame("Button", nil, owner, "UIPanelButtonTemplate")
  b:SetPoint("TOPLEFT", x, y)
  b:SetSize(w, 22)
  b:SetText(text)
  b:SetScript("OnClick", onclick)
  VB.util.AttachTooltip(b, tipTitle or text, tipBody)
  return b
end

function VB.options.ApplyToUI()
  local s = VaultBuddyDB.settings
  local f = VB.state.frames
  if f.opt_chk_always     then f.opt_chk_always:SetChecked(s.alwaysShow) end
  if f.opt_chk_hideCombat then f.opt_chk_hideCombat:SetChecked(s.hideInCombat) end
  if f.opt_chk_preReset   then f.opt_chk_preReset:SetChecked(s.preResetShow) end
  -- Debug checkbox removed - now using debug button
end
local function BuildOptionsFrame()
  local f = VB.state.frames
  if f.optionsFrame then return end

  local of = CreateFrame("Frame", "VaultBuddyOptions", UIParent, "BackdropTemplate")
  of:SetSize(420, 238)
  of:SetFrameStrata("DIALOG")
  of:SetBackdrop({
      bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
      edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
      tile = true, tileSize = 16, edgeSize = 16,
      insets = { left = 4, right = 4, top = 4, bottom = 4 }
  })
  of:EnableMouse(true)
  of:SetMovable(true)
  of:RegisterForDrag("LeftButton")
  of:SetScript("OnDragStart", of.StartMoving)
  of:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    local p, _, rp, x, y = self:GetPoint(1)
    VaultBuddyDB.settings.optPos = {p or "CENTER", rp or "CENTER", x or 0, y or 0}
  end)
  local pos = VaultBuddyDB.settings.optPos
  of:ClearAllPoints()
  if type(pos)=="table" and pos[1] and pos[2] and pos[3] and pos[4] then
    of:SetPoint(pos[1], UIParent, pos[2], pos[3], pos[4])
  else
    of:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
  end
  if UISpecialFrames then table.insert(UISpecialFrames, "VaultBuddyOptions") end

  local title = of:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
  title:SetPoint("TOPLEFT", 14, -12)
  title:SetText("VaultBuddy Options")

  local close = CreateFrame("Button", nil, of, "UIPanelCloseButton")
  close:SetPoint("TOPRIGHT", 2, 2)
  VB.util.AttachTooltip(close, "Close", "Close this window. You can reopen via right-click → Options…")

  VB.state.frames.opt_chk_always = options_AddCheck(of, -48,  "Always Show",
    function() return VaultBuddyDB.settings.alwaysShow end,
    function(v) VaultBuddyDB.settings.alwaysShow=v; VB.logic.applyIconStateAccordingToDB() end,
    "Always Show", {"Keep the VaultBuddy icon visible at all times.","Useful if you want to move or test the icon."})

  VB.state.frames.opt_chk_hideCombat = options_AddCheck(of, -78,  "Hide in Combat",
    function() return VaultBuddyDB.settings.hideInCombat end,
    function(v) VaultBuddyDB.settings.hideInCombat=v; VB.logic.applyIconStateAccordingToDB() end,
    "Hide in Combat", {"Automatically hide the icon and list when you enter combat.","They reappear after combat ends."})

  VB.state.frames.opt_chk_preReset = options_AddCheck(of, -108, "Show Pre-Reset",
    function() return VaultBuddyDB.settings.preResetShow end,
    function(v) VaultBuddyDB.settings.preResetShow=v; VB.logic.applyIconStateAccordingToDB() end,
    "Show Pre-Reset", {"Show the icon if any character will earn a reward after the next weekly reset.","(e.g., enough activities done but can't claim yet.)"})

  -- Debug button instead of checkbox
  options_AddButton(of, 16, -138, 120, "Run Debug Report",
    function() VB.util.runDebugReport() end,
    "Run Debug Report", {"Run a comprehensive debug report and output to chat.","Shows all character statuses, reset timing, and auto-flip results."})

  options_AddButton(of, 16, -180, 90, "Center",
    function() VaultBuddyCharacterDB.pos={"CENTER","CENTER",0,0}; VB.ui.updateButtonPosition(); VB.logic.applyIconStateAccordingToDB() end,
    "Center", "Move the icon back to the center of the screen.")

  options_AddButton(of, 112, -180, 70, "Show",
    function() if VB.state.frames.mainButton then VB.state.frames.mainButton:Show(); VB.logic.applyIconStateAccordingToDB() end end,
    "Show Icon", "Force-show the icon.")

  options_AddButton(of, 188, -180, 70, "Hide",
    function() if VB.state.frames.mainButton then VB.state.frames.mainButton:Hide() end end,
    "Hide Icon", "Hide the icon until conditions make it visible again.")

  options_AddButton(of, 16, -208, 110, "Reset Current", function()
    local me = VB.util.playerFullName()
    if VaultBuddyDB.chars[me] then
      VaultBuddyDB.chars[me].hasVault=false; VaultBuddyDB.chars[me].willHave=false; VaultBuddyDB.chars[me].lastUpdate=time()
      VB.logic.applyIconStateAccordingToDB(); print(("VaultBuddy: %s reset."):format(me))
    else
      print(("VaultBuddy: %s not found in DB."):format(me))
    end
  end, "Reset Current","Clear claimable/pre-reset flags for your current character.")

  options_AddButton(of, 132, -208, 110, "Reset All", function()
    for _,v in pairs(VaultBuddyDB.chars) do v.hasVault=false; v.willHave=false; v.lastUpdate=time() end
    VB.logic.applyIconStateAccordingToDB(); print("VaultBuddy: All characters reset.")
  end, "Reset All",{"Clear claimable/pre-reset flags for ALL characters.","Use with care!"})

  VB.state.frames.optionsFrame = of
end

function VB.options.Open()  VB.options.Toggle() end
function VB.options.Toggle()
  BuildOptionsFrame()
  local f = VB.state.frames.optionsFrame
  if f:IsShown() then f:Hide() else f:Show() end
  VB.options.ApplyToUI()
end

-- ============================================================================
-- Weekly Rewards hooks (post-claim live update)
-- ============================================================================
local function tryHookWeeklyRewardsFrame()
  if not _G.WeeklyRewardsFrame or _G.WeeklyRewardsFrame._VaultBuddyHooked097 then return end
  _G.WeeklyRewardsFrame._VaultBuddyHooked097 = true
  _G.WeeklyRewardsFrame:HookScript("OnShow", function() VB.weekly.RequestWeeklyInfo() end)
  _G.WeeklyRewardsFrame:HookScript("OnHide", function()
    VB.logic.refreshMyStatus("VaultClosed")
    C_Timer.After(0.4, function() VB.logic.refreshMyStatus("VaultClosed+0.4s") end)
    C_Timer.After(2.0, function() VB.logic.refreshMyStatus("VaultClosed+2.0s") end)
  end)
end

local function ensureWeeklyRewardsHook()
  if VB.util.IsBlizzAddonLoaded("Blizzard_WeeklyRewards") or _G.WeeklyRewardsFrame then
    tryHookWeeklyRewardsFrame(); return
  end
  if VB.util.SafeLoadBlizzAddon("Blizzard_WeeklyRewards") then tryHookWeeklyRewardsFrame(); return end
  if not VaultBuddy_WeeklyRewardsLoader_V097 then
    local loader=CreateFrame("Frame"); loader:RegisterEvent("ADDON_LOADED")
    loader:SetScript("OnEvent", function(_,_,name)
      if name=="Blizzard_WeeklyRewards" then
        tryHookWeeklyRewardsFrame()
        loader:UnregisterAllEvents()
        VaultBuddy_WeeklyRewardsLoader_V097=nil
      end
    end)
    VaultBuddy_WeeklyRewardsLoader_V097=loader
  end
end

-- ============================================================================
-- Refresh & Events
-- ============================================================================
function VB.logic.refreshMyStatus(reason)
  local me = VB.util.playerFullName()
  VB.weekly.RequestWeeklyInfo()

  local activitySummary = ""
  local list = select(1, VB.weekly.collectActivities())
  if type(list) == "table" then
    local done, total = 0, 0
    for _, a in ipairs(list) do
      total = total + 1
      local thr = a and (a.threshold or a.required) or 0
      local prog = a and (a.progress or a.progressAmount) or 0
      if thr > 0 and prog >= thr then done = done + 1 end
    end
    if total > 0 then activitySummary = (done.."/"..total) end
  end

  local claim = VB.weekly.currentCharHasVaultClaimable()
  local will  = VB.weekly.currentCharWillHaveAfterReset()

  -- FIX: If vault is ready but not collected, Blizzard API stops reporting progress (returns 0/9).
  -- We must freeze the stored progress to avoid overwriting it with empty data.
  if claim then
    local existing = VaultBuddyDB.chars and VaultBuddyDB.chars[me]
    if existing and existing.hasVault and existing.activitySummary and existing.activitySummary ~= "" then
       activitySummary = existing.activitySummary
       VB.util.dprint("Freeze progress (vault ready): kept", activitySummary)
    end
  end

  VB.weekly.setCharVaultStatus(me, claim, will, VB.util.playerClassFile(), activitySummary)
  VB.util.dprint((reason or "refresh"), "->", me, "claimable=", claim, "willHave=", will, "summary=", activitySummary)
  VB.logic.applyIconStateAccordingToDB()
end

local function StartStatusTicker()
  if VB.state.statusTicker then VB.state.statusTicker:Cancel() end
  local sec = math.max(10, tonumber(VaultBuddyDB.settings.statusPoll) or VB.cfg.DEFAULT_STATUS_POLL_SEC)
  VB.state.statusTicker = C_Timer.NewTicker(sec, function() VB.logic.refreshMyStatus("Ticker") end)
end

-- Flip ticker removed - now using login-based checks only

local ef = CreateFrame("Frame")
ef:RegisterEvent("ADDON_LOADED")
ef:RegisterEvent("PLAYER_LOGIN")
ef:RegisterEvent("PLAYER_ENTERING_WORLD")
ef:RegisterEvent("PLAYER_REGEN_DISABLED")
ef:RegisterEvent("PLAYER_REGEN_ENABLED")
ef:RegisterEvent("WEEKLY_REWARDS_UPDATE")

ef:SetScript("OnEvent", function(_, event, arg1)
  if event=="ADDON_LOADED" and arg1==ADDON_NAME then
    VB.util.ensureDB(); VB.util.ensureCharDB()
    VB.util.dprint("Loaded. Debug=", VaultBuddyDB.debug and "true" or "false")
  elseif event=="PLAYER_LOGIN" then
    VB.util.ensureDB(); VB.util.ensureCharDB()
    -- CRITICAL: Promote WillHave → HasVault BEFORE any UI or state evaluation
    -- This must run first to ensure all vault state is correct before display logic
    VB.weekly.computeAndStoreNextWeeklyAbs()
    VB.weekly.autoFlipPreResetIfDue()
    VB.util.info("VaultBuddy loaded. Type /vb help for commands.")
    VB.ui.BuildMainButton()
    VB.ui.BuildListFrame()
    VB.minimap.Build()
    ensureWeeklyRewardsHook()
    VB.logic.refreshMyStatus("PLAYER_LOGIN")
    StartStatusTicker()
    C_Timer.After(0.1, function() if VB.state.frames.mmb then VB.state.frames.mmb:SetScript("OnMouseUp", MinimapMouseUp) end end)
    C_Timer.After(0.5, function() if VB.state.frames.mmb then VB.state.frames.mmb:SetScript("OnMouseUp", MinimapMouseUp) end end)
    C_Timer.After(2.0, function() if VB.state.frames.mmb then VB.state.frames.mmb:SetScript("OnMouseUp", MinimapMouseUp) end end)
    C_Timer.After(2, function() VB.logic.refreshMyStatus("Delayed(2s)") end)
    C_Timer.After(5, function() VB.logic.refreshMyStatus("Delayed(5s)") end)
  elseif event=="PLAYER_ENTERING_WORLD" then
    VB.util.dprint("PLAYER_ENTERING_WORLD")
    VB.ui.updateButtonSize(); VB.ui.updateButtonPosition()
    if VB.state.frames.mainButton then VB.state.frames.mainButton:Show() end
    -- Safety check: ensure auto-flip runs on every world entry
    VB.weekly.autoFlipPreResetIfDue()
    VB.logic.applyIconStateAccordingToDB()
    if VB.state.frames.mmb then
      MinimapUpdateAnchor()
      VB.state.frames.mmb:SetScript("OnMouseUp", MinimapMouseUp)
    end
  elseif event=="PLAYER_REGEN_DISABLED" then
    if VaultBuddyDB.settings.hideInCombat then
      if VB.state.frames.listFrame then VB.state.frames.listFrame:Hide() end
      if VB.state.frames.mainButton then VB.state.frames.mainButton:Hide() end
    end
    if VB.state.frames.optionsFrame and VB.state.frames.optionsFrame:IsShown() then VB.state.frames.optionsFrame:Hide() end
  elseif event=="PLAYER_REGEN_ENABLED" then
    VB.logic.applyIconStateAccordingToDB()
  elseif event=="WEEKLY_REWARDS_UPDATE" then
    VB.util.dprint("WEEKLY_REWARDS_UPDATE")
    VB.logic.refreshMyStatus("WEEKLY_REWARDS_UPDATE")
  end
end)

-- ============================================================================
-- Slash
-- ============================================================================
SLASH_VAULTBUDDY1 = "/vb"
SlashCmdList["VAULTBUDDY"] = function(msg)
  local cmd, rest = msg:match("^(%S+)%s*(.-)$"); cmd = cmd and cmd:lower() or ""
  local function bprint(...) print("|cff00d1ffVaultBuddy|r", ...) end

  if cmd=="" or cmd=="help" then
    bprint("commands:")
    bprint("/vb toggle - Show/Hide the list (anchors to last button, incl. minimap).")
    bprint("/vb lock on|off - Lock or unlock the icon")
    bprint("/vb size N - Set icon size in pixels (min 16)")
    bprint("/vb alwaysshow on|off")
    bprint("/vb hideincombat on|off")
    bprint("/vb prereset on|off")
    bprint("/vb reset [all|Name-Realm]")
    bprint("/vb center | /vb where | /vb show | /vb hide")
    bprint("/vb pulse on|off")
    bprint("/vb minimap show|hide | /vb minimap nudge x y")
    bprint("/vb flip - Force manual flip of willHave -> hasVault")
    bprint("/vb flip force - Force flip regardless of reset timing")
    return
  end

  if cmd=="toggle" then
    local anchor = VB.state.frames.listAnchorFrame or VB.state.frames.mainButton or VB.state.frames.mmb
    VB.ui.ToggleList(anchor); return
  end

  if cmd=="lock" then
    VB.util.ensureCharDB(); VaultBuddyCharacterDB.locked=VB.util.safe_toboolean(rest)
    bprint("Lock is now", VaultBuddyCharacterDB.locked and "ON" or "OFF")
    VB.ui.layoutGrip(); return
  end

  if cmd=="size" then
    VB.util.ensureCharDB()
    local n=tonumber(rest); if not n then bprint("/vb size N"); return end
    n = VB.util.clamp_quantize2(n)
    VaultBuddyCharacterDB.size = n
    VB.ui.updateButtonSize()
    bprint("Icon size set to", n)
    return
  end

  if cmd=="alwaysshow" then VaultBuddyDB.settings.alwaysShow=VB.util.safe_toboolean(rest); VB.logic.applyIconStateAccordingToDB(); bprint("Always show is now", VaultBuddyDB.settings.alwaysShow and "ON" or "OFF"); return end
  if cmd=="hideincombat" then VaultBuddyDB.settings.hideInCombat=VB.util.safe_toboolean(rest); VB.logic.applyIconStateAccordingToDB(); bprint("Hide in combat is now", VaultBuddyDB.settings.hideInCombat and "ON" or "OFF"); return end
  if cmd=="prereset" then VaultBuddyDB.settings.preResetShow=VB.util.safe_toboolean(rest); VB.logic.applyIconStateAccordingToDB(); bprint("Pre-reset visibility is now", VaultBuddyDB.settings.preResetShow and "ON" or "OFF"); return end

  if cmd=="reset" then
    local target = rest~="" and rest or VB.util.playerFullName()
    if target:lower()=="all" then
      for _,v in pairs(VaultBuddyDB.chars) do v.hasVault=false; v.willHave=false; v.lastUpdate=time() end
      VB.logic.applyIconStateAccordingToDB(); bprint("All characters reset.")
    else
      if VaultBuddyDB.chars[target] then
        VaultBuddyDB.chars[target].hasVault=false; VaultBuddyDB.chars[target].willHave=false; VaultBuddyDB.chars[target].lastUpdate=time()
        VB.logic.applyIconStateAccordingToDB(); bprint(("%s reset."):format(target))
      else
        bprint(("%s not found in DB."):format(target))
      end
    end
    return
  end

  if cmd=="center" then
    VB.util.ensureCharDB(); VaultBuddyCharacterDB.pos={"CENTER","CENTER",0,0}; VB.ui.updateButtonPosition(); VB.logic.applyIconStateAccordingToDB()
    bprint("Icon moved to center."); return
  end
  if cmd=="where" then
    VB.util.ensureCharDB()
    local p     = VaultBuddyCharacterDB.pos or {}
    local vis   = VB.state.frames.mainButton and VB.state.frames.mainButton:IsVisible()
    local shown = VB.state.frames.mainButton and VB.state.frames.mainButton:IsShown()
    local a     = VB.state.frames.mainButton and VB.state.frames.mainButton:GetAlpha()
    local strata= VB.state.frames.mainButton and VB.state.frames.mainButton:GetFrameStrata()
    bprint(("pos={%s,%s,%d,%d} size=%d strata=%s visible=%s shown=%s alpha=%.2f locked=%s"):format(
        tostring(p[1] or "?"), tostring(p[2] or "?"),
        tonumber(p[3] or 0), tonumber(p[4] or 0),
        tonumber(VaultBuddyCharacterDB.size or 0), tostring(strata or "?"),
        tostring(vis or false), tostring(shown or false),
        tonumber(a or 0), tostring(VaultBuddyCharacterDB.locked)))
    return
  end
  if cmd=="show" then if VB.state.frames.mainButton then VB.state.frames.mainButton:SetAlpha(1); VB.state.frames.mainButton:Show(); bprint("Forced show.") end; return end
  if cmd=="hide" then if VB.state.frames.mainButton then VB.state.frames.mainButton:Hide(); bprint("Forced hide icon.") end; return end

  if cmd=="pulse" then
    VaultBuddyDB.settings.pulseEnabled = VB.util.safe_toboolean(rest)
    VB.logic.updateBadgeAndPulse()
    bprint("Pulse is now", VaultBuddyDB.settings.pulseEnabled and "ON" or "OFF")
    return
  end

  if cmd=="minimap" then
    local sub, a, b = rest:match("^(%S+)%s*(%-?%d*%.?%d*)%s*(%-?%d*%.?%d*)$")
    sub = sub and sub:lower() or ""
    if sub=="show" then
      VaultBuddyDB.settings.minimap.hide=false; if VB.state.frames.mmb then VB.state.frames.mmb:Show() end; bprint("Minimap button shown."); return
    elseif sub=="hide" then
      VaultBuddyDB.settings.minimap.hide=true; if VB.state.frames.mmb then VB.state.frames.mmb:Hide() end; bprint("Minimap button hidden."); return
    elseif sub=="nudge" then
      local nx = tonumber(a) or 0; local ny = tonumber(b) or 0
      VaultBuddyDB.settings.minimap.ringNudgeX = nx
      VaultBuddyDB.settings.minimap.ringNudgeY = ny
      if VB.state.frames.mmb then MinimapRingLayout() end
      bprint("Ring nudge set to", nx, ny); return
    else
      bprint("/vb minimap show|hide | /vb minimap nudge x y")
      return
    end
  end

  if cmd=="flip" then
    if rest:lower() == "force" then
      -- Force flip all willHave -> hasVault regardless of reset timing
      local flipped = 0
      local now = time()
      for nameRealm, v in pairs(VaultBuddyDB.chars) do
        if v.willHave and not v.hasVault then
          v.willHave=false
          v.hasVault=true
          v.lastUpdate=now
          flipped = flipped + 1
          VB.util.dprint("Force-flipped", nameRealm, "from willHave to hasVault")
        end
      end
      VB.logic.applyIconStateAccordingToDB()
      bprint("Force flip executed. Flipped", flipped, "characters. Check debug output for details.")
    else
      VB.weekly.autoFlipPreResetIfDue()
      VB.logic.applyIconStateAccordingToDB()
      bprint("Manual flip executed. Check debug output for details.")
    end
    return
  end

  bprint("Unknown command. Use /vb help")
end

-- =========================
-- END OF FILE (v0.9.14)
-- =========================
