local ADDON_NAME = ...
local AP = _G.AmmePlayed or {}
_G.AmmePlayed = AP

local function SafeMeta(field, fallback)
  if C_AddOns and C_AddOns.GetAddOnMetadata then
    return C_AddOns.GetAddOnMetadata(ADDON_NAME, field) or fallback
  end
  return fallback
end

AP.META = {
  Title = SafeMeta("Title", ADDON_NAME),
  Version = SafeMeta("Version", "?.?.?"),
  Author = SafeMeta("Author", "Unknown"),
  Description = SafeMeta("Notes", SafeMeta("Description", "")),
}

local FONT_SIZE_DELTA = 1

local function CreateBumpedFontObject(name, baseFontObject)
  local fontObject = _G[name]
  if not fontObject then
    fontObject = CreateFont(name)
  end
  fontObject:CopyFontObject(baseFontObject)

  local fontFile, fontSize, fontFlags = fontObject:GetFont()
  if fontFile and fontSize then
    fontObject:SetFont(fontFile, fontSize + FONT_SIZE_DELTA, fontFlags)
  end

  return fontObject
end

local AP_FONT = {
  Normal = CreateBumpedFontObject("AmmePlayed_FontNormal", _G.GameFontNormal),
  NormalSmall = CreateBumpedFontObject("AmmePlayed_FontNormalSmall", _G.GameFontNormalSmall),
  NormalLarge = CreateBumpedFontObject("AmmePlayed_FontNormalLarge", _G.GameFontNormalLarge),
  Highlight = CreateBumpedFontObject("AmmePlayed_FontHighlight", _G.GameFontHighlight),
  HighlightSmall = CreateBumpedFontObject("AmmePlayed_FontHighlightSmall", _G.GameFontHighlightSmall),
  HighlightLarge = CreateBumpedFontObject("AmmePlayed_FontHighlightLarge", _G.GameFontHighlightLarge),
  ChatNormal = CreateBumpedFontObject("AmmePlayed_FontChatNormal", _G.ChatFontNormal),
}

if _G.GameFontHighlightItalic then
  AP_FONT.HighlightItalic = CreateBumpedFontObject("AmmePlayed_FontHighlightItalic", _G.GameFontHighlightItalic)
elseif _G.ChatFontItalic then
  AP_FONT.HighlightItalic = CreateBumpedFontObject("AmmePlayed_FontHighlightItalic", _G.ChatFontItalic)
elseif _G.ChatFont_Italic then
  AP_FONT.HighlightItalic = CreateBumpedFontObject("AmmePlayed_FontHighlightItalic", _G.ChatFont_Italic)
end

local function ClampScale(v)
  local n = tonumber(v) or 0.70
  return math.max(0.50, math.min(2.00, n))
end

local function RoundScaleStep(v)
  return math.floor((ClampScale(v) * 10) + 0.5) / 10
end

local CLASS_ICON_FILES = {
  WARRIOR = "Interface\\Icons\\ClassIcon_Warrior",
  PALADIN = "Interface\\Icons\\ClassIcon_Paladin",
  HUNTER = "Interface\\Icons\\ClassIcon_Hunter",
  ROGUE = "Interface\\Icons\\ClassIcon_Rogue",
  PRIEST = "Interface\\Icons\\ClassIcon_Priest",
  DEATHKNIGHT = "Interface\\Icons\\ClassIcon_DeathKnight",
  SHAMAN = "Interface\\Icons\\ClassIcon_Shaman",
  MAGE = "Interface\\Icons\\ClassIcon_Mage",
  WARLOCK = "Interface\\Icons\\ClassIcon_Warlock",
  MONK = "Interface\\Icons\\ClassIcon_Monk",
  DRUID = "Interface\\Icons\\ClassIcon_Druid",
  DEMONHUNTER = "Interface\\Icons\\ClassIcon_DemonHunter",
  EVOKER = "Interface\\Icons\\ClassIcon_Evoker",
}

local RACE_ICON_FILES = {
  HUMAN = "Interface\\Icons\\Achievement_Character_Human_Male",
  ORC = "Interface\\Icons\\Achievement_Character_Orc_Male",
  DWARF = "Interface\\Icons\\Achievement_Character_Dwarf_Male",
  NIGHTELF = "Interface\\Icons\\Achievement_Character_Nightelf_Male",
  SCOURGE = "Interface\\Icons\\Achievement_Character_Undead_Male",
  TAUREN = "Interface\\Icons\\Achievement_Character_Tauren_Male",
  GNOME = "Interface\\Icons\\Achievement_Character_Gnome_Male",
  TROLL = "Interface\\Icons\\Achievement_Character_Troll_Male",
  GOBLIN = "Interface\\Icons\\Achievement_Character_Goblin_Male",
  BLOODELF = "Interface\\Icons\\Achievement_Character_Bloodelf_Male",
  DRAENEI = "Interface\\Icons\\Achievement_Character_Draenei_Male",
  WORGEN = "Interface\\Icons\\Achievement_Character_Worgen_Male",
  PANDAREN = "Interface\\Icons\\Achievement_Character_Pandaren_Male",
  NIGHTBORNE = "Interface\\Icons\\Achievement_Character_Nightborne_Male",
  HIGHMOUNTAINTAUREN = "Interface\\Icons\\Achievement_Character_Highmountaintauren_Male",
  LIGHTFORGEDDRAENEI = "Interface\\Icons\\Achievement_Character_Lightforgeddraenei_Male",
  ZANDALARITROLL = "Interface\\Icons\\Achievement_Character_ZandalariTroll_Male",
  KULTIRAN = "Interface\\Icons\\Achievement_Character_KulTiran_Male",
  DARKIRONDWARF = "Interface\\Icons\\Achievement_Character_DarkIronDwarf_Male",
  VULPERA = "Interface\\Icons\\Achievement_Character_Vulpera_Male",
  MAGHARORC = "Interface\\Icons\\Achievement_Character_MagharOrc_Male",
  MECHAGNOME = "Interface\\Icons\\Achievement_Character_Mechagnome_Male",
  DRACTHYR = "Interface\\Icons\\Achievement_Character_Dracthyr_Male",
  VOIDELF = "Interface\\Icons\\Ability_Racial_SpatialRift",
  EARTHEN = "Interface\\Icons\\Achievement_Character_Earthen_Male",
}

local RACE_ICON_ATLASES = {
  HUMAN = "raceicon128-human-male",
  ORC = "raceicon128-orc-male",
  DWARF = "raceicon128-dwarf-male",
  NIGHTELF = "raceicon128-nightelf-male",
  UNDEAD = "raceicon128-undead-male",
  SCOURGE = "raceicon128-undead-male",
  TAUREN = "raceicon128-tauren-male",
  GNOME = "raceicon128-gnome-male",
  TROLL = "raceicon128-troll-male",
  GOBLIN = "raceicon128-goblin-male",
  BLOODELF = "raceicon128-bloodelf-male",
  DRAENEI = "raceicon128-draenei-male",
  WORGEN = "raceicon128-worgen-male",
  PANDAREN = "raceicon128-pandaren-male",
  NIGHTBORNE = "raceicon128-nightborne-male",
  HIGHMOUNTAINTAUREN = "raceicon128-highmountain-male",
  LIGHTFORGEDDRAENEI = "raceicon128-lightforged-male",
  VOIDELF = "raceicon128-voidelf-male",
  DARKIRONDWARF = "raceicon128-darkirondwarf-male",
  MAGHARORC = "raceicon128-magharorc-male",
  ZANDALARI = "raceicon128-zandalari-male",
  ZANDALARITROLL = "raceicon128-zandalari-male",
  KULTIRAN = "raceicon128-kultiran-male",
  VULPERA = "raceicon128-vulpera-male",
  MECHAGNOME = "raceicon128-mechagnome-male",
  DRACTHYR = "raceicon128-dracthyr-male",
  EARTHEN = "raceicon128-earthen-male",
}

local RACE_ICON_ALIASES = {
  UNDEAD = "SCOURGE",
  ZANDALARI = "ZANDALARITROLL",
  ZANDALARITROLLS = "ZANDALARITROLL",
  KULTIRANHUMAN = "KULTIRAN",
  KULTIRAS = "KULTIRAN",
  MAGHAR = "MAGHARORC",
  LIGHTFORGED = "LIGHTFORGEDDRAENEI",
  EARTHENDWARF = "EARTHEN",
}

local RACE_ICON_ATLAS_FALLBACKS = {
  LIGHTFORGEDDRAENEI = "raceicon128-draenei-male",
  HIGHMOUNTAINTAUREN = "raceicon128-tauren-male",
}

local function SafeNumber(v, fallback)
  v = tonumber(v)
  if not v then return fallback or 0 end
  return v
end

local RACE_BAR_COLORS = {
  HUMAN = { 0.12, 0.39, 0.78 },              -- Stormwind blue
  ORC = { 0.32, 0.60, 0.24 },                -- Orc green
  DWARF = { 0.47, 0.53, 0.58 },              -- Stone blue/grey
  NIGHTELF = { 0.62, 0.50, 0.78 },           -- Lilac purple
  UNDEAD = { 0.82, 0.78, 0.67 },             -- Bone
  SCOURGE = { 0.82, 0.78, 0.67 },            -- Alias used by WoW API token
  TAUREN = { 0.53, 0.38, 0.23 },             -- Earthy brown
  GNOME = { 0.49, 0.78, 0.30 },              -- Toxic green vibe
  TROLL = { 0.16, 0.44, 0.74 },              -- Troll blue
  GOBLIN = { 0.55, 0.72, 0.18 },             -- Acid green
  BLOODELF = { 0.62, 0.20, 0.24 },           -- Muted blood red
  DRAENEI = { 0.53, 0.69, 0.86 },            -- Washed light blue
  WORGEN = { 0.44, 0.45, 0.50 },             -- Steel grey
  PANDAREN = { 0.70, 0.63, 0.48 },           -- Parchment bamboo tone
  DRACTHYR = { 0.12, 0.66, 0.62 },           -- Dragon teal
  EARTHEN = { 0.61, 0.56, 0.45 },            -- Stone/earth

  VOIDELF = { 0.44, 0.23, 0.70 },            -- Void purple
  NIGHTBORNE = { 0.48, 0.35, 0.68 },         -- Darker night-elf purple
  HIGHMOUNTAINTAUREN = { 0.45, 0.31, 0.18 }, -- Dark earthy brown
  LIGHTFORGEDDRAENEI = { 0.76, 0.72, 0.52 }, -- Holy gold-ivory
  ZANDALARITROLL = { 0.29, 0.55, 0.77 },     -- Regal troll blue
  KULTIRAN = { 0.20, 0.33, 0.55 },           -- Kul Tiras navy
  DARKIRONDWARF = { 0.40, 0.33, 0.31 },      -- Dark iron/ash
  VULPERA = { 0.77, 0.56, 0.30 },            -- Desert fox amber
  MAGHARORC = { 0.42, 0.47, 0.29 },          -- Dusty orc olive
  MECHAGNOME = { 0.62, 0.67, 0.73 },         -- Clockwork metal
}

local RACE_BAR_COLOR_ALIASES = {
  ["KULTIRANHUMAN"] = "KULTIRAN",
  ["KULTIRAS"] = "KULTIRAN",
  ["KULTIRAN"] = "KULTIRAN",
  ["ZANDALARITROLL"] = "ZANDALARITROLL",
  ["ZANDALARITROLLS"] = "ZANDALARITROLL",
  ["MAGHARORC"] = "MAGHARORC",
  ["LIGHTFORGEDDRAENEI"] = "LIGHTFORGEDDRAENEI",
  ["HIGHMOUNTAINTAUREN"] = "HIGHMOUNTAINTAUREN",
  ["DARKIRONDWARF"] = "DARKIRONDWARF",
  ["MECHAGNOME"] = "MECHAGNOME",
  ["VOIDELF"] = "VOIDELF",
  ["NIGHTBORNE"] = "NIGHTBORNE",
}

local function NormalizeRaceColorToken(raw)
  raw = tostring(raw or ""):upper()
  raw = raw:gsub("[^A-Z0-9]", "")
  return raw
end

local function GetRaceColor(name)
  local token = NormalizeRaceColorToken(name)
  token = RACE_BAR_COLOR_ALIASES[token] or token

  local mapped = RACE_BAR_COLORS[token]
  if mapped then
    return mapped[1], mapped[2], mapped[3]
  end

  -- Deterministic fallback for unknown future races.
  local seed = 0
  local source = token ~= "" and token or "UNKNOWN"
  for i = 1, #source do
    seed = (seed * 31 + string.byte(source, i)) % 997
  end
  local r = 0.30 + ((seed % 100) / 210)
  local g = 0.30 + (((seed * 7) % 100) / 210)
  local b = 0.30 + (((seed * 13) % 100) / 210)
  return r, g, b
end

local function ResolveRaceIconTexture(raceToken)
  raceToken = tostring(raceToken or ""):upper():gsub("[^A-Z0-9]", "")
  raceToken = RACE_ICON_ALIASES[raceToken] or raceToken
  local atlas = RACE_ICON_ATLASES[raceToken]
  if atlas and C_Texture and C_Texture.GetAtlasInfo and (not C_Texture.GetAtlasInfo(atlas)) then
    local fallbackAtlas = RACE_ICON_ATLAS_FALLBACKS[raceToken]
    if fallbackAtlas and C_Texture.GetAtlasInfo(fallbackAtlas) then
      atlas = fallbackAtlas
    end
  end

  local texture = RACE_ICON_FILES[raceToken]
  if texture then
    return texture, false, atlas
  end

  if raceToken == "LIGHTFORGEDDRAENEI" then
    local lightsJudgmentSpellID = 255647
    if C_Spell and C_Spell.GetSpellTexture then
      local t = C_Spell.GetSpellTexture(lightsJudgmentSpellID)
      if t then return t, false, atlas end
    end
    if GetSpellTexture then
      local t = GetSpellTexture(lightsJudgmentSpellID)
      if t then return t, false, atlas end
    end
  end

  if raceToken == "HIGHMOUNTAINTAUREN" then
    local bullRushSpellID = 255654
    if C_Spell and C_Spell.GetSpellTexture then
      local t = C_Spell.GetSpellTexture(bullRushSpellID)
      if t then return t, false, atlas end
    end
    if GetSpellTexture then
      local t = GetSpellTexture(bullRushSpellID)
      if t then return t, false, atlas end
    end
  end

  -- Void Elf fallback icon if the race portrait key is unavailable on this client.
  if raceToken == "VOIDELF" then
    local spatialRiftSpellID = 256948
    if C_Spell and C_Spell.GetSpellTexture then
      local t = C_Spell.GetSpellTexture(spatialRiftSpellID)
      if t then return t, false, nil end
    end
    if GetSpellTexture then
      local t = GetSpellTexture(spatialRiftSpellID)
      if t then return t, false, nil end
    end
  end

  return "Interface\\Icons\\INV_MISC_QUESTIONMARK", false, atlas
end

function AP:SetMinimapHidden(hidden)
  self.DB = self.DB or {}
  self.DB.minimap = self.DB.minimap or { hide = false }
  self.DB.minimap.hide = hidden and true or false

  local icon = LibStub and LibStub("LibDBIcon-1.0", true)
  if not icon then return end

  if self.DB.minimap.hide then
    icon:Hide("AmmePlayed")
  else
    icon:Show("AmmePlayed")
  end
end

function AP:ShowMinimapMenu()
  if not self.MinimapMenu then
    self.MinimapMenu = CreateFrame("Frame", "AmmePlayed_MinimapMenu", UIParent, "UIDropDownMenuTemplate")
  end

  local function initMenu(_, level)
    local info = UIDropDownMenu_CreateInfo()

    info.isTitle = true
    info.text = "AmmePlayed"
    info.notCheckable = true
    UIDropDownMenu_AddButton(info, level)

    info = UIDropDownMenu_CreateInfo()
    info.text = "Open"
    info.notCheckable = true
    info.func = function() AP:ToggleMainWindow() end
    UIDropDownMenu_AddButton(info, level)

    info = UIDropDownMenu_CreateInfo()
    info.text = "Refresh current character"
    info.notCheckable = true
    info.func = function() AP:RequestPlayedTime("minimap-menu", true) end
    UIDropDownMenu_AddButton(info, level)

    info = UIDropDownMenu_CreateInfo()
    info.text = self.DB and self.DB.minimap and self.DB.minimap.hide and "Show minimap button" or "Hide minimap button"
    info.notCheckable = true
    info.func = function()
      AP:SetMinimapHidden(not (AP.DB and AP.DB.minimap and AP.DB.minimap.hide))
    end
    UIDropDownMenu_AddButton(info, level)

    info = UIDropDownMenu_CreateInfo()
    info.text = "Reset window position"
    info.notCheckable = true
    info.func = function()
      AP:ResetWindowPosition()
    end
    UIDropDownMenu_AddButton(info, level)

    info = UIDropDownMenu_CreateInfo()
    info.text = "Close"
    info.notCheckable = true
    info.func = function() CloseDropDownMenus() end
    UIDropDownMenu_AddButton(info, level)
  end

  UIDropDownMenu_Initialize(self.MinimapMenu, initMenu, "MENU")
  ToggleDropDownMenu(1, nil, self.MinimapMenu, "cursor", 0, 0)
end

function AP:ResetWindowPosition()
  self.DB = self.DB or {}
  self.DB.settings = self.DB.settings or {}
  self.DB.settings.window = self.DB.settings.window or {}
  self.DB.settings.window.point = "CENTER"
  self.DB.settings.window.x = 0
  self.DB.settings.window.y = 0

  if self.MainFrame then
    self.MainFrame:ClearAllPoints()
    self.MainFrame:SetPoint("CENTER")
  end
end

function AP:AnimateMainWindowScale(targetScale, duration)
  if not self.MainFrame then return end
  targetScale = ClampScale(targetScale)
  duration = math.max(0.05, math.min(0.80, SafeNumber(duration, 0.45)))

  local frame = self.MainFrame
  local startScale = frame:GetScale()
  if math.abs(startScale - targetScale) < 0.001 then
    frame:SetScale(targetScale)
    return
  end

  self._scaleAnimState = self._scaleAnimState or {}
  self._scaleAnimState.active = true
  self._scaleAnimState.elapsed = 0
  self._scaleAnimState.duration = duration
  self._scaleAnimState.startScale = startScale
  self._scaleAnimState.targetScale = targetScale

  frame:SetScript("OnUpdate", function(animFrame, elapsed)
    local st = AP._scaleAnimState
    if not st or not st.active then
      animFrame:SetScript("OnUpdate", nil)
      return
    end

    st.elapsed = st.elapsed + elapsed
    local t = st.elapsed / st.duration
    if t >= 1 then
      animFrame:SetScale(st.targetScale)
      st.active = false
      animFrame:SetScript("OnUpdate", nil)
      return
    end

    local eased = 1 - ((1 - t) * (1 - t) * (1 - t))
    local scale = st.startScale + ((st.targetScale - st.startScale) * eased)
    animFrame:SetScale(scale)
  end)
end

function AP:ApplyWindowScale(scale, animate)
  local target = RoundScaleStep(scale)
  self.DB = self.DB or {}
  self.DB.settings = self.DB.settings or {}
  self.DB.settings.scale = target

  if self.MainFrame then
    if animate and self.MainFrame:IsShown() then
      self:AnimateMainWindowScale(target, 0.45)
    else
      self.MainFrame:SetScale(target)
    end
  end
end

