-- Cogworks-1.0 | The mainspring of the Cogworks WoW addon suite.
--
-- An embeddable LibStub library providing the primitives shared across every
-- cog: event bus, theme palette, character-key helpers, print utilities, and
-- a Syndicator capability bridge.
--
-- Design notes:
--   * No Ace3 — built on LibStub + CallbackHandler-1.0, matching the rest of
--     the suite. Both dependencies are already loaded by LibDataBroker in
--     every cog, so Cogworks adds no new library cost.
--   * Additive only. MINOR bumps on every API addition; old functions never
--     go away. A breaking change would force every cog to re-release in
--     lockstep, which is exactly what this library exists to avoid.
--   * Syndicator is a hard dependency for inventory-aware cogs (FlipQueue,
--     Tally). They declare it in their TOC and consume it directly with no
--     fallback scanner. Character keys follow Syndicator's "Name-Realm"
--     convention so all suite data shares one keyspace.

assert(LibStub, "Cogworks-1.0 requires LibStub")
assert(LibStub:GetLibrary("CallbackHandler-1.0", true), "Cogworks-1.0 requires CallbackHandler-1.0")

-- The addonName whose .toc included this file. Used by libArtPath so bundled
-- art (CogBorder, inner glyphs) resolves whether Cogworks is loaded standalone
-- or embedded under a consumer's Libs/. The first cog to load Cogworks wins;
-- because every consumer ships the same external, the path resolves either way.
local LIB_LOADER_ADDON = ...

local MAJOR, MINOR = "Cogworks-1.0", 31
local lib, oldminor = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end  -- already loaded at this version or newer
oldminor = oldminor or 0
lib.loaderAddon = lib.loaderAddon or LIB_LOADER_ADDON

-- ============================================================================
-- Version
-- ============================================================================

lib.version      = "0.16.0"  -- human-facing semver of the Cogworks suite
lib.minorVersion = MINOR     -- LibStub minor; bumps on any API addition

-- ============================================================================
-- Event bus
-- ============================================================================
-- A single CallbackHandler-backed registry that any cog can subscribe to.
-- Event names are centralized in lib.Events so typos fail loudly instead of
-- silently registering for an event that never fires.
--
-- Usage:
--   local cw = LibStub("Cogworks-1.0")
--   cw.RegisterCallback(self, cw.Events.SaleLogged, function(event, itemKey, price, qty)
--     -- react to a sale from any other cog
--   end)
--   cw:Fire(cw.Events.SaleLogged, itemKey, price, qty, "FlipQueue")

