local ADDON_NAME, ns = ...

local UI = {}
ns.UI = UI

UI.FONT = "Fonts\\FRIZQT__.TTF"

-- Semantic callout accents (muted). Color = meaning, not decoration.
UI.CARD_COLORS = {
    info = { 0.45, 0.58, 0.78 },      -- muted blue (friendly / info)
    rec = { 0.42, 0.68, 0.45 },       -- muted green (success / recommend)
    warn = { 0.82, 0.55, 0.32 },      -- muted orange (warnings)
    advanced = { 0.62, 0.52, 0.78 },  -- soft purple (buffs / advanced)
    neutral = { 0.52, 0.50, 0.48 },
}

-- Component library. Each tactical concept renders as a recognizable component:
-- an icon anchor + accent tone (meaning, never hierarchy) + inset styling. The
-- eye should know "this is a warning / priority / rotation" before reading it.
-- accent reuses CARD_COLORS semantics: info=blue, rec=green, warn=orange,
-- advanced=purple, neutral=stone.
UI.COMPONENTS = {
    priority  = { icon = "inv_sword_27",                  accent = "rec" },
    objective = { icon = "inv_bannerpvp_03",              accent = "info" },
    opening   = { icon = "inv_sword_04",                  accent = "rec" },
    rotation  = { icon = "ability_hunter_pathfinding",    accent = "info" },
    timeline  = { icon = "ability_hunter_pathfinding",    accent = "info" },
    defense   = { icon = "inv_shield_06",                 accent = "info" },
    warning   = { icon = "ability_creature_cursed_02",    accent = "warn" },
    mistake   = { icon = "ability_creature_cursed_02",    accent = "warn" },
    reminder  = { icon = "inv_misc_book_11",              accent = "neutral" },
    tip       = { icon = "inv_misc_note_05",              accent = "rec" },
    advanced  = { icon = "icons/advanced_tip",             accent = "advanced" },
    role      = { icon = "icons/role_responsibilities",   accent = "info" },
    note      = { icon = "inv_misc_note_05",              accent = "neutral" },
    mapnote   = { icon = "icons/map",                     accent = "neutral" },
    facts     = { icon = "inv_misc_note_05",              accent = "neutral" },
}

-- Map a card type + title keywords to a component so authored data flows into the
-- library without needing per-block component tags.
UI.COMPONENT_BY_TYPE = {
    warn = "warning", rec = "priority", info = "objective",
    advanced = "advanced", neutral = "note",
}
UI.COMPONENT_KEYWORDS = {
    { "opening",      "opening" },
    { "split",        "opening" },
    { "rotation",     "rotation" },
    { "rotate",       "rotation" },
    { "unlock order", "timeline" },
    { "timeline",     "timeline" },
    { "sequence",     "timeline" },
    { "win condition","priority" },
    { "priority",     "priority" },
    { "objective",    "objective" },
    { "defend",       "defense" },
    { "defense",      "defense" },
    { "avoid",        "warning" },
    { "mistake",      "mistake" },
    { "danger",       "warning" },
    { "warning",      "warning" },
    { "never",        "warning" },
    { "remember",     "reminder" },
    { "reminder",     "reminder" },
    { "advanced",     "advanced" },
    { "tip",          "tip" },
    { "shadow sight", "advanced" },
    { "flag carrier", "objective" },
    { "orb",          "objective" },
}

UI.ROLE_TITLES = {
    tank = "Tank",
    healer = "Healer",
    melee = "Melee",
    ranged = "Ranged",
}
UI.ROLE_ORDER = { "tank", "healer", "melee", "ranged" }

UI.ROLE_BUCKETS = {
    { key = "primary", label = "Priorities", type = "rec" },
    { key = "objectives", label = "Objectives", type = "info" },
    { key = "mistakes", label = "Avoid", type = "warn" },
    { key = "advanced", label = "Advanced", type = "advanced" },
}

UI.BUFF_ICONS = {
    berserking = "spell_nature_bloodlust",
    restoration = "spell_nature_rejuvenation",
    ["rune-of-frequency"] = "spell_holy_borrowedtime",
    ["cooldown-reset"] = "spell_nature_timestop",
    ["into-the-shadows"] = "ability_stealth",
    ["absorption-shield"] = "spell_holy_powerwordshield",
    ["shadow-sight"] = "spell_shadow_evileye",
    ["runners-boots"] = "ability_rogue_sprint",
    ["random-empowerment"] = "inv_misc_gift_02",
    crystal = "inv_misc_gem_diamond_03",
    ["kotmogu-orb"] = "inv_misc_orb_04",
    ["objective-flag"] = "inv_bannerpvp_03",
}

UI.RESOURCE_ICONS = {
    addon = "INV_Misc_Gear_01",
    website = "INV_Misc_Map_01",
    creator = "INV_Misc_GroupLooking",
}

-- Blizzard atlas names preferred; icon path is fallback.
UI.PIN_ART = {
    ["objective-flag"] = { atlas = "CrossedFlags", icon = "inv_bannerpvp_03", size = 22 },
    ["kotmogu-orb"] = { atlas = "DungeonSkull", icon = "inv_misc_orb_04", size = 20 },
    crystal = { icon = "inv_misc_gem_diamond_03", size = 18 },
    ["mine-depot"] = { icon = "inv_misc_key_03", size = 18 },
    ["track-switch"] = { icon = "trade_engineering", size = 16 },
    ["cart-spawn-horde"] = { icon = "inv_crate_02", size = 16 },
    ["cart-spawn-alliance"] = { icon = "inv_crate_01", size = 16 },
    ["cart-capture-horde"] = { icon = "inv_crate_02", size = 16 },
    ["cart-capture-alliance"] = { icon = "inv_crate_01", size = 16 },
    ["cart-capture"] = { icon = "inv_crate_03", size = 16 },
    ["cart-turnin"] = { icon = "inv_crate_03", size = 16 },
    ["crystal-turnin"] = { icon = "inv_misc_coin_02", size = 16 },
    graveyard = { atlas = "poi-graveyard", icon = "achievement_bg_xkills_avgraveyard", size = 18 },
    ["kotmogu-graveyard"] = { atlas = "poi-graveyard", icon = "achievement_bg_xkills_avgraveyard", size = 18 },
    ["graveyard-node"] = { atlas = "poi-graveyard", icon = "achievement_bg_xkills_avgraveyard", size = 18 },
    ["graveyard-horde"] = { atlas = "poi-graveyard", icon = "achievement_bg_xkills_avgraveyard", size = 18 },
    ["graveyard-alliance"] = { atlas = "poi-graveyard", icon = "achievement_bg_xkills_avgraveyard", size = 18 },
    ["lobby-horde"] = { atlas = "poi-town", icon = "inv_misc_groupneedmore", size = 18 },
    ["lobby-alliance"] = { atlas = "poi-town", icon = "inv_misc_groupneedmore", size = 18 },
    berserking = { icon = "spell_nature_bloodlust", size = 18 },
    restoration = { icon = "spell_nature_rejuvenation", size = 18 },
    ["rune-of-frequency"] = { icon = "spell_holy_borrowedtime", size = 18 },
    ["cooldown-reset"] = { icon = "spell_nature_timestop", size = 18 },
    ["into-the-shadows"] = { icon = "ability_stealth", size = 18 },
    ["absorption-shield"] = { icon = "spell_holy_powerwordshield", size = 18 },
    ["shadow-sight"] = { icon = "spell_shadow_evileye", size = 16 },
    ["runners-boots"] = { icon = "ability_rogue_sprint", size = 18 },
    ["random-empowerment"] = { icon = "inv_misc_gift_02", size = 18 },
    ["base-stables"] = { atlas = "poi-town", icon = "inv_misc_food_19", size = 14 },
    ["base-blacksmith"] = { atlas = "poi-workorders", icon = "inv_hammer_20", size = 14 },
    ["base-farm"] = { atlas = "poi-town", icon = "inv_misc_food_wheat_02", size = 14 },
    ["base-gold-mine"] = { atlas = "poi-workorders", icon = "inv_pick_02", size = 14 },
    ["base-lumber-mill"] = { atlas = "poi-workorders", icon = "inv_axe_11", size = 14 },
}

local ADDON_MEDIA = "Interface\\AddOns\\RatedTrackerFieldManual\\Media\\"