function AP:CreateMinimapButton()
  if self.MinimapButton then return end
  if not LibStub then return end

  self.DB = self.DB or {}
  self.DB.minimap = self.DB.minimap or { hide = false }

  local ldb = LibStub("LibDataBroker-1.1"):NewDataObject("AmmePlayed", {
    type = "data source",
    text = "AmmePlayed",
    icon = "Interface\\Icons\\INV_Misc_PocketWatch_02",
    OnClick = function(_, button)
      if button == "LeftButton" then
        AP:ToggleMainWindow()
      else
        AP:ShowMinimapMenu()
      end
    end,
    OnTooltipShow = function(tt)
      tt:AddLine("AmmePlayed")
      tt:AddLine("Left-click: Open dashboard", 0.9, 0.9, 0.9)
      tt:AddLine("Right-click: Menu", 0.9, 0.9, 0.9)
      tt:AddLine("Slash: /ammeplayed", 0.9, 0.9, 0.9)
    end,
  })

  local icon = LibStub("LibDBIcon-1.0", true)
  if icon then
    icon:Register("AmmePlayed", ldb, self.DB.minimap)
    self.MinimapButton = icon:GetMinimapButton("AmmePlayed")
    if self.DB.minimap.hide then
      icon:Hide("AmmePlayed")
    end
  end
end

local function CreateTabButton(parent, id, text, width)
  local b = CreateFrame("Button", nil, parent)
  b:SetSize(width or 172, 30)

  b.bg = b:CreateTexture(nil, "BACKGROUND")
  b.bg:SetAllPoints(true)
  b.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)

  b.borderTop = b:CreateTexture(nil, "BORDER")
  b.borderTop:SetPoint("TOPLEFT", -1, 1)
  b.borderTop:SetPoint("TOPRIGHT", 1, 1)
  b.borderTop:SetHeight(1)
  b.borderTop:SetColorTexture(0.55, 0.46, 0.28, 1)

  b.borderBottom = b:CreateTexture(nil, "BORDER")
  b.borderBottom:SetPoint("BOTTOMLEFT", -1, -1)
  b.borderBottom:SetPoint("BOTTOMRIGHT", 1, -1)
  b.borderBottom:SetHeight(1)
  b.borderBottom:SetColorTexture(0.55, 0.46, 0.28, 1)

  b.borderLeft = b:CreateTexture(nil, "BORDER")
  b.borderLeft:SetPoint("TOPLEFT", -1, 1)
  b.borderLeft:SetPoint("BOTTOMLEFT", -1, -1)
  b.borderLeft:SetWidth(1)
  b.borderLeft:SetColorTexture(0.55, 0.46, 0.28, 1)

  b.borderRight = b:CreateTexture(nil, "BORDER")
  b.borderRight:SetPoint("TOPRIGHT", 1, 1)
  b.borderRight:SetPoint("BOTTOMRIGHT", 1, -1)
  b.borderRight:SetWidth(1)
  b.borderRight:SetColorTexture(0.55, 0.46, 0.28, 1)

  b.text = b:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  b.text:SetPoint("CENTER")
  if (width or 172) <= 140 then
    b.text:SetFontObject(AP_FONT.NormalSmall)
  end
  b.text:SetText(text)
  b.text:SetTextColor(0.96, 0.96, 0.96)

  b:SetScript("OnMouseDown", function(btn)
    btn.bg:SetColorTexture(0.20, 0.17, 0.12, 0.66)
  end)
  b:SetScript("OnMouseUp", function(btn)
    if btn.selected then
      btn.bg:SetColorTexture(0.42, 0.33, 0.18, 0.60)
    else
      btn.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)
    end
  end)

  b:SetScript("OnEnter", function(btn)
    if not btn.selected then
      btn.bg:SetColorTexture(0.34, 0.27, 0.19, 0.64)
    end
  end)

  b:SetScript("OnLeave", function(btn)
    if btn.selected then
      btn.bg:SetColorTexture(0.42, 0.33, 0.18, 0.60)
    else
      btn.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)
    end
  end)

  b.id = id
  return b
end

local function HashString(s)
  local h = 17
  for i = 1, #s do
    h = (h * 31 + string.byte(s, i)) % 2147483647
  end
  return h
end

local function GetJokeSource()
  return _G.AmmePlayedJokes or {}
end

local function GetCommentSource()
  return _G.AmmePlayedComments or {}
end

local function NormalizeToken(raw)
  raw = tostring(raw or ""):upper()
  raw = raw:gsub("[^A-Z0-9]", "")
  return raw
end