lib.Events = lib.Events or {
  -- Lifecycle
  Ready            = "Ready",            -- fired once at PLAYER_LOGIN
  AddonRegistered  = "AddonRegistered",  -- (addonName) — a new cog clicked in

  -- Character / account state
  CharacterChanged = "CharacterChanged", -- (charKey)
  GoldChanged      = "GoldChanged",      -- (charKey, newGold, delta)

  -- Inventory signals (typically bridged from Syndicator or FlipQueue's scanner)
  InventoryChanged = "InventoryChanged", -- (charKey, updates)
  MailChanged      = "MailChanged",      -- (charKey)
  AuctionsChanged  = "AuctionsChanged",  -- (charKey)

  -- Suite domain events (cross-cog signalling)
  SaleLogged       = "SaleLogged",       -- (itemKey, price, qty, source)
  CraftCompleted   = "CraftCompleted",   -- (recipeID, charKey)
  ResetDue         = "ResetDue",         -- (period)  -- "daily" / "weekly" / ...
  PriceUpdated     = "PriceUpdated",     -- (itemKey, source, price)

  -- Settings
  SettingsChanged  = "SettingsChanged",  -- (key, value, oldValue)

  -- Public-API registry (see API.lua)
  APIRegistered    = "APIRegistered",    -- (name, major, minor)

  -- Lib-side debug bridge (see Debug.lua). Lib internals fire LibDebug; every
  -- per-cog debug logger auto-subscribes and tags the entry "[Cogworks-1.0]"
  -- in the cog's own ring buffer.
  LibDebug         = "LibDebug",         -- (msg, scope?)  scope is a free-form tag like "Profile"
}

if not lib.callbacks then
  local CallbackHandler = LibStub("CallbackHandler-1.0")
  lib.callbacks = CallbackHandler:New(lib, "RegisterCallback", "UnregisterCallback", "UnregisterAllCallbacks")
end

function lib:Fire(event, ...)
  self.callbacks:Fire(event, ...)
end

-- ============================================================================
-- Registered addons (cogs)
-- ============================================================================
-- Each cog registers itself with Cogworks on load. The registry lets any cog
-- enumerate its siblings — useful for an "About" panel or for cross-promotion
-- without any hard dependency between cogs.

lib.addons = lib.addons or {}  -- [name] = { prefix, version, icon, website }

function lib:RegisterAddon(name, info)
  assert(type(name) == "string" and name ~= "", "RegisterAddon: name required")
  info = info or {}
  self.addons[name] = {
    prefix  = info.prefix  or ("|cffffd100[" .. name .. "]|r "),
    version = info.version or "unknown",
    icon    = info.icon,
    website = info.website,
  }
  self:Fire(self.Events.AddonRegistered, name)
end

function lib:GetAddon(name)
  return self.addons[name]
end

function lib:GetRegisteredAddons()
  local list = {}
  for name in pairs(self.addons) do
    list[#list + 1] = name
  end
  table.sort(list)
  return list
end

-- ============================================================================
-- Print helpers
-- ============================================================================

local function joinArgs(...)
  local n = select("#", ...)
  if n == 0 then return "" end
  local parts = {}
  for i = 1, n do
    parts[i] = tostring((select(i, ...)))
  end
  return table.concat(parts, " ")
end

function lib:Print(addonName, ...)
  local info = self.addons[addonName]
  local prefix = (info and info.prefix) or ("|cffffd100[" .. (addonName or "Cogworks") .. "]|r ")
  DEFAULT_CHAT_FRAME:AddMessage(prefix .. joinArgs(...))
end

function lib:PrintError(addonName, ...)
  self:Print(addonName, "|cffff4040" .. joinArgs(...) .. "|r")
end

-- ============================================================================
-- Theme system
-- ============================================================================
-- Named themes with a full color palette. The active theme is applied to
-- lib.Theme in-place so existing local references stay valid. Players can
-- customize colors, export/import themes, and switch with a preview.

local THEME_KEYS = {
  "bg", "bgLight", "bgDark", "header", "sidebar", "border",
  "rowAlt", "rowHover",
  "gold", "arcane", "brass",
  "success", "warning", "error",
  "text", "textDim", "textDisabled", "muted",
}

local function copyColor(c) return { c[1], c[2], c[3], c[4] or 1 } end

local function deepCopyTheme(src)
  local dst = {}
  for _, k in ipairs(THEME_KEYS) do
    if src[k] then dst[k] = copyColor(src[k]) end
  end
  if src.quality then
    dst.quality = {}
    for i, c in pairs(src.quality) do dst.quality[i] = copyColor(c) end
  end
  return dst
end

-- Quality colors are shared across all themes
local QUALITY_COLORS = {
  [0] = { 0.62, 0.62, 0.62 },  -- Poor
  [1] = { 1.00, 1.00, 1.00 },  -- Common
  [2] = { 0.12, 1.00, 0.00 },  -- Uncommon
  [3] = { 0.00, 0.44, 0.87 },  -- Rare
  [4] = { 0.64, 0.21, 0.93 },  -- Epic
  [5] = { 1.00, 0.50, 0.00 },  -- Legendary
  [6] = { 0.90, 0.80, 0.50 },  -- Artifact
  [7] = { 0.00, 0.80, 1.00 },  -- Heirloom
  [8] = { 0.00, 0.80, 1.00 },  -- WoW Token
}

-- Built-in theme presets
lib.ThemePresets = lib.ThemePresets or {}

lib.ThemePresets["Cogworks"] = {
  bg={0.08,0.08,0.12,0.95}, bgLight={0.12,0.12,0.16,0.95}, bgDark={0.04,0.04,0.07,1},
  header={0.15,0.15,0.20,1}, sidebar={0.06,0.06,0.10,1}, border={0.30,0.30,0.40,1},
  rowAlt={1,1,1,0.03}, rowHover={1,1,1,0.08},
  gold={1,0.82,0,1}, arcane={0.55,0.36,0.96,1}, brass={0.83,0.63,0.09,1},
  success={0.30,0.85,0.30,1}, warning={1,0.78,0.10,1}, error={1,0.25,0.25,1},
  text={0.90,0.90,0.92,1}, textDim={0.60,0.60,0.60,1}, textDisabled={0.40,0.40,0.40,1},
  muted={0.55,0.55,0.60,1},
}

lib.ThemePresets["Midnight"] = {
  bg={0.05,0.05,0.10,0.95}, bgLight={0.08,0.08,0.14,0.95}, bgDark={0.02,0.02,0.06,1},
  header={0.10,0.10,0.18,1}, sidebar={0.04,0.04,0.08,1}, border={0.20,0.22,0.35,1},
  rowAlt={0.4,0.5,1,0.03}, rowHover={0.4,0.5,1,0.08},
  gold={0.60,0.75,1.00,1}, arcane={0.40,0.50,0.95,1}, brass={0.50,0.60,0.80,1},
  success={0.20,0.70,0.50,1}, warning={0.80,0.70,0.30,1}, error={0.90,0.25,0.30,1},
  text={0.80,0.85,0.95,1}, textDim={0.50,0.55,0.65,1}, textDisabled={0.35,0.38,0.48,1},
  muted={0.45,0.50,0.60,1},
}

lib.ThemePresets["Forge"] = {
  bg={0.10,0.06,0.04,0.95}, bgLight={0.14,0.09,0.06,0.95}, bgDark={0.06,0.03,0.02,1},
  header={0.18,0.10,0.06,1}, sidebar={0.08,0.05,0.03,1}, border={0.40,0.25,0.15,1},
  rowAlt={1,0.8,0.5,0.03}, rowHover={1,0.8,0.5,0.08},
  gold={1,0.65,0.15,1}, arcane={0.85,0.35,0.15,1}, brass={0.90,0.55,0.10,1},
  success={0.40,0.80,0.20,1}, warning={1,0.70,0.10,1}, error={1,0.20,0.15,1},
  text={0.95,0.88,0.80,1}, textDim={0.65,0.55,0.45,1}, textDisabled={0.45,0.38,0.30,1},
  muted={0.60,0.50,0.42,1},
}

lib.ThemePresets["Frost"] = {
  bg={0.06,0.08,0.12,0.95}, bgLight={0.08,0.12,0.18,0.95}, bgDark={0.03,0.04,0.07,1},
  header={0.10,0.14,0.22,1}, sidebar={0.04,0.06,0.10,1}, border={0.25,0.35,0.45,1},
  rowAlt={0.5,0.8,1,0.03}, rowHover={0.5,0.8,1,0.08},
  gold={0.40,0.85,1.00,1}, arcane={0.30,0.60,1.00,1}, brass={0.50,0.75,0.90,1},
  success={0.20,0.80,0.60,1}, warning={0.90,0.80,0.30,1}, error={0.90,0.30,0.35,1},
  text={0.85,0.92,0.98,1}, textDim={0.55,0.65,0.75,1}, textDisabled={0.38,0.45,0.55,1},
  muted={0.50,0.58,0.68,1},
}

lib.ThemePresets["Classic"] = {
  bg={0.10,0.10,0.10,0.95}, bgLight={0.15,0.15,0.15,0.95}, bgDark={0.05,0.05,0.05,1},
  header={0.18,0.18,0.18,1}, sidebar={0.08,0.08,0.08,1}, border={0.35,0.35,0.35,1},
  rowAlt={1,1,1,0.03}, rowHover={1,1,1,0.08},
  gold={1,0.82,0,1}, arcane={0.65,0.50,0.90,1}, brass={0.75,0.65,0.30,1},
  success={0.30,0.80,0.30,1}, warning={1,0.80,0.20,1}, error={1,0.25,0.25,1},
  text={0.90,0.90,0.90,1}, textDim={0.60,0.60,0.60,1}, textDisabled={0.40,0.40,0.40,1},
  muted={0.55,0.55,0.55,1},
}

-- Custom themes stored by users (populated from CogworksDB)
lib.CustomThemes = lib.CustomThemes or {}

-- Active theme name
lib.activeThemeName = lib.activeThemeName or "Cogworks"

-- The live theme table — updated in-place so existing references stay valid
lib.Theme = lib.Theme or deepCopyTheme(lib.ThemePresets["Cogworks"])
lib.Theme.quality = lib.Theme.quality or {}
for i, c in pairs(QUALITY_COLORS) do
  lib.Theme.quality[i] = lib.Theme.quality[i] or copyColor(c)
end

function lib:GetThemeNames()
  local names = {}
  for name in pairs(self.ThemePresets) do names[#names + 1] = name end
  for name in pairs(self.CustomThemes) do names[#names + 1] = name end
  table.sort(names)
  return names
end

function lib:GetThemeData(name)
  return self.ThemePresets[name] or self.CustomThemes[name]
end

function lib:SetTheme(name)
  local src = self:GetThemeData(name)
  if not src then return end
  self.activeThemeName = name
  for _, k in ipairs(THEME_KEYS) do
    if src[k] then
      local dst = self.Theme[k]
      if dst then
        dst[1], dst[2], dst[3], dst[4] = src[k][1], src[k][2], src[k][3], src[k][4] or 1
      else
        self.Theme[k] = copyColor(src[k])
      end
    end
  end
  -- Write-through to the active profile (theme is suite-wide, never per-cog).
  local db = _G.CogworksSharedDB
  if db and db.profiles and db.activeProfile then
    local p = db.profiles[db.activeProfile]
    if p then p.theme = name end
  end
  self:Fire(self.Events.SettingsChanged, "theme", name, nil)
end

function lib:SetThemeColor(key, r, g, b, a)
  if not self.Theme[key] then return end
  self.Theme[key][1] = r
  self.Theme[key][2] = g
  self.Theme[key][3] = b
  self.Theme[key][4] = a or 1
  self:Fire(self.Events.SettingsChanged, "themeColor", key, nil)
end

function lib:SaveCustomTheme(name)
  self.CustomThemes[name] = deepCopyTheme(self.Theme)
end

function lib:DeleteCustomTheme(name)
  self.CustomThemes[name] = nil
end

function lib:ExportTheme()
  local parts = { "CogworksTheme:" .. (self.activeThemeName or "Custom") }
  for _, k in ipairs(THEME_KEYS) do
    local c = self.Theme[k]
    if c then
      parts[#parts + 1] = string.format("%s=%02x%02x%02x%02x",
        k, math.floor(c[1]*255), math.floor(c[2]*255),
        math.floor(c[3]*255), math.floor((c[4] or 1)*255))
    end
  end
  return table.concat(parts, "|")
end

function lib:ImportTheme(str)
  if not str or str == "" then return nil, "Empty string" end
  local name = str:match("^CogworksTheme:([^|]+)")
  if not name then return nil, "Invalid format" end
  local theme = deepCopyTheme(self.ThemePresets["Cogworks"])
  for k, hex in str:gmatch("(%w+)=(%x+)") do
    if #hex == 8 and theme[k] then
      local r = tonumber(hex:sub(1,2), 16) / 255
      local g = tonumber(hex:sub(3,4), 16) / 255
      local b = tonumber(hex:sub(5,6), 16) / 255
      local a = tonumber(hex:sub(7,8), 16) / 255
      theme[k] = { r, g, b, a }
    end
  end
  self.CustomThemes[name] = theme
  return name
end

-- ============================================================================
-- Backdrop templates
-- ============================================================================

lib.Backdrop = lib.Backdrop or {
  bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
  edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
  edgeSize = 16,
  insets   = { left = 4, right = 4, top = 4, bottom = 4 },
}

lib.BackdropSmall = lib.BackdropSmall or {
  bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
  edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
  edgeSize = 10,
  insets   = { left = 2, right = 2, top = 2, bottom = 2 },
}

-- ============================================================================
-- Character key utilities
-- ============================================================================
-- Canonical "Name-RealmNormalized" keys, matching Syndicator's convention
-- so Cogworks and Syndicator data can be cross-referenced without a
-- translation layer.

local function currentRealm()
  if GetNormalizedRealmName then
    local nr = GetNormalizedRealmName()
    if nr and nr ~= "" then return nr end
  end
  return GetRealmName()
end

function lib:GetCharacterKey(name, realm)
  name  = name  or UnitName("player")
  realm = realm or currentRealm()
  return name .. "-" .. realm
end

-- ============================================================================
-- Syndicator bridge
-- ============================================================================
-- Cogworks itself does not require Syndicator. Inventory-aware cogs (FlipQueue,
-- Tally) declare it as a HARD dependency in their TOC and consume it directly
-- with no fallback scanner.
--
-- This helper exists for cogs that want to OPPORTUNISTICALLY enrich their data
-- when Syndicator happens to be present, without making it a hard requirement
-- (e.g. Maxcraft showing reagent counts from alts if it can).
--
-- See docs/PLAN.md in the cogworks repo for the suite's Syndicator strategy.

function lib:HasSyndicator()
  return _G.Syndicator ~= nil
    and _G.Syndicator.API ~= nil
    and _G.Syndicator.API.IsReady ~= nil
    and _G.Syndicator.API.IsReady() == true
end

-- ============================================================================
-- LibSharedMedia bridge
-- ============================================================================
-- Optional integration with LibSharedMedia-3.0. If LSM is loaded (most players
-- with ElvUI, WeakAuras, or DBM have it), fonts and sounds expand from 4
-- built-in choices to the full LSM catalog. If absent, the 4 built-ins work.

function lib:GetLSM()
  if not self._lsm then
    self._lsm = LibStub("LibSharedMedia-3.0", true) or false
  end
  return self._lsm or nil
end

function lib:HasSharedMedia()
  return self:GetLSM() ~= nil
end

function lib:GetFontList()
  local lsm = self:GetLSM()
  if lsm then
    local names = lsm:List("font")
    local list = {}
    for _, name in ipairs(names) do
      list[#list + 1] = { key = name, label = name, path = lsm:Fetch("font", name) }
    end
    return list
  end
  local list = {}
  local order = { "default", "arial", "morpheus", "skurri" }
  for _, k in ipairs(order) do
    local f = self.FontFamilies[k]
    list[#list + 1] = { key = k, label = f.label, path = f.path }
  end
  return list
end

function lib:GetFontPath(key)
  local lsm = self:GetLSM()
  if lsm then
    local path = lsm:Fetch("font", key)
    if path then return path end
  end
  local fam = self.FontFamilies[key]
  return fam and fam.path or self.FontFamilies.default.path
end

function lib:GetSoundList()
  local lsm = self:GetLSM()
  if lsm then
    local names = lsm:List("sound")
    local list = {}
    for _, name in ipairs(names) do
      list[#list + 1] = { key = name, label = name, path = lsm:Fetch("sound", name) }
    end
    return list
  end
  return self._builtinSounds or {}
end

-- Built-in alert sounds (available even without LSM)
lib._builtinSounds = {
  { key = "auction",  label = "Auction",  soundID = SOUNDKIT.AUCTION_WINDOW_OPEN },
  { key = "levelup",  label = "Level Up", soundID = SOUNDKIT.LEVEL_UP },
  { key = "ready",    label = "Ready Check", soundID = SOUNDKIT.READY_CHECK },
  { key = "warning",  label = "Raid Warning", soundID = SOUNDKIT.RAID_WARNING },
  { key = "coin",     label = "Coin",     soundID = SOUNDKIT.LOOT_WINDOW_COIN_SOUND },
  { key = "quest",    label = "Quest Complete", soundID = SOUNDKIT.UI_QUEST_ROLLING_FORWARD_01 },
}

function lib:PlayAlert(key)
  local lsm = self:GetLSM()
  if lsm then
    local path = lsm:Fetch("sound", key)
    if path then PlaySoundFile(path, "Master"); return true end
  end
  for _, s in ipairs(self._builtinSounds) do
    if s.key == key and s.soundID then
      PlaySound(s.soundID, "Master"); return true
    end
  end
  return false
end

-- ============================================================================
-- Minimap button — shared gear-bordered chrome
-- ============================================================================
-- Wraps LibDBIcon:Register so every Chronoforge cog gets the same gear-ring
-- border around its minimap button. Hides LibDBIcon's default circular
-- tracking border and adds a gear texture as an OVERLAY child on the LDBIcon
-- button. The dataobject's icon continues to provide the per-cog inner glyph.
--
-- Layout note: CogBorder.tga ships inside Cogworks-1.0/Art/ so the packager
-- external pulls it into every consumer's Libs/Cogworks-1.0/Art/. The texture
-- path is constructed from the caller's addonName so it resolves whether the
-- caller is the standalone Cogworks addon or any embedding cog.
--
-- Usage (from a cog's entry Lua):
--   local Cogworks = LibStub("Cogworks-1.0", true)
--   if Cogworks and Cogworks.RegisterCogMinimapButton then
--     local dataobj = LibStub("LibDataBroker-1.1"):NewDataObject(addonName, {
--       type = "launcher",
--       icon = "Interface\\AddOns\\FlipQueue\\Art\\fq-inner",
--       OnClick       = function(self, button) --[[ open UI ]] end,
--       OnTooltipShow = function(tt) tt:SetText(addonName) end,
--     })
--     FlipQueueDB.minimap = FlipQueueDB.minimap or { hide = false }
--     Cogworks:RegisterCogMinimapButton(addonName, dataobj, FlipQueueDB.minimap)
--   end

-- LDBIcon's default minimap button is ~31px. CogBorder.tga's silhouette
-- fills nearly the full 128×128 texture rect (much denser than LDBIcon's
-- own gold-ring asset, which is mostly transparent center), so rendering
-- it at the LDBIcon overlay's 53×53 footprint makes the gear visually
-- ~70% larger than the button. 32×32 sits the gear just past the button
-- edge — same value Tally tuned to before TLY-003 adopted this helper.
local COG_BORDER_SIZE = 32
local TRACKING_BORDER_FILE_ID = 136430
local TRACKING_BORDER_PATH    = "Interface\\Minimap\\MiniMap-TrackingBorder"

-- Resolve a path to a TGA shipped inside Cogworks-1.0/Art/. The standalone
-- Cogworks addon has no Libs\ wrapper around the lib subfolder; consumers do.
-- callerAddon defaults to whichever addon's TOC loaded the lib (lib.loaderAddon).
-- Comparison is case-insensitive because cogworks.toc is lowercase, so WoW
-- reports the addon name as "cogworks" — a literal == "Cogworks" misses.
local function libArtPath(callerAddon, relPath)
  callerAddon = callerAddon or lib.loaderAddon or "Cogworks"
  if callerAddon:lower() == "cogworks" then
    return string.format("Interface\\AddOns\\%s\\Cogworks-1.0\\Art\\%s", callerAddon, relPath)
  end
  return string.format("Interface\\AddOns\\%s\\Libs\\Cogworks-1.0\\Art\\%s", callerAddon, relPath)
end

local function gearBorderPath(addonName)
  return libArtPath(addonName, "CogBorder")
end

-- Registry of registered cog minimap buttons. Used by the easter-egg mesh-spin
-- so that hovering one cog's button can pulse-spin every other suite cog's
-- button without each cog wiring it up.
lib._cogMinimapButtons = lib._cogMinimapButtons or {}  -- [cogName] = { button, gear, spinGroup }

local MESH_SPIN_DURATION = 1.2

local function buildSpinGroup(gear)
  local g = gear:CreateAnimationGroup()
  local rot = g:CreateAnimation("Rotation")
  rot:SetDegrees(360)
  rot:SetDuration(MESH_SPIN_DURATION)
  return g
end

-- Fire a one-shot spin on every registered cog button EXCEPT the one being
-- hovered (it stays still as the "anchor" gear that the others mesh against).
local function pulseOtherCogs(skipCog)
  for cog, entry in pairs(lib._cogMinimapButtons) do
    if cog ~= skipCog and entry.spinGroup and not entry.spinGroup:IsPlaying() then
      entry.spinGroup:Play()
    end
  end
end

function lib:RegisterCogMinimapButton(addonName, dataobject, savedvars)
  local LDBIcon = LibStub("LibDBIcon-1.0", true)
  if not LDBIcon then
    self:PrintError(addonName, "LibDBIcon-1.0 required for Cogworks minimap button")
    return false
  end

  LDBIcon:Register(addonName, dataobject, savedvars)

  local button = (LDBIcon.GetMinimapButton and LDBIcon:GetMinimapButton(addonName))
              or (LDBIcon.objects and LDBIcon.objects[addonName])
  if not button then return false end

  for _, region in ipairs({ button:GetRegions() }) do
    if region:GetObjectType() == "Texture" then
      local tex = region:GetTexture()
      if tex == TRACKING_BORDER_FILE_ID or tex == TRACKING_BORDER_PATH then
        region:Hide()
      end
    end
  end

  local gear = button:CreateTexture("$parentCogBorder", "OVERLAY", nil, 7)
  gear:SetTexture(gearBorderPath(addonName))
  gear:SetSize(COG_BORDER_SIZE, COG_BORDER_SIZE)
  gear:SetPoint("CENTER", button, "CENTER", 0, 0)

  -- Mesh-spin easter egg: hovering or clicking any cog suite minimap button
  -- pulse-spins every OTHER registered cog button. One-shot animation per
  -- cog so it stays cheap; skipped when an animation is already playing
  -- (no chained spins from a hover-jiggle).
  local spinGroup = buildSpinGroup(gear)
  lib._cogMinimapButtons[addonName] = { button = button, gear = gear, spinGroup = spinGroup }

  button:HookScript("OnEnter", function() pulseOtherCogs(addonName) end)
  button:HookScript("OnClick", function() pulseOtherCogs(addonName) end)

  return true
end

-- ============================================================================
-- Settings
-- ============================================================================
-- Suite-wide settings with defaults. The standalone addon persists these in
-- CogworksDB; embedded copies use whatever the host cog loads into
-- lib.settings at startup. Changing a setting fires SettingsChanged.

local SETTING_DEFAULTS = {
  fontScale  = 1.0,        -- 0.8 .. 1.4
  uiScale    = 1.0,        -- 0.8 .. 1.4
  fontFamily = "default",  -- key into lib.FontFamilies or LSM font name
  theme      = "Cogworks", -- active theme name
}

lib.FontFamilies = {
  default  = { path = "Fonts\\FRIZQT__.TTF",  label = "Friz Quadrata" },
  arial    = { path = "Fonts\\ARIALN.TTF",     label = "Arial Narrow" },
  morpheus = { path = "Fonts\\MORPHEUS.TTF",   label = "Morpheus" },
  skurri   = { path = "Fonts\\SKURRI.TTF",     label = "Skurri" },
}

lib.settings = lib.settings or {}
for k, v in pairs(SETTING_DEFAULTS) do
  if lib.settings[k] == nil then lib.settings[k] = v end
end

function lib:GetSetting(key)
  return self.settings[key]
end

function lib:SetSetting(key, value)
  local old = self.settings[key]
  if old == value then return end
  self.settings[key] = value
  if key == "fontScale" or key == "fontFamily" then self:UpdateFonts() end
  -- Write-through to the active profile so changes survive even if the
  -- PLAYER_LOGOUT persist pass doesn't fire (e.g. game crash). The SV may
  -- not be visible yet at very early load; in that case the next ADDON_LOADED
  -- init pass will re-seed lib.settings from the persisted profile, so we
  -- silently no-op here.
  local db = _G.CogworksSharedDB
  if db and db.profiles and db.activeProfile then
    local p = db.profiles[db.activeProfile]
    if p then p[key] = value end
  end
  self:Fire(self.Events.SettingsChanged, key, value, old)
end

function lib:ApplySettingsTable(tbl)
  if not tbl then return end
  for k, v in pairs(tbl) do
    if SETTING_DEFAULTS[k] ~= nil then
      self.settings[k] = v
    end
  end
  if tbl.customThemes then
    for name, data in pairs(tbl.customThemes) do
      self.CustomThemes[name] = data
    end
  end
  if tbl.themeOverrides then
    for _, k in ipairs(THEME_KEYS) do
      if tbl.themeOverrides[k] then
        local c = tbl.themeOverrides[k]
        if self.Theme[k] then
          self.Theme[k][1], self.Theme[k][2] = c[1], c[2]
          self.Theme[k][3], self.Theme[k][4] = c[3], c[4] or 1
        end
      end
    end
  elseif self.settings.theme and self.settings.theme ~= "Cogworks" then
    self:SetTheme(self.settings.theme)
  end
  self:UpdateFonts()
end

function lib:GetSettingDefaults()
  local copy = {}
  for k, v in pairs(SETTING_DEFAULTS) do copy[k] = v end
  return copy
end

-- ============================================================================
-- Font system
-- ============================================================================
-- Named FontObjects that respect lib.settings.fontScale. Widget factories use
-- these instead of hardcoded "GameFontNormal" so every Cogworks-built widget
-- scales together when the user adjusts font size.

local FONT_DEFS = {
  normal = { base = "GameFontNormal",      size = 12 },
  small  = { base = "GameFontNormalSmall",  size = 10 },
  large  = { base = "GameFontNormalLarge",  size = 16 },
  header = { base = "GameFontNormalSmall",  size = 10 },
}

lib.Fonts = lib.Fonts or {}

local function ensureFontObject(key, def)
  local name = "CogworksFont_" .. key
  if not lib.Fonts[key] then
    lib.Fonts[key] = CreateFont(name)
    lib.Fonts[key]:CopyFontObject(def.base)
  end
  return lib.Fonts[key]
end

function lib:UpdateFonts()
  local scale = self.settings.fontScale or 1.0
  scale = math.max(0.8, math.min(1.4, scale))
  local familyKey = self.settings.fontFamily or "default"
  local fontPath = self:GetFontPath(familyKey)
  for key, def in pairs(FONT_DEFS) do
    local fo = ensureFontObject(key, def)
    local _, _, flags = fo:GetFont()
    if not flags then
      fo:CopyFontObject(def.base)
      _, _, flags = fo:GetFont()
    end
    fo:SetFont(fontPath, math.floor(def.size * scale + 0.5), flags or "")
  end
end

-- GetFont(key) returns the suite-active FontObject; GetFont(key, cogName)
-- returns the cog-specific FontObject when that cog has an active override
-- (see :SetCogProfile). Untagged callers always get the suite-active set,
-- preserving back-compat.
function lib:GetFont(key, cogName)
  if cogName and self._cogFonts and self._cogFonts[cogName] and self._cogFonts[cogName][key] then
    return self._cogFonts[cogName][key]
  end
  if not self.Fonts[key] then
    local def = FONT_DEFS[key]
    if def then ensureFontObject(key, def) end
  end
  return self.Fonts[key]
end

lib:UpdateFonts()

-- ============================================================================
-- Persistent settings (CogworksSharedDB) + profile system
-- ============================================================================
-- The library owns suite-wide persistence. Every consumer cog declares
-- `## SavedVariables: CogworksSharedDB` in its TOC and the lib's own
-- ADDON_LOADED handler initializes the SV the first time it becomes visible.
-- A one-shot migration pulls settings from legacy CogworksDB on first run.
--
-- Schema (CogworksSharedDB):
--   {
--     schemaVersion = 1,
--     activeProfile = "Default",
--     profiles = {
--       [name] = { fontScale, uiScale, fontFamily, theme, customThemes? }
--     },
--     cogOverrides = { [cogName] = profileName },
--   }
--
-- Override surface: only `fontScale` and `fontFamily` are per-cog. `uiScale`,
-- `theme`, and `customThemes` stay suite-wide because their state lives in
-- mutable theme tables and FontObjects already referenced by every widget.

lib._cogFonts    = lib._cogFonts or {}  -- [cogName] = { normal, small, large, header }
lib._dbInitDone  = lib._dbInitDone or false

local function newDefaultProfile()
  local p = {}
  for k, v in pairs(SETTING_DEFAULTS) do p[k] = v end
  return p
end

-- Idempotent. Creates the SV table + a Default profile, and runs the
-- one-shot CogworksDB → CogworksSharedDB migration when schemaVersion is nil.
local function ensureSharedDB()
  _G.CogworksSharedDB = _G.CogworksSharedDB or {}
  local db = _G.CogworksSharedDB
  if not db.schemaVersion then
    db.schemaVersion = 1
    db.activeProfile = db.activeProfile or "Default"
    db.profiles      = db.profiles      or {}
    db.cogOverrides  = db.cogOverrides  or {}

    local default = newDefaultProfile()
    local legacy  = _G.CogworksDB
    if legacy and type(legacy.settings) == "table" then
      for k, v in pairs(legacy.settings) do
        if SETTING_DEFAULTS[k] ~= nil then default[k] = v end
      end
    end
    if legacy and type(legacy.customThemes) == "table" then
      default.customThemes = legacy.customThemes
    end
    db.profiles.Default = db.profiles.Default or default
  end

  -- Defensive: a prior session set schemaVersion but the active profile entry
  -- may have been deleted manually. Rebuild as default.
  db.activeProfile = db.activeProfile or "Default"
  db.profiles      = db.profiles      or {}
  db.cogOverrides  = db.cogOverrides  or {}
  if not db.profiles[db.activeProfile] then
    db.profiles[db.activeProfile] = newDefaultProfile()
  end
  return db
end

-- Copy active profile values into lib.settings + apply derived state (fonts,
-- theme). Idempotent.
local function loadActiveProfile()
  local db = ensureSharedDB()
  local p  = db.profiles[db.activeProfile]
  for k, v in pairs(SETTING_DEFAULTS) do
    lib.settings[k] = (p[k] ~= nil) and p[k] or v
  end
  if type(p.customThemes) == "table" then
    for name, data in pairs(p.customThemes) do
      lib.CustomThemes[name] = data
    end
  end
  lib:UpdateFonts()
  if p.theme then lib:SetTheme(p.theme) end
  -- Rebuild any per-cog font registries that survived an in-session reload.
  for cog in pairs(lib._cogFonts) do
    lib:_RefreshCogFonts(cog)
  end
end

-- Write lib.settings + custom themes back to the active profile. Called from
-- PLAYER_LOGOUT; SetSetting / SetTheme already write through immediately, so
-- this is mostly a backstop for any direct lib.settings mutation.
local function persistActiveProfile()
  local db = ensureSharedDB()
  local p  = db.profiles[db.activeProfile]
  for k in pairs(SETTING_DEFAULTS) do
    p[k] = lib.settings[k]
  end
  p.theme = lib.activeThemeName or p.theme
  if next(lib.CustomThemes) then
    p.customThemes = lib.CustomThemes
  else
    p.customThemes = nil
  end
end

-- ---- Profile API ----------------------------------------------------------

function lib:GetProfileNames()
  local db = ensureSharedDB()
  local list = {}
  for name in pairs(db.profiles) do list[#list + 1] = name end
  table.sort(list)
  return list
end

function lib:GetActiveProfile()
  return ensureSharedDB().activeProfile
end

function lib:SetActiveProfile(name)
  local db = ensureSharedDB()
  if not db.profiles[name] then
    return false, "No profile named " .. tostring(name)
  end
  if db.activeProfile == name then return true end
  -- Persist the OLD profile's in-memory state before switching.
  persistActiveProfile()
  local old = db.activeProfile
  db.activeProfile = name
  loadActiveProfile()
  -- Fire fine-grained events first so font/theme/scale subscribers reflow,
  -- then announce the profile change itself.
  for k in pairs(SETTING_DEFAULTS) do
    self:Fire(self.Events.SettingsChanged, k, self.settings[k], nil)
  end
  self:Fire(self.Events.SettingsChanged, "activeProfile", name, old)
  return true
end

function lib:CreateProfile(name, copyFrom)
  if type(name) ~= "string" or name == "" then return false, "Name required" end
  local db = ensureSharedDB()
  if db.profiles[name] then return false, "Profile already exists: " .. name end
  local p
  if copyFrom and db.profiles[copyFrom] then
    -- Persist live state into the source profile first so the copy reflects
    -- whatever the user has changed in this session.
    if copyFrom == db.activeProfile then persistActiveProfile() end
    p = {}
    for k, v in pairs(db.profiles[copyFrom]) do
      if type(v) == "table" then
        local t = {}
        for kk, vv in pairs(v) do t[kk] = vv end
        p[k] = t
      else
        p[k] = v
      end
    end
  else
    p = newDefaultProfile()
  end
  db.profiles[name] = p
  return true
end

function lib:DeleteProfile(name)
  local db = ensureSharedDB()
  if name == db.activeProfile then return false, "Cannot delete the active profile" end
  if not db.profiles[name] then return false, "No profile named " .. name end
  db.profiles[name] = nil
  -- Drop any cog overrides that pointed at the deleted profile.
  for cog, p in pairs(db.cogOverrides) do
    if p == name then
      db.cogOverrides[cog] = nil
      self._cogFonts[cog] = nil
      self:Fire(self.Events.SettingsChanged, "cogProfile", cog, name)
    end
  end
  return true
end

function lib:RenameProfile(oldName, newName)
  local db = ensureSharedDB()
  if not db.profiles[oldName] then return false, "No profile named " .. oldName end
  if db.profiles[newName] then return false, "Profile already exists: " .. newName end
  if type(newName) ~= "string" or newName == "" then return false, "Name required" end
  db.profiles[newName] = db.profiles[oldName]
  db.profiles[oldName] = nil
  if db.activeProfile == oldName then db.activeProfile = newName end
  for cog, p in pairs(db.cogOverrides) do
    if p == oldName then db.cogOverrides[cog] = newName end
  end
  return true
end

function lib:ExportProfile(name)
  local db = ensureSharedDB()
  local p  = db.profiles[name]
  if not p then return nil, "No profile named " .. tostring(name) end
  if name == db.activeProfile then persistActiveProfile() end
  local parts = { "CogworksProfile:" .. name }
  for _, k in ipairs({ "fontScale", "uiScale", "fontFamily", "theme" }) do
    if p[k] ~= nil then
      parts[#parts + 1] = k .. "=" .. tostring(p[k])
    end
  end
  return table.concat(parts, "|")
end

function lib:ImportProfile(str)
  if type(str) ~= "string" or str == "" then return nil, "Empty string" end
  local name = str:match("^CogworksProfile:([^|]+)")
  if not name then return nil, "Invalid format" end
  local db = ensureSharedDB()
  -- Append (n) suffix if name collides so an import never silently replaces.
  local final, n = name, 1
  while db.profiles[final] do
    n = n + 1
    final = name .. " (" .. n .. ")"
  end
  local p = newDefaultProfile()
  for k, v in str:gmatch("([%w]+)=([^|]+)") do
    if k == "fontScale" or k == "uiScale" then
      p[k] = tonumber(v) or p[k]
    elseif k == "fontFamily" or k == "theme" then
      p[k] = v
    end
  end
  db.profiles[final] = p
  return final
end

-- ---- Per-cog override -----------------------------------------------------

function lib:GetCogProfile(cogName)
  local db = ensureSharedDB()
  return db.cogOverrides[cogName]
end

function lib:SetCogProfile(cogName, profileName)
  local db  = ensureSharedDB()
  local old = db.cogOverrides[cogName]
  if profileName == old then return true end
  if profileName ~= nil and not db.profiles[profileName] then
    return false, "No profile named " .. tostring(profileName)
  end
  db.cogOverrides[cogName] = profileName
  if profileName == nil then
    self._cogFonts[cogName] = nil
  else
    self:_RefreshCogFonts(cogName)
  end
  self:Fire(self.Events.SettingsChanged, "cogProfile", cogName, old)
  return true
end

function lib:GetCogSetting(cogName, key)
  local db    = ensureSharedDB()
  local pName = db.cogOverrides[cogName]
  -- Only fontScale + fontFamily are per-cog overridable; other keys always
  -- read from the suite-active settings.
  if pName and (key == "fontScale" or key == "fontFamily") then
    local p = db.profiles[pName]
    if p and p[key] ~= nil then return p[key] end
  end
  return self.settings[key]
end

function lib:GetCogTheme(cogName)
  -- Theme is suite-wide; per-cog overrides do not affect it.
  return self.activeThemeName
end

-- Rebuild the per-cog FontObject set using the cog's effective override.
-- Called when a cog gains an override and after Scaling.lua's slider edits
-- the override profile's font settings directly.
function lib:_RefreshCogFonts(cogName)
  local familyKey = self:GetCogSetting(cogName, "fontFamily") or "default"
  local scale     = self:GetCogSetting(cogName, "fontScale") or 1.0
  scale = math.max(0.8, math.min(1.4, scale))
  local fontPath  = self:GetFontPath(familyKey)
  self._cogFonts[cogName] = self._cogFonts[cogName] or {}
  for key, def in pairs(FONT_DEFS) do
    local foName = "CogworksFont_" .. cogName .. "_" .. key
    local fo = self._cogFonts[cogName][key]
    if not fo then
      fo = CreateFont(foName)
      fo:CopyFontObject(def.base)
      self._cogFonts[cogName][key] = fo
    end
    local _, _, flags = fo:GetFont()
    fo:SetFont(fontPath, math.floor(def.size * scale + 0.5), flags or "")
  end
end

-- ============================================================================
-- Suite roster
-- ============================================================================
-- The canonical list of all cogs (released and planned). Used by the gear
-- assembly widget to show installed vs. missing members regardless of which
-- cogs are loaded in the current session.

-- Each entry's `innerIcon` names a TGA bundled in Cogworks-1.0/Art/inner/, so
-- the gear assembly can render the suite even when consumer cogs aren't
-- installed. Resolved at runtime via libArtPath. `cluster` describes how the
-- entry sits in cluster mode: "core" = mesh-engaged with the hub,
-- "edge" = floating, "hub" = the central piece.
lib.SuiteRoster = {
  {
    name      = "Cogworks",
    role      = "Shared core library",
    innerIcon = "cw-inner",
    central   = true,
    cluster   = "hub",
  },
  {
    name      = "FlipQueue",
    role      = "Auction flipping workflow",
    innerIcon = "fq-inner",
    url       = "https://www.curseforge.com/wow/addons/flipqueue",
    cluster   = "core",
  },
  {
    name      = "Tempo",
    role      = "Reset & task tracking",
    innerIcon = "tm-inner",
    url       = "https://www.curseforge.com/wow/addons/tempo",
    cluster   = "core",
  },
  {
    name      = "Maxcraft",
    role      = "Profession optimization",
    innerIcon = "mc-inner",
    url       = "https://www.curseforge.com/wow/addons/maxcraft",
    cluster   = "edge",
  },
  {
    name      = "Tally",
    role      = "Net worth, sales evaluation, ledger snapshots",
    innerIcon = "tl-inner",
    -- url left unset: Tally has no CurseForge slug yet. The "click missing
    -- gear for download link" path is a no-op until that's filled in.
    cluster   = "edge",
  },
}

-- ============================================================================
-- Gear assembly widget
-- ============================================================================
-- A visual showing every cog in the suite as gear-bordered minimap-style nodes.
-- Two layouts:
--   "cluster" — Cogworks at center; FlipQueue and Tempo flank the hub with
--               teeth meshed; the hub spins clockwise and the meshed pair
--               counter-rotates. Maxcraft and Tally sit above as triangle
--               apexes (Maxcraft above the Tempo↔hub mesh, Tally above the
--               hub↔FlipQueue mesh) — visually a separate layer from the
--               core trio, reflecting that they're naturally less coupled.
--   "row"     — Linear arrangement, all gears spinning together, with thin
--               connector bars. Better when horizontal space is tight.
-- Embed via cw:CreateGearAssembly(parent, { layout = "cluster" }).

local GEAR_ICON_FALLBACK = "Interface\\Icons\\INV_Misc_Gear_01"

-- Sizes are tuned so the inner glyph reaches the inner edge of the gear
-- ring. CogBorder's central hole reads bigger than the tracking-border's
-- hole, so the inner glyph runs ~0.82 of the ring on core/edge tiers and
-- ~0.88 on the hub (the hub texture has more anti-aliased edge area, so
-- pushing the inner closer hides the alpha falloff behind ring teeth).
local HUB_RING_SIZE,  HUB_INNER_SIZE  = 64, 56
local CORE_RING_SIZE, CORE_INNER_SIZE = 56, 46
local EDGE_RING_SIZE, EDGE_INNER_SIZE = 44, 36

-- Spin periods. Meshed gears must counter-rotate at speeds proportional to
-- their tooth count (~ ring radius) so tooth velocity matches at the contact
-- point. CORE_PERIOD is derived from HUB_PERIOD via the radius ratio.
local HUB_PERIOD  = 24
local CORE_PERIOD = HUB_PERIOD * (CORE_RING_SIZE / HUB_RING_SIZE)
local EDGE_PERIOD = 30

-- Cluster geometry (frame-local coords, origin at the frame center). Names
-- match SuiteRoster `name` fields. Slots reflect the integration shape:
-- core trio meshed in a horizontal line; Maxcraft and Tally form triangle
-- apexes above the core (between Tempo↔hub and hub↔FlipQueue respectively).
-- Vertical centering chosen so content extents are roughly symmetric
-- around y = 0 (core trio sits below center, edges sit above center).
-- Edge-gear positions are tuned so each edge meshes with both its core
-- neighbour and the hub: distance(edge, core) ≈ EDGE_RING_SIZE/2 + CORE_RING_SIZE/2
-- and distance(edge, hub) ≈ EDGE_RING_SIZE/2 + HUB_RING_SIZE/2, both with the
-- same ~5 px overlap as the CW↔FQ↔TM mesh. Earlier values floated the edges
-- well above the core; the triangle apex was visually disconnected.
local CLUSTER_POSITIONS = {
  Cogworks  = { x =   0, y = -21 },
  FlipQueue = { x =  54, y = -21 },
  Tempo     = { x = -54, y = -21 },
  Maxcraft  = { x = -30, y =  17 },
  Tally     = { x =  30, y =  17 },
}
local CLUSTER_FRAME_W, CLUSTER_FRAME_H = 180, 120
local ROW_SPACING = 8

-- Decide ring/inner size, spin period, and rotation direction for a roster
-- entry under the active layout. In cluster mode the hub spins one way and
-- the meshed core trio spins the other; edges idle slowly. In row mode every
-- non-hub uses the core size and everything spins the same direction.
local function gearStyle(entry, layout)
  local roleKey
  if entry.central or entry.cluster == "hub" then
    roleKey = "hub"
  elseif layout == "cluster" then
    roleKey = entry.cluster or "core"
  else
    roleKey = "core"  -- row mode flattens edge gears to core size
  end

  if roleKey == "hub"  then return roleKey, HUB_RING_SIZE,  HUB_INNER_SIZE,  HUB_PERIOD,  -1 end
  if roleKey == "core" then return roleKey, CORE_RING_SIZE, CORE_INNER_SIZE, CORE_PERIOD, layout == "cluster" and 1 or -1 end
  return roleKey, EDGE_RING_SIZE, EDGE_INNER_SIZE, EDGE_PERIOD, -1
end

function lib:CreateGearAssembly(parent, opts)
  opts = opts or {}
  local T      = self.Theme
  local roster = self.SuiteRoster
  local layout = opts.layout or "cluster"
  local showLabels = opts.showLabels
  if showLabels == nil then showLabels = (layout == "row") end

  local f = CreateFrame("Frame", nil, parent)
  local gears = {}

  local function createGear(entry)
    local roleKey, ringSize, innerSize, period, dirSign = gearStyle(entry, layout)

    local g = CreateFrame("Button", nil, f)
    g:SetSize(ringSize, ringSize)

    -- Gear-tooth ring (rotates).
    g.ring = g:CreateTexture(nil, "BACKGROUND")
    g.ring:SetSize(ringSize, ringSize)
    g.ring:SetPoint("CENTER")
    g.ring:SetTexture(libArtPath(nil, "CogBorder"))

    g.spinGroup = g.ring:CreateAnimationGroup()
    local spin = g.spinGroup:CreateAnimation("Rotation")
    spin:SetDegrees(dirSign * 360)
    spin:SetDuration(period)
    g.spinGroup:SetLooping("REPEAT")

    -- Inner glyph (does NOT rotate — minimap convention keeps the brand upright).
    g.icon = g:CreateTexture(nil, "ARTWORK")
    g.icon:SetSize(innerSize, innerSize)
    g.icon:SetPoint("CENTER")

    if showLabels then
      g.label = g:CreateFontString(nil, "OVERLAY")
      g.label:SetFontObject(lib.Fonts.small)
      g.label:SetPoint("TOP", g, "BOTTOM", 0, -2)
      g.label:SetText(entry.name)
    end

    g.entry = entry
    g.role  = roleKey
    return g
  end

  local function applyState(g)
    local entry = g.entry
    local installed = lib.addons[entry.name] ~= nil
    local planned   = entry.planned

    -- Inner glyph source. Cogworks-bundled innerIcon is preferred because
    -- it always resolves; addonInfo.icon and entry.icon are fallbacks for
    -- cogs whose art lives in their own AddOns folder.
    local innerPath
    if entry.innerIcon then
      innerPath = libArtPath(nil, "inner\\" .. entry.innerIcon)
    else
      local addonInfo = lib.addons[entry.name]
      innerPath = (addonInfo and addonInfo.icon) or entry.icon or GEAR_ICON_FALLBACK
    end
    g.icon:SetTexture(innerPath)

    -- Saturation + ring tint carry the install-state signal — no overlay
    -- glyph. (A "?" on dimmed gears wasn't communicating anything beyond
    -- what desaturation already conveyed, so it was just noise.)
    if installed or entry.central then
      g.icon:SetDesaturated(false)
      g.icon:SetVertexColor(1, 1, 1, 1)
      g.ring:SetVertexColor(1, 1, 1, 1)
      if g.label then g.label:SetTextColor(unpack(T.text)) end
    elseif planned then
      g.icon:SetDesaturated(true)
      g.icon:SetVertexColor(0.45, 0.45, 0.5, 1)
      g.ring:SetVertexColor(0.55, 0.55, 0.6, 0.55)
      if g.label then g.label:SetTextColor(unpack(T.textDisabled)) end
    else
      g.icon:SetDesaturated(true)
      g.icon:SetVertexColor(0.55, 0.55, 0.6, 1)
      g.ring:SetVertexColor(0.65, 0.65, 0.7, 0.7)
      if g.label then g.label:SetTextColor(unpack(T.textDim)) end
    end

    -- Always-on rotation: the assembly is a *visual* of the suite, so the
    -- cluster keeps turning regardless of which cogs happen to be installed.
    if not g.spinGroup:IsPlaying() then g.spinGroup:Play() end

    g:SetScript("OnEnter", function(self)
      GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
      if installed or entry.central then
        local info = lib.addons[entry.name]
        local ver = info and info.version or lib.version
        GameTooltip:AddLine(entry.name, T.gold[1], T.gold[2], T.gold[3])
        GameTooltip:AddLine(entry.role, T.textDim[1], T.textDim[2], T.textDim[3])
        GameTooltip:AddLine("v" .. ver .. " |cff30d530installed|r", T.text[1], T.text[2], T.text[3])
      elseif planned then
        GameTooltip:AddLine(entry.name, T.textDim[1], T.textDim[2], T.textDim[3])
        GameTooltip:AddLine(entry.role, T.textDim[1], T.textDim[2], T.textDim[3])
        GameTooltip:AddLine("Coming soon", T.arcane[1], T.arcane[2], T.arcane[3])
      else
        GameTooltip:AddLine(entry.name, T.warning[1], T.warning[2], T.warning[3])
        GameTooltip:AddLine(entry.role, T.textDim[1], T.textDim[2], T.textDim[3])
        GameTooltip:AddLine("Not installed — click for info", T.gold[1], T.gold[2], T.gold[3])
      end
      GameTooltip:Show()
    end)
    g:SetScript("OnLeave", function() GameTooltip:Hide() end)

    g:SetScript("OnClick", function()
      if not installed and not entry.central and entry.url then
        lib:Print("Cogworks", "Get " .. entry.name .. ": " .. entry.url)
      end
    end)
  end

  -- Build a gear node for every roster entry.
  for _, entry in ipairs(roster) do
    gears[#gears + 1] = createGear(entry)
  end

  -- Connector bars (used in row layout; cluster relies on tooth overlap).
  f.connectors = f.connectors or {}
  local function hideConnectors()
    for _, c in ipairs(f.connectors) do c:Hide() end
  end

  local function layoutCluster()
    hideConnectors()
    -- Position each gear at its named slot. Anything not in the slot map
    -- (shouldn't happen with current roster) gets parked at the lower-left
    -- so it's visible and can be repositioned later.
    local fallbackY = -CLUSTER_FRAME_H / 2 + 30
    for _, g in ipairs(gears) do
      local pos = CLUSTER_POSITIONS[g.entry.name]
      if not pos then
        pos = { x = -CLUSTER_FRAME_W / 2 + 30, y = fallbackY }
        fallbackY = fallbackY - 36
      end
      g:ClearAllPoints()
      g:SetPoint("CENTER", f, "CENTER", pos.x, pos.y)
    end
    f:SetSize(CLUSTER_FRAME_W, CLUSTER_FRAME_H)
  end

  local function layoutRow()
    -- Hub centered; non-hub gears alternating on either side in roster order.
    local hub
    local sides = {}
    for _, g in ipairs(gears) do
      if g.role == "hub" then hub = g else sides[#sides + 1] = g end
    end

    local labelH = showLabels and 14 or 0
    local rowH   = HUB_RING_SIZE + labelH + 4

    local yShift = labelH / 2
    if hub then
      hub:ClearAllPoints()
      hub:SetPoint("CENTER", f, "CENTER", 0, yShift)
    end

    local halfCount = math.ceil(#sides / 2)
    local hubHalf   = HUB_RING_SIZE / 2
    local coreHalf  = CORE_RING_SIZE / 2

    local positioned = {}
    for i, g in ipairs(sides) do
      g:ClearAllPoints()
      if i <= halfCount then
        local rank = halfCount - i  -- 0 = closest to hub
        local x = -(hubHalf + ROW_SPACING + coreHalf + rank * (CORE_RING_SIZE + ROW_SPACING))
        g:SetPoint("CENTER", f, "CENTER", x, yShift)
      else
        local rank = i - halfCount - 1
        local x = (hubHalf + ROW_SPACING + coreHalf + rank * (CORE_RING_SIZE + ROW_SPACING))
        g:SetPoint("CENTER", f, "CENTER", x, yShift)
      end
    end

    -- Build the visual order (left → right) for connector bars.
    for i = halfCount, 1, -1 do positioned[#positioned + 1] = sides[i] end
    if hub then positioned[#positioned + 1] = hub end
    for i = halfCount + 1, #sides do positioned[#positioned + 1] = sides[i] end

    hideConnectors()
    for i = 1, #positioned - 1 do
      local conn = f.connectors[i]
      if not conn then
        conn = f:CreateTexture(nil, "BACKGROUND", nil, -1)
        f.connectors[i] = conn
      end
      conn:SetColorTexture(T.brass[1], T.brass[2], T.brass[3], 0.25)
      conn:SetHeight(2)
      conn:SetPoint("LEFT", positioned[i], "RIGHT", -2, 0)
      conn:SetPoint("RIGHT", positioned[i + 1], "LEFT", 2, 0)
      conn:Show()
    end

    local rowW = HUB_RING_SIZE + 2 * (ROW_SPACING + halfCount * (CORE_RING_SIZE + ROW_SPACING)) + 16
    f:SetSize(rowW, rowH)
  end

  if layout == "row" then layoutRow() else layoutCluster() end

  for _, g in ipairs(gears) do applyState(g) end
  f.gears  = gears
  f.layout = layout

  function f:Refresh()
    for _, g in ipairs(gears) do applyState(g) end
  end

  -- Refresh when a new cog clicks in.
  local owner = {}
  lib.RegisterCallback(owner, lib.Events.AddonRegistered, function()
    if f:IsShown() then f:Refresh() end
  end)

  return f
end

-- ============================================================================
-- UI widget factories
-- ============================================================================
-- Themed widget constructors that every cog can use instead of duplicating
-- the same dark+gold button/checkbox/header code in their own UI/Shared.lua.
-- All factories return standard WoW frames; cogs own layout and positioning.

function lib:CreateButton(parent, label, width, height, onClick)
  local T = self.Theme
  local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
  btn:SetSize(width or 100, height or 24)
  btn:SetBackdrop(self.BackdropSmall)
  btn:SetBackdropColor(0.15, 0.15, 0.2, 1)
  btn:SetBackdropBorderColor(unpack(T.border))

  btn.text = btn:CreateFontString(nil, "OVERLAY")
  btn.text:SetFontObject(self.Fonts.normal)
  btn.text:SetPoint("CENTER")
  btn.text:SetText(label or "")

  btn:SetScript("OnEnter", function(b)
    b:SetBackdropColor(0.25, 0.25, 0.35, 1)
    b:SetBackdropBorderColor(unpack(T.gold))
  end)
  btn:SetScript("OnLeave", function(b)
    b:SetBackdropColor(0.15, 0.15, 0.2, 1)
    b:SetBackdropBorderColor(unpack(T.border))
  end)
  btn:SetScript("OnMouseDown", function(b)
    b:SetBackdropColor(0.1, 0.1, 0.15, 1)
  end)
  btn:SetScript("OnMouseUp", function(b)
    b:SetBackdropColor(0.25, 0.25, 0.35, 1)
  end)

  if onClick then btn:SetScript("OnClick", onClick) end
  return btn
end

function lib:CreateCheckbox(parent, label, description, initialValue, onChange)
  local T = self.Theme
  local cb = CreateFrame("CheckButton", nil, parent, "UICheckButtonTemplate")
  cb:SetSize(26, 26)

  cb.label = cb:CreateFontString(nil, "OVERLAY")
  cb.label:SetFontObject(self.Fonts.normal)
  cb.label:SetPoint("LEFT", cb, "RIGHT", 4, 0)
  cb.label:SetText(label or "")
  cb.label:SetTextColor(unpack(T.text))

  if description and description ~= "" then
    cb.description = cb:CreateFontString(nil, "OVERLAY")
    cb.description:SetFontObject(self.Fonts.small)
    cb.description:SetPoint("TOPLEFT", cb.label, "BOTTOMLEFT", 0, -2)
    cb.description:SetPoint("RIGHT", parent, "RIGHT", -8, 0)
    cb.description:SetJustifyH("LEFT")
    cb.description:SetText(description)
    cb.description:SetTextColor(unpack(T.textDim))
    cb.description:SetWordWrap(true)
  end

  cb:SetChecked(initialValue and true or false)
  cb:SetScript("OnClick", function(c)
    local checked = c:GetChecked()
    PlaySound(checked and SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON
                       or SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_OFF)
    if onChange then onChange(checked) end
  end)
  return cb
end

function lib:CreateIconButton(parent, icon, size, tooltip, onClick)
  local btn = CreateFrame("Button", nil, parent)
  btn:SetSize(size or 16, size or 16)

  btn.tex = btn:CreateTexture(nil, "ARTWORK")
  btn.tex:SetAllPoints()
  btn.tex:SetTexture(icon)

  btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT")
  btn.highlight:SetAllPoints()
  btn.highlight:SetColorTexture(1, 1, 1, 0.2)

  if onClick then btn:SetScript("OnClick", onClick) end
  if tooltip then
    btn:SetScript("OnEnter", function(b)
      GameTooltip:SetOwner(b, "ANCHOR_BOTTOM")
      GameTooltip:SetText(tooltip, 1, 1, 1)
      GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
  end
  return btn
end

-- CreateSectionHeader supports two call shapes:
--
--   Legacy positional: cw:CreateSectionHeader(parent, "TEXT", -8)
--     Always anchors TOPLEFT/RIGHT to the parent.
--
--   Opts table:        cw:CreateSectionHeader(parent, { text=..., rule=true,
--                                                       anchor={prevHeader, "BOTTOMLEFT"},
--                                                       gap=12, color={...} })
--     - text     : header text (uppercased)
--     - rule     : when true, adds a 1px theme.border underline
--     - anchor   : { frame, point } — anchor TOPLEFT to frame at point.
--                  Lets section headers cascade off siblings instead of
--                  always anchoring to the parent.
--     - gap      : pixel offset from the anchor point (default 0; for
--                  legacy mode this is the yOffset arg)
--     - color    : { r, g, b, a } — defaults to theme textDim
function lib:CreateSectionHeader(parent, textOrOpts, yOffset)
  local T = self.Theme
  local opts
  if type(textOrOpts) == "table" then
    opts = textOrOpts
  else
    opts = { text = textOrOpts, gap = yOffset }
  end
  local color = opts.color or T.textDim
  local gap   = opts.gap or 0

  local h = parent:CreateFontString(nil, "OVERLAY")
  h:SetFontObject(self.Fonts.header)
  if opts.anchor then
    local af, ap = opts.anchor[1], opts.anchor[2] or "BOTTOMLEFT"
    h:SetPoint("TOPLEFT", af, ap, 0, gap)
    h:SetPoint("RIGHT",   parent, "RIGHT", -8, 0)
  else
    h:SetPoint("TOPLEFT", parent, "TOPLEFT", 8, gap)
    h:SetPoint("RIGHT",   parent, "RIGHT", -8, 0)
  end
  h:SetJustifyH("LEFT")
  h:SetText((opts.text or ""):upper())
  h:SetTextColor(color[1], color[2], color[3], color[4] or 1)

  if opts.rule then
    -- 1px underline keyed off theme.border. Anchored to the header's bottom
    -- so it follows the header when fontScale changes.
    local rule = parent:CreateTexture(nil, "OVERLAY")
    rule:SetHeight(1)
    rule:SetPoint("TOPLEFT",  h, "BOTTOMLEFT",  0, -2)
    rule:SetPoint("TOPRIGHT", h, "BOTTOMRIGHT", 0, -2)
    rule:SetColorTexture(T.border[1], T.border[2], T.border[3], T.border[4] or 1)
    h.rule = rule
  end
  return h
end

function lib:CreateProgressBar(parent, width, height)
  local T = self.Theme
  local bar = CreateFrame("Frame", nil, parent, "BackdropTemplate")
  bar:SetSize(width or 150, height or 14)
  bar:SetBackdrop({
    bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
    edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
    edgeSize = 8,
    insets   = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  bar:SetBackdropColor(0.05, 0.05, 0.08, 1)
  bar:SetBackdropBorderColor(unpack(T.border))

  bar.fill = bar:CreateTexture(nil, "ARTWORK")
  bar.fill:SetPoint("TOPLEFT", bar, "TOPLEFT", 2, -2)
  bar.fill:SetPoint("BOTTOMLEFT", bar, "BOTTOMLEFT", 2, 2)
  bar.fill:SetColorTexture(unpack(T.success))
  bar.fill:SetWidth(0.001)

  bar.text = bar:CreateFontString(nil, "OVERLAY")
  bar.text:SetFontObject(self.Fonts.small)
  bar.text:SetPoint("CENTER")
  bar.text:SetTextColor(1, 1, 1, 1)
  bar.text:SetText("")

  function bar:SetProgress(current, max)
    current = current or 0
    max = max or 0
    if max <= 0 then
      self.fill:SetWidth(0.001)
      self.text:SetText("0/0")
      return
    end
    local frac = math.min(1, current / max)
    local inner = self:GetWidth() - 4
    self.fill:SetWidth(math.max(0.001, inner * frac))
    self.text:SetText(current .. "/" .. max)
  end

  function bar:SetBarColor(r, g, b)
    self.fill:SetColorTexture(r, g, b, 0.8)
  end

  return bar
end

function lib:CreateNavButton(parent, navItem, onClick)
  local T = self.Theme
  local btn = CreateFrame("Button", nil, parent)
  btn:SetSize(parent:GetWidth() or 160, 32)

  btn.bg = btn:CreateTexture(nil, "BACKGROUND")
  btn.bg:SetAllPoints()
  btn.bg:SetColorTexture(1, 1, 1, 0)

  btn.accent = btn:CreateTexture(nil, "ARTWORK")
  btn.accent:SetSize(3, 24)
  btn.accent:SetPoint("LEFT", btn, "LEFT", 0, 0)
  btn.accent:SetColorTexture(unpack(T.gold))
  btn.accent:Hide()

  if navItem.icon then
    btn.icon = btn:CreateTexture(nil, "ARTWORK")
    btn.icon:SetSize(16, 16)
    btn.icon:SetPoint("LEFT", btn, "LEFT", 12, 0)
    btn.icon:SetTexture(navItem.icon)
    btn.icon:SetDesaturated(true)
  end

  btn.label = btn:CreateFontString(nil, "OVERLAY")
  btn.label:SetFontObject(self.Fonts.normal)
  btn.label:SetPoint("LEFT",  btn, "LEFT",  navItem.icon and 34 or 12, 0)
  -- Bound the right edge and disable wrap so labels clip cleanly at the
  -- button width when the user runs at fontScale=1.4 instead of overflowing
  -- past the button's visual boundary.
  btn.label:SetPoint("RIGHT", btn, "RIGHT", -8, 0)
  btn.label:SetJustifyH("LEFT")
  btn.label:SetWordWrap(false)
  btn.label:SetText(navItem.label or "")
  btn.label:SetTextColor(unpack(T.textDim))

  if navItem.badge then
    btn.badge = btn:CreateFontString(nil, "OVERLAY")
    btn.badge:SetFontObject(self.Fonts.small)
    btn.badge:SetPoint("RIGHT", btn, "RIGHT", -8, 0)
    btn.badge:SetTextColor(unpack(T.textDim))
  end

  btn:SetScript("OnEnter", function(b)
    b.bg:SetColorTexture(1, 1, 1, 0.08)
    b.label:SetTextColor(unpack(T.text))
  end)
  btn:SetScript("OnLeave", function(b)
    if b.active then
      b.bg:SetColorTexture(1, 1, 1, 0.06)
      b.label:SetTextColor(unpack(T.text))
    else
      b.bg:SetColorTexture(1, 1, 1, 0)
      b.label:SetTextColor(unpack(T.textDim))
    end
  end)

  if onClick then btn:SetScript("OnClick", onClick) end
  btn.active = false
  return btn
end

function lib:SetNavButtonActive(btn, isActive)
  local T = self.Theme
  btn.active = isActive
  if isActive then
    btn.accent:Show()
    btn.label:SetTextColor(unpack(T.text))
    btn.bg:SetColorTexture(1, 1, 1, 0.06)
    if btn.icon then btn.icon:SetDesaturated(false) end
  else
    btn.accent:Hide()
    btn.label:SetTextColor(unpack(T.textDim))
    btn.bg:SetColorTexture(1, 1, 1, 0)
    if btn.icon then btn.icon:SetDesaturated(true) end
  end
end

-- ============================================================================
-- Dropdown
-- ============================================================================
-- A themed dropdown selector. Items is a list of {key, label} tables.
-- onChange(key, label) fires when the user picks a new value.

-- opts (5th arg, optional) — extends the legacy positional signature additively.
--   opts.width       — explicit fixed width (overrides autoWidth)
--   opts.autoWidth   — when true, fits the dropdown to the widest item label
--                      (clamped by minWidth / maxWidth) and re-fits on SetItems
--                      and on fontScale / fontFamily change
--   opts.minWidth    — auto-width floor (default 80)
--   opts.maxWidth    — auto-width ceiling (default 600)
--   opts.itemBuilder — function(rowFrame, item, isSelected) for custom menu rows
--                      (COG-45). Called once per row when its bound item changes;
--                      populate rowFrame however you like (multi-line, sublabel,
--                      right-aligned badge). The lib still owns selection paint +
--                      the row click handler, so use non-mouse regions (font
--                      strings / textures) so clicks fall through to the row.
--                      Omitted → default flat-label rendering (unchanged). With a
--                      builder, set `item.width` so auto-width can measure rows.
function lib:CreateDropdown(parent, items, selectedKey, onChange, opts)
  opts = opts or {}
  local T = self.Theme
  local itemBuilder = opts.itemBuilder
  local dd = CreateFrame("Frame", nil, parent, "BackdropTemplate")
  local explicitWidth = opts.width
  local autoWidth     = opts.autoWidth and not explicitWidth
  local minWidth      = opts.minWidth or 80
  local maxWidth      = opts.maxWidth or 600
  dd:SetSize(explicitWidth or 200, 26)
  dd:SetBackdrop(self.BackdropSmall)
  dd:SetBackdropColor(0.15, 0.15, 0.2, 1)
  dd:SetBackdropBorderColor(unpack(T.border))

  dd.selected = dd:CreateFontString(nil, "OVERLAY")
  dd.selected:SetFontObject(self.Fonts.normal)
  dd.selected:SetPoint("LEFT", dd, "LEFT", 8, 0)
  dd.selected:SetPoint("RIGHT", dd, "RIGHT", -20, 0)
  dd.selected:SetJustifyH("LEFT")
  dd.selected:SetWordWrap(false)

  dd.arrow = dd:CreateFontString(nil, "OVERLAY")
  dd.arrow:SetFontObject(self.Fonts.small)
  dd.arrow:SetPoint("RIGHT", dd, "RIGHT", -6, 0)
  dd.arrow:SetText("v")
  dd.arrow:SetTextColor(unpack(T.textDim))

  dd._items = items or {}
  dd._selectedKey = selectedKey
  dd._onChange = onChange

  local function updateLabel()
    for _, item in ipairs(dd._items) do
      if item.key == dd._selectedKey then
        dd.selected:SetText(item.label)
        dd.selected:SetTextColor(unpack(T.text))
        return
      end
    end
    dd.selected:SetText(dd._selectedKey or "Select...")
    dd.selected:SetTextColor(unpack(T.textDim))
  end
  updateLabel()

  -- Menu frame (created once, reused)
  local menu = CreateFrame("Frame", nil, dd, "BackdropTemplate")
  menu:SetBackdrop(self.Backdrop)
  menu:SetBackdropColor(T.bg[1], T.bg[2], T.bg[3], 0.98)
  menu:SetBackdropBorderColor(unpack(T.border))
  menu:SetFrameStrata("FULLSCREEN_DIALOG")
  menu:SetClampedToScreen(true)
  menu:Hide()

  local menuScroll = CreateFrame("ScrollFrame", nil, menu)
  menuScroll:SetPoint("TOPLEFT", menu, "TOPLEFT", 4, -4)
  menuScroll:SetPoint("BOTTOMRIGHT", menu, "BOTTOMRIGHT", -4, 4)
  local menuContent = CreateFrame("Frame", nil, menuScroll)
  menuContent:SetWidth(1)
  menuContent:SetHeight(1)
  menuScroll:SetScrollChild(menuContent)
  menuScroll:EnableMouseWheel(true)
  menuScroll:SetScript("OnMouseWheel", function(sf, delta)
    local range = math.max(0, menuContent:GetHeight() - sf:GetHeight())
    local cur = sf:GetVerticalScroll()
    sf:SetVerticalScroll(math.max(0, math.min(range, cur - delta * 20)))
  end)

  local menuRows = {}
  local _fontObjects = {}

  local function getItemFontObject(item)
    if not item.fontPath then return lib.Fonts.normal end
    local foKey = item.fontPath
    if not _fontObjects[foKey] then
      local fo = CreateFont("CogworksDDFont_" .. (#_fontObjects + 1))
      fo:CopyFontObject(lib.Fonts.normal)
      local _, size, flags = fo:GetFont()
      fo:SetFont(item.fontPath, size, flags or "")
      _fontObjects[foKey] = fo
    end
    return _fontObjects[foKey]
  end

  -- Auto-width: measure each label with its FontObject, take the max, add
  -- chrome padding for left inset + right arrow space, clamp by min/max.
  local _measureFs
  local function applyAutoWidth()
    if not autoWidth then return end
    if not _measureFs then
      _measureFs = dd:CreateFontString(nil, "ARTWORK")
      _measureFs:Hide()
    end
    local maxW = 0
    for _, item in ipairs(dd._items) do
      local w
      if itemBuilder and item.width then
        w = item.width  -- custom rows report their own width hint
      else
        _measureFs:SetFontObject(getItemFontObject(item))
        _measureFs:SetText(item.label or "")
        w = _measureFs:GetStringWidth()
      end
      if w > maxW then maxW = w end
    end
    -- 8 left inset + 20 right arrow gutter, plus a bit of breathing room
    local target = math.max(minWidth, math.min(maxWidth, maxW + 36))
    dd:SetWidth(target)
  end

  local function buildMenu()
    for _, r in ipairs(menuRows) do r:Hide() end
    local itemH = 22
    local maxVisible = 14
    local count = #dd._items
    local visCount = math.min(count, maxVisible)
    local menuW = math.max(dd:GetWidth(), 260)
    local menuH = visCount * itemH + 8

    menu:SetSize(menuW, menuH)
    menu:ClearAllPoints()
    -- Open below by default; flip up when there isn't room below the dropdown
    -- and there's more space above (bottom-of-frame / bottom-of-screen case).
    local ddBottom    = dd:GetBottom()
    local ddTop       = dd:GetTop()
    local screenH     = UIParent:GetHeight()
    local spaceBelow  = ddBottom or screenH
    local spaceAbove  = ddTop and (screenH - ddTop) or 0
    local fitsBelow   = spaceBelow >= menuH + 4
    if fitsBelow or spaceBelow >= spaceAbove then
      menu:SetPoint("TOPLEFT", dd, "BOTTOMLEFT", 0, -2)
    else
      menu:SetPoint("BOTTOMLEFT", dd, "TOPLEFT", 0, 2)
    end
    menuContent:SetWidth(menuW - 8)
    menuContent:SetHeight(count * itemH)
    menuScroll:SetVerticalScroll(0)

    for i, item in ipairs(dd._items) do
      local row = menuRows[i]
      if not row then
        row = CreateFrame("Button", nil, menuContent)
        row:SetHeight(itemH)
        row.bg = row:CreateTexture(nil, "BACKGROUND")
        row.bg:SetAllPoints()
        row.bg:SetColorTexture(1, 1, 1, 0)
        if itemBuilder then
          -- Custom-row host. Builder content lands here; selection + click stay
          -- lib-owned via row.bg + the row button's OnClick.
          row.builderHost = CreateFrame("Frame", nil, row)
          row.builderHost:SetPoint("TOPLEFT", row, "TOPLEFT", 6, 0)
          row.builderHost:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -6, 0)
        else
          row.label = row:CreateFontString(nil, "OVERLAY")
          row.label:SetPoint("LEFT", row, "LEFT", 6, 0)
          row.label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
          row.label:SetJustifyH("LEFT")
          row.label:SetWordWrap(false)
        end
        -- Resting paint (mouse not over): a faint gold wash marks the selected
        -- custom row, since custom rows have no lib-painted gold label.
        local function rest(r)
          if itemBuilder and r._selected then
            r.bg:SetColorTexture(T.gold[1], T.gold[2], T.gold[3], 0.14)
          else
            r.bg:SetColorTexture(1, 1, 1, 0)
          end
        end
        row._rest = rest
        row:SetScript("OnEnter", function(r) r.bg:SetColorTexture(unpack(T.rowHover)) end)
        row:SetScript("OnLeave", function(r) r._rest(r) end)
        menuRows[i] = row
      end
      row:SetPoint("TOPLEFT", menuContent, "TOPLEFT", 0, -(i-1) * itemH)
      row:SetPoint("RIGHT", menuContent, "RIGHT", 0, 0)
      row._selected = (item.key == dd._selectedKey)
      if itemBuilder then
        -- Rebuild custom content only when the bound item changes — keeps repeat
        -- opens free of work and avoids accumulating builder widgets.
        if row._builtKey ~= item.key then
          for _, c in ipairs({ row.builderHost:GetChildren() }) do c:Hide(); c:SetParent(nil) end
          for _, rg in ipairs({ row.builderHost:GetRegions() }) do rg:Hide() end
          itemBuilder(row.builderHost, item, row._selected)
          row._builtKey = item.key
        end
        row._rest(row)
      else
        row.label:SetFontObject(getItemFontObject(item))
        row.label:SetText(item.label)
        if row._selected then
          row.label:SetTextColor(T.gold[1], T.gold[2], T.gold[3])
        else
          row.label:SetTextColor(unpack(T.text))
        end
      end
      row:SetScript("OnClick", function()
        dd._selectedKey = item.key
        updateLabel()
        menu:Hide()
        if dd._onChange then dd._onChange(item.key, item.label) end
      end)
      row:Show()
    end
  end

  -- Click to toggle (not hold)
  dd:EnableMouse(true)
  dd:SetScript("OnMouseUp", function()
    if menu:IsShown() then
      menu:Hide()
    else
      buildMenu()
      menu:Show()
      menu._openTime = GetTime()
    end
  end)
  dd:SetScript("OnEnter", function(d)
    d:SetBackdropBorderColor(unpack(T.gold))
  end)
  dd:SetScript("OnLeave", function(d)
    if not menu:IsShown() then
      d:SetBackdropBorderColor(unpack(T.border))
    end
  end)

  -- Close menu when mouse leaves both dropdown and menu
  menu:SetScript("OnShow", function()
    menu._openTime = GetTime()
    menu:SetScript("OnUpdate", function()
      if (GetTime() - (menu._openTime or 0)) < 0.3 then return end
      if not dd:IsMouseOver() and not menu:IsMouseOver() then
        menu:Hide()
        dd:SetBackdropBorderColor(unpack(T.border))
      end
    end)
  end)
  menu:SetScript("OnHide", function()
    menu:SetScript("OnUpdate", nil)
  end)

  function dd:SetItems(newItems)
    dd._items = newItems
    if itemBuilder then
      -- Force custom rows to rebuild against the new item set on next open.
      for _, row in ipairs(menuRows) do row._builtKey = nil end
    end
    updateLabel()
    applyAutoWidth()
  end

  function dd:SetSelectedKey(key)
    dd._selectedKey = key
    updateLabel()
  end

  function dd:GetSelectedKey()
    return dd._selectedKey
  end

  -- Re-fit when font scale / family changes — rendered widths change with
  -- the FontObject, so a snapshot taken at construction time goes stale.
  if autoWidth then
    local owner = {}
    self.RegisterCallback(owner, self.Events.SettingsChanged, function(_, key)
      if key == "fontScale" or key == "fontFamily" then
        applyAutoWidth()
      end
    end)
  end

  applyAutoWidth()
  return dd
end

-- ============================================================================
-- Scroll table
-- ============================================================================
-- A generic sortable, resizable data table. Define columns, call SetData(),
-- done. Sorting, column drag-resize, row hover, scroll bar auto-hide, icon
-- and tooltip support are all built in.
--
-- Usage:
--   local tbl = cw:CreateScrollTable(parent, {
--     { key="name", label="Name", width=150, sortable=true, minWidth=60 },
--     { key="gold", label="Gold", width=80, align="RIGHT", format=function(v) ... end },
--   })
--   tbl:SetData(rows)
--   tbl:SetOnRowClick(function(rowData, button, index) ... end)
--
-- Optional 3rd arg `opts` (COG-46 / COG-52) — all additive; omit for the
-- v0.13.x behavior (resize-only, fit-to-frame, no row actions):
--   allowReorder     bool  — drag column headers to reorder
--   horizontalScroll bool  — SHIFT+wheel scrolls when columns overflow the view
--   saveLayoutTo     table — persisted { [colKey] = { width, order } }, applied
--                            on construction and written on resize / reorder
--   onLayoutChange   fn(layout) — fired after a resize / reorder commit
--   rowActions       array — inline per-row buttons, hover-revealed:
--                            { { icon, tooltip, onClick(rowData), visible(rowData) }, ... }
-- Also: tbl:EnableRowActions(defs) to set/replace row actions after creation.

local ST_ROW_HEIGHT   = 20
local ST_HEADER_HEIGHT = 22
local ST_COL_PADDING   = 4

local ScrollTableMixin = {}

function ScrollTableMixin:Init(parent, columns, opts)
  opts = opts or {}
  self.columns = columns
  self.data = {}
  self.sortKey = nil
  self.sortAsc = true
  self.onRowClick = nil
  self.rows = {}
  self.headerButtons = {}
  self.resizeHandles = {}
  self.container = parent

  -- Optional layout features (COG-46 / COG-52). All additive — omitting opts
  -- keeps the v0.13.x behavior (resize-only, fit-to-frame, no row actions).
  self.allowReorder     = opts.allowReorder
  self.horizontalScroll = opts.horizontalScroll
  self.saveLayoutTo     = opts.saveLayoutTo
  self.onLayoutChange   = opts.onLayoutChange
  self.rowActions       = opts.rowActions
  self._hOff            = 0

  -- Restore persisted widths + order before the header is built.
  self:ApplySavedLayout()

  self:BuildHeader(parent)
  self:BuildScrollArea(parent)
end

-- Sum of all column widths (the table's natural width before fit-to-frame).
function ScrollTableMixin:TotalColumnsWidth()
  local w = 0
  for _, c in ipairs(self.columns) do w = w + (c.width or 0) end
  return w
end

-- True when horizontal scrolling is enabled AND the columns overflow the view.
function ScrollTableMixin:IsHScrolling()
  if not self.horizontalScroll or not self.scrollFrame then return false end
  return self:TotalColumnsWidth() > self.scrollFrame:GetWidth() + 0.5
end

-- Apply persisted { [colKey] = { width, order } } onto self.columns.
function ScrollTableMixin:ApplySavedLayout()
  local layout = self.saveLayoutTo
  if not layout then return end
  for _, col in ipairs(self.columns) do
    local L = layout[col.key]
    if L and L.width and (not col.minWidth or L.width >= col.minWidth) then
      col.width = L.width
    end
  end
  local function ord(c) local L = layout[c.key]; return (L and L.order) or 1e9 end
  table.sort(self.columns, function(a, b) return ord(a) < ord(b) end)
end

-- Persist current widths + order back into saveLayoutTo and notify the caller.
function ScrollTableMixin:CommitLayout()
  if self.saveLayoutTo then
    for i, col in ipairs(self.columns) do
      local L = self.saveLayoutTo[col.key]
      if not L then L = {}; self.saveLayoutTo[col.key] = L end
      L.width = col.width
      L.order = i
    end
  end
  if self.onLayoutChange then self.onLayoutChange(self.saveLayoutTo) end
end

function ScrollTableMixin:BuildHeader(parent)
  local T = lib.Theme
  if not self.headerFrame then
    self.headerFrame = CreateFrame("Frame", nil, parent)
    self.headerFrame:SetHeight(ST_HEADER_HEIGHT)
    self.headerFrame:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
    self.headerFrame:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, 0)
    self.headerFrame:SetClipsChildren(true)  -- clip columns that scroll out of view

    local bg = self.headerFrame:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(unpack(T.header))

    local border = self.headerFrame:CreateTexture(nil, "BORDER")
    border:SetHeight(1)
    border:SetPoint("BOTTOMLEFT"); border:SetPoint("BOTTOMRIGHT")
    border:SetColorTexture(T.border[1], T.border[2], T.border[3], 1)
  else
    -- Rebuild (e.g. after a reorder): drop the old per-column buttons.
    for _, b in ipairs(self.headerButtons) do b:Hide(); b:SetParent(nil) end
    wipe(self.headerButtons)
  end

  for i, col in ipairs(self.columns) do
    local btn = CreateFrame("Button", nil, self.headerFrame)
    btn:SetHeight(ST_HEADER_HEIGHT)
    btn:SetWidth(col.width)

    btn.label = btn:CreateFontString(nil, "OVERLAY")
    btn.label:SetFontObject(lib.Fonts.small)
    btn.label:SetPoint("LEFT", btn, "LEFT", ST_COL_PADDING, 0)
    btn.label:SetPoint("RIGHT", btn, "RIGHT", -ST_COL_PADDING, 0)
    btn.label:SetJustifyH(col.align or "LEFT")
    btn.label:SetText(col.label)
    btn.label:SetTextColor(0.8, 0.8, 0.8)

    btn.arrow = btn:CreateFontString(nil, "OVERLAY")
    btn.arrow:SetFontObject(lib.Fonts.small)
    btn.arrow:SetPoint("RIGHT", btn, "RIGHT", -2, 0)
    btn.arrow:SetText("")

    btn.highlight = btn:CreateTexture(nil, "HIGHLIGHT")
    btn.highlight:SetAllPoints()
    btn.highlight:SetColorTexture(1, 1, 1, 0.05)

    if col.sortable ~= false then
      local colKey = col.key
      local tbl = self
      btn:SetScript("OnClick", function()
        if tbl.sortKey == colKey then
          tbl.sortAsc = not tbl.sortAsc
        else
          tbl.sortKey = colKey
          tbl.sortAsc = true
        end
        tbl:RefreshSort()
        tbl:Render()
      end)
    end

    -- Drag-to-reorder (COG-46). A drag gesture (not a plain click) on the
    -- header picks the column up; dropping over another header re-orders.
    if self.allowReorder then
      local tbl = self
      local colIdx = i
      btn:RegisterForDrag("LeftButton")
      btn:SetScript("OnDragStart", function(b) tbl:_BeginColumnDrag(colIdx, b) end)
      btn:SetScript("OnDragStop",  function()  tbl:_EndColumnDrag()        end)
    end

    self.headerButtons[i] = btn
  end

  -- Column resize handles (one between each adjacent pair).
  for _, h in ipairs(self.resizeHandles) do h:Hide() end
  for i = 1, #self.columns - 1 do
    local handle = self.resizeHandles[i]
    if not handle then
      handle = CreateFrame("Button", nil, self.headerFrame)
      handle:SetWidth(6)
      handle.tex = handle:CreateTexture(nil, "OVERLAY")
      handle.tex:SetAllPoints()
      handle.tex:SetColorTexture(0.4, 0.4, 0.5, 0)
      self.resizeHandles[i] = handle
    end
    handle:SetHeight(ST_HEADER_HEIGHT)
    handle:SetFrameLevel(self.headerFrame:GetFrameLevel() + 2)
    handle:Show()

    handle:SetScript("OnEnter", function(h) h.tex:SetColorTexture(1, 0.82, 0, 0.4) end)
    handle:SetScript("OnLeave", function(h)
      if not h._dragging then h.tex:SetColorTexture(0.4, 0.4, 0.5, 0) end
    end)

    local colIdx = i
    local tbl = self
    handle:SetScript("OnMouseDown", function(h, button)
      if button ~= "LeftButton" then return end
      h._dragging = true
      h._startX = GetCursorPosition() / UIParent:GetEffectiveScale()
      h._startW1 = tbl.columns[colIdx].width
      h._startW2 = tbl.columns[colIdx + 1].width
      local min1 = tbl.columns[colIdx].minWidth or 30
      local min2 = tbl.columns[colIdx + 1].minWidth or 30
      h.tex:SetColorTexture(1, 0.82, 0, 0.6)
      h:SetScript("OnUpdate", function()
        local curX = GetCursorPosition() / UIParent:GetEffectiveScale()
        local delta = curX - h._startX
        local nw1 = math.max(min1, h._startW1 + delta)
        local nw2 = math.max(min2, h._startW2 - delta)
        -- When horizontal scrolling is on, columns keep their own widths
        -- (overflow scrolls) so we don't need to steal width from the
        -- neighbor; otherwise resizing is zero-sum to stay fit-to-frame.
        if tbl.horizontalScroll then
          tbl.columns[colIdx].width = nw1
        elseif nw1 >= min1 and nw2 >= min2 then
          tbl.columns[colIdx].width = nw1
          tbl.columns[colIdx + 1].width = nw2
        end
        tbl:RelayoutColumns()
      end)
    end)
    handle:SetScript("OnMouseUp", function(h)
      h._dragging = false
      h.tex:SetColorTexture(0.4, 0.4, 0.5, 0)
      h:SetScript("OnUpdate", nil)
      for _, row in ipairs(tbl.rows) do row:Hide(); row:SetParent(nil) end
      wipe(tbl.rows)
      tbl:Render()
      tbl:CommitLayout()
    end)
  end

  self:RelayoutColumns()
end

-- Position header buttons + resize handles from current column widths/order,
-- honoring the horizontal-scroll offset. Single source of truth so resize,
-- reorder, and h-scroll all stay consistent.
function ScrollTableMixin:RelayoutColumns()
  local hOff    = self._hOff or 0
  local hscroll = self:IsHScrolling()
  local x = 0
  for j, col in ipairs(self.columns) do
    local btn = self.headerButtons[j]
    if btn then
      btn:ClearAllPoints()
      btn:SetPoint("LEFT", self.headerFrame, "LEFT", x - hOff, 0)
      if (not hscroll) and j == #self.columns then
        btn:SetPoint("RIGHT", self.headerFrame, "RIGHT", 0, 0)
      else
        btn:SetWidth(col.width)
      end
    end
    x = x + col.width
  end
  for j = 1, #self.columns - 1 do
    local h = self.resizeHandles[j]
    if h and self.headerButtons[j] then
      h:ClearAllPoints()
      h:SetPoint("LEFT", self.headerButtons[j], "RIGHT", -3, 0)
    end
  end
end

-- ---- Column reorder drag (COG-46) -----------------------------------------
function ScrollTableMixin:_BeginColumnDrag(colIdx)
  self._dragCol = colIdx
  if not self._dragGhost then
    local g = CreateFrame("Frame", nil, UIParent)
    g:SetFrameStrata("TOOLTIP")
    g:SetSize(80, ST_HEADER_HEIGHT)
    local bg = g:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(); bg:SetColorTexture(lib.Theme.gold[1], lib.Theme.gold[2], lib.Theme.gold[3], 0.4)
    g.text = g:CreateFontString(nil, "OVERLAY")
    g.text:SetFontObject(lib.Fonts.small)
    g.text:SetPoint("CENTER")
    g.text:SetTextColor(1, 1, 1)
    self._dragGhost = g
  end
  self._dragGhost.text:SetText(self.columns[colIdx].label or "")
  self._dragGhost:Show()
  self._dragGhost:SetScript("OnUpdate", function(g)
    local scale = UIParent:GetEffectiveScale()
    local mx, my = GetCursorPosition()
    g:ClearAllPoints()
    g:SetPoint("CENTER", UIParent, "BOTTOMLEFT", mx / scale, my / scale)
  end)
end

function ScrollTableMixin:_EndColumnDrag()
  local from = self._dragCol
  self._dragCol = nil
  if self._dragGhost then self._dragGhost:Hide(); self._dragGhost:SetScript("OnUpdate", nil) end
  if not from then return end
  -- Resolve drop target from cursor X over the header buttons.
  local mx = GetCursorPosition() / UIParent:GetEffectiveScale()
  local to = from
  for j, btn in ipairs(self.headerButtons) do
    local l, r = btn:GetLeft(), btn:GetRight()
    if l and r and mx >= l and mx < r then to = j; break end
  end
  if to ~= from then self:MoveColumn(from, to) end
end

-- Move a column to a new position and rebuild header + rows in the new order.
function ScrollTableMixin:MoveColumn(from, to)
  if from == to or not self.columns[from] then return end
  local col = table.remove(self.columns, from)
  table.insert(self.columns, math.max(1, math.min(#self.columns + 1, to)), col)
  self:BuildHeader(self.container)
  for _, row in ipairs(self.rows) do row:Hide(); row:SetParent(nil) end
  wipe(self.rows)
  self:Render()
  self:CommitLayout()
end

-- Register inline per-row action buttons (COG-52). Each def:
--   { icon = path, tooltip = str, onClick = fn(rowData), visible = fn(rowData) }
-- Buttons appear right-aligned on row hover so they don't cover cell text.
function ScrollTableMixin:EnableRowActions(defs)
  self.rowActions = defs
  for _, row in ipairs(self.rows) do row:Hide(); row:SetParent(nil) end
  wipe(self.rows)
  self:Render()
end

function ScrollTableMixin:BuildScrollArea(parent)
  local T = lib.Theme
  self.scrollFrame = CreateFrame("ScrollFrame", nil, parent)
  self.scrollFrame:SetPoint("TOPLEFT", self.headerFrame, "BOTTOMLEFT", 0, 0)
  self.scrollFrame:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -10, 0)

  self.content = CreateFrame("Frame", nil, self.scrollFrame)
  self.content:SetWidth(self.scrollFrame:GetWidth())
  self.content:SetHeight(1)
  self.scrollFrame:SetScrollChild(self.content)

  -- Thin themed scrollbar thumb
  local track = CreateFrame("Frame", nil, parent)
  track:SetWidth(6)
  track:SetPoint("TOPLEFT", self.scrollFrame, "TOPRIGHT", 2, 0)
  track:SetPoint("BOTTOMLEFT", self.scrollFrame, "BOTTOMRIGHT", 2, 0)
  local trackBg = track:CreateTexture(nil, "BACKGROUND")
  trackBg:SetAllPoints()
  trackBg:SetColorTexture(T.border[1], T.border[2], T.border[3], 0.15)
  self._track = track

  local thumb = CreateFrame("Frame", nil, track)
  thumb:SetWidth(6)
  thumb:SetHeight(40)
  thumb:SetPoint("TOP", track, "TOP", 0, 0)
  local thumbTex = thumb:CreateTexture(nil, "ARTWORK")
  thumbTex:SetAllPoints()
  thumbTex:SetColorTexture(T.brass[1], T.brass[2], T.brass[3], 0.5)
  self._thumb = thumb
  self._thumbTex = thumbTex

  -- Mousewheel scrolling. SHIFT+wheel scrolls horizontally when the columns
  -- overflow the view (COG-52); plain wheel scrolls vertically as before.
  local tbl = self
  self.scrollFrame:EnableMouseWheel(true)
  self.scrollFrame:SetScript("OnMouseWheel", function(sf, delta)
    if IsShiftKeyDown() and tbl:IsHScrolling() then
      local hmax = math.max(0, tbl:TotalColumnsWidth() - sf:GetWidth())
      tbl._hOff = math.max(0, math.min(hmax, (tbl._hOff or 0) - delta * 40))
      sf:SetHorizontalScroll(tbl._hOff)
      tbl:RelayoutColumns()
      return
    end
    local cur = sf:GetVerticalScroll()
    local range = tbl:GetScrollRange()
    local step = ST_ROW_HEIGHT * 3
    local newVal = math.max(0, math.min(range, cur - delta * step))
    sf:SetVerticalScroll(newVal)
    tbl:UpdateThumb()
  end)

  self.scrollFrame:SetScript("OnSizeChanged", function(sf, w)
    -- Width tracks the view, unless columns overflow and h-scroll is on, in
    -- which case the scroll child takes the full column width.
    self.content:SetWidth(tbl:IsHScrolling() and tbl:TotalColumnsWidth() or w)
    -- Clamp any stale horizontal offset to the new range.
    local hmax = math.max(0, tbl:TotalColumnsWidth() - w)
    tbl._hOff = math.max(0, math.min(hmax, tbl._hOff or 0))
    sf:SetHorizontalScroll(tbl._hOff)
    tbl:RelayoutColumns()
    tbl:UpdateThumb()
  end)

  track:Hide()
end

function ScrollTableMixin:GetScrollRange()
  local contentH = self.content:GetHeight()
  local viewH = self.scrollFrame:GetHeight()
  return math.max(0, contentH - viewH)
end

function ScrollTableMixin:UpdateThumb()
  local range = self:GetScrollRange()
  if range <= 0.5 then
    self._track:Hide()
    return
  end
  self._track:Show()
  local trackH = self._track:GetHeight()
  local viewH = self.scrollFrame:GetHeight()
  local contentH = self.content:GetHeight()
  local thumbH = math.max(20, trackH * (viewH / contentH))
  self._thumb:SetHeight(thumbH)
  local cur = self.scrollFrame:GetVerticalScroll()
  local frac = cur / range
  local travel = trackH - thumbH
  self._thumb:ClearAllPoints()
  self._thumb:SetPoint("TOP", self._track, "TOP", 0, -frac * travel)
end

function ScrollTableMixin:UpdateHeaderArrows()
  for i, col in ipairs(self.columns) do
    local btn = self.headerButtons[i]
    if btn then
      if col.key == self.sortKey then
        btn.arrow:SetText(self.sortAsc and "  v" or "  ^")
        btn.label:SetTextColor(1, 1, 1)
      else
        btn.arrow:SetText("")
        btn.label:SetTextColor(0.8, 0.8, 0.8)
      end
    end
  end
end

function ScrollTableMixin:RefreshSort()
  if not self.sortKey then return end
  local key = self.sortKey
  local asc = self.sortAsc
  local override = "_sort" .. key:sub(1,1):upper() .. key:sub(2)
  table.sort(self.data, function(a, b)
    local va = a[override] or a[key]
    local vb = b[override] or b[key]
    if va == nil then va = "" end
    if vb == nil then vb = "" end
    local na, nb = tonumber(va), tonumber(vb)
    if na and nb then return asc and na < nb or (not asc and na > nb) end
    va = tostring(va):lower()
    vb = tostring(vb):lower()
    return asc and va < vb or (not asc and va > vb)
  end)
end

function ScrollTableMixin:GetOrCreateRow(index)
  if self.rows[index] then return self.rows[index] end
  local T = lib.Theme
  local row = CreateFrame("Frame", nil, self.content)
  row:SetHeight(ST_ROW_HEIGHT)
  row:SetPoint("TOPLEFT", self.content, "TOPLEFT", 0, -(index - 1) * ST_ROW_HEIGHT)
  row:SetPoint("RIGHT", self.content, "RIGHT", 0, 0)
  row:EnableMouse(true)

  row.bg = row:CreateTexture(nil, "BACKGROUND")
  row.bg:SetAllPoints()
  row.bg:SetColorTexture(1, 1, 1, index % 2 == 0 and T.rowAlt[4] or 0)

  row:SetScript("OnEnter", function(r)
    r.bg:SetColorTexture(unpack(T.rowHover))
    if row._onEnter then row._onEnter(row) end
  end)
  row:SetScript("OnLeave", function(r)
    r.bg:SetColorTexture(1, 1, 1, index % 2 == 0 and T.rowAlt[4] or 0)
    GameTooltip:Hide()
  end)

  row.cells = {}
  row._cellClips = {}
  local xOff = 0
  for i, col in ipairs(self.columns) do
    local clip = CreateFrame("Frame", nil, row)
    clip:SetHeight(ST_ROW_HEIGHT)
    clip:SetWidth(col.width)
    clip:SetPoint("LEFT", row, "LEFT", xOff, 0)
    if i == #self.columns then clip:SetPoint("RIGHT", row, "RIGHT", 0, 0) end
    clip:SetClipsChildren(true)

    local cell = clip:CreateFontString(nil, "OVERLAY")
    cell:SetFontObject(lib.Fonts.small)
    cell:SetHeight(ST_ROW_HEIGHT)
    cell:SetPoint("LEFT", clip, "LEFT", ST_COL_PADDING, 0)
    cell:SetPoint("RIGHT", clip, "RIGHT", -ST_COL_PADDING, 0)
    cell:SetJustifyH(col.align or "LEFT")
    cell:SetWordWrap(false)
    row.cells[i] = cell
    row._cellClips[i] = clip
    xOff = xOff + col.width
  end

  row.icon = row._cellClips[1]:CreateTexture(nil, "ARTWORK")
  row.icon:SetSize(ST_ROW_HEIGHT - 4, ST_ROW_HEIGHT - 4)
  row.icon:SetPoint("LEFT", row._cellClips[1], "LEFT", 2, 0)
  row.icon:Hide()

  -- Inline row-action buttons (COG-52). Created once per row, right-aligned,
  -- revealed on hover so they never sit on top of cell text. Built right-to-
  -- left so the declared order reads left-to-right on screen.
  row._actionBtns = {}
  if self.rowActions then
    local prev
    for k = #self.rowActions, 1, -1 do
      local b = CreateFrame("Button", nil, row)
      b:SetSize(ST_ROW_HEIGHT - 4, ST_ROW_HEIGHT - 4)
      b:SetFrameLevel(row:GetFrameLevel() + 2)
      if prev then b:SetPoint("RIGHT", prev, "LEFT",  -2, 0)
      else         b:SetPoint("RIGHT", row,  "RIGHT", -4, 0) end
      b.tex = b:CreateTexture(nil, "ARTWORK"); b.tex:SetAllPoints()
      b.hl  = b:CreateTexture(nil, "HIGHLIGHT"); b.hl:SetAllPoints(); b.hl:SetColorTexture(1, 1, 1, 0.25)
      b:Hide()
      row._actionBtns[k] = b
      prev = b
    end
  end

  self.rows[index] = row
  return row
end

function ScrollTableMixin:SetData(data)
  self.data = data
  if self.sortKey then self:RefreshSort() end
  self:Render()
end

function ScrollTableMixin:Render()
  local T = lib.Theme
  self:UpdateHeaderArrows()
  for _, row in ipairs(self.rows) do
    row:Hide(); row._onEnter = nil; row:SetScript("OnMouseDown", nil)
  end

  for i, rowData in ipairs(self.data) do
    local row = self:GetOrCreateRow(i)
    for j, col in ipairs(self.columns) do
      local v = rowData[col.key]
      if col.format then v = col.format(v, rowData) end
      row.cells[j]:SetText(v or "")
    end

    -- Bind inline row-action buttons for this rowData (COG-52); kept hidden
    -- until the row is hovered.
    if self.rowActions then
      for k, def in ipairs(self.rowActions) do
        local b = row._actionBtns[k]
        if b then
          b.tex:SetTexture(def.icon)
          b:SetScript("OnClick", function() if def.onClick then def.onClick(rowData) end end)
          if def.tooltip then
            b:SetScript("OnEnter", function(self2)
              GameTooltip:SetOwner(self2, "ANCHOR_RIGHT")
              GameTooltip:SetText(def.tooltip, 1, 1, 1)
              GameTooltip:Show()
            end)
            b:SetScript("OnLeave", function() GameTooltip:Hide() end)
          end
          b._eligible = (not def.visible) or def.visible(rowData) and true or false
          b:Hide()
        end
      end
    end
    local function showActions() for _, b in ipairs(row._actionBtns) do if b._eligible then b:Show() end end end
    local function hideActions() for _, b in ipairs(row._actionBtns) do b:Hide() end end

    local defaultAlpha = i % 2 == 0 and T.rowAlt[4] or 0
    if rowData._rowColor then
      local c = rowData._rowColor
      local ba = c[4] or 0.15
      row.bg:SetColorTexture(c[1], c[2], c[3], ba)
      row:SetScript("OnEnter", function(r)
        r.bg:SetColorTexture(c[1], c[2], c[3], ba + 0.08)
        showActions()
        if row._onEnter then row._onEnter(row) end
      end)
      row:SetScript("OnLeave", function(r)
        if r:IsMouseOver() then return end   -- still over a child action button
        r.bg:SetColorTexture(c[1], c[2], c[3], ba)
        hideActions()
        GameTooltip:Hide()
      end)
    else
      row.bg:SetColorTexture(1, 1, 1, defaultAlpha)
      row:SetScript("OnEnter", function(r)
        r.bg:SetColorTexture(unpack(T.rowHover))
        showActions()
        if row._onEnter then row._onEnter(row) end
      end)
      row:SetScript("OnLeave", function(r)
        if r:IsMouseOver() then return end
        r.bg:SetColorTexture(1, 1, 1, defaultAlpha)
        hideActions()
        GameTooltip:Hide()
      end)
    end

    -- Update clip widths for resized columns
    local cx = 0
    for j, col in ipairs(self.columns) do
      if row._cellClips[j] then
        row._cellClips[j]:SetWidth(col.width)
        row._cellClips[j]:ClearAllPoints()
        row._cellClips[j]:SetPoint("LEFT", row, "LEFT", cx, 0)
        if j == #self.columns then
          row._cellClips[j]:SetPoint("RIGHT", row, "RIGHT", 0, 0)
        end
      end
      cx = cx + col.width
    end

    if rowData._icon then
      row.icon:SetTexture(rowData._icon); row.icon:Show()
      row.cells[1]:SetPoint("LEFT", row.icon, "RIGHT", 2, 0)
    else
      row.icon:Hide()
      row.cells[1]:SetPoint("LEFT", row._cellClips[1], "LEFT", ST_COL_PADDING, 0)
    end

    if rowData._tooltipText then
      row._onEnter = function()
        GameTooltip:SetOwner(row, "ANCHOR_RIGHT")
        GameTooltip:SetText(rowData._tooltipText, 1, 1, 1)
        if rowData._tooltipExtra then
          GameTooltip:AddLine(rowData._tooltipExtra, 0.7, 0.7, 0.7, true)
        end
        GameTooltip:Show()
      end
    end

    if self.onRowClick then
      local d, idx = rowData, i
      row:SetScript("OnMouseDown", function(_, button)
        self.onRowClick(d, button, idx)
      end)
    end

    row:Show()
  end

  self.content:SetWidth(self:IsHScrolling() and self:TotalColumnsWidth()
                                            or self.scrollFrame:GetWidth())
  self.content:SetHeight(math.max(1, #self.data * ST_ROW_HEIGHT))
  self.scrollFrame:SetVerticalScroll(0)
  self.scrollFrame:SetHorizontalScroll(self._hOff or 0)
  self:RelayoutColumns()
  self:UpdateThumb()
end

function ScrollTableMixin:SetOnRowClick(fn) self.onRowClick = fn end
function ScrollTableMixin:SetSort(key, asc)
  self.sortKey = key; self.sortAsc = asc ~= false
end
function ScrollTableMixin:Show()
  if self.headerFrame then self.headerFrame:Show() end
  if self.scrollFrame then self.scrollFrame:Show() end
end
function ScrollTableMixin:Hide()
  if self.headerFrame then self.headerFrame:Hide() end
  if self.scrollFrame then self.scrollFrame:Hide() end
end
function ScrollTableMixin:GetRowHeight() return ST_ROW_HEIGHT end

function lib:CreateScrollTable(parent, columns, opts)
  local tbl = setmetatable({}, { __index = ScrollTableMixin })
  tbl:Init(parent, columns, opts)
  return tbl
end

-- ============================================================================
-- Popup / dialog
-- ============================================================================
-- Modal popups with title, content area, and action buttons. Covers the common
-- confirm/cancel pattern and arbitrary content dialogs.
--
-- Usage:
--   local popup = cw:CreatePopup({ title = "Confirm", width = 350, height = 180 })
--   -- popup.content is the interior frame for custom widgets
--   popup:SetButtons({ { label = "OK", onClick = fn }, { label = "Cancel" } })
--   popup:Show()
--
--   -- Shortcut for confirm dialogs:
--   cw:ShowConfirmDialog("Delete item?", "This cannot be undone.", onYes, onNo)

function lib:CreatePopup(opts)
  opts = opts or {}
  local T = self.Theme
  local w = opts.width or 360
  local h = opts.height or 200

  -- Overlay needs a global name so ESC-to-close can register via
  -- UISpecialFrames. Auto-generate one when the caller didn't supply
  -- opts.overlayName so popups created ad-hoc still dismiss on ESC.
  lib._popupCounter = (lib._popupCounter or 0) + 1
  local overlayName = opts.overlayName or ("CogworksPopupOverlay_" .. lib._popupCounter)

  local overlay = CreateFrame("Frame", overlayName, UIParent)
  overlay:SetAllPoints()
  overlay:SetFrameStrata("FULLSCREEN_DIALOG")
  overlay:EnableMouse(true)
  overlay:Hide()

  local dimBg = overlay:CreateTexture(nil, "BACKGROUND")
  dimBg:SetAllPoints()
  dimBg:SetColorTexture(0, 0, 0, 0.5)

  local f = CreateFrame("Frame", nil, overlay, "BackdropTemplate")
  f:SetSize(w, h)
  f:SetPoint("CENTER")
  f:SetBackdrop(self.Backdrop)
  f:SetBackdropColor(unpack(T.bg))
  f:SetBackdropBorderColor(unpack(T.border))
  f:SetMovable(true)

  -- Title bar
  local titleBar = CreateFrame("Frame", nil, f)
  titleBar:SetHeight(28)
  titleBar:SetPoint("TOPLEFT"); titleBar:SetPoint("TOPRIGHT")
  titleBar:EnableMouse(true)
  titleBar:RegisterForDrag("LeftButton")
  titleBar:SetScript("OnDragStart", function() f:StartMoving() end)
  titleBar:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)

  local titleBg = titleBar:CreateTexture(nil, "BACKGROUND")
  titleBg:SetAllPoints()
  titleBg:SetColorTexture(unpack(T.header))

  local titleText = titleBar:CreateFontString(nil, "OVERLAY")
  titleText:SetFontObject(self.Fonts.normal)
  titleText:SetPoint("LEFT", titleBar, "LEFT", 10, 0)
  titleText:SetText(opts.title or "")
  titleText:SetTextColor(unpack(T.gold))

  local closeBtn = self:CreateIconButton(titleBar,
    "Interface\\Buttons\\UI-Panel-MinimizeButton-Up", 18, "Close",
    function() overlay:Hide() end)
  closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", -4, 0)

  -- Content area
  f.content = CreateFrame("Frame", nil, f)
  f.content:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 10, -8)
  f.content:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 40)

  -- Button bar
  f.buttonBar = CreateFrame("Frame", nil, f)
  f.buttonBar:SetHeight(32)
  f.buttonBar:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 6)
  f.buttonBar:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 6)

  f._buttons = {}
  function f:SetButtons(btnDefs)
    for _, b in ipairs(self._buttons) do b:Hide() end
    wipe(self._buttons)
    local xOff = 0
    for i = #btnDefs, 1, -1 do
      local def = btnDefs[i]
      local btn = lib:CreateButton(self.buttonBar, def.label, def.width or 90, 26, function()
        if def.onClick then def.onClick() end
        if def.close ~= false then overlay:Hide() end
      end)
      btn:SetPoint("RIGHT", self.buttonBar, "RIGHT", -xOff, 0)
      self._buttons[#self._buttons + 1] = btn
      xOff = xOff + (def.width or 90) + 8
    end
  end

  function f:SetTitle(text)
    titleText:SetText(text or "")
  end
  function f:Show()
    overlay:Show()
  end
  function f:Hide()
    overlay:Hide()
  end
  function f:IsShown()
    return overlay:IsShown()
  end

  -- ESC-to-close: UISpecialFrames is Blizzard's supported mechanism. The
  -- earlier OnKeyDown + SetPropagateKeyboardInput pattern tainted the
  -- secure ToggleGameMenu path (see ThemedMainFrame.lua for the full
  -- explanation of why that approach is forbidden).
  if opts.closeOnEscape ~= false then
    tinsert(UISpecialFrames, overlayName)
  end

  if opts.buttons then f:SetButtons(opts.buttons) end
  return f
end

function lib:ShowConfirmDialog(title, message, onConfirm, onCancel)
  local popup = self:CreatePopup({
    title = title, width = 380, height = 160,
  })

  local msg = popup.content:CreateFontString(nil, "OVERLAY")
  msg:SetFontObject(self.Fonts.normal)
  msg:SetAllPoints()
  msg:SetJustifyH("LEFT"); msg:SetJustifyV("TOP")
  msg:SetWordWrap(true)
  msg:SetText(message or "")
  msg:SetTextColor(unpack(self.Theme.text))

  popup:SetButtons({
    { label = "Confirm", onClick = onConfirm },
    { label = "Cancel", onClick = onCancel },
  })
  popup:Show()
  return popup
end

-- ============================================================================
-- Initialization hook
-- ============================================================================
-- ADDON_LOADED — initialize CogworksSharedDB the first time it becomes
--                visible (any consumer cog that declares the SV in its TOC
--                triggers this). Migrates from legacy CogworksDB on first run.
-- PLAYER_LOGIN — final fallback init for sessions where no cog declared the
--                SV (in-memory only); fires lib.Events.Ready for subscribers.
-- PLAYER_LOGOUT — backstop persist of the active profile. SetSetting /
--                 SetTheme already write through, but this catches anyone
--                 who mutated lib.settings directly.

if not lib._readyFrame then
  lib._readyFrame = CreateFrame("Frame")
  lib._readyFrame:RegisterEvent("ADDON_LOADED")
  lib._readyFrame:RegisterEvent("PLAYER_LOGIN")
  lib._readyFrame:RegisterEvent("PLAYER_LOGOUT")
  lib._readyFrame:SetScript("OnEvent", function(_, event, arg1)
    if event == "ADDON_LOADED" then
      if not lib._dbInitDone and (_G.CogworksSharedDB ~= nil or _G.CogworksDB ~= nil) then
        ensureSharedDB()
        loadActiveProfile()
        lib._dbInitDone = true
      end
    elseif event == "PLAYER_LOGIN" then
      if not lib._dbInitDone then
        ensureSharedDB()
        loadActiveProfile()
        lib._dbInitDone = true
      end
      lib:Fire(lib.Events.Ready)
    elseif event == "PLAYER_LOGOUT" then
      if lib._dbInitDone then persistActiveProfile() end
    end
  end)
end