-- Returns texture path, isCustomAsset (true = full 0-1 UVs, no icon crop).
function UI.ResolveIcon(icon, fallback)
    fallback = fallback or "INV_Misc_QuestionMark"
    if type(icon) ~= "string" or icon == "" then
        return "Interface\\Icons\\" .. fallback, false
    end
    -- Full addon media path (with or without extension).
    if icon:find("^Interface\\AddOns\\RatedTrackerFieldManual\\", 1, true) then
        return (icon:gsub("%.png$", ""):gsub("%.tga$", "")), true
    end
    -- Shorthand: "icons/advanced_tip" -> Media\icons\advanced_tip.tga
    local file = icon:match("^[Ii]cons[/\\]([^/\\]+)%.?%w*$") or icon:match("^[Ii]cons[/\\]([^/\\]+)$")
    if file then
        file = file:gsub("%.png$", ""):gsub("%.tga$", "")
        return ADDON_MEDIA .. "icons\\" .. file, true
    end
    if icon:find("[/\\]") or icon:find("%.png") or icon:find("%.jpg") or icon:find("%.tga") then
        return "Interface\\Icons\\" .. fallback, false
    end
    return "Interface\\Icons\\" .. icon, false
end

-- Pin sizes by website tier, plus faction tinting so Horde/Alliance spawn
-- graveyards and carts read as faction-owned (the website uses colored glow).
-- Uniform marker footprint across EVERY map. Pixel size is fixed in code -
-- never read from MarkerTypes size/tier. Objectives (flags, orbs, carts, ...)
-- are larger; everything else shares one smaller size. Art that fills its TGA
-- more aggressively (tombstones, leaves) is drawn inset so visual weight matches.
local MARKER_SIZE = 16
local OBJECTIVE_SIZE = 22

-- Objective categories: capture flags, CTF flag rooms (lobby-*), Kotmogu orbs,
-- bases, carts, crystals, mine depots, track switches.
local function IsObjectiveCat(cat)
    if not cat then return false end
    if cat == "objective-flag" or cat == "kotmogu-orb" or cat == "mine-depot"
        or cat == "track-switch" or cat == "lobby-horde" or cat == "lobby-alliance" then
        return true
    end
    if cat:find("^base") or cat:find("^cart") or cat:find("^crystal") then
        return true
    end
    return false
end

-- Source art that reads oversized at the same pixel box.
local BULKY_ART = {
    graveyard = true, ["graveyard-horde"] = true, ["graveyard-alliance"] = true,
    ["graveyard-node"] = true, ["kotmogu-graveyard"] = true,
    restoration = true, berserking = true,
    ["absorption-shield"] = true, ["into-the-shadows"] = true,
    ["cooldown-reset"] = true, ["rune-of-frequency"] = true,
    ["random-empowerment"] = true, ["runners-boots"] = true,
    ["shadow-sight"] = true,
}

UI.MARKER_TINT = {
    ["graveyard-horde"] = { 1.0, 0.45, 0.45 },
    ["graveyard-alliance"] = { 0.5, 0.68, 1.0 },
    ["cart-spawn-horde"] = { 1.0, 0.55, 0.55 },
    ["cart-capture-horde"] = { 1.0, 0.55, 0.55 },
    ["cart-spawn-alliance"] = { 0.55, 0.72, 1.0 },
    ["cart-capture-alliance"] = { 0.55, 0.72, 1.0 },
}

-- Render a marker's icon using the website's own marker art. Returns pin size.
-- opts.noTint: skip faction color tint (legend rows use the plain icon).
function UI.ApplyPinArt(texture, category, opts)
    opts = opts or {}
    local mt = ns.MarkerTypes and ns.MarkerTypes[category]
    local iconVal = mt and mt.icon
    local path, isAsset = "Interface\\Icons\\INV_Misc_QuestionMark", false
    if ns.ResolveMarkerIcon then
        path, isAsset = ns.ResolveMarkerIcon(iconVal or UI.BUFF_ICONS[category])
    else
        path = UI.ResolveIcon(iconVal or UI.BUFF_ICONS[category] or "INV_Misc_QuestionMark")
    end
    texture:SetTexture(path)
    if isAsset then
        texture:SetTexCoord(0, 1, 0, 1)
    else
        texture:SetTexCoord(0.08, 0.92, 0.08, 0.92)
    end

    local tint = (not opts.noTint) and UI.MARKER_TINT[category]
    if tint then
        texture:SetVertexColor(tint[1], tint[2], tint[3])
    else
        texture:SetVertexColor(1, 1, 1)
    end

    local objective = IsObjectiveCat(category)
    local size = objective and OBJECTIVE_SIZE or MARKER_SIZE
    -- opts.scale lets the map view shrink every pin proportionally to how large
    -- the map is actually drawn, so markers never dwarf a small/clustered map
    -- (e.g. Arathi Basin). One rule, all nine maps.
    if opts.scale and opts.scale > 0 then
        size = math.max(9, math.floor(size * opts.scale + 0.5))
    end
    local parent = texture:GetParent()
    texture:ClearAllPoints()
    if parent and BULKY_ART[category] and not objective then
        -- Same pin hitbox, smaller drawn glyph so fat art doesn't dwarf flags.
        local vis = math.max(9, math.floor(size * 0.72 + 0.5))
        texture:SetSize(vis, vis)
        texture:SetPoint("CENTER", parent, "CENTER")
    elseif parent then
        texture:SetAllPoints(parent)
    end
    return size
end

-- Spell hyperlinks ------------------------------------------------------------
-- Guide text can embed {spell:ID} or {spell:ID:Display Name} tokens. These
-- expand to real spell hyperlinks (blue, hoverable in-game tooltip, shift-click
-- to link in chat) exactly like ability links in Blizzard's dungeon journal.

local function SpellExists(id)
    if not id then return false end
    if C_Spell and C_Spell.DoesSpellExist then return C_Spell.DoesSpellExist(id) end
    if C_Spell and C_Spell.GetSpellName then return C_Spell.GetSpellName(id) ~= nil end
    if GetSpellInfo then return (GetSpellInfo(id)) ~= nil end
    return false
end

-- Spell links use the standard WoW spell-link blue everywhere, exactly like the
-- website. One color for every hyperlink so the list never looks inconsistent.
-- Darker than Blizzard's default light cyan so ability/buff links stay readable
-- on parchment without washing out.
local SPELL_LINK_HEX = "1a4d70"
-- Single, consistent emphasis color used for **bold** terms in EVERY guide, so
-- mechanics / node / map names always highlight the same way. Distinct from the
-- blue spell links and readable on the parchment page.
local EMPHASIS_HEX = "9c3a1e"
ns.UI.EMPHASIS_HEX = EMPHASIS_HEX
local LINK_HEX = {
    ability = SPELL_LINK_HEX,
    buff = SPELL_LINK_HEX,
    debuff = SPELL_LINK_HEX,
    location = SPELL_LINK_HEX,
}

local function SpellLink(id, display, kind)
    local hex = LINK_HEX[kind or "ability"] or LINK_HEX.ability
    -- Stability guard: if the ID is not a real spell in THIS client, never emit
    -- a broken link - plain emphasis color for that meaning instead.
    if not SpellExists(id) then
        if display and display ~= "" then return "|cff" .. hex .. display .. "|r" end
        return display or ""
    end
    local link
    if C_Spell and C_Spell.GetSpellLink then link = C_Spell.GetSpellLink(id) end
    if not link and GetSpellLink then link = GetSpellLink(id) end
    if not link then
        local name = display
        if (not name or name == "") and C_Spell and C_Spell.GetSpellName then
            name = C_Spell.GetSpellName(id)
        end
        name = name or tostring(id)
        link = "|cff" .. hex .. "|Hspell:" .. tostring(id) .. "|h[" .. name .. "]|h|r"
    else
        -- Force our semantic color (Blizzard links are often gold/cyan).
        link = link:gsub("|c%x%x%x%x%x%x%x%x", "|cff" .. hex, 1)
        if display and display ~= "" then
            link = link:gsub("%[.-%]", function() return "[" .. display .. "]" end, 1)
        end
    end
    return link
end

-- Hand-maintained category -> spell ID map for buffs that correspond to a real
-- in-game spell. Kept in code (NOT in the generated MarkerTypes.lua) so it
-- survives data regeneration. Unknown/invalid IDs degrade to plain text via the
-- SpellLink guard, so it is always safe to add a best-guess ID here.
UI.SPELL_IDS = {
    berserking = 23505,          -- Berserking battleground power-up aura
    restoration = 23493,         -- Restoration battleground power-up aura
    ["runners-boots"] = 23451,   -- Speed battleground power-up aura
    ["kotmogu-orb"] = 116524,    -- Power Orb (Temple of Kotmogu)
}