local function PluralizeClassName(className)
  className = tostring(className or "Class")
  local lastWord = className:match("(%S+)%s*$") or className
  local prefix = className:sub(1, #className - #lastWord)
  local lw = lastWord:lower()
  local pluralWord

  if lw:match("(s|x|z)$") or lw:match("ch$") or lw:match("sh$") then
    pluralWord = lastWord .. "es"
  else
    pluralWord = lastWord .. "s"
  end

  return prefix .. pluralWord
end

local function ResolveRaceToken(jokes, raceName, raceTag)
  local directTag = NormalizeToken(raceTag)
  if jokes[directTag] then
    return directTag
  end

  local directName = NormalizeToken(raceName)
  if jokes[directName] then
    return directName
  end

  return nil
end

local function ChooseJokeLine(lines, keySeed)
  if type(lines) ~= "table" or #lines == 0 then
    return nil
  end
  local idx = (HashString(keySeed) % #lines) + 1
  return lines[idx]
end

local function GetGenericClassJoke(jokes, classTag, raceName)
  local classToken = NormalizeToken(classTag)
  local list = jokes.GENERIC and jokes.GENERIC[classToken]
  local chosen = ChooseJokeLine(list, "GENERIC|" .. classToken .. "|" .. tostring(raceName or "Unknown"))
  if not chosen then return nil end
  return chosen:gsub("{race}", tostring(raceName or "Unknown"))
end

local function GetComboDadJoke(classTag, raceName, raceTag)
  local jokes = GetJokeSource()
  local classToken = NormalizeToken(classTag)
  local raceToken = ResolveRaceToken(jokes, raceName, raceTag)

  if raceToken and jokes[raceToken] and jokes[raceToken][classToken] then
    local chosen = ChooseJokeLine(jokes[raceToken][classToken], "COMBO|" .. raceToken .. "|" .. classToken)
    if chosen then
      return chosen
    end
  end

  local generic = GetGenericClassJoke(jokes, classToken, raceName)
  if generic then
    return generic
  end

  return string.format("Your %s %s combo is still collecting joke data.", tostring(raceName or "Unknown"), tostring(classTag or "Class"))
end

local function GetRandomClassComment(classTag)
  local comments = GetCommentSource()
  local classBucket = comments.CLASS
  if type(classBucket) ~= "table" then return nil end
  local list = classBucket[NormalizeToken(classTag)]
  if type(list) ~= "table" or #list == 0 then return nil end
  return ChooseJokeLine(list, "CLASSCOMMENT|" .. NormalizeToken(classTag))
end

local function GetRandomRaceComment(raceTag, raceName)
  local comments = GetCommentSource()
  local raceBucket = comments.RACE
  if type(raceBucket) ~= "table" then return nil end

  local list = raceBucket[NormalizeToken(raceTag)]
  if (type(list) ~= "table" or #list == 0) and raceName then
    list = raceBucket[NormalizeToken(raceName)]
  end
  if type(list) ~= "table" or #list == 0 then return nil end
  local token = NormalizeToken(raceTag)
  if token == "" then
    token = NormalizeToken(raceName)
  end
  return ChooseJokeLine(list, "RACECOMMENT|" .. token)
end

local function CreateFlatButton(parent, width, height, text)
  local b = CreateFrame("Button", nil, parent)
  b:SetSize(width, height)

  b.shadow = b:CreateTexture(nil, "BACKGROUND")
  b.shadow:SetPoint("TOPLEFT", -2, 2)
  b.shadow:SetPoint("BOTTOMRIGHT", 2, -2)
  b.shadow:SetColorTexture(0, 0, 0, 0.34)

  b.bg = b:CreateTexture(nil, "BACKGROUND")
  b.bg:SetAllPoints(true)
  b.bg:SetColorTexture(0.24, 0.19, 0.13, 0.92)

  b.border = b:CreateTexture(nil, "BORDER")
  b.border:SetPoint("TOPLEFT", -1, 1)
  b.border:SetPoint("BOTTOMRIGHT", 1, -1)
  b.border:SetColorTexture(0.58, 0.49, 0.30, 1)

  b.text = b:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  b.text:SetPoint("CENTER")
  b.text:SetText(text or "")
  b.text:SetShadowOffset(1, -1)
  b.text:SetShadowColor(0, 0, 0, 0.8)

  b:SetScript("OnEnter", function(btn)
    btn.bg:SetColorTexture(0.30, 0.24, 0.16, 0.95)
    btn.text:SetTextColor(1, 0.94, 0.45)
  end)

  b:SetScript("OnLeave", function(btn)
    btn.bg:SetColorTexture(0.24, 0.19, 0.13, 0.92)
    btn.text:SetTextColor(1, 1, 1)
  end)

  b:SetScript("OnMouseDown", function(btn)
    btn.bg:SetColorTexture(0.18, 0.14, 0.10, 0.98)
  end)

  b:SetScript("OnMouseUp", function(btn)
    btn.bg:SetColorTexture(0.30, 0.24, 0.16, 0.95)
  end)

  return b
end

local function StyleTabLikeButton(btn)
  if not btn then return end

  btn.shadow:Hide()
  btn.border:Hide()
  btn.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)
  btn.text:SetTextColor(0.96, 0.96, 0.96)

  if not btn.tabBorderTop then
    btn.tabBorderTop = btn:CreateTexture(nil, "BORDER")
    btn.tabBorderTop:SetPoint("TOPLEFT", 0, 0)
    btn.tabBorderTop:SetPoint("TOPRIGHT", 0, 0)
    btn.tabBorderTop:SetHeight(1)
    btn.tabBorderTop:SetColorTexture(0.55, 0.46, 0.28, 1)

    btn.tabBorderBottom = btn:CreateTexture(nil, "BORDER")
    btn.tabBorderBottom:SetPoint("BOTTOMLEFT", 0, 0)
    btn.tabBorderBottom:SetPoint("BOTTOMRIGHT", 0, 0)
    btn.tabBorderBottom:SetHeight(1)
    btn.tabBorderBottom:SetColorTexture(0.55, 0.46, 0.28, 1)

    btn.tabBorderLeft = btn:CreateTexture(nil, "BORDER")
    btn.tabBorderLeft:SetPoint("TOPLEFT", 0, 0)
    btn.tabBorderLeft:SetPoint("BOTTOMLEFT", 0, 0)
    btn.tabBorderLeft:SetWidth(1)
    btn.tabBorderLeft:SetColorTexture(0.55, 0.46, 0.28, 1)

    btn.tabBorderRight = btn:CreateTexture(nil, "BORDER")
    btn.tabBorderRight:SetPoint("TOPRIGHT", 0, 0)
    btn.tabBorderRight:SetPoint("BOTTOMRIGHT", 0, 0)
    btn.tabBorderRight:SetWidth(1)
    btn.tabBorderRight:SetColorTexture(0.55, 0.46, 0.28, 1)
  end

  btn:SetScript("OnEnter", function(b)
    b.bg:SetColorTexture(0.34, 0.27, 0.19, 0.64)
    b.text:SetTextColor(1.0, 0.86, 0.20)
  end)
  btn:SetScript("OnLeave", function(b)
    b.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)
    b.text:SetTextColor(0.96, 0.96, 0.96)
  end)
  btn:SetScript("OnMouseDown", function(b)
    b.bg:SetColorTexture(0.20, 0.17, 0.12, 0.66)
  end)
  btn:SetScript("OnMouseUp", function(b)
    b.bg:SetColorTexture(0.34, 0.27, 0.19, 0.64)
    b.text:SetTextColor(1.0, 0.86, 0.20)
  end)
end

local function BuildSummaryNarrativeData(stats)
  if not stats.hasEnoughForNarrative then
    local gated = "Log into at least 3 characters and reach a combined total of at least 40h /played to unlock your account summary narrative."
    return {
      available = false,
      classTag = nil,
      className = nil,
      raceName = nil,
      raceTag = nil,
      comboJoke = nil,
      gatedText = gated,
    }
  end

  local favoriteClassTag = stats.favoriteClassTag or "PRIEST"
  local favoriteClassName = AP:GetLocalizedClassName(favoriteClassTag, stats.favoriteClassName or favoriteClassTag)
  local favoriteRaceName = stats.favoriteRace or "Unknown"
  local favoriteRaceTag = nil
  local topCharacter = stats.topCharacter or (stats.characters and stats.characters[1]) or {}
  local topCharacterClassTag = topCharacter.classTag or favoriteClassTag
  local topCharacterClassName = AP:GetLocalizedClassName(topCharacterClassTag, topCharacter.class or favoriteClassName)
  local topCharacterRaceName = topCharacter.race or favoriteRaceName
  local topCharacterRaceTag = topCharacter.raceTag or favoriteRaceTag

  for _, c in ipairs(stats.characters or {}) do
    if c.race == favoriteRaceName and c.raceTag then
      favoriteRaceTag = c.raceTag
      break
    end
  end

  if not topCharacterRaceTag then
    for _, c in ipairs(stats.characters or {}) do
      if c.race == topCharacterRaceName and c.raceTag then
        topCharacterRaceTag = c.raceTag
        break
      end
    end
  end

  local comboJoke = GetComboDadJoke(topCharacterClassTag, topCharacterRaceName, topCharacterRaceTag)

  return {
    available = true,
    totalPlayedSeconds = SafeNumber(stats.totalPlayedSeconds, 0),
    topCharacterName = topCharacter.name or "Unknown",
    topCharacterRealm = topCharacter.realm or "UnknownRealm",
    topCharacterClassTag = topCharacterClassTag,
    topCharacterClassName = topCharacterClassName,
    topCharacterRaceName = topCharacterRaceName,
    topCharacterRaceTag = topCharacterRaceTag,
    topCharacterPlayedSeconds = SafeNumber(topCharacter.totalPlayedSeconds, 0),
    classTag = favoriteClassTag,
    className = favoriteClassName,
    raceName = favoriteRaceName,
    raceTag = favoriteRaceTag,
    comboJoke = comboJoke,
    classComment = GetRandomClassComment(favoriteClassTag),
    raceComment = GetRandomRaceComment(favoriteRaceTag, favoriteRaceName),
    comboPlayedSeconds = (function()
      local sum = 0
      for _, c in ipairs(stats.characters or {}) do
        if c.classTag == topCharacterClassTag and c.race == topCharacterRaceName then
          sum = sum + SafeNumber(c.totalPlayedSeconds, 0)
        end
      end
      return sum
    end)(),
    classPlayedSeconds = SafeNumber((stats.classTotals[favoriteClassTag] and stats.classTotals[favoriteClassTag].seconds) or 0, 0),
    racePlayedSeconds = SafeNumber(stats.raceTotals[favoriteRaceName] or 0, 0),
  }
end

local function GetTotalPlayedLine(totalSeconds)
  local seconds = SafeNumber(totalSeconds, 0)
  local hours = seconds / 3600
  local played = AP:FormatDurationLong(seconds)
  local prefix = "Your total /played time across all your characters is "
  local suffix

  if hours <= 1000 then
    suffix = ", that is a solid start."
  elseif hours <= 2000 then
    suffix = ", your /played is getting serious."
  elseif hours <= 4000 then
    suffix = ", Azeroth might be your second job."
  elseif hours <= 7000 then
    suffix = ", you crazy person you!"
  elseif hours <= 10000 then
    suffix = ", your productivity is owned by Blizzard."
  elseif hours <= 14000 then
    suffix = ", are you paying rent in game now?"
  elseif hours <= 20000 then
    suffix = ", You really need to go and touch some grass."
  elseif hours <= 30000 then
    suffix = ", You're giving off some real 'moms basement' vibes."
  else
    suffix = ", Exposure to natural sunlight would probably burn you."
  end

  return prefix, played, suffix, (prefix .. played .. suffix)
end

local function BuildNarrativeTextFormats(narrative)
  if not narrative.available then
    local gated = narrative.gatedText or "Not enough data yet."
    local bbcodeGated = "[b]AmmePlayed Summary[/b]\n\n" .. gated
    local markdownGated = "# AmmePlayed Summary\n\n" .. gated
    return gated, bbcodeGated, markdownGated
  end

  local className = tostring(narrative.className or "Unknown")
  local raceName = tostring(narrative.raceName or "Unknown")
  local comboClassName = tostring(narrative.topCharacterClassName or className)
  local comboRaceName = tostring(narrative.topCharacterRaceName or raceName)
  local topCharacterName = tostring(narrative.topCharacterName or "Unknown")
  local topCharacterRealm = tostring(narrative.topCharacterRealm or "UnknownRealm")
  local classComment = tostring(narrative.classComment or "")
  local raceComment = tostring(narrative.raceComment or "")
  local classCommentItalic = (classComment ~= "" and ("_" .. classComment .. "_")) or ""
  local raceCommentItalic = (raceComment ~= "" and ("_" .. raceComment .. "_")) or ""
  local totalSeconds = SafeNumber(narrative.totalPlayedSeconds, 0)
  local _, _, _, totalPlayedLine = GetTotalPlayedLine(totalSeconds)
  local totalHours = totalSeconds / 3600
  local totalDays = totalSeconds / 86400
  local totalWeeks = totalDays / 7
  local totalMonths = totalDays / 30.4375
  local totalYears = totalDays / 365.25
  local totalBreakdownPlain = {
    string.format("Raw hours: %.1f", totalHours),
    string.format("AKA %.2f days", totalDays),
    string.format("AKA %.2f weeks", totalWeeks),
    string.format("AKA %.2f months", totalMonths),
    string.format("AKA %.2f years", totalYears),
  }

  local comboStats = string.format("You have played a %s %s for %s.", comboRaceName, comboClassName, AP:FormatDurationLong(SafeNumber(narrative.comboPlayedSeconds, 0)))
  local classPlural = PluralizeClassName(className)
  local classStats = string.format("You have played on all %s for %s.", classPlural, AP:FormatDurationLong(SafeNumber(narrative.classPlayedSeconds, 0)))
  local raceStats = string.format("You have played on all %s for %s.", raceName, AP:FormatDurationLong(SafeNumber(narrative.racePlayedSeconds, 0)))
  local topCharacterStats = string.format("%s-%s has %s played.", topCharacterName, topCharacterRealm, AP:FormatDurationLong(SafeNumber(narrative.topCharacterPlayedSeconds, 0)))

  local plainText = table.concat({
    totalPlayedLine,
    totalBreakdownPlain[1],
    totalBreakdownPlain[2],
    totalBreakdownPlain[3],
    totalBreakdownPlain[4],
    totalBreakdownPlain[5],
    "",
    "Your Favorite character is:",
    string.format("%s-%s (%s %s)", topCharacterName, topCharacterRealm, comboRaceName, comboClassName),
    topCharacterStats,
    tostring(narrative.comboJoke or "No combo joke available yet."),
    "",
    "Your Favorite class is:",
    className,
    (classComment ~= "" and classComment or ""),
    "",
    "Your Favorite race is:",
    raceName,
    (raceComment ~= "" and raceComment or ""),
    "",
    comboStats,
    classStats,
    raceStats,
  }, "\n")

  local bbcode = table.concat({
    "[b]AmmePlayed Summary[/b]",
    "",
    "[i]" .. totalPlayedLine .. "[/i]",
    "[i]" .. totalBreakdownPlain[1] .. "[/i]",
    "[i]" .. totalBreakdownPlain[2] .. "[/i]",
    "[i]" .. totalBreakdownPlain[3] .. "[/i]",
    "[i]" .. totalBreakdownPlain[4] .. "[/i]",
    "[i]" .. totalBreakdownPlain[5] .. "[/i]",
    "",
    "[b]Your Favorite character is:[/b] " .. topCharacterName .. "-" .. topCharacterRealm .. " (" .. comboRaceName .. " " .. comboClassName .. ")",
    "[i]" .. topCharacterStats .. "[/i]",
    "[i]" .. tostring(narrative.comboJoke or "No combo joke available yet.") .. "[/i]",
    "",
    "[b]Your Favorite class is:[/b] " .. className,
    (classComment ~= "" and ("[i]" .. classComment .. "[/i]") or ""),
    "[b]Your Favorite race is:[/b] " .. raceName,
    (raceComment ~= "" and ("[i]" .. raceComment .. "[/i]") or ""),
    "",
    "[list]",
    "[*]" .. comboStats,
    "[*]" .. classStats,
    "[*]" .. raceStats,
    "[/list]",
  }, "\n")

  local markdown = table.concat({
    "# AmmePlayed Summary",
    "",
    "*" .. totalPlayedLine .. "*",
    "*" .. totalBreakdownPlain[1] .. "*",
    "*" .. totalBreakdownPlain[2] .. "*",
    "*" .. totalBreakdownPlain[3] .. "*",
    "*" .. totalBreakdownPlain[4] .. "*",
    "*" .. totalBreakdownPlain[5] .. "*",
    "",
    "**Your Favorite character is:** " .. topCharacterName .. "-" .. topCharacterRealm .. " (" .. comboRaceName .. " " .. comboClassName .. ")",
    "*" .. topCharacterStats .. "*",
    "*" .. tostring(narrative.comboJoke or "No combo joke available yet.") .. "*",
    "",
    "**Your Favorite class is:** " .. className,
    classCommentItalic,
    "",
    "**Your Favorite race is:** " .. raceName,
    raceCommentItalic,
    "",
    "- " .. comboStats,
    "- " .. classStats,
    "- " .. raceStats,
  }, "\n")

  return plainText, bbcode, markdown
end

local function CreateSummaryTab(parent)
  local tab = CreateFrame("Frame", nil, parent)
  tab:SetAllPoints(true)

  tab.summaryPanel = CreateFrame("Frame", nil, tab)
  tab.summaryPanel:SetPoint("TOPLEFT", 18, 7)
  tab.summaryPanel:SetPoint("TOPRIGHT", -18, 7)
  tab.summaryPanel:SetHeight(228)

  tab.summaryPanel.bg = tab.summaryPanel:CreateTexture(nil, "BACKGROUND")
  tab.summaryPanel.bg:SetAllPoints(true)
  tab.summaryPanel.bg:SetColorTexture(0.19, 0.15, 0.1, 0.38)

  tab.footerPanel = CreateFrame("Frame", nil, tab.summaryPanel)
  tab.footerPanel:SetPoint("BOTTOMLEFT", 8, 8)
  tab.footerPanel:SetPoint("BOTTOMRIGHT", -8, 8)
  tab.footerPanel:SetHeight(44)

  tab.shareTitle = tab.footerPanel:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.shareTitle:SetPoint("TOP", tab.footerPanel, "TOP", 0, -2)
  tab.shareTitle:SetTextColor(1.0, 0.82, 0.15)
  tab.shareTitle:SetText("Share Your Summary")

  tab.narrativeScroll = CreateFrame("ScrollFrame", nil, tab.summaryPanel, "UIPanelScrollFrameTemplate")
  tab.narrativeScroll:SetPoint("TOPLEFT", 10, -10)
  tab.narrativeScroll:SetPoint("BOTTOMRIGHT", tab.footerPanel, "TOPRIGHT", -22, 4)

  tab.narrativeContent = CreateFrame("Frame", nil, tab.narrativeScroll)
  tab.narrativeContent:SetSize(1, 1)
  tab.narrativeScroll:SetScrollChild(tab.narrativeContent)

  tab.totalPlayedPrefix = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.totalPlayedPrefix:SetJustifyH("LEFT")
  tab.totalPlayedPrefix:SetJustifyV("TOP")
  tab.totalPlayedPrefix:SetTextColor(0.96, 0.92, 0.80)
  tab.totalPlayedPrefix:SetText("")

  tab.totalPlayedValueButton = CreateFrame("Button", nil, tab.narrativeContent)
  tab.totalPlayedValueButton:SetSize(1, 1)
  tab.totalPlayedValueButton:EnableMouse(true)
  tab.totalPlayedValueButton.text = tab.totalPlayedValueButton:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.totalPlayedValueButton.text:SetPoint("TOPLEFT", 0, 0)
  tab.totalPlayedValueButton.text:SetTextColor(0.40, 0.72, 1.00)
  tab.totalPlayedValueButton.text:SetText("")
  tab.totalPlayedValueButton.underline = tab.totalPlayedValueButton:CreateTexture(nil, "ARTWORK")
  tab.totalPlayedValueButton.underline:SetPoint("BOTTOMLEFT", tab.totalPlayedValueButton.text, "BOTTOMLEFT", 0, -1)
  tab.totalPlayedValueButton.underline:SetPoint("BOTTOMRIGHT", tab.totalPlayedValueButton.text, "BOTTOMRIGHT", 0, -1)
  tab.totalPlayedValueButton.underline:SetHeight(1)
  tab.totalPlayedValueButton.underline:SetColorTexture(0.40, 0.72, 1.00, 0.95)
  tab.totalPlayedValueButton:SetScript("OnEnter", function(btn)
    btn.text:SetTextColor(0.58, 0.83, 1.00)
    btn.underline:SetColorTexture(0.58, 0.83, 1.00, 1.0)
    AP:ShowTotalPlayedTooltip(btn, btn.totalSeconds or 0)
  end)
  tab.totalPlayedValueButton:SetScript("OnLeave", function(btn)
    btn.text:SetTextColor(0.40, 0.72, 1.00)
    btn.underline:SetColorTexture(0.40, 0.72, 1.00, 0.95)
    AP:HideTotalPlayedTooltip()
  end)

  tab.totalPlayedSuffix = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.totalPlayedSuffix:SetJustifyH("LEFT")
  tab.totalPlayedSuffix:SetJustifyV("TOP")
  tab.totalPlayedSuffix:SetTextColor(0.96, 0.92, 0.80)
  tab.totalPlayedSuffix:SetText("")

  tab.characterLabel = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.characterLabel:SetJustifyH("LEFT")
  tab.characterLabel:SetTextColor(0.95, 0.95, 0.90)
  tab.characterLabel:SetText("")

  tab.characterName = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalLarge")
  tab.characterName:SetJustifyH("CENTER")
  tab.characterName:SetTextColor(1.0, 0.82, 0.15)
  tab.characterName:SetText("")

  tab.characterIcon = tab.narrativeContent:CreateTexture(nil, "ARTWORK")
  tab.characterIcon:SetSize(56, 56)
  tab.characterIcon:Hide()

  tab.characterComment = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.characterComment:SetJustifyH("LEFT")
  tab.characterComment:SetJustifyV("TOP")
  tab.characterComment:SetTextColor(0.93, 0.90, 0.82)
  tab.characterComment:SetText("")

  tab.characterPlayed = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.characterPlayed:SetJustifyH("LEFT")
  tab.characterPlayed:SetJustifyV("TOP")
  tab.characterPlayed:SetTextColor(0.93, 0.90, 0.82)
  tab.characterPlayed:SetText("")

  tab.characterJoke = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.characterJoke:SetJustifyH("LEFT")
  tab.characterJoke:SetJustifyV("TOP")
  tab.characterJoke:SetTextColor(0.93, 0.90, 0.82)
  if AP_FONT.HighlightItalic then
    tab.characterJoke:SetFontObject(AP_FONT.HighlightItalic)
  end
  tab.characterJoke:SetText("")

  tab.classLabel = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.classLabel:SetJustifyH("LEFT")
  tab.classLabel:SetTextColor(0.95, 0.95, 0.90)
  tab.classLabel:SetText("")

  tab.className = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalLarge")
  tab.className:SetJustifyH("CENTER")
  tab.className:SetTextColor(1.0, 0.82, 0.15)
  tab.className:SetText("")

  tab.classIcon = tab.narrativeContent:CreateTexture(nil, "ARTWORK")
  tab.classIcon:SetSize(56, 56)
  tab.classIcon:Hide()

  tab.classComment = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.classComment:SetJustifyH("LEFT")
  tab.classComment:SetJustifyV("TOP")
  tab.classComment:SetTextColor(0.93, 0.90, 0.82)
  tab.classComment:SetText("")

  tab.raceLabel = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.raceLabel:SetJustifyH("LEFT")
  tab.raceLabel:SetTextColor(0.95, 0.95, 0.90)
  tab.raceLabel:SetText("")

  tab.raceName = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalLarge")
  tab.raceName:SetJustifyH("CENTER")
  tab.raceName:SetTextColor(1.0, 0.82, 0.15)
  tab.raceName:SetText("")

  tab.raceIcon = tab.narrativeContent:CreateTexture(nil, "ARTWORK")
  tab.raceIcon:SetSize(56, 56)
  tab.raceIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
  tab.raceIcon:Hide()

  tab.raceComment = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.raceComment:SetJustifyH("LEFT")
  tab.raceComment:SetJustifyV("TOP")
  tab.raceComment:SetTextColor(0.93, 0.90, 0.82)
  tab.raceComment:SetText("")

  tab.columnSep1 = tab.narrativeContent:CreateTexture(nil, "ARTWORK")
  tab.columnSep1:SetColorTexture(1, 1, 1, 0.40)
  tab.columnSep2 = tab.narrativeContent:CreateTexture(nil, "ARTWORK")
  tab.columnSep2:SetColorTexture(1, 1, 1, 0.40)

  tab.quoteBox = CreateFrame("Frame", nil, tab.narrativeContent)
  tab.quoteBox:SetHeight(52)

  tab.quoteBox.bg = tab.quoteBox:CreateTexture(nil, "BACKGROUND")
  tab.quoteBox.bg:SetAllPoints(true)
  tab.quoteBox.bg:SetColorTexture(0.10, 0.10, 0.10, 0.32)

  tab.quoteBox.border = tab.quoteBox:CreateTexture(nil, "BORDER")
  tab.quoteBox.border:SetPoint("TOPLEFT", -1, 1)
  tab.quoteBox.border:SetPoint("BOTTOMRIGHT", 1, -1)
  tab.quoteBox.border:SetColorTexture(0.56, 0.47, 0.29, 0.85)

  tab.quoteText = tab.quoteBox:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.quoteText:SetPoint("TOPLEFT", 8, -5)
  tab.quoteText:SetPoint("BOTTOMRIGHT", -8, 5)
  tab.quoteText:SetJustifyH("LEFT")
  tab.quoteText:SetJustifyV("MIDDLE")
  tab.quoteText:SetTextColor(0.95, 0.95, 0.90)
  if AP_FONT.HighlightItalic then
    tab.quoteText:SetFontObject(AP_FONT.HighlightItalic)
  end
  tab.quoteText:SetText("")

  tab.accuracyNote = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
  tab.accuracyNote:SetJustifyH("LEFT")
  tab.accuracyNote:SetJustifyV("TOP")
  tab.accuracyNote:SetTextColor(0.90, 0.88, 0.80)
  tab.accuracyNote:SetText("")

  tab.summaryText = tab.narrativeContent:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.summaryText:SetPoint("TOPLEFT", 8, -4)
  tab.summaryText:SetWidth(700)
  tab.summaryText:SetJustifyH("LEFT")
  tab.summaryText:SetJustifyV("TOP")
  tab.summaryText:SetText("Loading data...")

  tab.copyBBCodeButton = CreateFlatButton(tab.footerPanel, 160, 24, "Copy BBCode")
  tab.copyBBCodeButton:SetPoint("BOTTOM", tab.footerPanel, "BOTTOM", -168, 0)

  tab.copyMarkdownButton = CreateFlatButton(tab.footerPanel, 160, 24, "Copy Markdown")
  tab.copyMarkdownButton:SetPoint("LEFT", tab.copyBBCodeButton, "RIGHT", 8, 0)

  tab.copyPlainButton = CreateFlatButton(tab.footerPanel, 160, 24, "Copy Plain Text")
  tab.copyPlainButton:SetPoint("LEFT", tab.copyMarkdownButton, "RIGHT", 8, 0)

  tab.copyBBCodeButton:SetScript("OnClick", function()
    AP:ShowCopyDialog("AmmePlayed - BBCode", tab.copyBBCodeText or "")
  end)
  tab.copyMarkdownButton:SetScript("OnClick", function()
    AP:ShowCopyDialog("AmmePlayed - Markdown", tab.copyMarkdownText or "")
  end)
  tab.copyPlainButton:SetScript("OnClick", function()
    AP:ShowCopyDialog("AmmePlayed - Plain Text", tab.copyPlainText or "")
  end)

  tab.tablePanel = CreateFrame("Frame", nil, tab)
  tab.tablePanel:SetPoint("TOPLEFT", tab.summaryPanel, "BOTTOMLEFT", 0, -19)
  tab.tablePanel:SetPoint("TOPRIGHT", tab.summaryPanel, "BOTTOMRIGHT", 0, -19)
  tab.tablePanel:SetHeight(200)

  tab.tablePanel.bg = tab.tablePanel:CreateTexture(nil, "BACKGROUND")
  tab.tablePanel.bg:SetAllPoints(true)
  tab.tablePanel.bg:SetColorTexture(0.15, 0.12, 0.09, 0.32)

  local headers = {
    { key = "name", text = "Character", x = 10, width = 162 },
    { key = "realm", text = "Realm", x = 176, width = 160 },
    { key = "class", text = "Class", x = 338, width = 120 },
    { key = "race", text = "Race", x = 460, width = 120 },
    { key = "level", text = "Level", x = 582, width = 60 },
    { key = "played", text = "Total Played", x = 648, width = 130 },
  }

  for _, h in ipairs(headers) do
    local fs = tab.tablePanel:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    fs:SetPoint("TOPLEFT", h.x, -10)
    fs:SetWidth(h.width)
    fs:SetJustifyH("LEFT")
    fs:SetText(h.text)
  end

  tab.rows = {}
  tab.rowHeight = 30

  local scrollName = "AmmePlayedSummaryScrollFrame"
  tab.scroll = CreateFrame("ScrollFrame", scrollName, tab.tablePanel, "FauxScrollFrameTemplate")
  tab.scroll:SetPoint("TOPLEFT", 8, -30)
  tab.scroll:SetPoint("BOTTOMRIGHT", -28, -10)
  local summaryScrollBar = _G[scrollName .. "ScrollBar"]
  if summaryScrollBar then
    summaryScrollBar:ClearAllPoints()
    summaryScrollBar:SetPoint("TOPLEFT", tab.scroll, "TOPRIGHT", 0, -16)
    summaryScrollBar:SetPoint("BOTTOMLEFT", tab.scroll, "BOTTOMRIGHT", 0, 44)
  end

  for i = 1, 5 do
    local row = CreateFrame("Button", nil, tab.tablePanel)
    row:SetPoint("TOPLEFT", 8, -30 - ((i - 1) * tab.rowHeight))
    row:SetPoint("TOPRIGHT", -30, -30 - ((i - 1) * tab.rowHeight))
    row:SetHeight(tab.rowHeight)

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints(true)
    if i % 2 == 0 then
      row.bg:SetColorTexture(0.12, 0.10, 0.08, 0.40)
    else
      row.bg:SetColorTexture(0.17, 0.14, 0.10, 0.34)
    end

    row.cols = {}

    local function MakeCol(x, width)
      local fs = row:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
      fs:SetPoint("LEFT", x, 0)
      fs:SetWidth(width)
      fs:SetJustifyH("LEFT")
      return fs
    end

    row.cols.name = MakeCol(10, 162)
    row.cols.realm = MakeCol(176, 160)
    row.cols.class = MakeCol(338, 120)
    row.cols.race = MakeCol(460, 120)
    row.cols.level = MakeCol(582, 60)
    row.cols.played = MakeCol(648, 130)

    tab.rows[i] = row
  end

  tab.scroll:SetScript("OnVerticalScroll", function(scrollFrame, offset)
    FauxScrollFrame_OnVerticalScroll(scrollFrame, offset, tab.rowHeight, function()
      AP:RefreshSummaryTab()
    end)
  end)

  return tab
end

local function EnsureChartRow(panel, index)
  panel.rows = panel.rows or {}
  if panel.rows[index] then
    return panel.rows[index]
  end

  local row = CreateFrame("Frame", nil, panel.content)
  row:SetHeight(40)

  row.bg = row:CreateTexture(nil, "BACKGROUND")
  row.bg:SetAllPoints(true)
  row.bg:SetColorTexture(0.16, 0.12, 0.09, 0.34)

  row.icon = row:CreateTexture(nil, "ARTWORK")
  row.icon:SetSize(18, 18)
  row.icon:SetPoint("LEFT", 10, 0)
  row.icon:Hide()

  row.label = row:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  row.label:SetPoint("LEFT", 34, 0)
  row.label:SetWidth(186)
  row.label:SetJustifyH("LEFT")

  row.total = row:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  row.total:SetPoint("RIGHT", -10, 0)
  row.total:SetWidth(120)
  row.total:SetJustifyH("RIGHT")

  row.bar = CreateFrame("Frame", nil, row)
  row.bar:SetPoint("LEFT", 230, 0)
  row.bar:SetHeight(22)

  row.bar.bg = row.bar:CreateTexture(nil, "BACKGROUND")
  row.bar.bg:SetAllPoints(true)
  row.bar.bg:SetColorTexture(0.06, 0.06, 0.06, 0.55)

  row.segmentButtons = {}

  panel.rows[index] = row
  return row
end

local function CreateSegmentButton(parent)
  local b = CreateFrame("Button", nil, parent)
  b:SetHeight(parent:GetHeight())

  b.tex = b:CreateTexture(nil, "ARTWORK")
  b.tex:SetAllPoints(true)

  b.hl = b:CreateTexture(nil, "HIGHLIGHT")
  b.hl:SetAllPoints(true)
  b.hl:SetColorTexture(1, 1, 1, 0.10)

  local function CreateGlowRing(prefix, inset, alpha)
    b[prefix .. "Top"] = b:CreateTexture(nil, "OVERLAY")
    b[prefix .. "Top"]:SetPoint("TOPLEFT", -inset, inset)
    b[prefix .. "Top"]:SetPoint("TOPRIGHT", inset, inset)
    b[prefix .. "Top"]:SetHeight(1)
    b[prefix .. "Top"]:SetBlendMode("ADD")
    b[prefix .. "Top"]:SetColorTexture(1, 1, 1, alpha)
    b[prefix .. "Top"]:Hide()

    b[prefix .. "Bottom"] = b:CreateTexture(nil, "OVERLAY")
    b[prefix .. "Bottom"]:SetPoint("BOTTOMLEFT", -inset, -inset)
    b[prefix .. "Bottom"]:SetPoint("BOTTOMRIGHT", inset, -inset)
    b[prefix .. "Bottom"]:SetHeight(1)
    b[prefix .. "Bottom"]:SetBlendMode("ADD")
    b[prefix .. "Bottom"]:SetColorTexture(1, 1, 1, alpha)
    b[prefix .. "Bottom"]:Hide()

    b[prefix .. "Left"] = b:CreateTexture(nil, "OVERLAY")
    b[prefix .. "Left"]:SetPoint("TOPLEFT", -inset, inset)
    b[prefix .. "Left"]:SetPoint("BOTTOMLEFT", -inset, -inset)
    b[prefix .. "Left"]:SetWidth(1)
    b[prefix .. "Left"]:SetBlendMode("ADD")
    b[prefix .. "Left"]:SetColorTexture(1, 1, 1, alpha)
    b[prefix .. "Left"]:Hide()

    b[prefix .. "Right"] = b:CreateTexture(nil, "OVERLAY")
    b[prefix .. "Right"]:SetPoint("TOPRIGHT", inset, inset)
    b[prefix .. "Right"]:SetPoint("BOTTOMRIGHT", inset, -inset)
    b[prefix .. "Right"]:SetWidth(1)
    b[prefix .. "Right"]:SetBlendMode("ADD")
    b[prefix .. "Right"]:SetColorTexture(1, 1, 1, alpha)
    b[prefix .. "Right"]:Hide()
  end

  -- Faux glow: multiple fading rings so the highlight reads as glow, not a hard line.
  CreateGlowRing("glowInner", 1, 0.30)
  CreateGlowRing("glowMid", 2, 0.18)
  CreateGlowRing("glowOuter", 3, 0.09)

  b.borderTop = b:CreateTexture(nil, "OVERLAY")
  b.borderTop:SetPoint("TOPLEFT", 1, -1)
  b.borderTop:SetPoint("TOPRIGHT", -1, -1)
  b.borderTop:SetHeight(1)
  b.borderTop:SetColorTexture(1, 1, 1, 0.30)

  b.borderBottom = b:CreateTexture(nil, "OVERLAY")
  b.borderBottom:SetPoint("BOTTOMLEFT", 1, 1)
  b.borderBottom:SetPoint("BOTTOMRIGHT", -1, 1)
  b.borderBottom:SetHeight(1)
  b.borderBottom:SetColorTexture(1, 1, 1, 0.30)

  b.borderLeft = b:CreateTexture(nil, "OVERLAY")
  b.borderLeft:SetPoint("TOPLEFT", 1, -1)
  b.borderLeft:SetPoint("BOTTOMLEFT", 1, 1)
  b.borderLeft:SetWidth(1)
  b.borderLeft:SetColorTexture(1, 1, 1, 0.30)

  b.borderRight = b:CreateTexture(nil, "OVERLAY")
  b.borderRight:SetPoint("TOPRIGHT", -1, -1)
  b.borderRight:SetPoint("BOTTOMRIGHT", -1, 1)
  b.borderRight:SetWidth(1)
  b.borderRight:SetColorTexture(1, 1, 1, 0.30)

  b:SetScript("OnEnter", function(self)
    local data = self.segmentData
    if not data then return end
    local br = self.borderR or 1
    local bg = self.borderG or 1
    local bb = self.borderB or 1
    self.borderTop:SetColorTexture(br, bg, bb, 0.42)
    self.borderBottom:SetColorTexture(br, bg, bb, 0.42)
    self.borderLeft:SetColorTexture(br, bg, bb, 0.42)
    self.borderRight:SetColorTexture(br, bg, bb, 0.42)
    local gr = self.glowR or 1
    local gg = self.glowG or 1
    local gb = self.glowB or 1
    self.glowInnerTop:SetColorTexture(gr, gg, gb, 0.30)
    self.glowInnerBottom:SetColorTexture(gr, gg, gb, 0.30)
    self.glowInnerLeft:SetColorTexture(gr, gg, gb, 0.30)
    self.glowInnerRight:SetColorTexture(gr, gg, gb, 0.30)
    self.glowMidTop:SetColorTexture(gr, gg, gb, 0.18)
    self.glowMidBottom:SetColorTexture(gr, gg, gb, 0.18)
    self.glowMidLeft:SetColorTexture(gr, gg, gb, 0.18)
    self.glowMidRight:SetColorTexture(gr, gg, gb, 0.18)
    self.glowOuterTop:SetColorTexture(gr, gg, gb, 0.09)
    self.glowOuterBottom:SetColorTexture(gr, gg, gb, 0.09)
    self.glowOuterLeft:SetColorTexture(gr, gg, gb, 0.09)
    self.glowOuterRight:SetColorTexture(gr, gg, gb, 0.09)
    self.glowInnerTop:Show()
    self.glowInnerBottom:Show()
    self.glowInnerLeft:Show()
    self.glowInnerRight:Show()
    self.glowMidTop:Show()
    self.glowMidBottom:Show()
    self.glowMidLeft:Show()
    self.glowMidRight:Show()
    self.glowOuterTop:Show()
    self.glowOuterBottom:Show()
    self.glowOuterLeft:Show()
    self.glowOuterRight:Show()

    AP:ShowChartSegmentTooltip(self, data)
  end)

  b:SetScript("OnLeave", function()
    local br = b.borderR or 1
    local bg = b.borderG or 1
    local bb = b.borderB or 1
    b.borderTop:SetColorTexture(br, bg, bb, 0.30)
    b.borderBottom:SetColorTexture(br, bg, bb, 0.30)
    b.borderLeft:SetColorTexture(br, bg, bb, 0.30)
    b.borderRight:SetColorTexture(br, bg, bb, 0.30)
    b.glowInnerTop:Hide()
    b.glowInnerBottom:Hide()
    b.glowInnerLeft:Hide()
    b.glowInnerRight:Hide()
    b.glowMidTop:Hide()
    b.glowMidBottom:Hide()
    b.glowMidLeft:Hide()
    b.glowMidRight:Hide()
    b.glowOuterTop:Hide()
    b.glowOuterBottom:Hide()
    b.glowOuterLeft:Hide()
    b.glowOuterRight:Hide()
    AP:HideChartSegmentTooltip()
  end)

  return b
end

local function CreateChartTab(parent, headingText, groupBy, metric)
  local tab = CreateFrame("Frame", nil, parent)
  tab:SetAllPoints(true)
  tab.groupBy = groupBy
  tab.metric = metric

  tab.heading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightLarge")
  tab.heading:SetPoint("TOPLEFT", 20, -18)
  tab.heading:SetText(headingText)

  tab.sub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.sub:SetPoint("TOPLEFT", 20, -42)
  tab.sub:SetText("Hover any segment to inspect character details.")

  tab.scroll = CreateFrame("ScrollFrame", nil, tab, "UIPanelScrollFrameTemplate")
  tab.scroll:SetPoint("TOPLEFT", 18, -64)
  tab.scroll:SetPoint("BOTTOMRIGHT", -34, 16)

  tab.content = CreateFrame("Frame", nil, tab.scroll)
  tab.content:SetSize(900, 1)
  tab.scroll:SetScrollChild(tab.content)

  return tab
end

local function CreateCharacterDataTab(parent)
  local tab = CreateFrame("Frame", nil, parent)
  tab:SetAllPoints(true)

  tab.heading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightLarge")
  tab.heading:SetPoint("TOPLEFT", 20, -18)
  tab.heading:SetText("Character Data")

  tab.sub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.sub:SetPoint("TOPLEFT", 20, -42)
  tab.sub:SetPoint("TOPRIGHT", -20, -42)
  tab.sub:SetJustifyH("LEFT")
  tab.sub:SetText("Use this tab to remove outdated character snapshots (for example after race/faction changes). Deleting your current character will immediately re-add it.")

  tab.tablePanel = CreateFrame("Frame", nil, tab)
  tab.tablePanel:SetPoint("TOPLEFT", 18, -74)
  tab.tablePanel:SetPoint("TOPRIGHT", -18, -74)
  tab.tablePanel:SetPoint("BOTTOM", 0, 58)

  tab.tablePanel.bg = tab.tablePanel:CreateTexture(nil, "BACKGROUND")
  tab.tablePanel.bg:SetAllPoints(true)
  tab.tablePanel.bg:SetColorTexture(0.15, 0.12, 0.09, 0.32)

  local headers = {
    { text = "Character", x = 10, width = 126 },
    { text = "Realm", x = 140, width = 150 },
    { text = "Class", x = 294, width = 110 },
    { text = "Race", x = 408, width = 118 },
    { text = "Faction", x = 530, width = 95 },
    { text = "Level", x = 628, width = 58 },
    { text = "", x = 694, width = 30 },
  }

  for _, h in ipairs(headers) do
    local fs = tab.tablePanel:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    fs:SetPoint("TOPLEFT", h.x, -10)
    fs:SetWidth(h.width)
    fs:SetJustifyH("LEFT")
    fs:SetText(h.text)
  end

  tab.rows = {}
  tab.rowHeight = 30

  local scrollName = "AmmePlayedCharacterDataScrollFrame"
  tab.scroll = CreateFrame("ScrollFrame", scrollName, tab.tablePanel, "FauxScrollFrameTemplate")
  tab.scroll:SetPoint("TOPLEFT", 8, -30)
  tab.scroll:SetPoint("BOTTOMRIGHT", -28, -10)
  local characterDataScrollBar = _G[scrollName .. "ScrollBar"]
  if characterDataScrollBar then
    characterDataScrollBar:ClearAllPoints()
    characterDataScrollBar:SetPoint("TOPLEFT", tab.scroll, "TOPRIGHT", 0, -16)
    characterDataScrollBar:SetPoint("BOTTOMLEFT", tab.scroll, "BOTTOMRIGHT", 0, 76)
  end

  for i = 1, 8 do
    local row = CreateFrame("Frame", nil, tab.tablePanel)
    row:SetPoint("TOPLEFT", 8, -30 - ((i - 1) * tab.rowHeight))
    row:SetPoint("TOPRIGHT", -30, -30 - ((i - 1) * tab.rowHeight))
    row:SetHeight(tab.rowHeight)

    row.bg = row:CreateTexture(nil, "BACKGROUND")
    row.bg:SetAllPoints(true)
    if i % 2 == 0 then
      row.bg:SetColorTexture(0.12, 0.10, 0.08, 0.40)
    else
      row.bg:SetColorTexture(0.17, 0.14, 0.10, 0.34)
    end

    local function MakeCol(x, width)
      local fs = row:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
      fs:SetPoint("LEFT", x, 0)
      fs:SetWidth(width)
      fs:SetJustifyH("LEFT")
      return fs
    end

    row.name = MakeCol(10, 126)
    row.realm = MakeCol(140, 150)
    row.class = MakeCol(294, 110)
    row.race = MakeCol(408, 118)
    row.faction = MakeCol(530, 95)
    row.level = MakeCol(628, 58)

    row.deleteButton = CreateFlatButton(row, 24, 22, "X")
    row.deleteButton:SetPoint("RIGHT", -6, 0)
    row.deleteButton.text:SetTextColor(1.0, 1.0, 1.0)
    row.deleteButton.bg:SetColorTexture(0.22, 0.10, 0.10, 0.90)
    row.deleteButton.border:SetColorTexture(0.70, 0.30, 0.25, 1)
    row.deleteButton:SetScript("OnEnter", function(btn)
      btn.bg:SetColorTexture(0.30, 0.12, 0.12, 0.96)
      btn.border:SetColorTexture(0.85, 0.38, 0.32, 1)
      btn.text:SetTextColor(1.0, 1.0, 1.0)
    end)
    row.deleteButton:SetScript("OnLeave", function(btn)
      btn.bg:SetColorTexture(0.22, 0.10, 0.10, 0.90)
      btn.border:SetColorTexture(0.70, 0.30, 0.25, 1)
      btn.text:SetTextColor(1.0, 1.0, 1.0)
    end)

    tab.rows[i] = row
  end

  tab.scroll:SetScript("OnVerticalScroll", function(scrollFrame, offset)
    FauxScrollFrame_OnVerticalScroll(scrollFrame, offset, tab.rowHeight, function()
      AP:RefreshCharacterDataTab()
    end)
  end)

  tab.clearAllButton = CreateFlatButton(tab, 180, 26, "Clear All Characters")
  tab.clearAllButton:SetPoint("BOTTOMLEFT", 18, 20)
  tab.clearAllButton.text:SetTextColor(1.0, 1.0, 1.0)
  tab.clearAllButton.bg:SetColorTexture(0.20, 0.10, 0.10, 0.92)
  tab.clearAllButton.border:SetColorTexture(0.72, 0.32, 0.27, 1)
  tab.clearAllButton:SetScript("OnEnter", function(btn)
    btn.bg:SetColorTexture(0.30, 0.12, 0.12, 0.96)
    btn.border:SetColorTexture(0.85, 0.38, 0.32, 1)
    btn.text:SetTextColor(1.0, 1.0, 1.0)
  end)
  tab.clearAllButton:SetScript("OnLeave", function(btn)
    btn.bg:SetColorTexture(0.20, 0.10, 0.10, 0.92)
    btn.border:SetColorTexture(0.72, 0.32, 0.27, 1)
    btn.text:SetTextColor(1.0, 1.0, 1.0)
  end)
  tab.clearAllButton:SetScript("OnMouseDown", function(btn)
    btn.bg:SetColorTexture(0.14, 0.08, 0.08, 0.98)
    btn.border:SetColorTexture(0.62, 0.28, 0.24, 1)
    btn.text:SetTextColor(0.94, 0.94, 0.94)
  end)
  tab.clearAllButton:SetScript("OnMouseUp", function(btn)
    btn.bg:SetColorTexture(0.30, 0.12, 0.12, 0.96)
    btn.border:SetColorTexture(0.85, 0.38, 0.32, 1)
    btn.text:SetTextColor(1.0, 1.0, 1.0)
  end)

  tab.clearAllButton:SetScript("OnClick", function()
    AP:ShowConfirmDialog(
      "Clear All Character Data",
      "Are you sure you want to clear all stored character data? Your currently logged-in character will be re-added immediately.",
      function()
        AP:ClearAllCharacters()
        AP:RefreshCharacterDataTab()
      end
    )
  end)

  return tab
end

local function CreateSettingsTab(parent)
  local tab = CreateFrame("Frame", nil, parent)
  tab:SetAllPoints(true)
  tab.aboutPanel = CreateFrame("Frame", nil, tab)
  tab.aboutPanel:SetPoint("TOPRIGHT", -20, -20)
  tab.aboutPanel:SetPoint("BOTTOMRIGHT", -20, 20)
  tab.aboutPanel:SetWidth(196)

  tab.refreshHeading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.refreshHeading:SetPoint("TOPLEFT", 20, -20)
  tab.refreshHeading:SetTextColor(1.0, 0.82, 0.15)
  tab.refreshHeading:SetText("Refresh Character")

  tab.refreshSub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.refreshSub:SetPoint("TOPLEFT", tab.refreshHeading, "BOTTOMLEFT", 0, -4)
  tab.refreshSub:SetText("Runs the /played command and updates the data for the current character.")

  tab.refreshButton = CreateFlatButton(tab, 150, 30, "Refresh Character")
  tab.refreshButton:SetPoint("TOPLEFT", tab.refreshSub, "BOTTOMLEFT", 0, -8)
  tab.refreshButton.text:SetFontObject(AP_FONT.NormalSmall)
  StyleTabLikeButton(tab.refreshButton)
  tab.refreshButton:SetHeight(24)
  tab.refreshButton:SetScript("OnClick", function()
    AP:RequestPlayedTime("settings-tab", true)
  end)

  tab.scaleHeading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.scaleHeading:SetPoint("TOPLEFT", tab.refreshButton, "BOTTOMLEFT", 0, -16)
  tab.scaleHeading:SetTextColor(1.0, 0.82, 0.15)
  tab.scaleHeading:SetText("UI Scale")

  tab.scaleSub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.scaleSub:SetPoint("TOPLEFT", tab.scaleHeading, "BOTTOMLEFT", 0, -4)
  tab.scaleSub:SetText("Alter the size of the AmmePlayed Window. Does not effect anything else.")

  tab.scaleSlider = CreateFrame("Slider", "AmmePlayedScaleSlider", tab, "OptionsSliderTemplate")
  tab.scaleSlider:SetPoint("TOPLEFT", tab.scaleSub, "BOTTOMLEFT", 0, -12)
  tab.scaleSlider:SetPoint("TOPRIGHT", tab.aboutPanel, "TOPLEFT", -20, -12)
  tab.scaleSlider:SetMinMaxValues(0.50, 2.00)
  tab.scaleSlider:SetValueStep(0.1)
  if tab.scaleSlider.SetObeyStepOnDrag then
    tab.scaleSlider:SetObeyStepOnDrag(true)
  end
  _G.AmmePlayedScaleSliderLow:SetText("0.5")
  _G.AmmePlayedScaleSliderHigh:SetText("2.0")
  _G.AmmePlayedScaleSliderText:SetText("")

  tab.scaleBox = CreateFrame("EditBox", nil, tab, "BackdropTemplate")
  tab.scaleBox:SetSize(90, 24)
  tab.scaleBox:SetPoint("TOPLEFT", tab.scaleSlider, "BOTTOMLEFT", 0, -10)
  tab.scaleBox:SetAutoFocus(false)
  tab.scaleBox:SetFontObject(AP_FONT.Highlight)
  tab.scaleBox:SetJustifyH("CENTER")
  tab.scaleBox:SetTextInsets(4, 4, 0, 0)
  tab.scaleBox:SetBackdrop({
    bgFile = "Interface\\Buttons\\WHITE8X8",
    edgeFile = "Interface\\Buttons\\WHITE8X8",
    tile = false,
    tileSize = 0,
    edgeSize = 1,
    insets = { left = 1, right = 1, top = 1, bottom = 1 },
  })
  tab.scaleBox:SetBackdropColor(0.12, 0.10, 0.08, 0.85)
  tab.scaleBox:SetBackdropBorderColor(0.55, 0.46, 0.28, 1)
  tab.scaleBox:SetScript("OnEscapePressed", function(editBox)
    editBox:ClearFocus()
  end)
  tab.scaleBox:SetScript("OnEnterPressed", function(editBox)
    editBox:ClearFocus()
  end)

  tab.scaleOk = CreateFlatButton(tab, 44, 24, "OK")
  tab.scaleOk:SetPoint("LEFT", tab.scaleBox, "RIGHT", 8, 0)
  StyleTabLikeButton(tab.scaleOk)
  tab.scaleOk:SetHeight(tab.scaleBox:GetHeight())
  tab.scaleOk:SetScript("OnClick", function()
    local raw = tostring(tab.scaleBox:GetText() or ""):gsub(",", ".")
    local scale = tonumber(raw)
    if not scale then
      if AP.MainFrame then
        tab.scaleBox:SetText(string.format("%.1f", AP.MainFrame:GetScale()))
      end
      return
    end
    local target = RoundScaleStep(scale)
    tab._syncScaleWidgets = true
    tab.scaleSlider:SetValue(target)
    tab._syncScaleWidgets = false
    AP:ApplyWindowScale(target, true)
    tab.scaleBox:SetText(string.format("%.1f", target))
  end)

  tab.scaleSlider:SetScript("OnValueChanged", function(slider, value)
    local rounded = RoundScaleStep(value)
    if math.abs(rounded - value) > 0.001 then
      slider:SetValue(rounded)
      return
    end
    if tab._syncScaleWidgets then return end
    tab.scaleBox:SetText(string.format("%.1f", rounded))
    tab._pendingScale = rounded
  end)
  tab.scaleSlider:SetScript("OnMouseUp", function()
    local target = RoundScaleStep(tab._pendingScale or tab.scaleSlider:GetValue())
    AP:ApplyWindowScale(target, true)
    tab.scaleBox:SetText(string.format("%.1f", target))
  end)

  tab.minimapHeading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.minimapHeading:SetPoint("TOPLEFT", tab.scaleBox, "BOTTOMLEFT", 0, -16)
  tab.minimapHeading:SetTextColor(1.0, 0.82, 0.15)
  tab.minimapHeading:SetText("Minimap Button")

  tab.minimapSub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.minimapSub:SetPoint("TOPLEFT", tab.minimapHeading, "BOTTOMLEFT", 0, -4)
  tab.minimapSub:SetText("Show or hide the minimap icon.")

  tab.minimapCheckbox = CreateFrame("Button", nil, tab)
  tab.minimapCheckbox:SetSize(18, 18)
  tab.minimapCheckbox:SetPoint("TOPLEFT", tab.minimapSub, "BOTTOMLEFT", 0, -8)
  tab.minimapCheckbox:SetHitRectInsets(0, -180, 0, 0)

  tab.minimapCheckbox.bg = tab.minimapCheckbox:CreateTexture(nil, "BACKGROUND")
  tab.minimapCheckbox.bg:SetAllPoints(true)
  tab.minimapCheckbox.bg:SetColorTexture(0.20, 0.16, 0.12, 0.95)

  tab.minimapCheckbox.border = tab.minimapCheckbox:CreateTexture(nil, "BORDER")
  tab.minimapCheckbox.border:SetPoint("TOPLEFT", -1, 1)
  tab.minimapCheckbox.border:SetPoint("BOTTOMRIGHT", 1, -1)
  tab.minimapCheckbox.border:SetColorTexture(0.55, 0.46, 0.28, 1)

  tab.minimapCheckbox.check = tab.minimapCheckbox:CreateTexture(nil, "OVERLAY")
  tab.minimapCheckbox.check:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
  tab.minimapCheckbox.check:SetPoint("CENTER", 0, 0)
  tab.minimapCheckbox.check:SetSize(18, 18)
  tab.minimapCheckbox.check:SetVertexColor(1.0, 0.82, 0.15, 1.0)
  tab.minimapCheckbox.check:Hide()

  function tab.minimapCheckbox:SetChecked(checked)
    self._checked = checked and true or false
    self.check:SetShown(self._checked)
  end

  function tab.minimapCheckbox:GetChecked()
    return self._checked and true or false
  end

  tab.minimapCheckbox:SetChecked(false)

  tab.minimapCheckbox.label = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
  tab.minimapCheckbox.label:SetPoint("LEFT", tab.minimapCheckbox, "RIGHT", 8, 0)
  tab.minimapCheckbox.label:SetTextColor(0.96, 0.96, 0.96)
  tab.minimapCheckbox.label:SetText("Show minimap button")

  tab.minimapCheckbox:SetScript("OnClick", function(btn)
    if tab._syncMinimapCheckbox then return end
    local checked = not btn:GetChecked()
    btn:SetChecked(checked)
    AP:SetMinimapHidden(not checked)
  end)
  tab.minimapCheckbox:SetScript("OnEnter", function(btn)
    btn.bg:SetColorTexture(0.27, 0.21, 0.15, 0.95)
  end)
  tab.minimapCheckbox:SetScript("OnLeave", function(btn)
    btn.bg:SetColorTexture(0.20, 0.16, 0.12, 0.95)
  end)

  tab.transferHeading = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.transferHeading:SetPoint("TOPLEFT", tab.minimapCheckbox, "BOTTOMLEFT", 0, -18)
  tab.transferHeading:SetTextColor(1.0, 0.82, 0.15)
  tab.transferHeading:SetText("Data Transfer")

  tab.transferSub = tab:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  tab.transferSub:SetPoint("TOPLEFT", tab.transferHeading, "BOTTOMLEFT", 0, -4)
  tab.transferSub:SetPoint("TOPRIGHT", tab.aboutPanel, "TOPLEFT", -20, 0)
  tab.transferSub:SetJustifyH("LEFT")
  tab.transferSub:SetText("Export all character data for backup and import it on another install.")

  tab.exportButton = CreateFlatButton(tab, 130, 24, "Export Data")
  tab.exportButton:SetPoint("TOPLEFT", tab.transferSub, "BOTTOMLEFT", 0, -8)
  StyleTabLikeButton(tab.exportButton)
  tab.exportButton:SetScript("OnClick", function()
    AP:ShowExportDialog()
  end)

  tab.importButton = CreateFlatButton(tab, 130, 24, "Import Data")
  tab.importButton:SetPoint("LEFT", tab.exportButton, "RIGHT", 8, 0)
  StyleTabLikeButton(tab.importButton)
  tab.importButton:SetScript("OnClick", function()
    AP:ShowImportDialog()
  end)

  tab.aboutHeading = tab.aboutPanel:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
  tab.aboutHeading:SetPoint("TOPLEFT", 0, 0)
  tab.aboutHeading:SetTextColor(1.0, 0.82, 0.15)
  tab.aboutHeading:SetText("About")

  tab.aboutBox = CreateFrame("Frame", nil, tab.aboutPanel)
  tab.aboutBox:SetPoint("TOPLEFT", tab.aboutHeading, "BOTTOMLEFT", 0, -6)
  tab.aboutBox:SetPoint("TOPRIGHT", 0, -6)
  tab.aboutBox:SetPoint("BOTTOMLEFT", 0, 0)
  tab.aboutBox.bg = tab.aboutBox:CreateTexture(nil, "BACKGROUND")
  tab.aboutBox.bg:SetAllPoints(true)
  tab.aboutBox.bg:SetColorTexture(0, 0, 0, 0.20)
  tab.aboutBox.borderTop = tab.aboutBox:CreateTexture(nil, "BORDER")
  tab.aboutBox.borderTop:SetPoint("TOPLEFT", -1, 1)
  tab.aboutBox.borderTop:SetPoint("TOPRIGHT", 1, 1)
  tab.aboutBox.borderTop:SetHeight(1)
  tab.aboutBox.borderTop:SetColorTexture(0, 0, 0, 0.55)
  tab.aboutBox.borderBottom = tab.aboutBox:CreateTexture(nil, "BORDER")
  tab.aboutBox.borderBottom:SetPoint("BOTTOMLEFT", -1, -1)
  tab.aboutBox.borderBottom:SetPoint("BOTTOMRIGHT", 1, -1)
  tab.aboutBox.borderBottom:SetHeight(1)
  tab.aboutBox.borderBottom:SetColorTexture(0, 0, 0, 0.55)
  tab.aboutBox.borderLeft = tab.aboutBox:CreateTexture(nil, "BORDER")
  tab.aboutBox.borderLeft:SetPoint("TOPLEFT", -1, 1)
  tab.aboutBox.borderLeft:SetPoint("BOTTOMLEFT", -1, -1)
  tab.aboutBox.borderLeft:SetWidth(1)
  tab.aboutBox.borderLeft:SetColorTexture(0, 0, 0, 0.55)
  tab.aboutBox.borderRight = tab.aboutBox:CreateTexture(nil, "BORDER")
  tab.aboutBox.borderRight:SetPoint("TOPRIGHT", 1, 1)
  tab.aboutBox.borderRight:SetPoint("BOTTOMRIGHT", 1, -1)
  tab.aboutBox.borderRight:SetWidth(1)
  tab.aboutBox.borderRight:SetColorTexture(0, 0, 0, 0.55)

  tab.aboutRows = {}
  local aboutFields = {
    { key = "name", heading = "Name:" },
    { key = "version", heading = "Version:" },
    { key = "author", heading = "Author:" },
    { key = "description", heading = "Description:" },
  }

  local prevValue
  for i, item in ipairs(aboutFields) do
    local h = tab.aboutBox:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    if i == 1 then
      h:SetPoint("TOPLEFT", 4, -4)
      h:SetPoint("TOPRIGHT", -4, -4)
    else
      h:SetPoint("TOPLEFT", prevValue, "BOTTOMLEFT", 0, -6)
      h:SetPoint("TOPRIGHT", -4, 0)
    end
    h:SetJustifyH("LEFT")
    h:SetTextColor(1.0, 0.82, 0.15)
    h:SetText(item.heading)

    local v = tab.aboutBox:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
    v:SetPoint("TOPLEFT", h, "BOTTOMLEFT", 0, -1)
    v:SetPoint("TOPRIGHT", -4, 0)
    v:SetJustifyH("LEFT")
    v:SetJustifyV("TOP")
    v:SetTextColor(0.96, 0.96, 0.96)
    v:SetText("")

    tab.aboutRows[item.key] = { heading = h, value = v }
    prevValue = v
  end

  return tab
end

function AP:ShowCopyDialog(title, text)
  if not self.CopyDialog then
    local f = CreateFrame("Frame", "AmmePlayed_CopyDialog", UIParent, "BackdropTemplate")
    f:SetSize(620, 380)
    f:SetPoint("CENTER")
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetToplevel(true)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:SetBackdrop({
      bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
      edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
      tile = true,
      tileSize = 32,
      edgeSize = 24,
      insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })

    f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.title:SetPoint("TOPLEFT", 18, -14)
    f.title:SetText("AmmePlayed - Copy")

    f.selectAll = CreateFlatButton(f, 72, 22, "Select All")
    f.selectAll:SetPoint("TOPRIGHT", -74, -10)
    f.selectAll:SetScript("OnClick", function()
      f.edit:SetFocus()
      f.edit:HighlightText()
    end)

    local close = CreateFlatButton(f, 56, 22, "Close")
    close:SetPoint("TOPRIGHT", -12, -10)
    close:SetScript("OnClick", function() f:Hide() end)

    f.dragHandle = CreateFrame("Button", nil, f)
    f.dragHandle:SetPoint("TOPLEFT", 10, -8)
    f.dragHandle:SetPoint("TOPRIGHT", f.selectAll, "TOPLEFT", -8, 0)
    f.dragHandle:SetHeight(20)
    f.dragHandle:RegisterForDrag("LeftButton")
    f.dragHandle:SetScript("OnDragStart", function() f:StartMoving() end)
    f.dragHandle:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)

    f.scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scroll:SetPoint("TOPLEFT", 16, -44)
    f.scroll:SetPoint("BOTTOMRIGHT", -32, 30)

    f.edit = CreateFrame("EditBox", nil, f.scroll)
    f.edit:SetAutoFocus(false)
    f.edit:SetMultiLine(true)
    f.edit:SetFontObject(AP_FONT.ChatNormal)
    f.edit:EnableMouse(true)
    f.edit:SetWidth(540)
    f.edit._ap_sourceText = ""
    f.edit:SetScript("OnEscapePressed", function() f:Hide() end)
    f.edit:SetScript("OnMouseDown", nil)
    f.edit:SetScript("OnTextChanged", function(editBox, userInput)
      if userInput then
        local source = editBox._ap_sourceText or ""
        if editBox:GetText() ~= source then
          editBox:SetText(source)
        end
        return
      end
      editBox:SetWidth(540)
      local _, fontSize = editBox:GetFont()
      fontSize = tonumber(fontSize) or 14
      local text = editBox:GetText() or ""
      local lines = 1
      for _ in text:gmatch("\n") do
        lines = lines + 1
      end
      local targetHeight = math.max(320, math.floor(lines * (fontSize + 4) + 24))
      editBox:SetHeight(targetHeight)
      f.scroll:UpdateScrollChildRect()
    end)
    f.scroll:SetScrollChild(f.edit)

    f.copyHint = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
    f.copyHint:SetPoint("BOTTOMLEFT", 18, 12)
    f.copyHint:SetTextColor(0.90, 0.90, 0.90)
    f.copyHint:SetText("Ctrl+C to copy to clipboard.")

    self.CopyDialog = f
  end

  self.CopyDialog.title:SetText(title or "AmmePlayed - Copy")
  self.CopyDialog.edit._ap_sourceText = text or ""
  self.CopyDialog.edit:SetText(text or "")
  self.CopyDialog:Show()
  self.CopyDialog:Raise()
  self.CopyDialog.edit:SetFocus()
  self.CopyDialog.edit:HighlightText()