-- Phrase -> spell ID for auto-linking buff/objective names inside guide prose.
-- Longest phrases first so specific names win over substrings. Unknown IDs fall
-- back to a highlighted keyword via SpellLink, so listing a term is always safe.
-- kind drives semantic color: buff / debuff / ability (never gold).
local SPELL_TERMS = {
    { "Orb of Power", 116524, "buff" },
    { "Power Orb", 116524, "buff" },
    { "Focused Assault", 46392, "debuff" },
    { "Brutal Assault", 46393, "debuff" },
    { "Empowered Carrier", 438253, "buff" },
    { "Restoration Leaf", 23493, "buff" },
    { "Cooldown Reset", 294051, "buff" },
    { "Absorption Shield", 297899, "buff" },
    { "Shadow Sight", 34709, "buff" },
    { "Berserking", 23505, "buff" },
    { "Restoration", 23493, "buff" },
    { "Speed", 23451, "buff" },
}

-- Terms whose client spell tooltip is wrong/incomplete (or has no reliable spell
-- at all). These render as blue links exactly like spell links, but the hover
-- text comes straight from MarkerTypes (single source of truth) - just like the
-- website. This guarantees the full authored copy (e.g. "...for 30 seconds").
local AUTHORED_TERMS = {
    { "Into the Shadows", "into-the-shadows" },
    { "Rune of Frequency", "rune-of-frequency" },
}

local function AuthoredLink(key, display)
    return "|cff" .. SPELL_LINK_HEX .. "|Hrtfm:" .. key .. "|h[" .. (display or key) .. "]|h|r"
end

-- Escape Lua pattern magic characters in a literal phrase.
local function PatEsc(s) return (s:gsub("(%W)", "%%%1")) end

-- Decorate guide text: expand {spell:} tokens and auto-link known buff/objective
-- names. No class/spec rainbow coloring - class names use the same **emphasis**
-- as other guide terms. A stash of placeholders keeps finished substitutions
-- from being re-matched by later passes.
local function Decorate(text)
    if type(text) ~= "string" or text == "" then return text end

    local store, n = {}, 0
    local function stash(s)
        n = n + 1
        local key = "\001" .. n .. "\002"
        store[key] = s
        return key
    end

    -- Strip decorative gold/yellow (|cAARRGGBB FFD100 family) from authored data.
    -- Emphasis comes from spell links and readable weight - not gold.
    text = text:gsub("|c%x%x[fF][fF][dD]100(.-)|r", "%1")

    -- Explicit author tokens first.
    if text:find("{spell:", 1, true) then
        text = text:gsub("{spell:(%d+):([^}]*)}", function(id, name)
            return stash(SpellLink(tonumber(id), name, "ability"))
        end)
        text = text:gsub("{spell:(%d+)}", function(id)
            return stash(SpellLink(tonumber(id), nil, "ability"))
        end)
    end

    -- Replace each phrase (whether or not it is wrapped in a |cXXXXXXXX..|r color
    -- block in the data) with a finished, stashed decoration.
    local function applyPhrase(phrase, decorated)
        local esc = PatEsc(phrase)
        text = text:gsub("|c%x%x%x%x%x%x%x%x" .. esc .. "|r", function() return stash(decorated) end)
        -- Also match when authors wrapped the phrase in **emphasis** markers so
        -- tooltips/links win over bare red emphasis (e.g. **Shadow Sight**).
        text = text:gsub("%*%*" .. esc .. "%*%*", function() return stash(decorated) end)
        local out, last, changed = {}, 1, false
        while true do
            local s, e = text:find(esc, last)
            if not s then break end
            local pre = (s > 1) and text:sub(s - 1, s - 1) or ""
            local post = (e < #text) and text:sub(e + 1, e + 1) or ""
            if not pre:match("[%w\001\002]") and not post:match("[%w\001\002]") then
                out[#out + 1] = text:sub(last, s - 1)
                out[#out + 1] = stash(decorated)
                changed = true
            else
                out[#out + 1] = text:sub(last, e)
            end
            last = e + 1
        end
        if changed then
            out[#out + 1] = text:sub(last)
            text = table.concat(out)
        end
    end

    -- Tooltip phrases BEFORE ** emphasis, so **Shadow Sight** becomes a real
    -- spell/authored link (hover tooltip) instead of plain red text.
    for _, t in ipairs(AUTHORED_TERMS) do
        applyPhrase(t[1], AuthoredLink(t[2], t[1]))
    end
    for _, t in ipairs(SPELL_TERMS) do
        applyPhrase(t[1], SpellLink(t[2], t[1], t[3]))
    end

    -- Remaining **term** -> consistent emphasis color (nodes, classes, numbers).
    -- Expand any nested stash keys inside the capture first so a later single
    -- restore pass cannot leave \001N\002 tofu in the string.
    text = text:gsub("%*%*(.-)%*%*", function(s)
        s = s:gsub("\001%d+\002", function(k) return store[k] or "" end)
        return stash("|cff" .. EMPHASIS_HEX .. s .. "|r")
    end)

    -- Restore finished substitutions (loop: nested stashes from phrase->** wraps).
    local guard = 0
    while text:find("\001%d+\002") and guard < 16 do
        text = text:gsub("\001%d+\002", function(k) return store[k] or "" end)
        guard = guard + 1
    end
    return text
end

local ExpandTokens = Decorate
ns.UI.ExpandTokens = Decorate
ns.UI.Decorate = Decorate

-- Forced tooltip copy for spells whose client descriptions never resolve
-- ($w1 / $w4 stay literal). Values match the spell effect data / our guides.
local SPELL_TOOLTIP_FORCE = {
    [46392] = { -- Focused Assault
        name = "Focused Assault",
        lines = {
            "Stacking flag-carrier debuff.",
            "Applied to each carrier while flags are in play.",
        },
        bullets = {
            "+8% damage taken per stack",
            "-8% healing received per stack",
            "-8% absorb taken per stack",
        },
        footer = "At 5 stacks this becomes Brutal Assault: same stacking penalties, plus movement speed capped at normal run speed.",
    },
    [46393] = { -- Brutal Assault
        name = "Brutal Assault",
        lines = {
            "Replaces Focused Assault when it reaches 5 stacks.",
            "Keeps stacking on the flag carrier.",
        },
        bullets = {
            "+8% damage taken per stack",
            "-8% healing received per stack",
            "-8% absorb taken per stack",
            "Movement speed capped at normal run speed",
        },
    },
    [438253] = { -- Empowered Carrier
        name = "Empowered Carrier",
        lines = {
            "Applied to non-tank flag carriers in Battleground Blitz.",
        },
        bullets = {
            "+10% maximum health",
            "+30% movement speed",
            "25% damage reduction for 4 seconds when stunned",
        },
        footer = "Disabled for both teams if any tank is in the lobby.",
    },
    [116524] = { -- Power Orb / Orb of Power
        name = "Orb of Power",
        lines = {
            "Carry an orb to earn Victory Points over time.",
        },
        bullets = {
            "Increased damage dealt while held",
            "Increased damage taken while held",
            "Reduced healing received while held",
            "Dropped on death",
            "One orb per player",
        },
    },
    [23505] = {
        name = "Berserking",
        bullets = {
            "+30% damage caused for 60 seconds",
            "+10% damage taken for 60 seconds",
        },
    },
    [23493] = {
        name = "Restoration",
        bullets = {
            "Restores health and mana over 10 seconds",
            "Cancelled when you engage in combat",
        },
    },
    [23451] = {
        name = "Speed",
        bullets = {
            "Increases movement speed for a short duration",
        },
    },
}

local function ShowForcedSpellTooltip(owner, spellID)
    local force = SPELL_TOOLTIP_FORCE[spellID]
    if not force then return false end
    GameTooltip:SetOwner(owner, "ANCHOR_CURSOR")
    GameTooltip:ClearLines()
    GameTooltip:AddLine(force.name, 1, 0.82, 0)
    for _, line in ipairs(force.lines or {}) do
        GameTooltip:AddLine(line, 1, 1, 1, true)
    end
    for _, bullet in ipairs(force.bullets or {}) do
        GameTooltip:AddLine("•  " .. bullet, 1, 1, 1, true)
    end
    if force.footer and force.footer ~= "" then
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine(force.footer, 1, 0.82, 0, true)
    end
    GameTooltip:Show()
    return true
end

local function ShowSpellTooltip(owner, spellID)
    if ShowForcedSpellTooltip(owner, spellID) then return end
    GameTooltip:SetOwner(owner, "ANCHOR_CURSOR")
    if GameTooltip.SetSpellByID then
        GameTooltip:SetSpellByID(spellID)
        GameTooltip:Show()
        return
    end
    if pcall(GameTooltip.SetHyperlink, GameTooltip, "spell:" .. spellID) then
        GameTooltip:Show()
    else
        GameTooltip:Hide()
    end
end

-- Authored tooltip (rtfm:key link). Pulls copy straight from MarkerTypes so the
-- hover text always matches the guide/website and can't be truncated by a wrong
-- or incomplete client spell tooltip.
local function ShowAuthoredTooltip(owner, key)
    local mt = ns.MarkerTypes and ns.MarkerTypes[key]
    if not mt then GameTooltip:Hide(); return end
    GameTooltip:SetOwner(owner, "ANCHOR_CURSOR")
    GameTooltip:ClearLines()
    GameTooltip:AddLine(mt.label or key, 1, 0.82, 0)
    if mt.tooltip and mt.tooltip ~= "" then
        GameTooltip:AddLine(mt.tooltip, 1, 1, 1, true)
    end
    GameTooltip:Show()
end

-- Enable chat-style hyperlink hover/click on any frame that hosts guide text.
-- Guarded so each frame is wired only once.
local function EnableLinks(frame)
    if not frame or frame._rtfmLinks then return end
    frame._rtfmLinks = true
    -- FontString hyperlink hover only fires when the host frame accepts mouse.
    frame:EnableMouse(true)
    if frame.SetHyperlinksEnabled then frame:SetHyperlinksEnabled(true) end
    frame:SetScript("OnHyperlinkEnter", function(f, link)
        local spellID = link and tonumber(link:match("^spell:(%d+)"))
        if spellID then
            ShowSpellTooltip(f, spellID)
            return
        end
        local rtfmKey = link and link:match("^rtfm:(.+)")
        if rtfmKey then
            ShowAuthoredTooltip(f, rtfmKey)
            return
        end
        GameTooltip:SetOwner(f, "ANCHOR_CURSOR")
        if pcall(GameTooltip.SetHyperlink, GameTooltip, link) then
            GameTooltip:Show()
        else
            GameTooltip:Hide()
        end
    end)
    frame:SetScript("OnHyperlinkLeave", function() GameTooltip:Hide() end)
    frame:SetScript("OnHyperlinkClick", function(f, link, text, button)
        -- Shift-click inserts the link into chat; plain click re-shows our tip
        -- (never open ItemRefTooltip - that path shows the broken $w1 text).
        if IsModifiedClick("CHATLINK") and ChatEdit_InsertLink then
            local spellID = link and tonumber(link:match("^spell:(%d+)"))
            if spellID then
                local sLink = SpellLink(spellID)
                if sLink then ChatEdit_InsertLink(sLink) end
            elseif text then
                ChatEdit_InsertLink(text)
            end
            return
        end
        local spellID = link and tonumber(link:match("^spell:(%d+)"))
        if spellID then ShowSpellTooltip(f, spellID) end
        local rtfmKey = link and link:match("^rtfm:(.+)")
        if rtfmKey then ShowAuthoredTooltip(f, rtfmKey) end
    end)
end

-- Vertical stack builder ------------------------------------------------------

local Builder = {}
Builder.__index = Builder
ns.UI.Builder = Builder

-- Theme-aware text colors. On the parchment book (dark theme) text must be dark
-- to stay readable; on the dark browser it uses Blizzard's light fonts.
-- Typography hierarchy for parchment content. Gold is reserved for Adventure
-- Guide chrome (window title, tabs, Blizzard buttons) - never guide prose.
-- Guide content is printed ink on an aged-parchment page (Blizzard Encounter
-- Journal book). Hierarchy comes from weight, size, and spacing - never color.
-- Every ink tone is a dark brown-black so it reads like print on paper.
local function normalColor(b)   -- page / section / component titles
    if not b.dark then
        if NORMAL_FONT_COLOR then return NORMAL_FONT_COLOR:GetRGB() end
        return 1, 0.82, 0
    end
    return 0.10, 0.07, 0.03                         -- near-black brown ink on parchment
end
local function bodyColor(b)     -- printed body ink
    if not b.dark then
        if HIGHLIGHT_FONT_COLOR then return HIGHLIGHT_FONT_COLOR:GetRGB() end
        return 1, 1, 1
    end
    return 0.17, 0.12, 0.07                         -- dark charcoal-brown ink on parchment
end
local function metaColor(b)
    if b.dark then return 0.42, 0.35, 0.27 end       -- muted gray-brown, supporting
    return 0.55, 0.55, 0.55
end
local function ruleColor(b)
    -- Faint engraved line. Dividers only reinforce spacing; they never carry it.
    if b.dark then return 0.34, 0.24, 0.13, 0.28 end
    return 0.3, 0.3, 0.3, 0.45
end

-- Origin-x inset and effective content width. When rendering inside a boxed
-- section the builder indents content by b.ox so it sits within the panel.
local function OX(b) return b.ox or 0 end
local function CW(b) return b.width - 2 * (b.ox or 0) end

-- The frame that pooled widgets are parented to and that y-coordinates are
-- measured against. Normally the scroll content; inside a section box it is the
-- box's inner body frame so text draws above the panel border/fill.
local function P(b) return b.origin or b.parent end

-- Collapsible section (info box) geometry.
local HEADER_H = 34    -- title bar height
local HEADER_PAD = 10  -- inner padding of the title bar
local ICON_TILE = 22   -- icon square size
local BOX_PAD = 10     -- body inset inside the panel
local TOP_INSET = 5    -- panel edge inset (matches CollapsibleSection)

-- Vertical rhythm: gaps BETWEEN items only - never stack trailing pad of A
-- with leading pad of B. Keep these modest on parchment.
local GAP_BLOCK = 6      -- between major blocks (collapsibles, cards)
local GAP_GROUP = 6      -- before a subheading / key-value group
local GAP_LINE = 5       -- between bullets / paragraphs
local GAP_AFTER_TITLE = 4

function UI.NewBuilder(parent)
    local b = setmetatable({}, Builder)
    b.parent = parent
    b.fontPool = {}
    b.cardPool = {}
    b.iconPool = {}
    b.iconBtnPool = {}
    b.linePool = {}
    b.headerPool = {}
    b.boxPool = {}
    b.fontIdx = 0
    b.cardIdx = 0
    b.iconIdx = 0
    b.iconBtnIdx = 0
    b.lineIdx = 0
    b.headerIdx = 0
    b.boxIdx = 0
    b.y = 0
    b.ox = 0
    b.width = 100
    b.origin = parent
    return b
end

function Builder:SetTheme(dark)
    self.dark = dark and true or false
end

function Builder:Reset(width)
    self.width = width or self.width
    self.y = 0
    self.ox = 0
    self.origin = self.parent
    for _, fs in ipairs(self.fontPool) do fs:Hide() end
    for _, c in ipairs(self.cardPool) do
        if c.linkBtn then c.linkBtn:Hide() end
        c:Hide()
    end
    for _, ic in ipairs(self.iconPool) do ic:Hide() end
    for _, ib in ipairs(self.iconBtnPool) do ib:Hide() end
    for _, ln in ipairs(self.linePool) do ln:Hide() end
    for _, h in ipairs(self.headerPool) do h:Hide() end
    for _, bx in ipairs(self.boxPool) do bx:Hide() end
    self.fontIdx = 0
    self.cardIdx = 0
    self.iconIdx = 0
    self.iconBtnIdx = 0
    self.lineIdx = 0
    self.headerIdx = 0
    self.boxIdx = 0
end

function Builder:_font(template)
    self.fontIdx = self.fontIdx + 1
    local fs = self.fontPool[self.fontIdx]
    if not fs then
        fs = self.parent:CreateFontString(nil, "OVERLAY", template or "GameFontHighlight")
        self.fontPool[self.fontIdx] = fs
    end
    fs:SetParent(P(self))
    EnableLinks(P(self))       -- host frame handles hover/click on spell links
    fs:SetFontObject(template or "GameFontHighlight")
    fs:SetJustifyH("LEFT")
    fs:SetJustifyV("TOP")
    fs:SetWordWrap(true)
    fs:SetSpacing(5)          -- increased line height for easier scanning
    -- Kill the font's built-in drop shadow; dark text on parchment reads as a
    -- muddy double-image with it enabled.
    fs:SetShadowColor(0, 0, 0, 0)
    fs:SetShadowOffset(0, 0)
    fs:Show()
    return fs
end

function Builder:_card()
    self.cardIdx = self.cardIdx + 1
    local c = self.cardPool[self.cardIdx]
    if not c then
        c = CreateFrame("Frame", nil, self.parent, "BackdropTemplate")
        c:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        c.accent = c:CreateTexture(nil, "ARTWORK")
        c.accent:SetPoint("TOPLEFT", 0, 0)
        c.accent:SetPoint("BOTTOMLEFT", 0, 0)
        c.accent:SetWidth(2)
        c.accent:Hide()
        self.cardPool[self.cardIdx] = c
    end
    c:SetParent(P(self))
    if c.accent then c.accent:Hide() end
    c:Show()
    return c
end

function Builder:_icon()
    self.iconIdx = self.iconIdx + 1
    local ic = self.iconPool[self.iconIdx]
    if not ic then
        ic = self.parent:CreateTexture(nil, "ARTWORK")
        ic:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        self.iconPool[self.iconIdx] = ic
    end
    ic:SetParent(P(self))
    ic:Show()
    return ic
end

-- Interactive hit area laid over a legend icon: a Blizzard-style hover glow plus
-- a tooltip. Set btn.tipFn(self) before use; it runs on hover.
function Builder:_iconButton()
    self.iconBtnIdx = self.iconBtnIdx + 1
    local btn = self.iconBtnPool[self.iconBtnIdx]
    if not btn then
        btn = CreateFrame("Button", nil, self.parent)
        btn.hl = btn:CreateTexture(nil, "OVERLAY")
        btn.hl:SetAllPoints(btn)
        btn.hl:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
        btn.hl:SetBlendMode("ADD")
        btn.hl:SetAlpha(0.6)
        btn.hl:Hide()
        btn:SetScript("OnEnter", function(s)
            if s.hl and not s.noHl then s.hl:Show() end
            if s.tipFn then s.tipFn(s) end
        end)
        btn:SetScript("OnLeave", function(s)
            if s.hl then s.hl:Hide() end
            GameTooltip:Hide()
        end)
        self.iconBtnPool[self.iconBtnIdx] = btn
    end
    btn:SetParent(P(self))
    btn:SetFrameLevel((P(self):GetFrameLevel() or 1) + 5)
    btn.tipFn = nil
    btn.noHl = nil
    btn:Show()
    return btn
end

function Builder:_line()
    self.lineIdx = self.lineIdx + 1
    local ln = self.linePool[self.lineIdx]
    if not ln then
        ln = self.parent:CreateTexture(nil, "ARTWORK")
        self.linePool[self.lineIdx] = ln
    end
    ln:SetParent(P(self))
    ln:Show()
    return ln
end

function Builder:_header()
    self.headerIdx = self.headerIdx + 1
    local h = self.headerPool[self.headerIdx]
    if not h then
        h = CreateFrame("Button", nil, self.parent)
        h:SetHeight(HEADER_H)
        local hl = h:CreateTexture(nil, "HIGHLIGHT")
        hl:SetColorTexture(1, 0.9, 0.5, 0.10)
        hl:SetPoint("TOPLEFT", 2, -2)
        hl:SetPoint("BOTTOMRIGHT", -2, 2)

        -- Square icon tile with a thin border, Blizzard ability-entry style.
        h.iconBG = h:CreateTexture(nil, "OVERLAY", nil, 1)
        h.iconBG:SetColorTexture(0, 0, 0, 0.55)
        h.iconBG:SetSize(ICON_TILE + 2, ICON_TILE + 2)
        h.iconBG:SetPoint("LEFT", HEADER_PAD, 0)
        h.icon = h:CreateTexture(nil, "OVERLAY", nil, 2)
        h.icon:SetSize(ICON_TILE, ICON_TILE)
        h.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        h.icon:SetPoint("CENTER", h.iconBG, "CENTER", 0, 0)

        h.label = h:CreateFontString(nil, "OVERLAY", "GameFontNormalMed3")
        h.label:SetPoint("LEFT", h.iconBG, "RIGHT", 8, 0)
        h.label:SetJustifyH("LEFT")

        -- Expand / collapse indicator on the right edge.
        h.chevron = h:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        h.chevron:SetPoint("RIGHT", -HEADER_PAD, 0)
        h.chevron:SetWidth(16)
        h.chevron:SetJustifyH("CENTER")

        -- Divider under the header row (separates title bar from body).
        h.rule = h:CreateTexture(nil, "OVERLAY")
        h.rule:SetHeight(1)
        h.rule:SetPoint("BOTTOMLEFT", HEADER_PAD, 0)
        h.rule:SetPoint("BOTTOMRIGHT", -HEADER_PAD, 0)
        self.headerPool[self.headerIdx] = h
    end
    h:Show()
    return h
end

-- Beveled, rounded section panel (Blizzard ability-entry style): a Backdrop
-- frame with a darker title strip and an inner body frame that hosts the
-- section's content so text renders above the panel.
function Builder:_box()
    self.boxIdx = self.boxIdx + 1
    local bx = self.boxPool[self.boxIdx]
    if not bx then
        bx = CreateFrame("Frame", nil, self.parent, "BackdropTemplate")
        bx:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 14,
            insets = { left = 4, right = 4, top = 4, bottom = 4 },
        })
        bx.strip = bx:CreateTexture(nil, "BORDER")
        bx.strip:SetPoint("TOPLEFT", 5, -5)
        bx.strip:SetPoint("TOPRIGHT", -5, -5)
        bx.body = CreateFrame("Frame", nil, bx)
        self.boxPool[self.boxIdx] = bx
    end
    bx:SetParent(self.parent)
    bx:Show()
    return bx
end

function Builder:Spacer(h)
    self.y = self.y + (h or GAP_BLOCK)
end

-- Place a 1-2px bar with integer coords + fixed size (not TOPLEFT/TOPRIGHT
-- stretch). Dual-point stretch makes thickness flicker while the scroll frame
-- moves under subpixel offsets.
local function placeRuleLine(ln, parent, x, y, w, thick)
    y = math.floor(y + 0.5)
    x = math.floor(x + 0.5)
    w = math.floor(w + 0.5)
    ln:ClearAllPoints()
    if PixelUtil then
        PixelUtil.SetPoint(ln, "TOPLEFT", parent, "TOPLEFT", x, -y)
        PixelUtil.SetSize(ln, w, thick)
    else
        ln:SetPoint("TOPLEFT", parent, "TOPLEFT", x, -y)
        ln:SetSize(w, thick)
    end
end

-- Horizontal rule. opts.section = thicker/darker break between distinct blocks
-- (e.g. between Objectives cards). Reclaims a bit of the previous
-- widget's trailing gap so it doesn't stack into a double margin.
-- opts.bare = draw the line only (no reclaim / no trailing pad); caller
-- owns equal spacing above and below (subsection dividers).
function Builder:Rule(opts)
    opts = opts or {}
    local section = opts.section == true
    local bare = opts.bare == true
    local thick = opts.height or (section and 2 or 1)
    if not bare then
        if self.y > GAP_BLOCK then self.y = self.y - GAP_BLOCK end
        if section then self.y = self.y + 4 end
    end
    local ln = self:_line()
    local r, g, b, a = ruleColor(self)
    if section then
        -- Darker engraved bar so the install block reads as a new section.
        if self.dark then
            r, g, b, a = 0.28, 0.18, 0.10, 0.55
        else
            r, g, b, a = 0.35, 0.35, 0.35, 0.70
        end
    end
    ln:SetColorTexture(r, g, b, a)
    local x = OX(self)
    local w = CW(self)
    -- Inside a collapsible, body is inset; pull section rules to the panel edges.
    if section and self.inBox then
        x = -(TOP_INSET + BOX_PAD)
        w = self.width + 2 * BOX_PAD
    end
    placeRuleLine(ln, P(self), x, self.y, w, thick)
    if bare then
        self.y = self.y + thick
    else
        self.y = self.y + thick + (section and 12 or GAP_BLOCK)
    end
end

function Builder:GuideTitle(text)
    local fs = self:_font("GameFontNormalHuge")
    fs:SetTextColor(normalColor(self))
    -- Subtle shadow gives the page title weight so it anchors the view.
    fs:SetShadowColor(0, 0, 0, 0.35)
    fs:SetShadowOffset(1, -1)
    fs:SetWidth(CW(self))
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + fs:GetStringHeight() + 10
end

function Builder:Heading(text)
    if self.y > 0 then self.y = self.y + 10 end
    local fs = self:_font("GameFontNormalLarge")
    fs:SetTextColor(normalColor(self))
    fs:SetWidth(CW(self))
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + fs:GetStringHeight() + GAP_AFTER_TITLE
    local ln = self:_line()
    ln:SetColorTexture(ruleColor(self))
    placeRuleLine(ln, P(self), OX(self), self.y, CW(self), 1)
    self.y = self.y + GAP_BLOCK
end

-- Collapsible info box, matching Blizzard's Adventure Guide ability entries:
-- [-]/[+] chevron, icon tile, cream title on a darker strip, beveled rounded
-- border, and (when expanded) an inset body of readable text.
function Builder:CollapsibleSection(id, title, icon, collapsed, onToggle, renderBody)
    if self.y > 0 then self.y = self.y + GAP_BLOCK end
    local top = self.y
    local x0 = OX(self)
    local boxW = self.width - 2 * x0

    local bx = self:_box()
    if self.dark then
        -- Dark header bar (the "prior" look) over a light parchment body, so the
        -- header reads as a strong title strip and the content stays ink-on-page.
        bx:SetBackdropColor(0.86, 0.75, 0.55, 0.16)
        bx:SetBackdropBorderColor(0.40, 0.29, 0.15, 0.85)
        bx.strip:SetColorTexture(0, 0, 0, 0.55)
    else
        bx:SetBackdropColor(0, 0, 0, 0.30)
        bx:SetBackdropBorderColor(0.5, 0.5, 0.5, 0.85)
        bx.strip:SetColorTexture(0, 0, 0, 0.35)
    end
    bx.strip:SetHeight(HEADER_H)

    local h = self:_header()
    h:SetParent(bx)
    h:SetFrameLevel(bx:GetFrameLevel() + 3)
    h:ClearAllPoints()
    h:SetPoint("TOPLEFT", bx, "TOPLEFT", 5, -5)
    h:SetPoint("TOPRIGHT", bx, "TOPRIGHT", -5, -5)
    h:SetHeight(HEADER_H)

    h.chevron:ClearAllPoints()
    h.chevron:SetPoint("LEFT", HEADER_PAD, 0)
    h.chevron:SetText(collapsed and "+" or "-")

    if icon then
        h.iconBG:Show(); h.icon:Show()
        h.iconBG:ClearAllPoints()
        h.iconBG:SetPoint("LEFT", h.chevron, "RIGHT", 6, 0)
        local path, isAsset = UI.ResolveIcon(icon)
        h.icon:SetTexture(path)
        if isAsset then
            h.icon:SetTexCoord(0, 1, 0, 1)
        else
            h.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        end
        h.label:ClearAllPoints()
        h.label:SetPoint("LEFT", h.iconBG, "RIGHT", 8, 0)
    else
        h.iconBG:Hide(); h.icon:Hide()
        h.label:ClearAllPoints()
        h.label:SetPoint("LEFT", h.chevron, "RIGHT", 6, 0)
    end

    -- Off-white section title (not gold) on the dark inset - hierarchy via weight.
    local tr, tg, tb = 0.95, 0.93, 0.90
    if not self.dark then tr, tg, tb = normalColor(self) end
    h.chevron:SetTextColor(tr, tg, tb)
    h.label:SetText(title or "")
    h.label:SetTextColor(tr, tg, tb)
    h.label:SetWidth(boxW - 70)
    h.rule:Hide()
    h:SetScript("OnClick", function() if onToggle then onToggle(id) end end)

    local GAP = 6
    local bodyTop = TOP_INSET + HEADER_H + GAP

    local innerH = 0
    if not collapsed then
        local sY, sOrigin, sWidth, sOx, sBox = self.y, self.origin, self.width, self.ox, self.inBox
        self.origin = bx.body
        self.ox = 0
        self.width = boxW - 2 * BOX_PAD
        self.y = 0
        self.inBox = true
        renderBody(self)
        innerH = self.y
        self.origin, self.y, self.width, self.ox, self.inBox = sOrigin, sY, sWidth, sOx, sBox
    end

    local boxH = collapsed and (HEADER_H + 2 * TOP_INSET)
        or (bodyTop + innerH + BOX_PAD)

    bx:ClearAllPoints()
    bx:SetPoint("TOPLEFT", P(self), "TOPLEFT", x0, -top)
    bx:SetPoint("TOPRIGHT", P(self), "TOPLEFT", x0 + boxW, -top)
    bx:SetHeight(boxH)

    bx.body:ClearAllPoints()
    bx.body:SetPoint("TOPLEFT", bx, "TOPLEFT", TOP_INSET + BOX_PAD, -bodyTop)
    bx.body:SetSize(boxW - 2 * BOX_PAD, math.max(innerH, 1))
    bx.body:SetShown(not collapsed)

    self.y = top + boxH + GAP_BLOCK
end

function Builder:Subheading(text)
    if self.y > 0 then self.y = self.y + GAP_GROUP end
    local fs = self:_font("GameFontNormalMed3")
    fs:SetTextColor(normalColor(self))
    fs:SetWidth(CW(self))
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + fs:GetStringHeight() + GAP_AFTER_TITLE
end

-- Terminology reference hierarchy (category -> subsection -> term -> definition).
local GLOSS_SUB_INDENT = 16
local GLOSS_TERM_INDENT = 20
local GLOSS_BULLET_INDENT = 36
local GLOSS_CAT_GAP = 18
local GLOSS_SUB_GAP = 10
local GLOSS_TERM_GAP = 6
local GLOSS_TERM_DEF_GAP = 2

function Builder:GlossaryCategory(text)
    if self.y > 0 then self.y = self.y + GLOSS_CAT_GAP end
    local fs = self:_font("GameFontNormalLarge")
    fs:SetTextColor(normalColor(self))
    fs:SetWidth(CW(self))
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + fs:GetStringHeight() + 4
    local ln = self:_line()
    ln:SetColorTexture(ruleColor(self))
    placeRuleLine(ln, P(self), OX(self), self.y, CW(self), 2)
    self.y = self.y + 6
end

function Builder:GlossarySubsection(text)
    if self.y > 0 then self.y = self.y + GLOSS_SUB_GAP end
    local fs = self:_font("GameFontNormalMed3")
    fs:SetTextColor(normalColor(self))
    fs:SetWidth(CW(self) - GLOSS_SUB_INDENT)
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self) + GLOSS_SUB_INDENT, -self.y)
    self.y = self.y + fs:GetStringHeight() + 6
end

function Builder:GlossaryTerm(text)
    if self.y > 0 then self.y = self.y + GLOSS_TERM_GAP end
    local fs = self:_font("GameFontNormal")
    fs:SetTextColor(normalColor(self))
    fs:SetWidth(CW(self) - GLOSS_TERM_INDENT)
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self) + GLOSS_TERM_INDENT, -self.y)
    self.y = self.y + fs:GetStringHeight() + GLOSS_TERM_DEF_GAP