end

local function XmlEscape(v)
  local s = tostring(v or "")
  s = s:gsub("&", "&amp;")
  s = s:gsub("<", "&lt;")
  s = s:gsub(">", "&gt;")
  s = s:gsub("\"", "&quot;")
  s = s:gsub("'", "&apos;")
  return s
end

local function XmlUnescape(s)
  s = tostring(s or "")
  s = s:gsub("&apos;", "'")
  s = s:gsub("&quot;", "\"")
  s = s:gsub("&gt;", ">")
  s = s:gsub("&lt;", "<")
  s = s:gsub("&amp;", "&")
  return s
end

local function EncodePortableText(v)
  local s = tostring(v or "")
  local out = {}
  for i = 1, #s do
    local b = string.byte(s, i)
    if b == 92 then -- backslash
      out[#out + 1] = "\\\\"
    elseif b == 124 then -- pipe, prevents WoW inline format codes from rendering/hiding text
      out[#out + 1] = "\\x7C"
    elseif b >= 32 and b <= 126 then
      out[#out + 1] = string.char(b)
    else
      out[#out + 1] = string.format("\\x%02X", b)
    end
  end
  return table.concat(out)
end

local function DecodePortableText(v)
  local s = tostring(v or "")
  s = s:gsub("\\x([0-9A-Fa-f][0-9A-Fa-f])", function(h)
    local n = tonumber(h, 16)
    if not n then return "" end
    return string.char(n)
  end)
  s = s:gsub("\\\\", "\\")
  return s
end

local function ParseXmlAttrs(attrText)
  local attrs = {}
  for k, v in tostring(attrText or ""):gmatch("([%w_]+)%s*=%s*\"([^\"]*)\"") do
    attrs[k] = DecodePortableText(XmlUnescape(v))
  end
  return attrs
end

local function SerializeMaxLevelCaps(caps)
  if type(caps) ~= "table" then
    return ""
  end
  local parts = {}
  for k, v in pairs(caps) do
    local n = SafeNumber(v, 0)
    if n >= 0 then
      parts[#parts + 1] = tostring(k) .. ":" .. tostring(math.floor(n))
    end
  end
  table.sort(parts)
  return table.concat(parts, ";")
end

local function DeserializeMaxLevelCaps(raw)
  raw = tostring(raw or "")
  if raw == "" then
    return {}
  end
  local caps = {}
  for entry in raw:gmatch("[^;]+") do
    local k, v = entry:match("^([^:]+):([^:]+)$")
    if not k or not v then
      return nil, "Invalid max-level-caps field."
    end
    local n = tonumber(v)
    if not n then
      return nil, "Invalid max-level-caps value."
    end
    caps[tostring(k)] = math.max(0, math.floor(n))
  end
  return caps
end

local function ParseMajorVersion(version)
  local major = tostring(version or ""):match("^(%d+)")
  return tonumber(major) or 0
end

function AP:BuildExportText()
  local db = self.DB or {}
  local characters = db.characters or {}
  local keys = {}
  for key in pairs(characters) do
    keys[#keys + 1] = key
  end
  table.sort(keys)

  local lines = {}
  lines[#lines + 1] = string.format(
    "<AmmePlayedExport format=\"1\" addonVersion=\"%s\" account=\"%s\" region=\"%s\" count=\"%d\">",
    XmlEscape(EncodePortableText(AP.META.Version or "0.0.0")),
    XmlEscape(EncodePortableText(self.GetAccountIdentifier and self:GetAccountIdentifier() or "UNKNOWN")),
    XmlEscape(EncodePortableText(self.GetCurrentRegionLabel and self:GetCurrentRegionLabel() or "0")),
    #keys
  )

  for _, key in ipairs(keys) do
    local c = characters[key] or {}
    lines[#lines + 1] = string.format(
      "  <Character key=\"%s\" name=\"%s\" realm=\"%s\" class=\"%s\" classTag=\"%s\" race=\"%s\" raceTag=\"%s\" faction=\"%s\" level=\"%d\" totalPlayedSeconds=\"%d\" currentLevelPlayedSeconds=\"%d\" maxLevelPlayedSeconds=\"%d\" maxLevelCaps=\"%s\" />",
      XmlEscape(EncodePortableText(key)),
      XmlEscape(EncodePortableText(c.name or "")),
      XmlEscape(EncodePortableText(c.realm or "")),
      XmlEscape(EncodePortableText(c.class or "")),
      XmlEscape(EncodePortableText(c.classTag or "")),
      XmlEscape(EncodePortableText(c.race or "")),
      XmlEscape(EncodePortableText(c.raceTag or "")),
      XmlEscape(EncodePortableText(c.faction or "")),
      math.floor(SafeNumber(c.level, 0)),
      math.floor(SafeNumber(c.totalPlayedSeconds, 0)),
      math.floor(SafeNumber(c.currentLevelPlayedSeconds, 0)),
      math.floor(SafeNumber(c.maxLevelPlayedSeconds, 0)),
      XmlEscape(EncodePortableText(SerializeMaxLevelCaps(c.maxLevelCaps)))
    )
  end
  lines[#lines + 1] = "</AmmePlayedExport>"

  return table.concat(lines, "\n")
end

function AP:ParseImportText(raw)
  raw = tostring(raw or ""):gsub("\r\n", "\n"):gsub("\r", "\n")
  if raw == "" then
    return nil, nil, "Import text is empty."
  end

  local rootAttrs = raw:match("^%s*<AmmePlayedExport%s+([^>]*)>")
  if not rootAttrs then
    return nil, nil, "Missing AmmePlayedExport root node."
  end
  local body = raw:match("^%s*<AmmePlayedExport%s+[^>]*>(.*)</AmmePlayedExport>%s*$")
  if not body then
    return nil, nil, "Invalid XML. Missing closing AmmePlayedExport tag."
  end

  local root = ParseXmlAttrs(rootAttrs)
  local exportVersion = tostring(root.addonVersion or "")
  local exportAccount = tostring(root.account or "")
  local exportRegion = tostring(root.region or "")
  local expectedCount = tonumber(root.count or "")
  if exportVersion == "" then
    return nil, nil, "Missing export addon version."
  end
  if exportAccount == "" then
    return nil, nil, "Missing export account identifier."
  end
  if not expectedCount then
    return nil, nil, "Missing or invalid export character count."
  end

  local imported = {}
  local importedCount = 0
  for charAttrText in body:gmatch("<Character%s+([^>/]-)%s*/>") do
    local c = ParseXmlAttrs(charAttrText)
    local required = {
      "key", "name", "realm", "class", "classTag", "race", "raceTag", "faction",
      "level", "totalPlayedSeconds", "currentLevelPlayedSeconds", "maxLevelPlayedSeconds", "maxLevelCaps",
    }
    for _, field in ipairs(required) do
      if c[field] == nil then
        return nil, nil, "Import XML is missing required field: " .. field
      end
    end

    local level = tonumber(c.level)
    local totalPlayed = tonumber(c.totalPlayedSeconds)
    local currentLevel = tonumber(c.currentLevelPlayedSeconds)
    local maxLevel = tonumber(c.maxLevelPlayedSeconds)
    if c.key == "" or not level or not totalPlayed or not currentLevel or not maxLevel then
      return nil, nil, "Invalid numeric field in character row."
    end

    local caps, capsErr = DeserializeMaxLevelCaps(c.maxLevelCaps)
    if not caps then
      return nil, nil, capsErr or "Invalid max-level-caps data."
    end

    imported[c.key] = {
      key = c.key,
      name = c.name,
      realm = c.realm,
      class = c.class,
      classTag = c.classTag,
      race = c.race,
      raceTag = c.raceTag,
      faction = c.faction,
      level = math.max(0, math.floor(level)),
      totalPlayedSeconds = math.max(0, math.floor(totalPlayed)),
      currentLevelPlayedSeconds = math.max(0, math.floor(currentLevel)),
      maxLevelPlayedSeconds = math.max(0, math.floor(maxLevel)),
      maxLevelCaps = caps,
    }
    importedCount = importedCount + 1
  end

  local stripped = body:gsub("<Character%s+[^>/]-%s*/>", "")
  if stripped:match("%S") then
    return nil, nil, "Import XML contains unexpected elements or malformed Character entries."
  end

  local currentAccount = tostring(self.GetAccountIdentifier and self:GetAccountIdentifier() or "UNKNOWN")
  if exportAccount ~= currentAccount then
    return nil, nil, "This export is from a different WoW account and cannot be imported."
  end

  if expectedCount ~= importedCount then
    return nil, nil, "Import count mismatch. Data may be incomplete."
  end

  local warn
  local currentMajor = ParseMajorVersion(AP.META.Version or "")
  local exportMajor = ParseMajorVersion(exportVersion)
  if currentMajor ~= exportMajor then
    warn = string.format(
      "This export was created by addon version %s (major %d), but you are running %s (major %d). Import may fail or behave unexpectedly.",
      tostring(exportVersion),
      exportMajor,
      tostring(AP.META.Version or "?"),
      currentMajor
    )
  end

  return {
    version = exportVersion,
    account = exportAccount,
    region = exportRegion,
    count = importedCount,
    characters = imported,
  }, warn, nil
end

function AP:ApplyImportedData(importData)
  if not importData or type(importData.characters) ~= "table" then
    return false
  end

  self.DB = self.DB or {}
  self.DB.characters = self.DB.characters or {}
  for key, data in pairs(importData.characters) do
    self.DB.characters[key] = data
  end

  if self.RefreshUI then
    self:RefreshUI()
  end
  return true
end

function AP:ImportCharacterDataFromText(raw)
  local importData, warn, err = self:ParseImportText(raw)
  if not importData then
    self:ShowConfirmDialog("Import Failed", tostring(err or "Unknown import error."), nil)
    return false
  end

  local function finishImport()
    self:ApplyImportedData(importData)
    self:RequestPlayedTime("import-post-refresh", true)
    if self.ImportDialog then
      self.ImportDialog:Hide()
    end
    self:ShowConfirmDialog("Import Complete", string.format("Imported %d character record(s).", SafeNumber(importData.count, 0)), nil)
  end

  if warn then
    self:ShowConfirmDialog("Version Warning", warn .. "\n\nContinue import anyway?", finishImport)
  else
    finishImport()
  end
  return true
end

function AP:ShowImportDialog()
  if not self.ImportDialog then
    local f = CreateFrame("Frame", "AmmePlayed_ImportDialog", UIParent)
    f:SetSize(620, 420)
    f:SetPoint("CENTER")
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:SetMovable(true)
    f:EnableMouse(true)

    f.bg = f:CreateTexture(nil, "BACKGROUND")
    f.bg:SetAllPoints(true)
    f.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment_small.png")
    f.bg:SetTexCoord(0, 1, 0, 1)
    f.bg:SetVertexColor(1, 1, 1, 0.98)

    f.borderTop = f:CreateTexture(nil, "BORDER")
    f.borderTop:SetPoint("TOPLEFT", 10, -10)
    f.borderTop:SetPoint("TOPRIGHT", -10, -10)
    f.borderTop:SetHeight(1)
    f.borderTop:SetColorTexture(0, 0, 0, 0.60)
    f.borderBottom = f:CreateTexture(nil, "BORDER")
    f.borderBottom:SetPoint("BOTTOMLEFT", 10, 10)
    f.borderBottom:SetPoint("BOTTOMRIGHT", -10, 10)
    f.borderBottom:SetHeight(1)
    f.borderBottom:SetColorTexture(0, 0, 0, 0.60)
    f.borderLeft = f:CreateTexture(nil, "BORDER")
    f.borderLeft:SetPoint("TOPLEFT", 10, -10)
    f.borderLeft:SetPoint("BOTTOMLEFT", 10, 10)
    f.borderLeft:SetWidth(1)
    f.borderLeft:SetColorTexture(0, 0, 0, 0.60)
    f.borderRight = f:CreateTexture(nil, "BORDER")
    f.borderRight:SetPoint("TOPRIGHT", -10, -10)
    f.borderRight:SetPoint("BOTTOMRIGHT", -10, 10)
    f.borderRight:SetWidth(1)
    f.borderRight:SetColorTexture(0, 0, 0, 0.60)

    f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.title:SetPoint("TOPLEFT", 18, -14)
    f.title:SetText("AmmePlayed - Import Data")

    f.dragHandle = CreateFrame("Button", nil, f)
    f.dragHandle:SetPoint("TOPLEFT", 10, -8)
    f.dragHandle:SetPoint("TOPRIGHT", -10, -8)
    f.dragHandle:SetHeight(20)
    f.dragHandle:RegisterForDrag("LeftButton")
    f.dragHandle:SetScript("OnDragStart", function() f:StartMoving() end)
    f.dragHandle:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)

    f.desc = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
    f.desc:SetPoint("TOPLEFT", 18, -36)
    f.desc:SetPoint("TOPRIGHT", -18, -36)
    f.desc:SetJustifyH("LEFT")
    f.desc:SetJustifyV("TOP")
    f.desc:SetText("Copy the data from your previously saved XML file and paste it here. It will be validated then imported. If any matching character data already exists it will be overwritten with the imported data.")

    f.textAreaPanel = CreateFrame("Frame", nil, f)
    f.textAreaPanel:SetPoint("TOPLEFT", 16, -86)
    f.textAreaPanel:SetPoint("BOTTOMRIGHT", -32, 54)
    f.textAreaPanel.bg = f.textAreaPanel:CreateTexture(nil, "BACKGROUND")
    f.textAreaPanel.bg:SetAllPoints(true)
    f.textAreaPanel.bg:SetColorTexture(0, 0, 0, 0.20)
    f.textAreaPanel.borderTop = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderTop:SetPoint("TOPLEFT", -1, 1)
    f.textAreaPanel.borderTop:SetPoint("TOPRIGHT", 1, 1)
    f.textAreaPanel.borderTop:SetHeight(1)
    f.textAreaPanel.borderTop:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderBottom = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderBottom:SetPoint("BOTTOMLEFT", -1, -1)
    f.textAreaPanel.borderBottom:SetPoint("BOTTOMRIGHT", 1, -1)
    f.textAreaPanel.borderBottom:SetHeight(1)
    f.textAreaPanel.borderBottom:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderLeft = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderLeft:SetPoint("TOPLEFT", -1, 1)
    f.textAreaPanel.borderLeft:SetPoint("BOTTOMLEFT", -1, -1)
    f.textAreaPanel.borderLeft:SetWidth(1)
    f.textAreaPanel.borderLeft:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderRight = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderRight:SetPoint("TOPRIGHT", 1, 1)
    f.textAreaPanel.borderRight:SetPoint("BOTTOMRIGHT", 1, -1)
    f.textAreaPanel.borderRight:SetWidth(1)
    f.textAreaPanel.borderRight:SetColorTexture(0, 0, 0, 0.55)

    f.scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scroll:SetPoint("TOPLEFT", f.textAreaPanel, "TOPLEFT", 4, -4)
    f.scroll:SetPoint("BOTTOMRIGHT", f.textAreaPanel, "BOTTOMRIGHT", -4, 4)

    f.edit = CreateFrame("EditBox", nil, f.scroll)
    f.edit:SetAutoFocus(true)
    f.edit:SetMultiLine(true)
    f.edit:SetFontObject(AP_FONT.ChatNormal)
    f.edit:SetTextColor(0.96, 0.96, 0.96, 1.0)
    f.edit:SetHighlightColor(1.0, 0.82, 0.15, 0.35)
    f.edit:EnableMouse(true)
    f.edit:SetWidth(540)
    f.edit:SetScript("OnEscapePressed", function() f:Hide() end)
    f.edit:SetScript("OnTextChanged", function(editBox)
      editBox:SetWidth(540)
      local _, fontSize = editBox:GetFont()
      fontSize = tonumber(fontSize) or 14
      local text = editBox:GetText() or ""
      local lines = 1
      for _ in text:gmatch("\n") do
        lines = lines + 1
      end
      local targetHeight = math.max(320, math.floor(lines * (fontSize + 4) + 24))
      editBox:SetHeight(targetHeight)
      f.scroll:UpdateScrollChildRect()
    end)
    f.scroll:SetScrollChild(f.edit)

    f.importButton = CreateFlatButton(f, 96, 24, "Import")
    f.importButton:SetPoint("BOTTOMRIGHT", -18, 14)
    f.importButton:SetScript("OnClick", function()
      AP:ImportCharacterDataFromText(f.edit:GetText() or "")
    end)

    f.closeButton = CreateFlatButton(f, 96, 24, "Close")
    f.closeButton:SetPoint("RIGHT", f.importButton, "LEFT", -8, 0)
    f.closeButton:SetScript("OnClick", function() f:Hide() end)

    self.ImportDialog = f
  end

  self.ImportDialog.edit:SetText("")
  self.ImportDialog:Show()
  self.ImportDialog:Raise()
  self.ImportDialog.edit:SetFocus()
end

function AP:ShowExportDialog()
  if not self.ExportDialog then
    local f = CreateFrame("Frame", "AmmePlayed_ExportDialog", UIParent)
    f:SetSize(620, 420)
    f:SetPoint("CENTER")
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:SetMovable(true)
    f:EnableMouse(true)

    f.bg = f:CreateTexture(nil, "BACKGROUND")
    f.bg:SetAllPoints(true)
    f.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment_small.png")
    f.bg:SetTexCoord(0, 1, 0, 1)
    f.bg:SetVertexColor(1, 1, 1, 0.98)

    f.borderTop = f:CreateTexture(nil, "BORDER")
    f.borderTop:SetPoint("TOPLEFT", 10, -10)
    f.borderTop:SetPoint("TOPRIGHT", -10, -10)
    f.borderTop:SetHeight(1)
    f.borderTop:SetColorTexture(0, 0, 0, 0.60)
    f.borderBottom = f:CreateTexture(nil, "BORDER")
    f.borderBottom:SetPoint("BOTTOMLEFT", 10, 10)
    f.borderBottom:SetPoint("BOTTOMRIGHT", -10, 10)
    f.borderBottom:SetHeight(1)
    f.borderBottom:SetColorTexture(0, 0, 0, 0.60)
    f.borderLeft = f:CreateTexture(nil, "BORDER")
    f.borderLeft:SetPoint("TOPLEFT", 10, -10)
    f.borderLeft:SetPoint("BOTTOMLEFT", 10, 10)
    f.borderLeft:SetWidth(1)
    f.borderLeft:SetColorTexture(0, 0, 0, 0.60)
    f.borderRight = f:CreateTexture(nil, "BORDER")
    f.borderRight:SetPoint("TOPRIGHT", -10, -10)
    f.borderRight:SetPoint("BOTTOMRIGHT", -10, 10)
    f.borderRight:SetWidth(1)
    f.borderRight:SetColorTexture(0, 0, 0, 0.60)

    f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.title:SetPoint("TOPLEFT", 18, -14)
    f.title:SetText("AmmePlayed - Export Data")

    f.desc = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
    f.desc:SetPoint("TOPLEFT", 18, -36)
    f.desc:SetPoint("TOPRIGHT", -18, -36)
    f.desc:SetJustifyH("LEFT")
    f.desc:SetJustifyV("TOP")
    f.desc:SetText("Copy this XML data and paste it into a text editor. If you save it as 'somefilename.xml' you can open it in software such as a web browser to read the data.")

    f.dragHandle = CreateFrame("Button", nil, f)
    f.dragHandle:SetPoint("TOPLEFT", 10, -8)
    f.dragHandle:SetPoint("TOPRIGHT", -10, -8)
    f.dragHandle:SetHeight(20)
    f.dragHandle:RegisterForDrag("LeftButton")
    f.dragHandle:SetScript("OnDragStart", function() f:StartMoving() end)
    f.dragHandle:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)

    f.textAreaPanel = CreateFrame("Frame", nil, f)
    f.textAreaPanel:SetPoint("TOPLEFT", 16, -86)
    f.textAreaPanel:SetPoint("BOTTOMRIGHT", -32, 54)
    f.textAreaPanel.bg = f.textAreaPanel:CreateTexture(nil, "BACKGROUND")
    f.textAreaPanel.bg:SetAllPoints(true)
    f.textAreaPanel.bg:SetColorTexture(0, 0, 0, 0.20)
    f.textAreaPanel.borderTop = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderTop:SetPoint("TOPLEFT", -1, 1)
    f.textAreaPanel.borderTop:SetPoint("TOPRIGHT", 1, 1)
    f.textAreaPanel.borderTop:SetHeight(1)
    f.textAreaPanel.borderTop:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderBottom = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderBottom:SetPoint("BOTTOMLEFT", -1, -1)
    f.textAreaPanel.borderBottom:SetPoint("BOTTOMRIGHT", 1, -1)
    f.textAreaPanel.borderBottom:SetHeight(1)
    f.textAreaPanel.borderBottom:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderLeft = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderLeft:SetPoint("TOPLEFT", -1, 1)
    f.textAreaPanel.borderLeft:SetPoint("BOTTOMLEFT", -1, -1)
    f.textAreaPanel.borderLeft:SetWidth(1)
    f.textAreaPanel.borderLeft:SetColorTexture(0, 0, 0, 0.55)
    f.textAreaPanel.borderRight = f.textAreaPanel:CreateTexture(nil, "BORDER")
    f.textAreaPanel.borderRight:SetPoint("TOPRIGHT", 1, 1)
    f.textAreaPanel.borderRight:SetPoint("BOTTOMRIGHT", 1, -1)
    f.textAreaPanel.borderRight:SetWidth(1)
    f.textAreaPanel.borderRight:SetColorTexture(0, 0, 0, 0.55)

    f.scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    f.scroll:SetPoint("TOPLEFT", f.textAreaPanel, "TOPLEFT", 4, -4)
    f.scroll:SetPoint("BOTTOMRIGHT", f.textAreaPanel, "BOTTOMRIGHT", -4, 4)

    f.edit = CreateFrame("EditBox", nil, f.scroll)
    f.edit:SetAutoFocus(false)
    f.edit:SetMultiLine(true)
    f.edit:SetFontObject(AP_FONT.ChatNormal)
    f.edit:SetTextColor(0.96, 0.96, 0.96, 1.0)
    f.edit:SetHighlightColor(1.0, 0.82, 0.15, 0.35)
    f.edit:EnableMouse(true)
    f.edit:SetWidth(540)
    f.edit._ap_sourceText = ""
    f.edit:SetScript("OnEscapePressed", function() f:Hide() end)
    f.edit:SetScript("OnTextChanged", function(editBox, userInput)
      if userInput then
        local source = editBox._ap_sourceText or ""
        if editBox:GetText() ~= source then
          editBox:SetText(source)
        end
        return
      end
      editBox:SetWidth(540)
      local _, fontSize = editBox:GetFont()
      fontSize = tonumber(fontSize) or 14
      local text = editBox:GetText() or ""
      local lines = 1
      for _ in text:gmatch("\n") do
        lines = lines + 1
      end
      local targetHeight = math.max(320, math.floor(lines * (fontSize + 4) + 24))
      editBox:SetHeight(targetHeight)
      f.scroll:UpdateScrollChildRect()
    end)
    f.scroll:SetScrollChild(f.edit)

    f.selectAll = CreateFlatButton(f, 96, 24, "Select All")
    f.selectAll:SetPoint("BOTTOMRIGHT", -122, 14)
    f.selectAll:SetScript("OnClick", function()
      f.edit:SetFocus()
      f.edit:HighlightText()
    end)

    f.copyHint = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
    f.copyHint:SetPoint("BOTTOMLEFT", 18, 14)
    f.copyHint:SetTextColor(0.90, 0.90, 0.90)
    f.copyHint:SetText("Press Control+C to copy highlighted text.")

    f.status = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalSmall")
    f.status:SetPoint("BOTTOMLEFT", 18, 30)
    f.status:SetTextColor(0.86, 0.86, 0.86)
    f.status:SetText("")

    f.closeButton = CreateFlatButton(f, 96, 24, "Close")
    f.closeButton:SetPoint("BOTTOMRIGHT", -18, 14)
    f.closeButton:SetScript("OnClick", function() f:Hide() end)

    self.ExportDialog = f
  end

  local exportText = self:BuildExportText()
  self.ExportDialog.edit._ap_sourceText = exportText
  self.ExportDialog.edit:SetText(self.ExportDialog.edit._ap_sourceText)
  local lineCount = 1
  for _ in exportText:gmatch("\n") do
    lineCount = lineCount + 1
  end
  self.ExportDialog.status:SetText(string.format("Loaded %d characters across %d line(s).", #exportText, lineCount))
  self.ExportDialog:Show()
  self.ExportDialog:Raise()
  self.ExportDialog.edit:SetFocus()
  self.ExportDialog.edit:HighlightText(0, 0)
  self.ExportDialog.edit:SetCursorPosition(0)
end

function AP:ShowConfirmDialog(title, message, onConfirm)
  if not self.ConfirmDialog then
    local f = CreateFrame("Frame", "AmmePlayed_ConfirmDialog", UIParent, "BackdropTemplate")
    f:SetSize(520, 180)
    f:SetPoint("CENTER")
    f:SetFrameStrata("FULLSCREEN_DIALOG")
    f:SetToplevel(true)
    f:SetMovable(true)
    f:EnableMouse(true)
    f:SetBackdrop({
      bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
      edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
      tile = true,
      tileSize = 32,
      edgeSize = 24,
      insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })

    f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.title:SetPoint("TOPLEFT", 18, -14)
    f.title:SetText("Confirm")

    f.body = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.body:SetPoint("TOPLEFT", 18, -42)
    f.body:SetPoint("TOPRIGHT", -18, -42)
    f.body:SetJustifyH("LEFT")
    f.body:SetJustifyV("TOP")
    f.body:SetText("")

    f.cancelButton = CreateFlatButton(f, 96, 24, "Cancel")
    f.cancelButton:SetPoint("BOTTOMRIGHT", -124, 14)
    f.cancelButton:SetScript("OnClick", function() f:Hide() end)

    f.confirmButton = CreateFlatButton(f, 96, 24, "Confirm")
    f.confirmButton:SetPoint("BOTTOMRIGHT", -18, 14)
    f.confirmButton.text:SetTextColor(1.0, 0.82, 0.80)
    f.confirmButton.bg:SetColorTexture(0.22, 0.10, 0.10, 0.94)
    f.confirmButton.border:SetColorTexture(0.80, 0.32, 0.28, 1)
    f.confirmButton:SetScript("OnClick", function()
      local cb = f._onConfirm
      f:Hide()
      if cb then cb() end
    end)

    self.ConfirmDialog = f
  end

  self.ConfirmDialog.title:SetText(title or "Confirm")
  self.ConfirmDialog.body:SetText(message or "")
  self.ConfirmDialog._onConfirm = onConfirm
  self.ConfirmDialog:Show()
  self.ConfirmDialog:Raise()
end

function AP:HideTotalPlayedTooltip()
  if self.TotalPlayedTooltip then
    self.TotalPlayedTooltip:Hide()
  end
end

function AP:ShowTotalPlayedTooltip(anchorFrame, totalSeconds)
  if not anchorFrame then return end

  if not self.TotalPlayedTooltip then
    local f = CreateFrame("Frame", "AmmePlayed_TotalPlayedTooltip", UIParent, "BackdropTemplate")
    f:SetSize(420, 142)
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:SetClampedToScreen(true)

    f.bg = f:CreateTexture(nil, "BACKGROUND")
    f.bg:SetAllPoints(true)
    f.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment_small.png")
    f.bg:SetTexCoord(0, 1, 0, 1)
    f.bg:SetVertexColor(1, 1, 1, 0.98)

    f.borderTop = f:CreateTexture(nil, "BORDER")
    f.borderTop:SetPoint("TOPLEFT", 20, -20)
    f.borderTop:SetPoint("TOPRIGHT", -20, -20)
    f.borderTop:SetHeight(1)
    f.borderTop:SetColorTexture(0, 0, 0, 0.60)

    f.borderBottom = f:CreateTexture(nil, "BORDER")
    f.borderBottom:SetPoint("BOTTOMLEFT", 20, 20)
    f.borderBottom:SetPoint("BOTTOMRIGHT", -20, 20)
    f.borderBottom:SetHeight(1)
    f.borderBottom:SetColorTexture(0, 0, 0, 0.60)

    f.borderLeft = f:CreateTexture(nil, "BORDER")
    f.borderLeft:SetPoint("TOPLEFT", 20, -20)
    f.borderLeft:SetPoint("BOTTOMLEFT", 20, 20)
    f.borderLeft:SetWidth(1)
    f.borderLeft:SetColorTexture(0, 0, 0, 0.60)

    f.borderRight = f:CreateTexture(nil, "BORDER")
    f.borderRight:SetPoint("TOPRIGHT", -20, -20)
    f.borderRight:SetPoint("BOTTOMRIGHT", -20, 20)
    f.borderRight:SetWidth(1)
    f.borderRight:SetColorTexture(0, 0, 0, 0.60)

    f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.title:SetPoint("TOPLEFT", 30, -30)
    f.title:SetPoint("TOPRIGHT", -30, -30)
    f.title:SetJustifyH("LEFT")
    f.title:SetTextColor(1.0, 0.82, 0.15)
    f.title:SetText("Total Played Breakdown")

    f.line1 = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.line1:SetPoint("TOPLEFT", f.title, "BOTTOMLEFT", 0, -6)
    f.line1:SetPoint("TOPRIGHT", f.title, "BOTTOMRIGHT", 0, -6)
    f.line1:SetJustifyH("LEFT")
    f.line1:SetTextColor(0.93, 0.90, 0.82)

    f.line2 = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.line2:SetPoint("TOPLEFT", f.line1, "BOTTOMLEFT", 0, -3)
    f.line2:SetPoint("TOPRIGHT", f.line1, "BOTTOMRIGHT", 0, -3)
    f.line2:SetJustifyH("LEFT")
    f.line2:SetTextColor(0.93, 0.90, 0.82)

    f.line3 = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.line3:SetPoint("TOPLEFT", f.line2, "BOTTOMLEFT", 0, -3)
    f.line3:SetPoint("TOPRIGHT", f.line2, "BOTTOMRIGHT", 0, -3)
    f.line3:SetJustifyH("LEFT")
    f.line3:SetTextColor(0.93, 0.90, 0.82)

    f.line4 = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.line4:SetPoint("TOPLEFT", f.line3, "BOTTOMLEFT", 0, -3)
    f.line4:SetPoint("TOPRIGHT", f.line3, "BOTTOMRIGHT", 0, -3)
    f.line4:SetJustifyH("LEFT")
    f.line4:SetTextColor(0.93, 0.90, 0.82)

    self.TotalPlayedTooltip = f
  end

  local t = self.TotalPlayedTooltip
  t.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment_small.png")
  t.bg:SetTexCoord(0, 1, 0, 1)
  t.bg:SetVertexColor(1, 1, 1, 0.98)

  local seconds = SafeNumber(totalSeconds, 0)
  local hours = seconds / 3600
  local days = seconds / 86400
  local weeks = days / 7
  local months = days / 30.4375
  local years = days / 365.25

  t.line1:SetText(string.format("Raw hours: %.1f", hours))
  t.line2:SetText(string.format("AKA %.2f weeks", weeks))
  t.line3:SetText(string.format("AKA %.2f months", months))
  t.line4:SetText(string.format("AKA %.2f years", years))

  -- Default placement is above the clickable time text.
  t:ClearAllPoints()
  t:SetPoint("BOTTOMLEFT", anchorFrame, "TOPLEFT", 0, 8)
  t:Show()
  t:Raise()

  -- Keep the tooltip fully on screen by nudging it if needed.
  local parent = UIParent
  local left = t:GetLeft() or 0
  local right = t:GetRight() or 0
  local top = t:GetTop() or 0
  local bottom = t:GetBottom() or 0
  local minX = (parent:GetLeft() or 0) + 6
  local maxX = (parent:GetRight() or 0) - 6
  local minY = (parent:GetBottom() or 0) + 6
  local maxY = (parent:GetTop() or 0) - 6
  local dx, dy = 0, 0

  if left < minX then
    dx = minX - left
  elseif right > maxX then
    dx = maxX - right
  end

  if bottom < minY then
    dy = minY - bottom
  elseif top > maxY then
    dy = maxY - top
  end

  if dx ~= 0 or dy ~= 0 then
    t:ClearAllPoints()
    t:SetPoint("BOTTOMLEFT", anchorFrame, "TOPLEFT", dx, 8 + dy)
  end
end

function AP:HideChartSegmentTooltip()
  if self.ChartSegmentTooltip then
    self.ChartSegmentTooltip:Hide()
  end
end

function AP:ShowChartSegmentTooltip(anchorFrame, data)
  if not anchorFrame or not data then return end

  if not self.ChartSegmentTooltip then
    local f = CreateFrame("Frame", "AmmePlayed_ChartSegmentTooltip", UIParent, "BackdropTemplate")
    f:SetSize(308, 118)
    f:SetFrameStrata("TOOLTIP")
    f:SetToplevel(true)
    f:SetClampedToScreen(true)

    f.bg = f:CreateTexture(nil, "BACKGROUND")
    f.bg:SetAllPoints(true)
    f.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment_small.png")
    f.bg:SetTexCoord(0, 1, 0, 1)
    f.bg:SetVertexColor(1, 1, 1, 0.98)

    f.borderTop = f:CreateTexture(nil, "BORDER")
    f.borderTop:SetPoint("TOPLEFT", 12, -12)
    f.borderTop:SetPoint("TOPRIGHT", -12, -12)
    f.borderTop:SetHeight(1)
    f.borderTop:SetColorTexture(0, 0, 0, 0.60)

    f.borderBottom = f:CreateTexture(nil, "BORDER")
    f.borderBottom:SetPoint("BOTTOMLEFT", 12, 12)
    f.borderBottom:SetPoint("BOTTOMRIGHT", -12, 12)
    f.borderBottom:SetHeight(1)
    f.borderBottom:SetColorTexture(0, 0, 0, 0.60)

    f.borderLeft = f:CreateTexture(nil, "BORDER")
    f.borderLeft:SetPoint("TOPLEFT", 12, -12)
    f.borderLeft:SetPoint("BOTTOMLEFT", 12, 12)
    f.borderLeft:SetWidth(1)
    f.borderLeft:SetColorTexture(0, 0, 0, 0.60)

    f.borderRight = f:CreateTexture(nil, "BORDER")
    f.borderRight:SetPoint("TOPRIGHT", -12, -12)
    f.borderRight:SetPoint("BOTTOMRIGHT", -12, 12)
    f.borderRight:SetWidth(1)
    f.borderRight:SetColorTexture(0, 0, 0, 0.60)

    f.classIcon = f:CreateTexture(nil, "ARTWORK")
    f.classIcon:SetPoint("TOPLEFT", 20, -20)
    f.classIcon:SetSize(40, 40)
    f.classIcon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

    f.name = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormal")
    f.name:SetPoint("TOPLEFT", f.classIcon, "TOPRIGHT", 10, -1)
    f.name:SetPoint("TOPRIGHT", -16, -21)
    f.name:SetJustifyH("LEFT")
    f.name:SetWordWrap(false)
    f.name:SetTextColor(1.0, 0.82, 0.15)

    f.meta = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.meta:SetPoint("TOPLEFT", f.name, "BOTTOMLEFT", 0, -2)
    f.meta:SetPoint("TOPRIGHT", -16, -40)
    f.meta:SetJustifyH("LEFT")
    f.meta:SetTextColor(0.93, 0.90, 0.82)

    f.totalPlayed = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.totalPlayed:SetPoint("TOPLEFT", 20, -68)
    f.totalPlayed:SetPoint("TOPRIGHT", -16, -68)
    f.totalPlayed:SetJustifyH("LEFT")
    f.totalPlayed:SetTextColor(0.93, 0.90, 0.82)

    f.maxPlayed = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlight")
    f.maxPlayed:SetPoint("TOPLEFT", f.totalPlayed, "BOTTOMLEFT", 0, -3)
    f.maxPlayed:SetPoint("TOPRIGHT", -16, -89)
    f.maxPlayed:SetJustifyH("LEFT")
    f.maxPlayed:SetTextColor(0.93, 0.90, 0.82)

    self.ChartSegmentTooltip = f
  end

  local t = self.ChartSegmentTooltip
  local classTag = tostring(data.classTag or "UNKNOWN")
  local className = AP:GetLocalizedClassName(classTag, data.class or "Unknown")
  local raceName = tostring(data.race or "Unknown")
  local level = SafeNumber(data.level, 0)
  local realmName = tostring(data.realm or "Unknown")
  if #realmName > 16 then
    realmName = realmName:sub(1, 13) .. "..."
  end

  local classTexture = CLASS_ICON_FILES[classTag]
  if classTexture then
    t.classIcon:SetTexture(classTexture)
  else
    t.classIcon:SetTexture("Interface\\Icons\\INV_MISC_QUESTIONMARK")
    t.classIcon:SetTexCoord(0, 1, 0, 1)
  end

  t.name:SetText(string.format("%s-%s", tostring(data.name or "Unknown"), realmName))
  t.meta:SetText(string.format("%s %s  Lv %d", raceName, className, level))
  t.totalPlayed:SetText("Total played: " .. AP:FormatDurationLong(SafeNumber(data.totalPlayedSeconds, 0)))
  t.maxPlayed:SetText("Played at max level: " .. AP:FormatDurationLong(SafeNumber(data.maxLevelPlayedSeconds, 0)))

  t:ClearAllPoints()
  t:SetPoint("BOTTOMLEFT", anchorFrame, "TOPRIGHT", 8, 8)
  t:Show()
  t:Raise()

  local parent = UIParent
  local left = t:GetLeft() or 0
  local right = t:GetRight() or 0
  local top = t:GetTop() or 0
  local bottom = t:GetBottom() or 0
  local minX = (parent:GetLeft() or 0) + 6
  local maxX = (parent:GetRight() or 0) - 6
  local minY = (parent:GetBottom() or 0) + 6
  local maxY = (parent:GetTop() or 0) - 6
  local dx, dy = 0, 0

  if left < minX then
    dx = minX - left
  elseif right > maxX then
    dx = maxX - right
  end

  if bottom < minY then
    dy = minY - bottom
  elseif top > maxY then
    dy = maxY - top
  end

  if dx ~= 0 or dy ~= 0 then
    t:ClearAllPoints()
    t:SetPoint("BOTTOMLEFT", anchorFrame, "TOPRIGHT", 8 + dx, 8 + dy)
  end
end

function AP:RefreshSummaryTab()
  if not self.MainFrame or not self.MainFrame.summaryTab then return end
  local tab = self.MainFrame.summaryTab
  local stats = self:GetSummaryStats()
  local narrative = BuildSummaryNarrativeData(stats)
  local plainText, bbcodeText, markdownText = BuildNarrativeTextFormats(narrative)

  tab.copyPlainText = plainText
  tab.copyBBCodeText = bbcodeText
  tab.copyMarkdownText = markdownText

  local scrollWidth = math.max(300, tab.narrativeScroll:GetWidth() - 26)
  local topLineHeight = 0
  if narrative.available then
    local prefixText, valueText, suffixText = GetTotalPlayedLine(stats.totalPlayedSeconds)

    tab.totalPlayedPrefix:SetText(prefixText)
    tab.totalPlayedValueButton.text:SetText(valueText)
    tab.totalPlayedSuffix:SetText(suffixText)
    tab.totalPlayedValueButton.totalSeconds = SafeNumber(stats.totalPlayedSeconds, 0)

    local prefixWidth = tab.totalPlayedPrefix:GetStringWidth()
    local valueWidth = tab.totalPlayedValueButton.text:GetStringWidth()
    local suffixWidth = tab.totalPlayedSuffix:GetStringWidth()
    local totalWidth = prefixWidth + valueWidth + suffixWidth
    local startX = math.max(8, math.floor((scrollWidth - totalWidth) / 2))

    tab.totalPlayedPrefix:ClearAllPoints()
    tab.totalPlayedPrefix:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", startX, -4)

    tab.totalPlayedValueButton:ClearAllPoints()
    tab.totalPlayedValueButton:SetPoint("TOPLEFT", tab.totalPlayedPrefix, "TOPRIGHT", 0, 0)
    tab.totalPlayedValueButton:SetSize(
      math.max(1, math.floor(valueWidth + 2)),
      math.max(1, math.floor(tab.totalPlayedValueButton.text:GetStringHeight() + 2))
    )

    tab.totalPlayedSuffix:ClearAllPoints()
    tab.totalPlayedSuffix:SetPoint("TOPLEFT", tab.totalPlayedValueButton, "TOPRIGHT", 0, 0)

    topLineHeight = math.max(
      tab.totalPlayedPrefix:GetStringHeight(),
      tab.totalPlayedValueButton.text:GetStringHeight(),
      tab.totalPlayedSuffix:GetStringHeight()
    ) + 8

    tab.totalPlayedPrefix:Show()
    tab.totalPlayedValueButton:Show()
    tab.totalPlayedSuffix:Show()
  else
    tab.totalPlayedPrefix:SetText("")
    tab.totalPlayedSuffix:SetText("")
    tab.totalPlayedValueButton.text:SetText("")
    tab.totalPlayedPrefix:Hide()
    tab.totalPlayedValueButton:Hide()
    tab.totalPlayedSuffix:Hide()
    self:HideTotalPlayedTooltip()
  end

  local outerPad = 8
  local sepPad = 4
  local sepWidth = 1
  local colWidth = math.floor((scrollWidth - (outerPad * 2) - (sepWidth * 2) - (sepPad * 4)) / 3)
  local col1X = outerPad
  local sep1X = col1X + colWidth + sepPad
  local col2X = sep1X + sepWidth + sepPad
  local sep2X = col2X + colWidth + sepPad
  local col3X = sep2X + sepWidth + sepPad
  local colTopOffset = 6 + topLineHeight + 8
  local colTopY = -colTopOffset

  tab.characterLabel:ClearAllPoints()
  tab.characterLabel:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", col1X, colTopY)
  tab.characterLabel:SetWidth(colWidth)
  tab.characterLabel:SetText("Your Favorite character is:")

  tab.characterName:ClearAllPoints()
  tab.characterName:SetPoint("TOPLEFT", tab.characterLabel, "BOTTOMLEFT", 0, -2)
  tab.characterName:SetWidth(colWidth)

  tab.characterIcon:ClearAllPoints()
  tab.characterIcon:SetPoint("TOP", tab.characterName, "BOTTOM", 0, -4)
  tab.characterComment:ClearAllPoints()
  tab.characterComment:SetPoint("TOPLEFT", tab.characterIcon, "BOTTOMLEFT", -math.floor((colWidth - 56) / 2), -4)
  tab.characterComment:SetWidth(colWidth)
  tab.characterPlayed:ClearAllPoints()
  tab.characterPlayed:SetPoint("TOPLEFT", tab.characterComment, "BOTTOMLEFT", 0, -4)
  tab.characterPlayed:SetWidth(colWidth)
  tab.characterJoke:ClearAllPoints()
  tab.characterJoke:SetPoint("TOPLEFT", tab.characterPlayed, "BOTTOMLEFT", 0, -4)
  tab.characterJoke:SetWidth(colWidth)

  tab.classLabel:ClearAllPoints()
  tab.classLabel:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", col2X, colTopY)
  tab.classLabel:SetWidth(colWidth)
  tab.classLabel:SetText("Your Favorite class is:")

  tab.className:ClearAllPoints()
  tab.className:SetPoint("TOPLEFT", tab.classLabel, "BOTTOMLEFT", 0, -2)
  tab.className:SetWidth(colWidth)

  tab.classIcon:ClearAllPoints()
  tab.classIcon:SetPoint("TOP", tab.className, "BOTTOM", 0, -4)
  tab.classComment:ClearAllPoints()
  tab.classComment:SetPoint("TOPLEFT", tab.classIcon, "BOTTOMLEFT", -math.floor((colWidth - 56) / 2), -4)
  tab.classComment:SetWidth(colWidth)

  tab.raceLabel:ClearAllPoints()
  tab.raceLabel:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", col3X, colTopY)
  tab.raceLabel:SetWidth(colWidth)
  tab.raceLabel:SetText("Your Favorite race is:")

  tab.raceName:ClearAllPoints()
  tab.raceName:SetPoint("TOPLEFT", tab.raceLabel, "BOTTOMLEFT", 0, -2)
  tab.raceName:SetWidth(colWidth)

  tab.raceIcon:ClearAllPoints()
  tab.raceIcon:SetPoint("TOP", tab.raceName, "BOTTOM", 0, -4)
  tab.raceComment:ClearAllPoints()
  tab.raceComment:SetPoint("TOPLEFT", tab.raceIcon, "BOTTOMLEFT", -math.floor((colWidth - 56) / 2), -4)
  tab.raceComment:SetWidth(colWidth)

  tab.quoteBox:Hide()

  tab.accuracyNote:ClearAllPoints()
  tab.accuracyNote:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", 8, -6)
  tab.accuracyNote:SetWidth(scrollWidth - 16)

  tab.summaryText:ClearAllPoints()
  tab.summaryText:SetPoint("TOPLEFT", tab.accuracyNote, "BOTTOMLEFT", 0, -8)
  tab.summaryText:SetWidth(scrollWidth - 16)

  if narrative.available then
    tab.characterName:SetText(tostring(narrative.topCharacterName or "Unknown"))
    tab.characterComment:SetText(string.format("%s %s", tostring(narrative.topCharacterRaceName or "Unknown"), tostring(narrative.topCharacterClassName or "Unknown")))
    tab.characterPlayed:SetText(string.format("%s-%s has %s played.", tostring(narrative.topCharacterName or "Unknown"), tostring(narrative.topCharacterRealm or "UnknownRealm"), AP:FormatDurationLong(SafeNumber(narrative.topCharacterPlayedSeconds, 0))))
    tab.characterJoke:SetText(tostring(narrative.comboJoke or ""))
    tab.className:SetText(tostring(narrative.className or "Unknown"))
    tab.raceName:SetText(tostring(narrative.raceName or "Unknown"))

    local topClassTexture = CLASS_ICON_FILES[tostring(narrative.topCharacterClassTag or "")]
    if topClassTexture then
      tab.characterIcon:SetTexture(topClassTexture)
      tab.characterIcon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
      tab.characterIcon:Show()
    else
      tab.characterIcon:Hide()
    end

    local classTexture = CLASS_ICON_FILES[tostring(narrative.classTag or "")]
    if classTexture then
      tab.classIcon:SetTexture(classTexture)
      tab.classIcon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
      tab.classIcon:Show()
    else
      tab.classIcon:Hide()
    end

    local raceToken = NormalizeToken(narrative.raceTag or narrative.raceName)
    local raceTexture, raceIsPortrait, raceAtlas = ResolveRaceIconTexture(raceToken)
    local usedAtlas = false
    if raceAtlas and tab.raceIcon.SetAtlas then
      usedAtlas = tab.raceIcon:SetAtlas(raceAtlas, false) and true or false
    end
    if usedAtlas then
      tab.raceIcon:SetTexCoord(0, 1, 0, 1)
    else
      tab.raceIcon:SetTexture(raceTexture)
      if raceIsPortrait then
        tab.raceIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
      else
        tab.raceIcon:SetTexCoord(0, 1, 0, 1)
      end
    end
    tab.raceIcon:SetVertexColor(1, 1, 1, 1)
    tab.raceIcon:Show()

    tab.classComment:SetText(tostring(narrative.classComment or ""))
    tab.raceComment:SetText(tostring(narrative.raceComment or ""))

    local charBottom = colTopOffset + tab.characterLabel:GetStringHeight() + 2 + tab.characterName:GetStringHeight() + 4 + 56 + 4 + tab.characterComment:GetStringHeight() + 4 + tab.characterPlayed:GetStringHeight() + 4 + tab.characterJoke:GetStringHeight()
    local classBottom = colTopOffset + tab.classLabel:GetStringHeight() + 2 + tab.className:GetStringHeight() + 4 + 56 + 4 + tab.classComment:GetStringHeight()
    local raceBottom = colTopOffset + tab.raceLabel:GetStringHeight() + 2 + tab.raceName:GetStringHeight() + 4 + 56 + 4 + tab.raceComment:GetStringHeight()
    local topSectionBottom = math.max(charBottom, classBottom, raceBottom)
    tab.columnSep1:ClearAllPoints()
    tab.columnSep1:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", sep1X, -(colTopOffset + 5))
    tab.columnSep1:SetSize(sepWidth, math.max(1, topSectionBottom - colTopOffset - 13))
    tab.columnSep1:Show()
    tab.columnSep2:ClearAllPoints()
    tab.columnSep2:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", sep2X, -(colTopOffset + 5))
    tab.columnSep2:SetSize(sepWidth, math.max(1, topSectionBottom - colTopOffset - 13))
    tab.columnSep2:Show()

    tab.accuracyNote:SetText("This summary is based on the characters you have logged into with this addon installed so far. Log into more characters if it looks inaccurate.")

    tab.summaryText:SetText(table.concat({
      "Stats:",
      string.format("- You have played a %s %s for %s.", tostring(narrative.topCharacterRaceName or "Unknown"), tostring(narrative.topCharacterClassName or "Unknown"), AP:FormatDurationLong(SafeNumber(narrative.comboPlayedSeconds, 0))),
      string.format("- You have played on all %s for %s.", PluralizeClassName(tostring(narrative.className or "Class")), AP:FormatDurationLong(SafeNumber(narrative.classPlayedSeconds, 0))),
      string.format("- You have played on all %s for %s.", tostring(narrative.raceName or "Unknown"), AP:FormatDurationLong(SafeNumber(narrative.racePlayedSeconds, 0))),
    }, "\n"))

    tab.accuracyNote:ClearAllPoints()
    tab.accuracyNote:SetPoint("TOPLEFT", tab.narrativeContent, "TOPLEFT", 8, -(topSectionBottom + 8))
    tab.accuracyNote:SetWidth(scrollWidth - 16)

    tab.summaryText:ClearAllPoints()
    tab.summaryText:SetPoint("TOPLEFT", tab.accuracyNote, "BOTTOMLEFT", 0, -8)
    tab.summaryText:SetWidth(scrollWidth - 16)

    local contentHeight = topSectionBottom
      + 8
      + 8
      + tab.accuracyNote:GetStringHeight()
      + 8
      + tab.summaryText:GetStringHeight()
      + 12
    tab.narrativeContent:SetSize(scrollWidth, math.max(150, contentHeight))
  else
    tab.characterLabel:SetText("")
    tab.characterName:SetText("")
    tab.characterComment:SetText("")
    tab.characterPlayed:SetText("")
    tab.characterJoke:SetText("")
    tab.classLabel:SetText("")
    tab.className:SetText("")
    tab.classComment:SetText("")
    tab.raceLabel:SetText("")
    tab.raceName:SetText("")
    tab.raceComment:SetText("")
    tab.characterIcon:Hide()
    tab.classIcon:Hide()
    tab.raceIcon:Hide()
    tab.columnSep1:Hide()
    tab.columnSep2:Hide()
    tab.quoteBox:Hide()
    tab.accuracyNote:SetText("")
    tab.summaryText:SetText(narrative.gatedText or "")
    local textHeight = math.max(30, tab.summaryText:GetStringHeight() + 20)
    tab.narrativeContent:SetSize(scrollWidth, textHeight)
  end

  tab.narrativeScroll:SetVerticalScroll(0)

  local chars = stats.characters
  local total = #chars

  FauxScrollFrame_Update(tab.scroll, total, 5, tab.rowHeight)
  local offset = FauxScrollFrame_GetOffset(tab.scroll)

  for i = 1, 5 do
    local row = tab.rows[i]
    local data = chars[offset + i]

    if data then
      local r, g, b = self:GetClassColor(data.classTag)
      row.cols.name:SetText(data.name or "")
      row.cols.realm:SetText(data.realm or "")
      row.cols.class:SetText(AP:GetLocalizedClassName(data.classTag or "UNKNOWN", data.class or ""))
      row.cols.class:SetTextColor(r, g, b)
      row.cols.race:SetText(data.race or "")
      row.cols.level:SetText(tostring(SafeNumber(data.level, 0)))
      row.cols.played:SetText(self:FormatDuration(SafeNumber(data.totalPlayedSeconds, 0)))
      row:Show()
    else
      row:Hide()
    end
  end
end
function AP:RefreshChartTab(tab)
  if not tab then return end

  local groups = self:GetGroupedData(tab.groupBy, tab.metric)
  local maxTotal = 0
  for _, g in ipairs(groups) do
    if g.totalSeconds > maxTotal then
      maxTotal = g.totalSeconds
    end
  end
  if maxTotal <= 0 then
    maxTotal = 1
  end

  local rowTop = -4
  local rowSpacing = 44

  for i, group in ipairs(groups) do
    local row = EnsureChartRow(tab, i)
    row:ClearAllPoints()
    row:SetPoint("TOPLEFT", 0, rowTop - ((i - 1) * rowSpacing))
    row:SetPoint("TOPRIGHT", -12, rowTop - ((i - 1) * rowSpacing))
    row:Show()

    row.label:SetText(group.label)
    row.total:SetText(self:FormatDuration(group.totalSeconds))

    if tab.groupBy == "race" then
      local raceTexture, raceIsPortrait, raceAtlas = ResolveRaceIconTexture(NormalizeToken(group.key))
      local usedAtlas = false
      if raceAtlas and row.icon.SetAtlas then
        usedAtlas = row.icon:SetAtlas(raceAtlas, false) and true or false
      end
      if usedAtlas then
        row.icon:SetTexCoord(0, 1, 0, 1)
      else
        row.icon:SetTexture(raceTexture)
        if raceIsPortrait then
          row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        else
          row.icon:SetTexCoord(0, 1, 0, 1)
        end
      end
      row.icon:SetVertexColor(1, 1, 1, 1)
      row.icon:Show()
    else
      local classTexture = CLASS_ICON_FILES[tostring(group.key or "")]
      if classTexture then
        row.icon:SetTexture(classTexture)
        row.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
        row.icon:SetVertexColor(1, 1, 1, 1)
        row.icon:Show()
      else
        row.icon:Hide()
      end
    end

    -- Keep bars clear of the right-side total column, plus an 8px visual inset.
    local rightValueColumnWidth = 120
    local rightValuePadding = 10
    local rightInset = 8
    local barMaxWidth = math.max(100, math.floor(row:GetWidth() - 230 - rightValueColumnWidth - rightValuePadding - rightInset))
    local rowBarWidth = math.max(12, math.floor(barMaxWidth * (group.totalSeconds / maxTotal)))
    row.bar:SetWidth(rowBarWidth)

    local count = #group.segments
    local minVisualWidth = (count > 1) and 6 or 1
    minVisualWidth = math.max(1, math.min(minVisualWidth, math.floor(rowBarWidth / math.max(1, count))))
    local widths = {}
    local sumWidths = 0
    local available = rowBarWidth

    for j, seg in ipairs(group.segments) do
      local raw = (group.totalSeconds > 0 and (rowBarWidth * (seg.value / group.totalSeconds))) or 0
      local w = math.floor(raw)
      if count > 1 then
        w = math.max(minVisualWidth, w)
      else
        w = math.max(1, w)
      end
      widths[j] = w
      sumWidths = sumWidths + w
    end

    if sumWidths > available then
      local overflow = sumWidths - available
      while overflow > 0 do
        local reduced = false
        for j = count, 1, -1 do
          local floorW = (count > 1) and minVisualWidth or 1
          if widths[j] > floorW then
            widths[j] = widths[j] - 1
            overflow = overflow - 1
            reduced = true
            if overflow <= 0 then break end
          end
        end
        if not reduced then break end
      end
    elseif sumWidths < available then
      local deficit = available - sumWidths
      local idx = 1
      while deficit > 0 do
        widths[idx] = widths[idx] + 1
        deficit = deficit - 1
        idx = idx + 1
        if idx > count then idx = 1 end
      end
    end

    local used = 0

    for j, seg in ipairs(group.segments) do
      local btn = row.segmentButtons[j]
      if not btn then
        btn = CreateSegmentButton(row.bar)
        row.segmentButtons[j] = btn
      end

      local segmentWidth = math.max(1, widths[j] or 1)

      btn:ClearAllPoints()
      btn:SetPoint("LEFT", row.bar, "LEFT", used, 0)
      btn:SetWidth(segmentWidth)
      used = used + segmentWidth

      local cr, cg, cb
      if tab.groupBy == "race" then
        cr, cg, cb = GetRaceColor(group.key)
      else
        cr, cg, cb = self:GetClassColor(seg.classTag)
      end

      btn.tex:SetColorTexture(cr, cg, cb, 0.95)
      local luminance = (0.2126 * cr) + (0.7152 * cg) + (0.0722 * cb)
      if seg.classTag == "PRIEST" or luminance >= 0.82 then
        btn.borderR, btn.borderG, btn.borderB = 0.62, 0.62, 0.62
      else
        btn.borderR, btn.borderG, btn.borderB = 1, 1, 1
      end
      btn.glowR = math.min(1, (cr * 0.90) + 0.10)
      btn.glowG = math.min(1, (cg * 0.90) + 0.10)
      btn.glowB = math.min(1, (cb * 0.90) + 0.10)
      btn.glowInnerTop:Hide()
      btn.glowInnerBottom:Hide()
      btn.glowInnerLeft:Hide()
      btn.glowInnerRight:Hide()
      btn.glowMidTop:Hide()
      btn.glowMidBottom:Hide()
      btn.glowMidLeft:Hide()
      btn.glowMidRight:Hide()
      btn.glowOuterTop:Hide()
      btn.glowOuterBottom:Hide()
      btn.glowOuterLeft:Hide()
      btn.glowOuterRight:Hide()
      btn.borderTop:SetColorTexture(btn.borderR, btn.borderG, btn.borderB, 0.30)
      btn.borderBottom:SetColorTexture(btn.borderR, btn.borderG, btn.borderB, 0.30)
      btn.borderLeft:SetColorTexture(btn.borderR, btn.borderG, btn.borderB, 0.30)
      btn.borderRight:SetColorTexture(btn.borderR, btn.borderG, btn.borderB, 0.30)
      btn.segmentData = seg
      btn.metric = tab.metric
      btn:Show()
    end

    for j = #group.segments + 1, #row.segmentButtons do
      row.segmentButtons[j]:Hide()
    end
  end

  if tab.rows then
    for i = #groups + 1, #tab.rows do
      tab.rows[i]:Hide()
    end
  end

  tab.content:SetHeight(math.max(1, (#groups * rowSpacing) + 20))
end

function AP:RefreshCharacterDataTab()
  if not self.MainFrame or not self.MainFrame.characterDataTab then return end
  local tab = self.MainFrame.characterDataTab
  local chars = self:GetAllCharacters()
  local total = #chars

  FauxScrollFrame_Update(tab.scroll, total, 8, tab.rowHeight)
  local offset = FauxScrollFrame_GetOffset(tab.scroll)

  for i = 1, 8 do
    local row = tab.rows[i]
    local data = chars[offset + i]

    if data then
      local r, g, b = self:GetClassColor(data.classTag)
      row.name:SetText(data.name or "")
      row.realm:SetText(data.realm or "")
      row.class:SetText(AP:GetLocalizedClassName(data.classTag or "UNKNOWN", data.class or ""))
      row.class:SetTextColor(r, g, b)
      row.race:SetText(data.race or "")
      row.faction:SetText(data.faction or "Unknown")
      row.level:SetText(tostring(SafeNumber(data.level, 0)))
      row.deleteButton:SetScript("OnClick", function()
        local label = string.format("%s-%s (%s %s)", data.name or "Unknown", data.realm or "UnknownRealm", data.race or "Unknown", data.class or "Unknown")
        AP:ShowConfirmDialog(
          "Delete Character Data",
          "Are you sure you want to delete stored data for:\n" .. label .. "\n\nIf this is your current character it will be re-added immediately.",
          function()
            AP:DeleteCharacterData(data.key)
            AP:RefreshCharacterDataTab()
          end
        )
      end)
      row:Show()
    else
      row:Hide()
    end
  end
end

function AP:RefreshSettingsTab()
  if not self.MainFrame or not self.MainFrame.settingsTab then return end
  local tab = self.MainFrame.settingsTab
  local scale = RoundScaleStep((self.MainFrame and self.MainFrame:GetScale()) or 0.70)
  tab.scaleBox:SetText(string.format("%.1f", scale))
  tab._syncScaleWidgets = true
  tab.scaleSlider:SetValue(scale)
  tab._syncScaleWidgets = false
  tab._pendingScale = scale
  tab._syncMinimapCheckbox = true
  tab.minimapCheckbox:SetChecked(not (self.DB and self.DB.minimap and self.DB.minimap.hide))
  tab._syncMinimapCheckbox = false
  if tab.aboutRows then
    if tab.aboutRows.name then
      tab.aboutRows.name.value:SetText(tostring(AP.META.Title or ADDON_NAME))
    end
    if tab.aboutRows.version then
      tab.aboutRows.version.value:SetText(tostring(AP.META.Version or "?"))
    end
    if tab.aboutRows.author then
      tab.aboutRows.author.value:SetText(tostring(AP.META.Author or "Unknown"))
    end
    if tab.aboutRows.description then
      tab.aboutRows.description.value:SetText(tostring(AP.META.Description or ""))
    end
  end
end

function AP:RefreshUI()
  if not self.MainFrame then return end

  self:RefreshSummaryTab()
  self:RefreshChartTab(self.MainFrame.classTotalTab)
  self:RefreshChartTab(self.MainFrame.classMaxTab)
  self:RefreshChartTab(self.MainFrame.raceTotalTab)
  self:RefreshCharacterDataTab()
  self:RefreshSettingsTab()
end

function AP:SelectTab(tabID)
  if not self.MainFrame then return end
  local f = self.MainFrame

  f.summaryTab:SetShown(tabID == 1)
  f.classTotalTab:SetShown(tabID == 2)
  f.classMaxTab:SetShown(tabID == 3)
  f.raceTotalTab:SetShown(tabID == 4)
  f.characterDataTab:SetShown(tabID == 5)
  f.settingsTab:SetShown(tabID == 6)

  for _, b in ipairs(f.tabButtons) do
    local selected = b.id == tabID
    b.selected = selected
    if selected then
      b.bg:SetColorTexture(0.42, 0.33, 0.18, 0.60)
      b.text:SetTextColor(1.0, 0.86, 0.20)
    else
      b.bg:SetColorTexture(0.28, 0.22, 0.16, 0.60)
      b.text:SetTextColor(0.96, 0.96, 0.96)
    end
  end

  self.DB = self.DB or {}
  self.DB.settings = self.DB.settings or {}
  self.DB.settings.selectedTab = tabID

  self:RefreshUI()
end

function AP:CreateMainWindow()
  if self.MainFrame then return end

  local SAFE_INSET_X = 58
  local SAFE_TOP = 36
  local SAFE_BOTTOM = 44

  local f = CreateFrame("Frame", "AmmePlayed_MainFrame", UIParent, "BackdropTemplate")
  f:SetSize(940, 620)
  local configuredScale = RoundScaleStep(self.DB and self.DB.settings and self.DB.settings.scale)
  f:SetScale(configuredScale)
  f:SetFrameStrata("DIALOG")

  local win = self.DB and self.DB.settings and self.DB.settings.window
  if win and win.point then
    f:SetPoint(win.point, UIParent, win.point, SafeNumber(win.x, 0), SafeNumber(win.y, 0))
  else
    f:SetPoint("CENTER")
  end

  f:SetMovable(true)
  f:EnableMouse(true)
  f:RegisterForDrag("LeftButton")
  f:SetScript("OnDragStart", f.StartMoving)
  f:SetScript("OnDragStop", function(frame)
    frame:StopMovingOrSizing()
    local point, _, _, x, y = frame:GetPoint(1)
    AP.DB = AP.DB or {}
    AP.DB.settings = AP.DB.settings or {}
    AP.DB.settings.window = AP.DB.settings.window or {}
    AP.DB.settings.window.point = point or "CENTER"
    AP.DB.settings.window.x = x or 0
    AP.DB.settings.window.y = y or 0
  end)

  f.bg = f:CreateTexture(nil, "BACKGROUND")
  f.bg:SetAllPoints(true)
  f.bg:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment.png")
  f.bg:SetTexCoord(0, 1, 0, 1)

  -- Darken the top area using the same parchment alpha so it feels part of the sheet.
  f.borderTop = f:CreateTexture(nil, "ARTWORK")
  f.borderTop:SetPoint("TOPLEFT", f.bg, "TOPLEFT", -2, -8)
  f.borderTop:SetPoint("TOPRIGHT", f.bg, "TOPRIGHT", 2, -8)
  f.borderTop:SetHeight(68)
  f.borderTop:SetTexture("Interface\\AddOns\\AmmePlayed\\Art\\ui_partchment.png")
  f.borderTop:SetTexCoord(0, 1, 0, 0.15)
  f.borderTop:SetVertexColor(0.22, 0.18, 0.12, 0.90)

  f.title = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalLarge")
  f.title:SetPoint("TOPLEFT", f.bg, "TOPLEFT", SAFE_INSET_X, -SAFE_TOP)
  f.title:SetText("AmmePlayed")

  f.subtitle = f:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontHighlightSmall")
  f.subtitle:SetPoint("TOPLEFT", f.title, "BOTTOMLEFT", 0, -2)
  f.subtitle:SetText(string.format("v%s  by %s", AP.META.Version or "?", AP.META.Author or "Unknown"))

  f.close = CreateFrame("Button", nil, f)
  f.close:SetSize(20, 20)
  f.close:SetPoint("TOPRIGHT", f.bg, "TOPRIGHT", -45, -32)

  f.close.text = f.close:CreateFontString(nil, "OVERLAY", "AmmePlayed_FontNormalLarge")
  f.close.text:SetPoint("CENTER", 0, -1)
  f.close.text:SetText("X")
  f.close.text:SetTextColor(0.98, 0.30, 0.20)
  f.close.text:SetShadowOffset(1, -1)
  f.close.text:SetShadowColor(0, 0, 0, 0.85)

  f.close:SetScript("OnEnter", function(btn)
    btn.text:SetTextColor(1.0, 0.55, 0.35)
  end)
  f.close:SetScript("OnLeave", function(btn)
    btn.text:SetTextColor(0.98, 0.30, 0.20)
  end)
  f.close:SetScript("OnClick", function()
    f:Hide()
  end)

  f.tabButtons = {}

  local tabs = {
    "Summary",
    "Played by Class",
    "Max-Level by Class",
    "Played by Race",
    "Character Data",
    "Settings",
  }

  local tabWidth = 132
  for i, text in ipairs(tabs) do
    local b = CreateTabButton(f, i, text, tabWidth)
    if i == 1 then
      b:SetPoint("TOPLEFT", f.bg, "TOPLEFT", SAFE_INSET_X, -76)
    else
      b:SetPoint("LEFT", f.tabButtons[i - 1], "RIGHT", 6, 0)
    end
    b:SetScript("OnClick", function()
      AP:SelectTab(i)
    end)
    f.tabButtons[i] = b
  end


  f.content = CreateFrame("Frame", nil, f)
  f.content:SetPoint("TOPLEFT", f.bg, "TOPLEFT", SAFE_INSET_X, -124)
  f.content:SetPoint("BOTTOMRIGHT", f.bg, "BOTTOMRIGHT", -SAFE_INSET_X, SAFE_BOTTOM)

  f.summaryTab = CreateSummaryTab(f.content)
  f.classTotalTab = CreateChartTab(f.content, "Total Played Time by Class", "class", "total")
  f.classMaxTab = CreateChartTab(f.content, "Played Time at Max Level by Class", "class", "max")
  f.raceTotalTab = CreateChartTab(f.content, "Total Played Time by Race", "race", "total")
  f.characterDataTab = CreateCharacterDataTab(f.content)
  f.settingsTab = CreateSettingsTab(f.content)

  self.MainFrame = f
  f:Hide()
end

function AP:ToggleMainWindow()
  if not self.MainFrame then
    self:CreateMainWindow()
  end

  if self.MainFrame:IsShown() then
    self.MainFrame:Hide()
    return
  end

  self.MainFrame:Show()
  local selected = self.DB and self.DB.settings and self.DB.settings.selectedTab or 1
  self:SelectTab(selected)
end