end

function Builder:GlossaryDefinition(text)
    if not text or text == "" then return end
    local fs = self:_font("GameFontHighlight")
    fs:SetTextColor(bodyColor(self))
    fs:SetWidth(CW(self) - GLOSS_TERM_INDENT)
    fs:SetText(ExpandTokens(text))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self) + GLOSS_TERM_INDENT, -self.y)
    self.y = self.y + math.max(fs:GetStringHeight(), 12) + 3
end

function Builder:GlossaryBullets(list)
    if not list or #list == 0 then return end
    self:Bullets(list, GLOSS_BULLET_INDENT)
end

function Builder:Paragraph(text, indent)
    indent = indent or 0
    local fs = self:_font("GameFontHighlight")
    fs:SetTextColor(bodyColor(self))
    fs:SetWidth(CW(self) - indent)
    fs:SetText(ExpandTokens(text or ""))
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self) + indent, -self.y)
    self.y = self.y + math.max(fs:GetStringHeight(), 12) + GAP_LINE
end

function Builder:Meta(text)
    local fs = self:_font("GameFontDisable")
    fs:SetTextColor(metaColor(self))
    fs:SetWidth(CW(self))
    fs:SetText(text or "")
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + math.max(fs:GetStringHeight(), 12) + GAP_LINE
end

-- Like Meta, but readable on any background (used under the interactive map,
-- which may sit over light map art): light text with a dark shadow.
function Builder:Hint(text)
    local fs = self:_font("GameFontHighlightSmall")
    fs:SetTextColor(0.90, 0.88, 0.84)
    fs:SetShadowColor(0, 0, 0, 1)
    fs:SetShadowOffset(1, -1)
    fs:SetWidth(CW(self))
    fs:SetText(text or "")
    fs:ClearAllPoints()
    fs:SetPoint("TOPLEFT", OX(self), -self.y)
    self.y = self.y + math.max(fs:GetStringHeight(), 12) + GAP_LINE
end

-- level 1 = primary bullet (*), level 2+ = nested (- with extra indent).
-- Items may be a string or a table: { "Parent", { "sub" } } / { text=, bullets= }.
function Builder:Bullets(list, indent, level)
    if not list then return end
    indent = indent or 0
    level = level or 1
    local bulletHex = "4a3826"
    local glyph = (level >= 2) and "\226\128\147" or "\226\128\162"   -- – or •
    local rowIndent = indent + ((level >= 2) and 12 or 0)
    local HANG = 16
    for i = 1, #list do
        local item = list[i]
        local text, children
        if type(item) == "table" then
            text = item[1] or item.text or item.title or ""
            children = item[2] or item.bullets or item.children
        else
            text = item
        end

        local x = OX(self) + rowIndent + 4
        local g = self:_font("GameFontHighlight")
        g:SetTextColor(bodyColor(self))
        g:SetWidth(HANG)
        g:SetText("|cff" .. bulletHex .. glyph .. "|r")
        g:ClearAllPoints()
        g:SetPoint("TOPLEFT", x, -self.y)

        local fs = self:_font("GameFontHighlight")
        fs:SetTextColor(bodyColor(self))
        fs:SetWidth(CW(self) - rowIndent - 4 - HANG)
        fs:SetText(ExpandTokens(tostring(text or "")))
        fs:ClearAllPoints()
        fs:SetPoint("TOPLEFT", x + HANG, -self.y)
        self.y = self.y + math.max(fs:GetStringHeight(), g:GetStringHeight(), 12)

        if children and #children > 0 then
            self.y = self.y + GAP_LINE
            self:Bullets(children, indent, level + 1)
        end

        if i < #list then self.y = self.y + GAP_LINE end
    end
end

-- Editorial layout: bold label, then bulleted points beneath it (never a flat
-- "Label: sentence" line). Values may be a string (one bullet) or a list.
function Builder:KeyValues(pairs_, order)
    for i = 1, #order do
        local row = order[i]
        local val = pairs_[row.key]
        if val ~= nil and val ~= "" then
            if self.y > 0 then self.y = self.y + GAP_GROUP end
            local lbl = self:_font("GameFontNormalMed3")
            lbl:SetTextColor(normalColor(self))
            lbl:SetWidth(CW(self))
            lbl:SetText(row.label)
            lbl:ClearAllPoints()
            lbl:SetPoint("TOPLEFT", OX(self), -self.y)
            self.y = self.y + lbl:GetStringHeight() + GAP_AFTER_TITLE

            local list = type(val) == "table" and val or { tostring(val) }
            self:Bullets(list)
        end
    end
end

-- Timeline component: an ordered sequence presented as a vertical tactical flow
-- (step, down-arrow, step). Order is communicated by layout, not prose.
function Builder:Flow(list)
    if not list or #list == 0 then return end
    -- Same dark ink as the step text - order comes from the numbers themselves,
    -- not a semantic blue that fights the parchment palette.
    local rowLeft = 10
    local numW = 24
    for i = 1, #list do
        local n = self:_font("GameFontNormal")
        n:SetTextColor(normalColor(self))
        n:SetWidth(numW)
        n:SetJustifyH("LEFT")
        n:SetText(tostring(i) .. ".")
        n:ClearAllPoints()
        n:SetPoint("TOPLEFT", OX(self) + rowLeft, -self.y)

        local fs = self:_font("GameFontNormal")
        fs:SetTextColor(normalColor(self))
        fs:SetWidth(CW(self) - rowLeft - numW)
        fs:SetText(ExpandTokens(tostring(list[i])))
        fs:ClearAllPoints()
        fs:SetPoint("TOPLEFT", OX(self) + rowLeft + numW, -self.y)
        self.y = self.y + math.max(fs:GetStringHeight(), 14) + 3

        if i < #list then
            -- Small drawn connector (Unicode down tofu's in many WoW fonts).
            local a = self:_line()
            local mr, mg, mb = metaColor(self)
            a:SetColorTexture(mr, mg, mb, 0.85)
            a:SetSize(2, 8)
            a:ClearAllPoints()
            a:SetPoint("TOPLEFT", P(self), "TOPLEFT", OX(self) + rowLeft + 6, -self.y)
            self.y = self.y + 8
        end
    end
end

-- Shared tooltip for a hovered legend icon: prefer a real spell tooltip when a
-- spellID is known, otherwise show the authored name + description.
local function ShowIconTip(owner, spec)
    GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
    if spec.spellID and GameTooltip.SetSpellByID then
        local ok = pcall(GameTooltip.SetSpellByID, GameTooltip, spec.spellID)
        if ok then GameTooltip:Show(); return end
    end
    GameTooltip:ClearLines()
    if spec.title and spec.title ~= "" then
        GameTooltip:AddLine(spec.title, 1, 0.82, 0)
    end
    if spec.body and spec.body ~= "" then
        GameTooltip:AddLine(spec.body, 1, 1, 1, true)
    end
    GameTooltip:Show()
end

-- Map-legend row: marker icon(s) + title + short description.
-- opts.categories = { "lobby-alliance", "lobby-horde" } draws multiple icons.
function Builder:LegendRow(opts)
    local iconSize = 22
    local pad = 4
    local iconGap = 4
    local textGap = 10
    local cats = opts.categories
    if (not cats or #cats == 0) and opts.category then
        cats = { opts.category }
    end
    local nIcons = (cats and #cats) or (opts.icon and 1) or 0
    local iconsW = (nIcons > 0) and (nIcons * iconSize + (nIcons - 1) * iconGap) or 0
    local textLeft = pad + iconsW + ((nIcons > 0) and textGap or 0)
    local innerW = CW(self) - textLeft
    local startY = self.y
    local x = OX(self) + pad

    if cats then
        for _, cat in ipairs(cats) do
            local ic = self:_icon()
            ic:SetSize(iconSize, iconSize)
            ic:ClearAllPoints()
            ic:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -self.y)
            if UI.ApplyPinArt then
                UI.ApplyPinArt(ic, cat, { noTint = true })
                ic:ClearAllPoints()
                ic:SetSize(iconSize, iconSize)
                ic:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -self.y)
                ic:SetVertexColor(1, 1, 1)
            end
            x = x + iconSize + iconGap
        end
    elseif opts.icon then
        local ic = self:_icon()
        ic:SetSize(iconSize, iconSize)
        ic:ClearAllPoints()
        ic:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -self.y)
        local path, isAsset
        if ns.ResolveMarkerIcon then
            path, isAsset = ns.ResolveMarkerIcon(opts.icon)
        end
        ic:SetTexture(path or UI.ResolveIcon(opts.icon))
        if isAsset then
            ic:SetTexCoord(0, 1, 0, 1)
        else
            ic:SetTexCoord(0.08, 0.92, 0.08, 0.92)
        end
        ic:SetVertexColor(1, 1, 1)
    end

    -- Hover layer over the icon strip: Blizzard-style glow + tooltip.
    if nIcons > 0 then
        local hit = self:_iconButton()
        hit:SetSize(iconsW, iconSize)
        hit:ClearAllPoints()
        hit:SetPoint("TOPLEFT", P(self), "TOPLEFT", OX(self) + pad, -startY)
        local spellID = opts.spellID
        local tipTitle = opts.tipTitle or opts.title
        local tipBody = opts.body
        hit.tipFn = function(s) ShowIconTip(s, { spellID = spellID, title = tipTitle, body = tipBody }) end
    end

    local title = opts.title or ""
    local t = self:_font("GameFontNormal")
    t:SetTextColor(normalColor(self))
    t:SetWidth(innerW)
    t:SetText(ExpandTokens(title))
    t:ClearAllPoints()
    t:SetPoint("TOPLEFT", OX(self) + textLeft, -self.y)
    self.y = self.y + t:GetStringHeight() + 3

    if opts.body and opts.body ~= "" then
        local bfs = self:_font("GameFontHighlightSmall")
        bfs:SetTextColor(bodyColor(self))
        bfs:SetWidth(innerW)
        bfs:SetText(ExpandTokens(opts.body))
        bfs:ClearAllPoints()
        bfs:SetPoint("TOPLEFT", OX(self) + textLeft, -self.y)
        self.y = self.y + math.max(bfs:GetStringHeight(), 12) + 2
    end

    local used = self.y - startY
    if used < iconSize + 6 then
        self.y = startY + iconSize + 6
    else
        self.y = self.y + 6
    end
end

-- Nested outcomes under a legend parent (e.g. Random Empowerment "Can spawn").
-- Indented + smaller icons so they read as children, not sibling legend rows
-- (Absorption Shield / Shadow Sight must stay clearly separate below).
-- Each entry: { category = markerType key, text = description }.
function Builder:EmpowermentList(blocks)
    if not blocks or #blocks == 0 then return end
    local nest = 22
    local iconSize = 16
    local textLeft = nest + 8 + iconSize + 8
    local innerW = CW(self) - textLeft
    for _, blk in ipairs(blocks) do
        local mt = ns.MarkerTypes and ns.MarkerTypes[blk.category]
        local name = (mt and mt.label) or blk.category or ""
        local startY = self.y
        local x = OX(self) + nest + 8

        local ic = self:_icon()
        ic:SetSize(iconSize, iconSize)
        ic:ClearAllPoints()
        ic:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -self.y)
        if UI.ApplyPinArt and mt then
            UI.ApplyPinArt(ic, blk.category, { noTint = true })
            ic:ClearAllPoints()
            ic:SetSize(iconSize, iconSize)
            ic:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -self.y)
            ic:SetVertexColor(1, 1, 1)
        end

        local spellID = ns.UI.SPELL_IDS and ns.UI.SPELL_IDS[blk.category]
        local bText = blk.text
        local tipFn = function(s) ShowIconTip(s, { spellID = spellID, title = name, body = bText }) end

        local hit = self:_iconButton()
        hit:SetSize(iconSize, iconSize)
        hit:ClearAllPoints()
        hit:SetPoint("TOPLEFT", P(self), "TOPLEFT", x, -startY)
        hit.tipFn = tipFn

        local lr, lg, lb = normalColor(self)
        local hex = string.format("%02x%02x%02x", lr * 255, lg * 255, lb * 255)
        local fs = self:_font("GameFontHighlightSmall")
        fs:SetTextColor(bodyColor(self))
        fs:SetWidth(innerW)
        fs:SetText("|cff" .. hex .. name .. ":|r " .. ExpandTokens(tostring(blk.text or "")))
        fs:ClearAllPoints()
        fs:SetPoint("TOPLEFT", OX(self) + textLeft, -self.y)
        local rowH = math.max(fs:GetStringHeight(), iconSize)

        -- Invisible hover over the name/blurb so the spell tip isn't icon-only.
        local textHit = self:_iconButton()
        textHit.noHl = true
        textHit:SetSize(innerW, rowH)
        textHit:ClearAllPoints()
        textHit:SetPoint("TOPLEFT", P(self), "TOPLEFT", OX(self) + textLeft, -startY)
        textHit.tipFn = tipFn

        self.y = self.y + rowH + 5
        if (self.y - startY) < iconSize then self.y = startY + iconSize + 5 end
    end
    -- Extra gap so the next top-level legend row doesn't continue this list.
    self.y = self.y + 10
end

function Builder:Card(opts)
    local card = self:_card()
    if card.linkBtn then card.linkBtn:Hide() end
    -- Light callout: barely-there fill, Adventure Guide inline notes - no
    -- colored left accent rail (type color is unused for chrome).
    card:SetBackdropColor(0, 0, 0, 0)
    card:SetBackdropBorderColor(0, 0, 0, 0)
    if card.accent then card.accent:Hide() end

    local pad = 12
    local topPad = (opts.icon or opts.category) and pad or 2
    local textLeft = pad
    local iconSize = 0
    if opts.icon or opts.category then
        iconSize = 30
        local ic = self:_icon()
        ic:SetSize(iconSize, iconSize)
        ic:ClearAllPoints()
        ic:SetPoint("TOPLEFT", card, "TOPLEFT", pad, -pad)
        if opts.category and UI.ApplyPinArt then
            UI.ApplyPinArt(ic, opts.category)
            ic:ClearAllPoints()
            ic:SetSize(iconSize, iconSize)
            ic:SetPoint("TOPLEFT", card, "TOPLEFT", pad, -pad)
        else
            local path, isAsset
            if ns.ResolveMarkerIcon then
                path, isAsset = ns.ResolveMarkerIcon(opts.icon)
            end
            ic:SetTexture(path or UI.ResolveIcon(opts.icon))
            if isAsset then
                ic:SetTexCoord(0, 1, 0, 1)
            else
                ic:SetTexCoord(0.08, 0.92, 0.08, 0.92)
            end
        end
        textLeft = pad + iconSize + 10
    end
    local innerW = CW(self) - textLeft - pad
    local cy = topPad

    if opts.title and opts.title ~= "" then
        local t = self:_font("GameFontNormal")
        t:SetTextColor(normalColor(self))
        t:SetWidth(innerW)
        t:SetText(ExpandTokens(opts.title))
        t:ClearAllPoints()
        t:SetPoint("TOPLEFT", card, "TOPLEFT", textLeft, -cy)
        cy = cy + t:GetStringHeight() + 6
    end

    if opts.body and opts.body ~= "" then
        local bfs = self:_font("GameFontHighlight")
        bfs:SetTextColor(bodyColor(self))
        bfs:SetWidth(innerW)
        bfs:SetText(ExpandTokens(opts.body))
        bfs:ClearAllPoints()
        bfs:SetPoint("TOPLEFT", card, "TOPLEFT", textLeft, -cy)
        cy = cy + math.max(bfs:GetStringHeight(), 12) + 5
    end

    if opts.bullets then
        local bulletHex = "4a3826"
        local HANG = 16
        for i = 1, #opts.bullets do
            local g = self:_font("GameFontHighlight")
            g:SetTextColor(bodyColor(self))
            g:SetWidth(HANG)
            g:SetText("|cff" .. bulletHex .. "\226\128\162|r")
            g:ClearAllPoints()
            g:SetPoint("TOPLEFT", card, "TOPLEFT", textLeft + 4, -cy)

            local lfs = self:_font("GameFontHighlight")
            lfs:SetTextColor(bodyColor(self))
            lfs:SetWidth(innerW - 4 - HANG)
            lfs:SetText(ExpandTokens(tostring(opts.bullets[i])))
            lfs:ClearAllPoints()
            lfs:SetPoint("TOPLEFT", card, "TOPLEFT", textLeft + 4 + HANG, -cy)
            local gap = (i < #opts.bullets) and GAP_LINE or 0
            cy = cy + math.max(lfs:GetStringHeight(), g:GetStringHeight(), 12) + gap
        end
    end

    if opts.link and opts.link ~= "" then
        -- Breath between the last bullet and the CurseForge / copy button.
        cy = cy + 12
        local btn = card.linkBtn
        if not btn then
            btn = CreateFrame("Button", nil, card, "UIPanelButtonTemplate")
            btn:SetHeight(20)
            card.linkBtn = btn
        end
        local label = opts.linkLabel or "Copy Link"
        btn:SetText(label)
        btn:SetWidth(math.max((btn:GetTextWidth() or 40) + 22, 90))
        btn:ClearAllPoints()
        btn:SetPoint("TOPLEFT", card, "TOPLEFT", textLeft, -cy)
        local url, ttl = opts.link, opts.title or "Link"
        btn:SetScript("OnClick", function() ns.CopyDialog:Open(ttl, url, "url") end)
        btn:Show()
        cy = cy + 26
    end

    -- Text-only callouts (no icon/chrome) keep a short bottom pad so a Rule
    -- or following card doesn't sit under a huge empty pocket.
    local bottomPad = (iconSize > 0) and pad or 4
    cy = cy + bottomPad
    if iconSize > 0 then cy = math.max(cy, iconSize + pad * 2) end
    card:SetHeight(math.max(cy, 28))
    card:ClearAllPoints()
    card:SetPoint("TOPLEFT", OX(self), -self.y)
    card:SetPoint("TOPRIGHT", P(self), "TOPLEFT", self.width - OX(self), -self.y)
    self.y = self.y + card:GetHeight() + GAP_BLOCK
end

-- Concept block: one gameplay concept - bold title + indented body/bullets.
-- No per-card icon: the collapsible section already carries the phase icon
-- (Opening sword, etc.). Repeating it on every card looked like bullet chrome.
function Builder:ConceptBlock(opts)
    if not opts then return end

    if self.y > 0 then self.y = self.y + GAP_BLOCK end
    local bodyIndent = 12

    if opts.title and opts.title ~= "" then
        local h = self:_font("GameFontNormal")     -- component title: smaller than section
        h:SetTextColor(normalColor(self))
        h:SetWidth(CW(self))
        h:SetText(ExpandTokens(opts.title))
        h:ClearAllPoints()
        h:SetPoint("TOPLEFT", OX(self), -self.y)
        self.y = self.y + math.max(h:GetStringHeight(), 14) + GAP_AFTER_TITLE
    end

    -- Indent the explanation beneath the title (left only; right margin held).
    local sOx, sW = self.ox, self.width
    self.ox = (self.ox or 0) + bodyIndent
    self.width = self.width + bodyIndent
    if opts.body and opts.body ~= "" then self:Paragraph(opts.body) end
    if opts.bullets then self:Bullets(opts.bullets) end
    self.ox, self.width = sOx, sW
end

function Builder:Finish()
    self.parent:SetHeight(math.max(self.y + 16, 10))
    return self.y
end
