
-- ============================================================
-- FILE: QuestInterface.lua
-- PURPOSE: Replaces Blizzard's QuestFrame and GossipFrame with
--   a custom full-screen-dialog styled quest interface. Handles
--   quest detail, quest progress (objectives), quest completion
--   (reward selection), and NPC gossip dialogs. Features a
--   typewriter text animation, parchment-aesthetic backdrop,
--   NPC model viewer, and an animated "quest accepted" banner.
-- HOW IT WORKS:
--   1. CFG and C tables define all dimensions, durations, and
--      colors used throughout the frame.
--   2. State table is the central state machine:
--      currentMode (DETAIL/PROGRESS/COMPLETE/GOSSIP/nil),
--      typewriter cursor, fade direction, scroll position.
--   3. QI = MidnightUI_QuestInterface: main Frame created at
--      file scope. All panels (detail, progress, completion,
--      gossip) are children of QI.
--   4. Populate* functions (PopulateDetail, PopulateProgress,
--      PopulateComplete, PopulateGossip) fill the frame content
--      for each mode; called from the OnEvent handler.
--   5. QI:SetScript("OnUpdate") runs the fade-in/out alpha
--      curve, typewriter character reveal tick, and glow pulse.
--   6. EventFrame OnEvent routes quest/gossip events:
--      QUEST_DETAIL → PopulateDetail + show,
--      QUEST_PROGRESS → PopulateProgress + show,
--      QUEST_COMPLETE → PopulateComplete + show,
--      GOSSIP_SHOW → PopulateGossip + show,
--      PLAYER_LOGIN → suppress default frames.
--   7. bannerFrame: floating "quest accepted" notification with
--      class-specific atlas, fade-in/hold/fade-out animation,
--      triggered after quest acceptance.
--   8. SuppressDefaultQuestFrame / RestoreDefaultQuestFrame:
--      SetAlpha(0)/EnableMouse(false) on QuestFrame/GossipFrame.
-- DEPENDS ON:
--   MidnightUI_ResolveFontPath (font path helper)
--   MidnightUISettings.QuestInterface (font size settings)
-- EXPORTS: QI (global "MidnightUI_QuestInterface" frame)
--   QI.GetState, QI.ForceClose, QI.ApplyFontSizes (public API)
-- OWNED SAVEDVARIABLES: reads MidnightUISettings.QuestInterface
-- EVENTS REGISTERED:
--   QUEST_DETAIL, QUEST_PROGRESS, QUEST_COMPLETE
--   GOSSIP_SHOW, GOSSIP_CLOSED, QUEST_GREETING,
--   PLAYER_LOGIN, PLAYER_ENTERING_WORLD
-- FRAMES CREATED:
--   MidnightUI_QuestInterface (QI — main dialog)
--   bannerFrame (quest accepted notification)
--   All QI children: NPC model, scroll frame, reward buttons
-- GOTCHAS:
--   useBlizzardQuestingInterface guard: entire file returns
--   early if that Setting is true. EventFrame checks it again
--   per-event to handle runtime Settings changes.
--   Typewriter animation uses pre-tokenized "atoms" (words +
--   spaces) for smooth reveal without mid-word cuts.
--   Reward item buttons use SetItemButtonCount + GameTooltip
--   hover — these require the item to be in C_Item cache.
-- ============================================================

local ADDON_NAME = "MidnightUI"

local _G            = _G
local CreateFrame   = CreateFrame
local UIParent      = UIParent
local C_Timer       = C_Timer
local GetTime       = GetTime
local pcall         = pcall
local pairs         = pairs
local ipairs        = ipairs
local type          = type
local tostring      = tostring
local tonumber      = tonumber
local math          = math
local string        = string
local table         = table
local select        = select
local unpack        = unpack

local UnitExists            = UnitExists
local UnitName              = UnitName
local InCombatLockdown      = InCombatLockdown
local PlaySound             = PlaySound
local GameTooltip           = GameTooltip

do
    -- The user's runtime settings live on MidnightUISettingsPerChar (Bootstrap.lua
    -- aliases MidnightUISettings to it on ADDON_LOADED). At file-load that swap
    -- hasn't happened yet, so check per-char first.
    local function IsBlizzardModeEnabled()
        local perChar = _G.MidnightUISettingsPerChar
        if type(perChar) == "table" and type(perChar.General) == "table"
           and perChar.General.useBlizzardQuestingInterface == true then
            return true
        end
        local s = _G.MidnightUISettings
        if type(s) == "table" and type(s.General) == "table"
           and s.General.useBlizzardQuestingInterface == true then
            return true
        end
        return false
    end
    if IsBlizzardModeEnabled() then
        return
    end
end

local WHITE8X8    = "Interface\\Buttons\\WHITE8X8"
local TITLE_FONT  = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\FRIZQT__.TTF")) or "Fonts\\FRIZQT__.TTF"
local BODY_FONT   = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath("Fonts\\FRIZQT__.TTF")) or "Fonts\\FRIZQT__.TTF"

local function ResolveSavedFontSize(field, fallback)
    local s = _G.MidnightUISettings
    if type(s) == "table" and type(s.QuestInterface) == "table" then
        local v = tonumber(s.QuestInterface[field])
        if v and v > 0 then return math.floor(v) end
    end
    return fallback
end

-- ============================================================
-- SECTION: Constants, Config, Colors + State Machine
-- READS: MidnightUISettings.QuestInterface (font sizes)
-- WRITES: CFG{}, C{}, State{} — no persistent state here
-- CFG: frame dimensions, timing (typewriter speed, fade
--   durations, hold duration for banner), reward slot count.
-- C: background colors, text tints, border tints, quality
--   item-level color table keyed by quality index.
-- State: currentMode, typewriterAtoms/cursor, fadeDir/Alpha,
--   chosenReward, gossipRefreshNonce, scrollPos.
-- ResolveSavedFontSize() reads Settings for user font prefs.
-- ============================================================
local CFG = {
    frameWidth       = 780,
    frameHeight      = 560,
    modelWidth       = 300,
    contentPadding   = 22,
    titleSize        = ResolveSavedFontSize("titleFontSize",     24),
    bodySize         = ResolveSavedFontSize("bodyFontSize",      14),
    objectiveSize    = ResolveSavedFontSize("objectiveFontSize", 13),
    sectionSize      = ResolveSavedFontSize("sectionFontSize",   15),
    buttonHeight     = 32,
    buttonWidth      = 155,
    rewardIconSize   = 40,
    rewardRowHeight  = 48,
    scrollWheelStep  = 40,
    greetingRowH     = 28,

    typewriterRate   = 38,
    typewriterDelay  = 0.15,

    fadeInDuration   = 0.25,
    fadeOutDuration  = 0.18,
    modelFacing      = 0.00,

    modelLight = { true, false, -0.4, 0.8, -0.5, 0.95, 0.82, 0.72, 0.58, 0.7, 1.0, 0.94, 0.84 },

    maxRewardSlots   = 20,
    maxGreetingRows  = 24,
}

local C = {
    frameBg         = { 0.10, 0.08, 0.05, 0.97 },
    modelBg         = { 0.05, 0.04, 0.02, 1.00 },
    contentBg       = { 0.12, 0.10, 0.06, 0.97 },
    border          = { 0.76, 0.67, 0.46, 1.00 },
    borderDim       = { 0.50, 0.42, 0.28, 0.70 },
    titleText       = { 0.96, 0.87, 0.58 },
    bodyText        = { 0.94, 0.90, 0.80 },
    mutedText       = { 0.71, 0.62, 0.44 },
    objectiveText   = { 0.86, 0.80, 0.64 },
    objectiveDone   = { 0.40, 0.80, 0.45 },
    sectionText     = { 0.90, 0.78, 0.48 },
    hoverBg         = { 0.20, 0.17, 0.12, 0.60 },
    buttonBg        = { 0.14, 0.12, 0.08, 0.95 },
    buttonBorder    = { 0.62, 0.54, 0.36, 1.00 },
    buttonText      = { 0.94, 0.86, 0.56 },
    buttonHover     = { 0.22, 0.19, 0.13, 0.95 },
    selectionGlow   = { 0.90, 0.75, 0.30, 0.55 },
    qualityCommon   = { 0.65, 0.65, 0.65 },
    qualityUncommon = { 0.12, 0.80, 0.12 },
    qualityRare     = { 0.00, 0.44, 0.87 },
    qualityEpic     = { 0.64, 0.21, 0.93 },
    questAvail      = { 1.00, 0.82, 0.20 },
    questActive     = { 0.75, 0.75, 0.75 },
    questReady      = { 1.00, 0.82, 0.20 },
    glowAmbient     = { 0.85, 0.70, 0.35, 0.18 },
    scrollThumb     = { 0.60, 0.52, 0.35, 0.45 },
    scrollTrack     = { 0.20, 0.17, 0.12, 0.30 },
    divider         = { 0.60, 0.52, 0.35, 0.50 },
}

local QUALITY_COLORS = {
    [0] = C.qualityCommon,
    [1] = C.qualityCommon,
    [2] = C.qualityUncommon,
    [3] = C.qualityRare,
    [4] = C.qualityEpic,
    [5] = { 1.00, 0.50, 0.00 },
    [6] = { 0.90, 0.80, 0.50 },
    [7] = { 0.00, 0.80, 1.00 },
}

local State = {
    initialized        = false,
    currentMode        = nil,
    chosenReward       = 0,
    typewriterAtoms    = nil,
    typewriterVisTotal = 0,
    typewriterVisCur   = 0,
    typewriterDone     = true,
    typewriterTimer    = 0,
    typewriterDelay    = 0,
    talkAnimWanted     = false,
    talkAnimRetry      = 0,
    talkAnimID         = nil,
    talkAnimIndex      = 1,
    talkAnimNoState    = false,
    talkAnimRefreshes  = 0,
    talkAnimApplied    = false,
    scrollTarget       = 0,
    scrollCurrent      = 0,
    fadeAlpha          = 0,
    fadeDir            = 0,
    fadeDone           = nil,
    modelDrift         = 0,
    lastDetailTitle    = nil,
    lastDetailText     = nil,
    lastProgressTitle  = nil,
    lastProgressText   = nil,
    lastCompleteTitle  = nil,
    lastCompleteText   = nil,
    lastGossipText     = "",
    lastGossipNpc      = nil,
    lastGossipTextAt   = 0,
    gossipRefreshNonce = 0,
    useBlizzardGossip  = false,
}

local FontRegistry = {}

local function RegisterQuestFont(fs, kind, fontPath, flags)
    if not fs then return fs end
    FontRegistry[#FontRegistry + 1] = {
        fs       = fs,
        kind     = kind,
        font     = fontPath,
        flags    = flags or "",
    }
    return fs
end

local function UseBlizzardQuestingInterface()
    local s = _G.MidnightUISettings
    return type(s) == "table"
        and type(s.General) == "table"
        and s.General.useBlizzardQuestingInterface == true
end

local function SafeCall(fn, ...)
    if type(fn) ~= "function" then
        return nil
    end
    local ok, r1, r2, r3, r4, r5, r6, r7, r8 = pcall(fn, ...)
    if not ok then
        return nil
    end
    return r1, r2, r3, r4, r5, r6, r7, r8
end

local function SafeDebugString(value, fallback)
    if value == nil then return fallback or "nil" end
    local valueType = type(value)
    if valueType == "string" then
        return fallback or "[protected]"
    end
    if valueType == "boolean" then
        return value and "true" or "false"
    end
    local ok, text = pcall(tostring, value)
    if ok and type(text) == "string" then
        return text
    end
    return fallback or "[protected]"
end

local function SafeLowerText(value)
    if value == nil then return nil end
    local ok, lowered = pcall(string.lower, value)
    if ok and type(lowered) == "string" then
        return lowered
    end
    return nil
end

local function IsEmptyTextSafe(value)
    if value == nil then return true end
    local ok, empty = pcall(function()
        return value == ""
    end)
    return ok and empty == true
end

local function IsNonEmptyTextSafe(value)
    return not IsEmptyTextSafe(value)
end

local function SafePositiveNumber(value)
    local ok, positive = pcall(function()
        return type(value) == "number" and value > 0
    end)
    if ok and positive then
        return value
    end
    return nil
end

local function SafeFrameNumber(frame, getter)
    if not frame or type(getter) ~= "function" then
        return nil
    end
    local ok, value = pcall(getter, frame)
    if ok then
        return SafePositiveNumber(value)
    end
    return nil
end

local SERVICE_GOSSIP_KEYWORDS = {
    "auction",
    "bank",
    "barber",
    "binder",
    "flight",
    "guild bank",
    "healer",
    "home",
    "inn",
    "mail",
    "merchant",
    "stable",
    "tabard",
    "taxi",
    "trainer",
    "transmog",
    "vendor",
    "void",
}

local DELVE_SUPPLIES_GOSSIP_KEYWORDS = {
    "brann",
    "curio",
    "delver",
    "delver's supplies",
    "supplies",
}

local function LooksLikeServiceGossipToken(value)
    if value == nil then return false end
    local token = SafeLowerText(value)
    if not token then return false end

    for i = 1, #SERVICE_GOSSIP_KEYWORDS do
        local okFind, found = pcall(string.find, token, SERVICE_GOSSIP_KEYWORDS[i], 1, true)
        if okFind and found then
            return true
        end
    end

    return false
end

local function LooksLikeDelveSuppliesToken(value)
    if value == nil then return false end
    local token = SafeLowerText(value)
    if not token then return false end

    for i = 1, #DELVE_SUPPLIES_GOSSIP_KEYWORDS do
        local okFind, found = pcall(string.find, token, DELVE_SUPPLIES_GOSSIP_KEYWORDS[i], 1, true)
        if okFind and found then
            return true
        end
    end

    return false
end

local function IsDelveSuppliesGossipOption(entry)
    if type(entry) ~= "table" then return false end

    if LooksLikeDelveSuppliesToken(entry.type)
        or LooksLikeDelveSuppliesToken(entry.gossipOptionType)
        or LooksLikeDelveSuppliesToken(entry.optionType)
        or LooksLikeDelveSuppliesToken(entry.status)
        or LooksLikeDelveSuppliesToken(entry.icon)
        or LooksLikeDelveSuppliesToken(entry.name)
        or LooksLikeDelveSuppliesToken(entry.text) then
        return true
    end

    return false
end

local function IsDelveSuppliesGossip(options)
    if LooksLikeDelveSuppliesToken(SafeCall(UnitName, "npc")) then
        return true
    end

    if type(C_GossipInfo) == "table" and type(C_GossipInfo.GetText) == "function" then
        local okText, textValue = pcall(C_GossipInfo.GetText)
        if okText and LooksLikeDelveSuppliesToken(textValue) then
            return true
        end
    end

    if type(options) == "table" then
        for i = 1, #options do
            if IsDelveSuppliesGossipOption(options[i]) then
                return true
            end
        end
    elseif type(GetGossipOptions) == "function" then
        local legacy = { SafeCall(GetGossipOptions) }
        for i = 1, #legacy, 2 do
            if LooksLikeDelveSuppliesToken(legacy[i]) then
                return true
            end
        end
    end

    return false
end

local function IsServiceGossipOption(entry)
    if type(entry) ~= "table" then return false end

    if LooksLikeServiceGossipToken(entry.type)
        or LooksLikeServiceGossipToken(entry.gossipOptionType)
        or LooksLikeServiceGossipToken(entry.optionType)
        or LooksLikeServiceGossipToken(entry.status)
        or LooksLikeServiceGossipToken(entry.icon)
        or LooksLikeServiceGossipToken(entry.name)
        or LooksLikeServiceGossipToken(entry.text) then
        return true
    end

    return false
end

local function LooksLikeDelveTierGossip(options)
    if not options or #options < 3 then return false end
    for i = 1, #options do
        local name = options[i].name or options[i].text or ""
        if not string.match(name, "^Tier %d+") then
            return false
        end
    end
    return true
end

local function ShouldUseBlizzardGossipFrame()
    local gossipOptions = nil

    if type(C_GossipInfo) == "table" and type(C_GossipInfo.GetOptions) == "function" then
        local okOpts, optionsList = pcall(C_GossipInfo.GetOptions)
        if okOpts and type(optionsList) == "table" then
            gossipOptions = optionsList
        end
    end

    if type(gossipOptions) == "table" then
        if IsDelveSuppliesGossip(gossipOptions) then
            return false
        end
        if LooksLikeDelveTierGossip(gossipOptions) then
            return true
        end
        for i = 1, #gossipOptions do
            if IsServiceGossipOption(gossipOptions[i]) then
                return true
            end
        end
    end

    if type(GetGossipOptions) == "function" then
        local legacy = { SafeCall(GetGossipOptions) }
        for i = 1, #legacy, 2 do
            local optionText = legacy[i]
            if LooksLikeServiceGossipToken(optionText) then
                return true
            end
        end
    end

    if IsDelveSuppliesGossip(gossipOptions) then
        return false
    end

    if LooksLikeServiceGossipToken(SafeCall(UnitName, "npc")) then
        return true
    end

    if type(C_GossipInfo) == "table" and type(C_GossipInfo.GetText) == "function" then
        local okText, textValue = pcall(C_GossipInfo.GetText)
        if okText and LooksLikeServiceGossipToken(textValue) then
            return true
        end
    end

    return false
end

local function TrySetFont(fs, font, size, flags)
    if not fs or not fs.SetFont then return end
    local resolved = (_G.MidnightUI_ResolveFontPath and _G.MidnightUI_ResolveFontPath(font or TITLE_FONT)) or (font or TITLE_FONT)
    pcall(fs.SetFont, fs, resolved, size or 12, flags or "")
end

local function GetQualityColor(quality)
    return QUALITY_COLORS[quality] or C.qualityCommon
end

local function FormatMoney(copper)
    if not copper or copper <= 0 then return nil end
    local gold   = math.floor(copper / 10000)
    local silver = math.floor((copper % 10000) / 100)
    local cop    = copper % 100
    local parts = {}
    if gold > 0 then parts[#parts+1] = "|cffffd700" .. gold .. "g|r" end
    if silver > 0 then parts[#parts+1] = "|cffc7c7cf" .. silver .. "s|r" end
    if cop > 0 then parts[#parts+1] = "|cffeda55f" .. cop .. "c|r" end
    return table.concat(parts, " ")
end

local function NormalizeDialogText(value)
    if type(value) ~= "string" then return nil end
    local trimmed = value:gsub("^%s+", ""):gsub("%s+$", "")
    if trimmed == "" then return nil end
    return value
end

local function ResolveStableModeText(titleKey, bodyKey, titleValue, bodyValue, titleFallback)
    local stableTitle = NormalizeDialogText(titleValue)
    local stableBody = NormalizeDialogText(bodyValue)
    if stableTitle then
        State[titleKey] = stableTitle
    elseif not NormalizeDialogText(State[titleKey]) and titleFallback then
        State[titleKey] = titleFallback
    end
    if stableBody then
        State[bodyKey] = stableBody
    elseif State[bodyKey] == nil then
        State[bodyKey] = ""
    end
    return State[titleKey] or titleFallback or "", State[bodyKey] or ""
end

local function Smoothstep(t)
    if t <= 0 then return 0 end
    if t >= 1 then return 1 end
    return t * t * (3 - 2 * t)
end

local StopTypewriter
local ForceTypewriterDone


local Typewriter = {}

function Typewriter.Parse(text)
    if type(text) ~= "string" or text == "" then return {}, 0 end
    local atoms = {}
    local visCount = 0
    local i = 1
    local len = #text
    while i <= len do
        local ch = text:sub(i, i)
        if ch == "|" and i < len then
            local nc = text:sub(i + 1, i + 1)
            if nc == "c" and i + 9 <= len then
                atoms[#atoms + 1] = { v = false, t = text:sub(i, i + 9) }
                i = i + 10
            elseif nc == "r" then
                atoms[#atoms + 1] = { v = false, t = "|r" }
                i = i + 2
            elseif nc == "n" then
                atoms[#atoms + 1] = { v = true, t = "\n" }
                visCount = visCount + 1
                i = i + 2
            elseif nc == "|" then
                atoms[#atoms + 1] = { v = true, t = "||" }
                visCount = visCount + 1
                i = i + 2
            elseif nc == "H" then
                local linkEnd = text:find("|h", i + 2, true)
                if linkEnd then
                    local closeH = text:find("|h", linkEnd + 2, true)
                    if closeH then
                        local linkText = text:sub(i, closeH + 1)
                        atoms[#atoms + 1] = { v = true, t = linkText }
                        visCount = visCount + 1
                        i = closeH + 2
                    else
                        atoms[#atoms + 1] = { v = true, t = ch }
                        visCount = visCount + 1
                        i = i + 1
                    end
                else
                    atoms[#atoms + 1] = { v = true, t = ch }
                    visCount = visCount + 1
                    i = i + 1
                end
            else
                atoms[#atoms + 1] = { v = true, t = ch }
                visCount = visCount + 1
                i = i + 1
            end
        else
            atoms[#atoms + 1] = { v = true, t = ch }
            visCount = visCount + 1
            i = i + 1
        end
    end
    return atoms, visCount
end

function Typewriter.Build(atoms, visLimit)
    local parts = {}
    local vis = 0
    local inColor = false
    for _, atom in ipairs(atoms) do
        if atom.v then
            vis = vis + 1
            if vis > visLimit then break end
        end
        parts[#parts + 1] = atom.t
        if not atom.v then
            if atom.t:sub(1, 2) == "|c" then
                inColor = true
            elseif atom.t == "|r" then
                inColor = false
            end
        end
    end
    if inColor then
        parts[#parts + 1] = "|r"
    end
    return table.concat(parts)
end

-- ============================================================
-- SECTION: QI Frame Construction + Panels
-- READS: CFG dimensions, C colors, UIParent
-- WRITES: QI + all child frames (panels, scroll, model, btns)
-- MidnightUI_QuestInterface: FULLSCREEN_DIALOG strata, level
-- 200. Contains:
--   - backdrop + shadow/edge textures
--   - header strip + title FontString
--   - NPC model viewer frame (SetDisplayInfo driven)
--   - ScrollFrame + scrollChild for quest body text
--   - PopulateDetail / PopulateProgress / PopulateComplete /
--     PopulateGossip — fill panels for each quest mode
--   - Reward item buttons (up to CFG.maxRewards slots) with
--     SetItemButtonCount, GameTooltip hover, click-to-select
--   - Accept / Decline / Continue buttons
-- SuppressDefaultQuestFrame / RestoreDefaultQuestFrame:
--   set QuestFrame + GossipFrame alpha=0/1 and mouse enable.
-- ============================================================
local QI = CreateFrame("Frame", "MidnightUI_QuestInterface", UIParent, "BackdropTemplate")
QI:SetSize(CFG.frameWidth, CFG.frameHeight)
QI:SetPoint("CENTER", UIParent, "CENTER", 0, 20)
QI:SetFrameStrata("FULLSCREEN_DIALOG")
QI:SetFrameLevel(200)
QI:SetToplevel(true)
QI:Hide()
QI:EnableMouse(true)
QI:SetMovable(true)
QI:RegisterForDrag("LeftButton")
QI:SetScript("OnDragStart", function(self) self:StartMoving() end)
QI:SetScript("OnDragStop", function(self) self:StopMovingOrSizing() end)
QI:SetClampedToScreen(true)

QI:SetBackdrop({
    bgFile   = WHITE8X8,
    edgeFile = WHITE8X8,
    edgeSize = 2,
    insets   = { left = 2, right = 2, top = 2, bottom = 2 },
})
QI:SetBackdropColor(C.frameBg[1], C.frameBg[2], C.frameBg[3], C.frameBg[4])
QI:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], C.border[4])

local function CreateShadowEdge(parent, point1, point2, w, h, isVertical)
    local tex = parent:CreateTexture(nil, "BACKGROUND", nil, -8)
    tex:SetTexture(WHITE8X8)
    tex:SetVertexColor(0, 0, 0, 0.45)
    if point1 and point2 then
        tex:SetPoint(point1[1], parent, point1[2], point1[3], point1[4])
        tex:SetPoint(point2[1], parent, point2[2], point2[3], point2[4])
    end
    if w then tex:SetWidth(w) end
    if h then tex:SetHeight(h) end
    return tex
end
CreateShadowEdge(QI,
    {"BOTTOMLEFT", "TOPLEFT", 4, 6}, {"BOTTOMRIGHT", "TOPRIGHT", -4, 6}, nil, 6)
CreateShadowEdge(QI,
    {"TOPLEFT", "BOTTOMLEFT", 4, -6}, {"TOPRIGHT", "BOTTOMRIGHT", -4, -6}, nil, 6)
CreateShadowEdge(QI,
    {"TOPRIGHT", "TOPLEFT", -6, -4}, {"BOTTOMRIGHT", "BOTTOMLEFT", -6, 4}, 6, nil)
CreateShadowEdge(QI,
    {"TOPLEFT", "TOPRIGHT", 6, -4}, {"BOTTOMLEFT", "BOTTOMRIGHT", 6, 4}, 6, nil)

local ambientBg = QI:CreateTexture(nil, "BACKGROUND", nil, 1)
ambientBg:SetAtlas("completiondialog-midnightcampaign-background", false)
ambientBg:SetAllPoints(QI)
ambientBg:SetVertexColor(0.55, 0.48, 0.38, 0.12)
ambientBg:SetBlendMode("ADD")

local topAccent = QI:CreateTexture(nil, "ARTWORK", nil, 1)
topAccent:SetTexture(WHITE8X8)
topAccent:SetPoint("TOPLEFT", 3, -3)
topAccent:SetPoint("TOPRIGHT", -3, -3)
topAccent:SetHeight(1)
topAccent:SetVertexColor(C.border[1], C.border[2], C.border[3], 0.6)

local closeBtn = CreateFrame("Button", nil, QI)
closeBtn:SetSize(28, 28)
closeBtn:SetPoint("TOPRIGHT", QI, "TOPRIGHT", -6, -6)
closeBtn:SetFrameLevel(QI:GetFrameLevel() + 10)

local closeTex = closeBtn:CreateFontString(nil, "OVERLAY")
TrySetFont(closeTex, TITLE_FONT, 18, "OUTLINE")
closeTex:SetPoint("CENTER", 0, 1)
closeTex:SetText("×")
closeTex:SetTextColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.8)

closeBtn:SetScript("OnEnter", function()
    closeTex:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3], 1)
end)
closeBtn:SetScript("OnLeave", function()
    closeTex:SetTextColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.8)
end)
closeBtn:SetScript("OnClick", function()
    local closed = false
    if State.currentMode == "gossip" then
        if type(C_GossipInfo) == "table" and type(C_GossipInfo.CloseGossip) == "function" then
            closed = pcall(C_GossipInfo.CloseGossip)
        elseif type(CloseGossip) == "function" then
            closed = pcall(CloseGossip)
        end
    end
    if not closed then
        closed = SafeCall(CloseQuest) and true or false
    end
    if QI:IsShown() then
        QI:Hide()
        QI:SetAlpha(0)
    end
end)

local modelPanel = CreateFrame("Frame", nil, QI, "BackdropTemplate")
modelPanel:SetPoint("TOPLEFT", QI, "TOPLEFT", 4, -4)
modelPanel:SetPoint("BOTTOMLEFT", QI, "BOTTOMLEFT", 4, 4)
modelPanel:SetWidth(CFG.modelWidth)
modelPanel:SetBackdrop({
    bgFile = WHITE8X8,
    edgeFile = WHITE8X8,
    edgeSize = 1,
    insets = { left = 1, right = 1, top = 1, bottom = 1 },
})
modelPanel:SetBackdropColor(C.modelBg[1], C.modelBg[2], C.modelBg[3], C.modelBg[4])
modelPanel:SetBackdropBorderColor(C.borderDim[1], C.borderDim[2], C.borderDim[3], 0.35)

local glowCenter = modelPanel:CreateTexture(nil, "BACKGROUND", nil, 1)
glowCenter:SetTexture(WHITE8X8)
glowCenter:SetPoint("CENTER", modelPanel, "CENTER", 0, 20)
glowCenter:SetSize(180, 220)
glowCenter:SetVertexColor(C.glowAmbient[1], C.glowAmbient[2], C.glowAmbient[3], C.glowAmbient[4])
glowCenter:SetBlendMode("ADD")
glowCenter:Hide()

local glowSoft = modelPanel:CreateTexture(nil, "BACKGROUND", nil, 2)
glowSoft:SetTexture("Interface\\COMMON\\RingBorder")
glowSoft:SetPoint("CENTER", modelPanel, "CENTER", 0, 10)
glowSoft:SetSize(240, 280)
glowSoft:SetVertexColor(0.80, 0.65, 0.30, 0.08)
glowSoft:SetBlendMode("ADD")
glowSoft:Hide()

local npcModel = CreateFrame("PlayerModel", nil, modelPanel)
npcModel:SetPoint("TOPLEFT", modelPanel, "TOPLEFT", 6, -6)
npcModel:SetPoint("BOTTOMRIGHT", modelPanel, "BOTTOMRIGHT", -6, 6)
npcModel:SetFrameLevel(modelPanel:GetFrameLevel() + 2)

local NPC_ANIM_IDLE = 0
local NPC_ANIM_TALK_CANDIDATES = { 64, 60, 65 }
local NPC_ANIM_APPLY_RETRY_SECONDS = 0.10
local NPC_ANIM_STATE_POLL_SECONDS = 0.20
local NPC_ANIM_NOSTATE_WARMUP_SECONDS = 0.90
local NPC_ANIM_NOSTATE_REAPPLY_SECONDS = 2.85

local function GetNPCAnimationSafe()
    if not npcModel or not npcModel:IsShown() then
        return nil, "model_hidden"
    end
    if type(npcModel.GetAnimation) ~= "function" then
        return nil, "no_GetAnimation"
    end
    local ok, animID = pcall(npcModel.GetAnimation, npcModel)
    if not ok then
        return nil, "get_error:" .. tostring(animID)
    end
    return animID, nil
end

local function ResolveNPCTalkAnimation()
    if not npcModel or type(npcModel.HasAnimation) ~= "function" then
        return NPC_ANIM_TALK_CANDIDATES[1], 1, false
    end
    for i = 1, #NPC_ANIM_TALK_CANDIDATES do
        local animID = NPC_ANIM_TALK_CANDIDATES[i]
        local ok, hasAnim = pcall(npcModel.HasAnimation, npcModel, animID)
        if ok and hasAnim then
            return animID, i, true
        end
    end
    return NPC_ANIM_TALK_CANDIDATES[1], 1, false
end

local function SetNPCAnimationSafe(animID, source)
    if not npcModel or not npcModel:IsShown() or type(npcModel.SetAnimation) ~= "function" then
        return false
    end
    local ok, err = pcall(npcModel.SetAnimation, npcModel, animID, 0)
    if not ok then
        ok, err = pcall(npcModel.SetAnimation, npcModel, animID)
    end
    if not ok then
        return false
    end
    return true
end

local function IsNPCTalkAnimationActive()
    if not npcModel or not npcModel:IsShown() then
        return false, nil, "model_hidden"
    end
    local currentAnimID, reason = GetNPCAnimationSafe()
    if reason then
        if reason == "no_GetAnimation" then
            return State.talkAnimApplied and true or false, nil, reason
        end
        return false, nil, reason
    end
    if currentAnimID == State.talkAnimID then
        return true, currentAnimID, "match"
    end
    return false, currentAnimID, "mismatch"
end

local modelFade = modelPanel:CreateTexture(nil, "OVERLAY", nil, 7)
modelFade:SetTexture(WHITE8X8)
modelFade:SetPoint("TOPRIGHT", modelPanel, "TOPRIGHT", 0, 0)
modelFade:SetPoint("BOTTOMRIGHT", modelPanel, "BOTTOMRIGHT", 0, 0)
modelFade:SetWidth(40)
modelFade:SetGradient("HORIZONTAL",
    CreateColor(C.modelBg[1], C.modelBg[2], C.modelBg[3], 0),
    CreateColor(C.contentBg[1], C.contentBg[2], C.contentBg[3], 0.7))

local npcNameText = modelPanel:CreateFontString(nil, "OVERLAY")
TrySetFont(npcNameText, TITLE_FONT, 13, "OUTLINE")
npcNameText:SetPoint("BOTTOM", modelPanel, "BOTTOM", 0, 12)
npcNameText:SetWidth(CFG.modelWidth - 20)
npcNameText:SetJustifyH("CENTER")
npcNameText:SetTextColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.9)
npcNameText:Hide()

local contentPanel = CreateFrame("Frame", nil, QI, "BackdropTemplate")
contentPanel:SetPoint("TOPLEFT", modelPanel, "TOPRIGHT", 0, 0)
contentPanel:SetPoint("BOTTOMRIGHT", QI, "BOTTOMRIGHT", -4, 4)
contentPanel:SetBackdrop({
    bgFile = WHITE8X8,
    edgeFile = WHITE8X8,
    edgeSize = 1,
    insets = { left = 1, right = 1, top = 1, bottom = 1 },
})
contentPanel:SetBackdropColor(C.contentBg[1], C.contentBg[2], C.contentBg[3], C.contentBg[4])
contentPanel:SetBackdropBorderColor(C.borderDim[1], C.borderDim[2], C.borderDim[3], 0.2)

local contentTopGlow = contentPanel:CreateTexture(nil, "BACKGROUND", nil, 1)
contentTopGlow:SetTexture(WHITE8X8)
contentTopGlow:SetPoint("TOPLEFT", 2, -2)
contentTopGlow:SetPoint("TOPRIGHT", -2, -2)
contentTopGlow:SetHeight(50)
contentTopGlow:SetVertexColor(0.18, 0.15, 0.10, 0.35)

local titleText = contentPanel:CreateFontString(nil, "OVERLAY")
TrySetFont(titleText, TITLE_FONT, CFG.titleSize, "OUTLINE")
RegisterQuestFont(titleText, "title", TITLE_FONT, "OUTLINE")
titleText:SetPoint("TOPLEFT", contentPanel, "TOPLEFT", CFG.contentPadding, -CFG.contentPadding)
titleText:SetPoint("TOPRIGHT", contentPanel, "TOPRIGHT", -CFG.contentPadding - 30, -CFG.contentPadding)
titleText:SetJustifyH("LEFT")
titleText:SetWordWrap(true)
titleText:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
titleText:SetShadowColor(0, 0, 0, 0.7)
titleText:SetShadowOffset(1, -1)

local titleDivider = contentPanel:CreateTexture(nil, "ARTWORK", nil, 2)
titleDivider:SetTexture(WHITE8X8)
titleDivider:SetPoint("TOPLEFT", titleText, "BOTTOMLEFT", 0, -12)
titleDivider:SetPoint("RIGHT", contentPanel, "RIGHT", -CFG.contentPadding, 0)
titleDivider:SetHeight(1)
titleDivider:SetVertexColor(C.divider[1], C.divider[2], C.divider[3], C.divider[4])

local dividerDot = contentPanel:CreateTexture(nil, "ARTWORK", nil, 3)
dividerDot:SetTexture(WHITE8X8)
dividerDot:SetPoint("LEFT", titleDivider, "LEFT", -2, 0)
dividerDot:SetSize(5, 5)
dividerDot:SetVertexColor(C.border[1], C.border[2], C.border[3], 0.7)

local BUTTON_BAR_HEIGHT = CFG.buttonHeight + 28

local scrollFrame = CreateFrame("ScrollFrame", nil, contentPanel)
scrollFrame:SetPoint("TOPLEFT", titleDivider, "BOTTOMLEFT", 0, -14)
scrollFrame:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT",
    -CFG.contentPadding, BUTTON_BAR_HEIGHT)
scrollFrame:EnableMouseWheel(true)

local scrollContent = CreateFrame("Frame", nil, scrollFrame)
scrollContent:SetWidth(1)
scrollFrame:SetScrollChild(scrollContent)

local bodyTextFS
local UpdateScrollContentHeight

scrollFrame:SetScript("OnSizeChanged", function(self, w)
    local width = SafePositiveNumber(w) or SafeFrameNumber(self, self.GetWidth)
    if width then
        pcall(scrollContent.SetWidth, scrollContent, width)
    end
end)

scrollFrame:SetScript("OnMouseWheel", function(self, delta)
    local maxScroll = SafeFrameNumber(self, self.GetVerticalScrollRange)
    if not maxScroll then return end
    State.scrollTarget = State.scrollTarget - (delta * CFG.scrollWheelStep)
    State.scrollTarget = math.max(0, math.min(maxScroll, State.scrollTarget))
end)

scrollFrame:SetScript("OnMouseDown", function()
    if not State.typewriterDone then
        State.typewriterVisCur = State.typewriterVisTotal
        StopTypewriter("skip")
        bodyTextFS:SetText(Typewriter.Build(State.typewriterAtoms, State.typewriterVisTotal))
        C_Timer.After(0.05, UpdateScrollContentHeight)
    end
end)

local scrollTrack = scrollFrame:CreateTexture(nil, "OVERLAY", nil, 1)
scrollTrack:SetTexture(WHITE8X8)
scrollTrack:SetPoint("TOPRIGHT", scrollFrame, "TOPRIGHT", -1, 0)
scrollTrack:SetPoint("BOTTOMRIGHT", scrollFrame, "BOTTOMRIGHT", -1, 0)
scrollTrack:SetWidth(3)
scrollTrack:SetVertexColor(C.scrollTrack[1], C.scrollTrack[2], C.scrollTrack[3], C.scrollTrack[4])

local scrollThumb = scrollFrame:CreateTexture(nil, "OVERLAY", nil, 2)
scrollThumb:SetTexture(WHITE8X8)
scrollThumb:SetWidth(3)
scrollThumb:SetVertexColor(C.scrollThumb[1], C.scrollThumb[2], C.scrollThumb[3], C.scrollThumb[4])

bodyTextFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(bodyTextFS, BODY_FONT, CFG.bodySize, "")
RegisterQuestFont(bodyTextFS, "body", BODY_FONT, "")
bodyTextFS:SetPoint("TOPLEFT", scrollContent, "TOPLEFT", 0, 0)
bodyTextFS:SetPoint("TOPRIGHT", scrollContent, "TOPRIGHT", -8, 0)
bodyTextFS:SetJustifyH("LEFT")
bodyTextFS:SetWordWrap(true)
bodyTextFS:SetSpacing(4)
bodyTextFS:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])

local objHeaderFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(objHeaderFS, TITLE_FONT, CFG.sectionSize, "OUTLINE")
RegisterQuestFont(objHeaderFS, "section", TITLE_FONT, "OUTLINE")
objHeaderFS:SetPoint("TOPLEFT", bodyTextFS, "BOTTOMLEFT", 0, -16)
objHeaderFS:SetJustifyH("LEFT")
objHeaderFS:SetTextColor(C.sectionText[1], C.sectionText[2], C.sectionText[3])
objHeaderFS:SetText("Objectives")
objHeaderFS:Hide()

local objDivider = scrollContent:CreateTexture(nil, "ARTWORK")
objDivider:SetTexture(WHITE8X8)
objDivider:SetPoint("TOPLEFT", objHeaderFS, "BOTTOMLEFT", 0, -4)
objDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
objDivider:SetHeight(1)
objDivider:SetVertexColor(C.divider[1], C.divider[2], C.divider[3], 0.3)
objDivider:Hide()

local objTextFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(objTextFS, BODY_FONT, CFG.objectiveSize, "")
RegisterQuestFont(objTextFS, "objective", BODY_FONT, "")
objTextFS:SetPoint("TOPLEFT", objDivider, "BOTTOMLEFT", 4, -6)
objTextFS:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
objTextFS:SetJustifyH("LEFT")
objTextFS:SetWordWrap(true)
objTextFS:SetSpacing(4)
objTextFS:SetTextColor(C.objectiveText[1], C.objectiveText[2], C.objectiveText[3])
objTextFS:Hide()

local reqHeaderFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(reqHeaderFS, TITLE_FONT, CFG.sectionSize, "OUTLINE")
RegisterQuestFont(reqHeaderFS, "section", TITLE_FONT, "OUTLINE")
reqHeaderFS:SetJustifyH("LEFT")
reqHeaderFS:SetTextColor(C.sectionText[1], C.sectionText[2], C.sectionText[3])
reqHeaderFS:SetText("Required Items")
reqHeaderFS:Hide()

local rewardHeaderFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(rewardHeaderFS, TITLE_FONT, CFG.sectionSize, "OUTLINE")
RegisterQuestFont(rewardHeaderFS, "section", TITLE_FONT, "OUTLINE")
rewardHeaderFS:SetJustifyH("LEFT")
rewardHeaderFS:SetTextColor(C.sectionText[1], C.sectionText[2], C.sectionText[3])
rewardHeaderFS:Hide()

local rewardDivider = scrollContent:CreateTexture(nil, "ARTWORK")
rewardDivider:SetTexture(WHITE8X8)
rewardDivider:SetHeight(1)
rewardDivider:SetVertexColor(C.divider[1], C.divider[2], C.divider[3], 0.3)
rewardDivider:Hide()

local moneyXPFS = scrollContent:CreateFontString(nil, "OVERLAY")
TrySetFont(moneyXPFS, BODY_FONT, CFG.objectiveSize, "")
RegisterQuestFont(moneyXPFS, "objective", BODY_FONT, "")
moneyXPFS:SetJustifyH("LEFT")
moneyXPFS:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
moneyXPFS:Hide()

local function CreateSubLabel(parent)
    local lbl = parent:CreateFontString(nil, "OVERLAY")
    TrySetFont(lbl, BODY_FONT, CFG.objectiveSize, "")
    RegisterQuestFont(lbl, "objective", BODY_FONT, "")
    lbl:SetJustifyH("LEFT")
    lbl:SetTextColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.70)
    lbl:Hide()
    return lbl
end

local lootSubLabel    = CreateSubLabel(scrollContent)
local currencySubLabel = CreateSubLabel(scrollContent)
local repSubLabel     = CreateSubLabel(scrollContent)

local rewardPool = {}

local function SetRewardIconBorder(frame, r, g, b, a)
    if not frame or not frame._qBorderParts then return end
    local alpha = a or 1
    for i = 1, #frame._qBorderParts do
        local part = frame._qBorderParts[i]
        if part and part.SetVertexColor then
            part:SetVertexColor(r, g, b, alpha)
        end
    end
end

local EQUIP_LOC_TO_INVENTORY_SLOTS = {
    INVTYPE_HEAD = { 1 },
    INVTYPE_NECK = { 2 },
    INVTYPE_SHOULDER = { 3 },
    INVTYPE_BODY = { 4 },
    INVTYPE_CHEST = { 5 },
    INVTYPE_ROBE = { 5 },
    INVTYPE_WAIST = { 6 },
    INVTYPE_LEGS = { 7 },
    INVTYPE_FEET = { 8 },
    INVTYPE_WRIST = { 9 },
    INVTYPE_HAND = { 10 },
    INVTYPE_FINGER = { 11, 12 },
    INVTYPE_TRINKET = { 13, 14 },
    INVTYPE_CLOAK = { 15 },
    INVTYPE_WEAPON = { 16, 17 },
    INVTYPE_SHIELD = { 17 },
    INVTYPE_HOLDABLE = { 17 },
    INVTYPE_2HWEAPON = { 16 },
    INVTYPE_WEAPONMAINHAND = { 16 },
    INVTYPE_WEAPONOFFHAND = { 17 },
    INVTYPE_RANGED = { 16 },
    INVTYPE_RANGEDRIGHT = { 16 },
}

local function GetItemLevelFromLink(itemLink)
    if not itemLink then return nil end
    local level = nil
    if C_Item and C_Item.GetDetailedItemLevelInfo then
        level = SafeCall(C_Item.GetDetailedItemLevelInfo, itemLink)
    elseif GetDetailedItemLevelInfo then
        level = SafeCall(GetDetailedItemLevelInfo, itemLink)
    end
    if type(level) ~= "number" or level <= 0 then
        return nil
    end
    return math.floor(level + 0.5)
end

local function GetQuestItemLevel(itemType, index)
    return GetItemLevelFromLink(SafeCall(GetQuestItemLink, itemType, index))
end

local function GetEquippedSlotItemLevel(slotID)
    if C_Item and C_Item.GetCurrentItemLevel then
        local itemLoc = ItemLocation:CreateFromEquipmentSlot(slotID)
        if itemLoc and C_Item.DoesItemExist(itemLoc) then
            local effectiveLevel = SafeCall(C_Item.GetCurrentItemLevel, itemLoc)
            if type(effectiveLevel) == "number" and effectiveLevel > 0 then
                return math.floor(effectiveLevel + 0.5)
            end
        end
    end
    local equippedLink = SafeCall(GetInventoryItemLink, "player", slotID)
    return GetItemLevelFromLink(equippedLink)
end

local function GetEquippedItemLevelForEquipLoc(equipLoc)
    local slots = EQUIP_LOC_TO_INVENTORY_SLOTS[equipLoc]
    if not slots then return nil end

    local lowest = nil
    for i = 1, #slots do
        local equippedLevel = GetEquippedSlotItemLevel(slots[i])
        if equippedLevel and (not lowest or equippedLevel < lowest) then
            lowest = equippedLevel
        end
    end
    return lowest
end

local function GetQuestItemLevelDelta(itemType, index, rewardItemLevel)
    if type(rewardItemLevel) ~= "number" then return nil end
    local link = SafeCall(GetQuestItemLink, itemType, index)
    if not link then return nil end
    local _, _, _, equipLoc = SafeCall(GetItemInfoInstant, link)
    if type(equipLoc) ~= "string" or equipLoc == "" then
        return nil
    end
    local equippedItemLevel = GetEquippedItemLevelForEquipLoc(equipLoc)
    if type(equippedItemLevel) ~= "number" then
        return nil
    end
    return rewardItemLevel - equippedItemLevel
end

local function SetRewardItemLevelLine(frame, itemType, index)
    if not frame or not frame._ilvl then return end
    local link = SafeCall(GetQuestItemLink, itemType, index)
    if not link then frame._ilvl:SetText(""); frame._ilvl:Hide(); return end
    local _, _, _, equipLoc, _, classID = SafeCall(GetItemInfoInstant, link)
    if not classID or (classID ~= 2 and classID ~= 4) then
        frame._ilvl:SetText("")
        frame._ilvl:Hide()
        return
    end
    if type(equipLoc) ~= "string" or equipLoc == "" or not EQUIP_LOC_TO_INVENTORY_SLOTS[equipLoc] then
        frame._ilvl:SetText("")
        frame._ilvl:Hide()
        return
    end
    local itemLevel = GetItemLevelFromLink(link)
    if not itemLevel then
        frame._ilvl:SetText("")
        frame._ilvl:Hide()
        return
    end

    local text = "iLvl " .. tostring(itemLevel)
    local diff = GetQuestItemLevelDelta(itemType, index, itemLevel)
    if type(diff) == "number" then
        if diff > 0 then
            text = text .. " (|cff33ff66+" .. tostring(diff) .. "|r)"
        elseif diff < 0 then
            text = text .. " (|cffff5555" .. tostring(diff) .. "|r)"
        else
            text = text .. " (|cffb0b0b00|r)"
        end
    end

    frame._ilvl:SetText(text)
    frame._ilvl:Show()
end

local function CreateRewardFrame(parent)
    local f = CreateFrame("Button", nil, parent, "BackdropTemplate")
    f:SetSize(200, CFG.rewardRowHeight)
    f:SetBackdrop({
        bgFile   = WHITE8X8,
        edgeFile = WHITE8X8,
        edgeSize = 1,
        insets   = { left = 1, right = 1, top = 1, bottom = 1 },
    })
    f:SetBackdropColor(0, 0, 0, 0)
    f:SetBackdropBorderColor(0, 0, 0, 0)

    local icon = f:CreateTexture(nil, "ARTWORK")
    icon:SetSize(CFG.rewardIconSize, CFG.rewardIconSize)
    icon:SetPoint("LEFT", f, "LEFT", 4, 0)
    icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    f._icon = icon

    local borderThickness = 2
    local qTop = f:CreateTexture(nil, "OVERLAY")
    qTop:SetTexture(WHITE8X8)
    qTop:SetPoint("TOPLEFT", icon, "TOPLEFT", -borderThickness, borderThickness)
    qTop:SetPoint("TOPRIGHT", icon, "TOPRIGHT", borderThickness, borderThickness)
    qTop:SetHeight(borderThickness)
    local qBottom = f:CreateTexture(nil, "OVERLAY")
    qBottom:SetTexture(WHITE8X8)
    qBottom:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", -borderThickness, -borderThickness)
    qBottom:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", borderThickness, -borderThickness)
    qBottom:SetHeight(borderThickness)
    local qLeft = f:CreateTexture(nil, "OVERLAY")
    qLeft:SetTexture(WHITE8X8)
    qLeft:SetPoint("TOPLEFT", icon, "TOPLEFT", -borderThickness, borderThickness)
    qLeft:SetPoint("BOTTOMLEFT", icon, "BOTTOMLEFT", -borderThickness, -borderThickness)
    qLeft:SetWidth(borderThickness)
    local qRight = f:CreateTexture(nil, "OVERLAY")
    qRight:SetTexture(WHITE8X8)
    qRight:SetPoint("TOPRIGHT", icon, "TOPRIGHT", borderThickness, borderThickness)
    qRight:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", borderThickness, -borderThickness)
    qRight:SetWidth(borderThickness)
    f._qBorderParts = { qTop, qBottom, qLeft, qRight }
    SetRewardIconBorder(f, C.borderDim[1], C.borderDim[2], C.borderDim[3], 0.5)

    local name = f:CreateFontString(nil, "OVERLAY")
    TrySetFont(name, BODY_FONT, CFG.objectiveSize, "")
    RegisterQuestFont(name, "objective", BODY_FONT, "")
    name:SetPoint("BOTTOMLEFT", icon, "RIGHT", 14, 2)
    name:SetPoint("RIGHT", f, "RIGHT", -8, 0)
    name:SetJustifyH("LEFT")
    name:SetWordWrap(false)
    f._name = name

    local ilvl = f:CreateFontString(nil, "OVERLAY")
    TrySetFont(ilvl, BODY_FONT, math.max(8, CFG.objectiveSize - 2), "")
    RegisterQuestFont(ilvl, "objective", BODY_FONT, "")
    ilvl._muiFontSizeOffset = -2
    ilvl:SetPoint("TOPLEFT", name, "BOTTOMLEFT", 0, -2)
    ilvl:SetPoint("RIGHT", f, "RIGHT", -8, 0)
    ilvl:SetJustifyH("LEFT")
    ilvl:SetWordWrap(false)
    ilvl:SetTextColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.95)
    ilvl:Hide()
    f._ilvl = ilvl

    local count = f:CreateFontString(nil, "OVERLAY")
    TrySetFont(count, BODY_FONT, 11, "OUTLINE")
    count:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -2, 2)
    count:SetJustifyH("RIGHT")
    count:SetTextColor(1, 1, 1, 1)
    f._count = count

    local glow = f:CreateTexture(nil, "BACKGROUND")
    glow:SetTexture(WHITE8X8)
    glow:SetAllPoints(f)
    glow:SetVertexColor(C.selectionGlow[1], C.selectionGlow[2], C.selectionGlow[3], C.selectionGlow[4])
    glow:Hide()
    f._glow = glow

    f:SetScript("OnEnter", function(self)
        self:SetBackdropColor(C.hoverBg[1], C.hoverBg[2], C.hoverBg[3], C.hoverBg[4])
        self:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], 0.6)
        if self._tooltipType and self._tooltipIndex then
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            if self._tooltipType == "reward" then
                GameTooltip:SetQuestItem(self._tooltipType, self._tooltipIndex)
            elseif self._tooltipType == "choice" then
                GameTooltip:SetQuestItem(self._tooltipType, self._tooltipIndex)
            elseif self._tooltipType == "required" then
                GameTooltip:SetQuestItem(self._tooltipType, self._tooltipIndex)
            elseif self._tooltipType == "currency" then
                -- Choice-currency slots: prefer the by-ID currency tooltip
                -- because SetQuestCurrency is bound to the "reward" channel
                -- (Blizzard's API doesn't have a "choice" mode here) and
                -- would resolve to the wrong slot. By-ID is always correct.
                if self._currencyID and C_CurrencyInfo and C_CurrencyInfo.GetCurrencyInfo then
                    local info = C_CurrencyInfo.GetCurrencyInfo(self._currencyID)
                    if info then
                        GameTooltip:SetText(info.name or "Currency", 1, 1, 1)
                        if info.description and info.description ~= "" then
                            GameTooltip:AddLine(info.description, 0.7, 0.7, 0.7, true)
                        end
                    end
                elseif GameTooltip.SetQuestCurrency then
                    GameTooltip:SetQuestCurrency("reward", self._tooltipIndex)
                end
            end
            GameTooltip:Show()
        end
    end)
    f:SetScript("OnLeave", function(self)
        self:SetBackdropColor(0, 0, 0, 0)
        self:SetBackdropBorderColor(0, 0, 0, 0)
        GameTooltip:Hide()
    end)

    f:Hide()
    return f
end

for i = 1, CFG.maxRewardSlots do
    rewardPool[i] = CreateRewardFrame(scrollContent)
end

local greetingPool = {}
local GOSSIP_OPTION_ICON_ATLASES = {
    "communities-icon-chat",
    "transmog-icon-chat",
}
local QUEST_AVAILABLE_ICON_ATLAS = "QuestLog-tab-icon-quest"

local function CreateGreetingRow(parent)
    local f = CreateFrame("Button", nil, parent)
    f:SetHeight(CFG.greetingRowH)

    local hl = f:CreateTexture(nil, "BACKGROUND")
    hl:SetTexture(WHITE8X8)
    hl:SetAllPoints(f)
    hl:SetVertexColor(C.hoverBg[1], C.hoverBg[2], C.hoverBg[3], 0)
    f._hl = hl

    local icon = f:CreateFontString(nil, "OVERLAY")
    TrySetFont(icon, TITLE_FONT, 16, "OUTLINE")
    icon:SetPoint("LEFT", f, "LEFT", 4, 0)
    icon:SetWidth(20)
    f._icon = icon

    local iconAtlas = f:CreateTexture(nil, "OVERLAY")
    iconAtlas:SetPoint("CENTER", icon, "CENTER", 0, 0)
    iconAtlas:SetSize(16, 16)
    local atlasApplied = false
    if iconAtlas.SetAtlas then
        for _, atlasName in ipairs(GOSSIP_OPTION_ICON_ATLASES) do
            atlasApplied = pcall(iconAtlas.SetAtlas, iconAtlas, atlasName, false)
            if atlasApplied then
                break
            end
        end
    end
    if atlasApplied then
        iconAtlas:SetBlendMode("ADD")
        iconAtlas:SetVertexColor(1, 1, 1, 0.95)
    else
        iconAtlas:SetBlendMode("BLEND")
        iconAtlas:SetTexture("Interface\\Buttons\\WHITE8X8")
        iconAtlas:SetVertexColor(C.mutedText[1], C.mutedText[2], C.mutedText[3], 0.9)
    end
    iconAtlas:Hide()
    f._iconAtlas = iconAtlas

    local title = f:CreateFontString(nil, "OVERLAY")
    TrySetFont(title, BODY_FONT, CFG.bodySize, "")
    title:SetPoint("LEFT", icon, "RIGHT", 6, 0)
    title:SetPoint("RIGHT", f, "RIGHT", -8, 0)
    title:SetJustifyH("LEFT")
    title:SetWordWrap(true)
    title:SetMaxLines(0)
    f._title = title

    f:SetScript("OnEnter", function(self)
        self._hl:SetVertexColor(C.hoverBg[1], C.hoverBg[2], C.hoverBg[3], C.hoverBg[4])
        self._title:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
    end)
    f:SetScript("OnLeave", function(self)
        self._hl:SetVertexColor(C.hoverBg[1], C.hoverBg[2], C.hoverBg[3], 0)
        self._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
    end)

    f:Hide()
    return f
end

local function FitGreetingRowHeight(row)
    if not row or not row._title then return end
    row:SetHeight(CFG.greetingRowH)
end

local function SetRowAtlasIcon(row, atlasSource, size, blendMode, rgba, fallbackText, fallbackColor)
    if not row or not row._icon then return end
    local atlasTex = row._iconAtlas
    local applied = false

    if atlasTex and atlasTex.SetAtlas then
        if type(atlasSource) == "table" then
            for i = 1, #atlasSource do
                local atlasName = atlasSource[i]
                if type(atlasName) == "string" and atlasName ~= "" then
                    applied = pcall(atlasTex.SetAtlas, atlasTex, atlasName, false)
                    if applied then break end
                end
            end
        elseif type(atlasSource) == "string" and atlasSource ~= "" then
            applied = pcall(atlasTex.SetAtlas, atlasTex, atlasSource, false)
        end
    end

    if applied and atlasTex then
        row._icon:SetText("")
        row._icon:Hide()
        atlasTex:SetSize(size or 16, size or 16)
        if blendMode and atlasTex.SetBlendMode then
            atlasTex:SetBlendMode(blendMode)
        end
        if rgba then
            atlasTex:SetVertexColor(rgba[1], rgba[2], rgba[3], rgba[4] or 1)
        else
            atlasTex:SetVertexColor(1, 1, 1, 1)
        end
        atlasTex:Show()
        return
    end

    row._icon:Show()
    if atlasTex then atlasTex:Hide() end
    row._icon:SetText(fallbackText or "")
    if fallbackColor then
        row._icon:SetTextColor(fallbackColor[1], fallbackColor[2], fallbackColor[3], fallbackColor[4] or 1)
    end
end

local function SetRowAvailableQuestIcon(row)
    SetRowAtlasIcon(
        row,
        QUEST_AVAILABLE_ICON_ATLAS,
        16,
        "BLEND",
        { 1, 1, 1, 1 },
        "!",
        C.questAvail
    )
end

local function SetRowGossipOptionIcon(row)
    SetRowAtlasIcon(
        row,
        GOSSIP_OPTION_ICON_ATLASES,
        16,
        "ADD",
        { 1, 1, 1, 0.95 },
        ">",
        C.mutedText
    )
end

for i = 1, CFG.maxGreetingRows do
    greetingPool[i] = CreateGreetingRow(scrollContent)
end

local function CreateQuestButton(parent, text, isPrimary)
    local btn = CreateFrame("Button", nil, parent, "BackdropTemplate")
    btn:SetSize(CFG.buttonWidth, CFG.buttonHeight)
    btn:SetBackdrop({
        bgFile   = WHITE8X8,
        edgeFile = WHITE8X8,
        edgeSize = 1,
        insets   = { left = 1, right = 1, top = 1, bottom = 1 },
    })

    if isPrimary then
        btn:SetBackdropColor(0.18, 0.15, 0.10, 0.95)
        btn:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], 0.9)
    else
        btn:SetBackdropColor(C.buttonBg[1], C.buttonBg[2], C.buttonBg[3], C.buttonBg[4])
        btn:SetBackdropBorderColor(C.buttonBorder[1], C.buttonBorder[2], C.buttonBorder[3], 0.5)
    end

    local label = btn:CreateFontString(nil, "OVERLAY")
    TrySetFont(label, TITLE_FONT, 14, isPrimary and "OUTLINE" or "")
    label:SetPoint("CENTER", 0, 1)
    label:SetText(text)
    if isPrimary then
        label:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
    else
        label:SetTextColor(C.buttonText[1], C.buttonText[2], C.buttonText[3])
    end
    btn._label = label
    btn._isPrimary = isPrimary

    btn:SetScript("OnEnter", function(self)
        self:SetBackdropColor(C.buttonHover[1], C.buttonHover[2], C.buttonHover[3], C.buttonHover[4])
        self:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], 1.0)
        self._label:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
    end)
    btn:SetScript("OnLeave", function(self)
        if self._isPrimary then
            self:SetBackdropColor(0.18, 0.15, 0.10, 0.95)
            self:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], 0.9)
        else
            self:SetBackdropColor(C.buttonBg[1], C.buttonBg[2], C.buttonBg[3], C.buttonBg[4])
            self:SetBackdropBorderColor(C.buttonBorder[1], C.buttonBorder[2], C.buttonBorder[3], 0.5)
        end
        if not self._isPrimary then
            self._label:SetTextColor(C.buttonText[1], C.buttonText[2], C.buttonText[3])
        else
            self._label:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
        end
    end)

    btn:Hide()
    return btn
end

local btnAccept   = CreateQuestButton(contentPanel, "Accept Quest", true)
local btnDecline  = CreateQuestButton(contentPanel, "Decline", false)
local btnContinue = CreateQuestButton(contentPanel, "Continue", true)
local btnComplete = CreateQuestButton(contentPanel, "Complete Quest", true)
local btnGoodbye  = CreateQuestButton(contentPanel, "Goodbye", false)
local ShowQuestAcceptedBanner
local ShowQuestCompleteBanner

btnAccept:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
btnDecline:SetPoint("RIGHT", btnAccept, "LEFT", -10, 0)
btnContinue:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
btnComplete:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
btnGoodbye:SetPoint("RIGHT", btnContinue, "LEFT", -10, 0)

local function TriggerQuestAcceptedBanner(questName)
    if type(ShowQuestAcceptedBanner) ~= "function" then
        return
    end

    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        pcall(C_Timer.After, 0.05, function()
            pcall(ShowQuestAcceptedBanner, questName)
        end)
        return
    end

    pcall(ShowQuestAcceptedBanner, questName)
end

btnAccept:SetScript("OnClick", function()
    local questName = SafeCall(GetTitleText) or ""
    SafeCall(AcceptQuest)
    pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LOG_OPEN or 844)
    TriggerQuestAcceptedBanner(questName)
end)
btnDecline:SetScript("OnClick", function()
    State.declining = true
    SafeCall(DeclineQuest)
end)
btnContinue:SetScript("OnClick", function()
    SafeCall(CompleteQuest)
end)
btnComplete:SetScript("OnClick", function()
    if State.currentMode == "complete" then
        local numChoices = SafeCall(GetNumQuestChoices) or 0
        if numChoices > 1 and State.chosenReward < 1 then
            for i = 1, numChoices do
                local rf = rewardPool[i]
                if rf and rf:IsShown() and rf._glow then
                    rf._glow:Show()
                    C_Timer.After(0.4, function()
                        if rf._glow then rf._glow:Hide() end
                    end)
                end
            end
            return
        end
        local questName = SafeCall(GetTitleText) or ""
        SafeCall(GetQuestReward, State.chosenReward > 0 and State.chosenReward or nil)
        if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
            pcall(C_Timer.After, 0.05, function()
                pcall(ShowQuestCompleteBanner, questName)
            end)
        else
            pcall(ShowQuestCompleteBanner, questName)
        end
    end
end)
btnGoodbye:SetScript("OnClick", function()
    if State.currentMode == "gossip" then
        if type(C_GossipInfo) == "table" and type(C_GossipInfo.CloseGossip) == "function" then
            pcall(C_GossipInfo.CloseGossip)
        elseif type(CloseGossip) == "function" then
            pcall(CloseGossip)
        else
            SafeCall(CloseQuest)
        end
        return
    end
    SafeCall(CloseQuest)
end)

local function HideAllButtons()
    btnAccept:Hide()
    btnDecline:Hide()
    btnContinue:Hide()
    btnComplete:Hide()
    btnGoodbye:Hide()
end


local function ResetContent()
    bodyTextFS:SetText("")
    objHeaderFS:Hide()
    objDivider:Hide()
    objTextFS:Hide()
    objTextFS:SetText("")
    reqHeaderFS:Hide()
    rewardHeaderFS:Hide()
    rewardDivider:Hide()
    moneyXPFS:Hide()
    moneyXPFS:SetText("")
    lootSubLabel:Hide()
    currencySubLabel:Hide()
    repSubLabel:Hide()
    State.chosenReward = 0

    for i = 1, CFG.maxRewardSlots do
        rewardPool[i]:Hide()
        rewardPool[i]._glow:Hide()
        rewardPool[i]._tooltipType = nil
        rewardPool[i]._tooltipIndex = nil
        rewardPool[i]:SetScript("OnClick", nil)
    end
    for i = 1, CFG.maxGreetingRows do
        greetingPool[i]:Hide()
        greetingPool[i]:SetScript("OnClick", nil)
    end

    State.scrollTarget = 0
    State.scrollCurrent = 0
    scrollFrame:SetVerticalScroll(0)
end

local function SetupModel()
    npcModel:ClearModel()
    npcNameText:SetText("")

    local hasQuestNPC = UnitExists("questnpc")
    local hasNPC      = UnitExists("npc")

    if hasQuestNPC or hasNPC then
        local unit = hasQuestNPC and "questnpc" or "npc"
        npcModel:SetUnit(unit)
        npcModel:SetPortraitZoom(0.58)
        npcModel:SetFacing(CFG.modelFacing)
        npcModel:SetPosition(0.00, 0.00, 0.10)
        npcModel:SetCamDistanceScale(1.10)

        local L = CFG.modelLight
        pcall(npcModel.SetLight, npcModel,
            L[1], L[2], L[3], L[4], L[5],
            L[6], L[7], L[8], L[9],
            L[10], L[11], L[12], L[13])

        npcNameText:SetText("")
        modelPanel:Show()
        npcModel:Show()
    else
        modelPanel:Hide()
        npcModel:Hide()
        contentPanel:SetPoint("TOPLEFT", QI, "TOPLEFT", 4, -4)
    end
    State.modelDrift = 0
end

local function RestoreContentAnchors()
    contentPanel:ClearAllPoints()
    if modelPanel:IsShown() then
        contentPanel:SetPoint("TOPLEFT", modelPanel, "TOPRIGHT", 0, 0)
    else
        contentPanel:SetPoint("TOPLEFT", QI, "TOPLEFT", 4, -4)
    end
    contentPanel:SetPoint("BOTTOMRIGHT", QI, "BOTTOMRIGHT", -4, 4)
end

StopTypewriter = function(reason)
    if State.typewriterDone then return end
    State.typewriterDone = true
    State.talkAnimWanted = false
    State.talkAnimRetry = 0
    State.talkAnimID = nil
    State.talkAnimIndex = 1
    State.talkAnimNoState = false
    State.talkAnimRefreshes = 0
    State.talkAnimApplied = false
    if npcModel:IsShown() then
        SetNPCAnimationSafe(NPC_ANIM_IDLE, reason or "stop")
    end
end

ForceTypewriterDone = function()
    State.typewriterDone = true
    State.talkAnimWanted = false
    State.talkAnimRetry = 0
    State.talkAnimID = nil
    State.talkAnimIndex = 1
    State.talkAnimNoState = false
    State.talkAnimRefreshes = 0
    State.talkAnimApplied = false
end

local function StartTypewriter(text)
    ForceTypewriterDone()
    local okParse, atoms, total = pcall(Typewriter.Parse, text or "")
    if not okParse then
        atoms, total = {}, 0
    end
    State.typewriterAtoms = atoms
    State.typewriterVisTotal = total
    State.typewriterVisCur = 0
    State.typewriterDone = (total == 0)
    State.typewriterTimer = 0
    State.typewriterDelay = CFG.typewriterDelay
    State.talkAnimWanted = (total > 0)

    if total == 0 then
        bodyTextFS:SetText(text or "")
    else
        bodyTextFS:SetText("")
    end

    if total > 0 and npcModel:IsShown() then
        local resolvedID, resolvedIndex, resolvedSupported = ResolveNPCTalkAnimation()
        State.talkAnimID = resolvedID
        State.talkAnimIndex = resolvedIndex or 1
        State.talkAnimNoState = not resolvedSupported
        local applied = SetNPCAnimationSafe(State.talkAnimID, "start")
        State.talkAnimApplied = applied and true or false
        local _, currentReason = GetNPCAnimationSafe()
        if State.talkAnimApplied then
            if State.talkAnimNoState or currentReason == "no_GetAnimation" then
                State.talkAnimRetry = NPC_ANIM_NOSTATE_WARMUP_SECONDS
            else
                State.talkAnimRetry = NPC_ANIM_STATE_POLL_SECONDS
            end
        else
            State.talkAnimRetry = NPC_ANIM_APPLY_RETRY_SECONDS
        end
    elseif npcModel:IsShown() then
        SetNPCAnimationSafe(NPC_ANIM_IDLE, "start-empty")
    end
end

local function PopulateObjectives(anchorBelow)
    local objText = SafeCall(GetObjectiveText)
    if not objText or objText == "" then
        objHeaderFS:Hide()
        objDivider:Hide()
        objTextFS:Hide()
        return anchorBelow
    end

    objHeaderFS:ClearAllPoints()
    objHeaderFS:SetPoint("TOPLEFT", anchorBelow, "BOTTOMLEFT", 0, -20)
    objHeaderFS:Show()
    objDivider:ClearAllPoints()
    objDivider:SetPoint("TOPLEFT", objHeaderFS, "BOTTOMLEFT", 0, -6)
    objDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
    objDivider:Show()

    objTextFS:ClearAllPoints()
    objTextFS:SetPoint("TOPLEFT", objDivider, "BOTTOMLEFT", 4, -6)
    objTextFS:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
    objTextFS:SetText(objText)
    objTextFS:Show()
    return objTextFS
end

local function PopulateRequiredItems(anchorBelow)
    local numReq = SafeCall(GetNumQuestItems) or 0
    if numReq <= 0 then
        reqHeaderFS:Hide()
        return anchorBelow
    end

    reqHeaderFS:ClearAllPoints()
    reqHeaderFS:SetPoint("TOPLEFT", anchorBelow, "BOTTOMLEFT", 0, -20)
    reqHeaderFS:Show()

    local lastAnchor = reqHeaderFS
    local col = 0
    local perRow = 2
    local slotW = 200

    for i = 1, numReq do
        local name, texture, numItems = SafeCall(GetQuestItemInfo, "required", i)
        if name then
            local rf = rewardPool[i]
            if not rf then break end

            rf._icon:SetTexture(texture or "Interface\\Icons\\INV_Misc_QuestionMark")
            rf._name:SetText(name or "???")
            rf._name:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
            if rf._ilvl then
                rf._ilvl:SetText("")
                rf._ilvl:Hide()
            end
            rf._count:SetText(numItems and numItems > 1 and numItems or "")
            SetRewardIconBorder(rf, C.borderDim[1], C.borderDim[2], C.borderDim[3], 0.5)
            rf._glow:Hide()
            rf._tooltipType = "required"
            rf._tooltipIndex = i

            rf:ClearAllPoints()
            if col == 0 then
                rf:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -8)
            else
                rf:SetPoint("LEFT", rewardPool[i - 1], "RIGHT", 8, 0)
            end
            rf:Show()

            col = col + 1
            if col >= perRow then
                col = 0
                lastAnchor = rf
            end
        end
    end

    local lastShown = rewardPool[numReq]
    return lastShown and lastShown:IsShown() and lastShown or reqHeaderFS
end


local function SetupRewardSlot(rf, icon, name, nameR, nameG, nameB, count, borderR, borderG, borderB, tipType, tipIdx)
    rf._icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark")
    rf._name:SetText(name or "")
    rf._name:SetTextColor(nameR or 0.94, nameG or 0.90, nameB or 0.80)
    if rf._ilvl then rf._ilvl:SetText(""); rf._ilvl:Hide() end
    rf._count:SetText(count or "")
    SetRewardIconBorder(rf, borderR or 0.50, borderG or 0.42, borderB or 0.28, 0.7)
    rf._glow:Hide()
    rf._tooltipType = tipType
    rf._tooltipIndex = tipIdx
    rf:SetScript("OnClick", nil)
end

local function PopulateRewards(anchorBelow, mode)
    local numRewards = SafeCall(GetNumQuestRewards) or 0
    local numChoices = SafeCall(GetNumQuestChoices) or 0
    local money      = SafeCall(GetRewardMoney) or 0
    local xp         = SafeCall(GetRewardXP) or 0

    local currencyList = {}
    local currencyRepList = {}
    if C_QuestOffer and C_QuestOffer.GetQuestRewardCurrencyInfo then
        local idx = 1
        while idx <= 12 do
            local ok, info = pcall(C_QuestOffer.GetQuestRewardCurrencyInfo, "reward", idx)
            if not ok or not info or not info.name or info.name == "" then break end
            local isRep = false
            local cID = info.currencyID
            if cID then
                if C_CurrencyInfo and C_CurrencyInfo.GetFactionGrantedByCurrency then
                    local fID = SafeCall(C_CurrencyInfo.GetFactionGrantedByCurrency, cID)
                    if fID and fID > 0 then isRep = true end
                end
            end
            if isRep then
                currencyRepList[#currencyRepList + 1] = info
            else
                currencyList[#currencyList + 1] = info
            end
            idx = idx + 1
        end
    end

    local repList = {}
    if C_QuestOffer and C_QuestOffer.GetQuestOfferMajorFactionReputationRewards then
        local repRewards = SafeCall(C_QuestOffer.GetQuestOfferMajorFactionReputationRewards)
        if type(repRewards) == "table" then
            for _, entry in ipairs(repRewards) do
                if entry then
                    local factionID = entry.factionID or entry.majorFactionID
                    local rewardAmount = entry.rewardAmount or entry.reward or 0
                    local factionName, factionIcon
                    if factionID then
                        if C_MajorFactions and C_MajorFactions.GetMajorFactionData then
                            local data = SafeCall(C_MajorFactions.GetMajorFactionData, factionID)
                            if data then factionName = data.name end
                        end
                        if not factionName and C_Reputation and C_Reputation.GetFactionDataByID then
                            local data = SafeCall(C_Reputation.GetFactionDataByID, factionID)
                            if data then factionName = data.name; factionIcon = data.icon end
                        end
                        if not factionName and GetFactionInfoByID then
                            factionName = SafeCall(GetFactionInfoByID, factionID)
                        end
                    end
                    if factionName then
                        repList[#repList + 1] = { name = factionName, icon = factionIcon, amount = rewardAmount }
                    end
                end
            end
        end
    end

    local hasAnything = (numRewards > 0) or (numChoices > 0) or (money > 0) or (xp > 0)
        or (#currencyList > 0) or (#currencyRepList > 0) or (#repList > 0)
    if not hasAnything then
        rewardHeaderFS:Hide()
        rewardDivider:Hide()
        moneyXPFS:Hide()
        return anchorBelow
    end

    rewardHeaderFS:ClearAllPoints()
    rewardHeaderFS:SetPoint("TOPLEFT", anchorBelow, "BOTTOMLEFT", 0, -22)
    if numChoices > 1 then
        rewardHeaderFS:SetText("Choose a Reward")
    elseif numChoices == 1 then
        rewardHeaderFS:SetText("Reward")
    else
        rewardHeaderFS:SetText("You will receive")
    end
    rewardHeaderFS:Show()

    rewardDivider:ClearAllPoints()
    rewardDivider:SetPoint("TOPLEFT", rewardHeaderFS, "BOTTOMLEFT", 0, -4)
    rewardDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
    rewardDivider:Show()

    local lastAnchor = rewardDivider
    local col = 0
    local perRow = 2
    local slotIndex = 0
    local fallbackContentW = CFG.frameWidth - CFG.modelWidth - (CFG.contentPadding * 2) - 48
    if fallbackContentW < 420 then
        fallbackContentW = 420
    end
    local contentW = SafeFrameNumber(scrollContent, scrollContent.GetWidth)
        or SafeFrameNumber(scrollFrame, scrollFrame.GetWidth)
        or fallbackContentW
    if contentW < fallbackContentW then
        contentW = fallbackContentW
    end
    local rightInset = 10
    local gap = 6
    local minSlotWidth = 180 + math.max(0, (CFG.objectiveSize or 13) - 13) * 8
    local function ResolveRewardColumns(preferredColumns)
        preferredColumns = math.max(1, preferredColumns or 1)
        local usableWidth = math.max(1, contentW - rightInset)
        local maxColumns = math.floor((usableWidth + gap) / (minSlotWidth + gap))
        if maxColumns < 1 then maxColumns = 1 end
        return math.min(preferredColumns, maxColumns)
    end
    local function ComputeSlotWidth(columns)
        columns = math.max(1, columns or 1)
        return math.floor((contentW - rightInset - (gap * (columns - 1))) / columns)
    end
    perRow = ResolveRewardColumns(perRow)
    local slotW = ComputeSlotWidth(perRow)

    local rowStartAnchor = nil

    local function SetRewardColumns(columns)
        columns = ResolveRewardColumns(columns)
        if col ~= 0 then
            lastAnchor = rowStartAnchor or rewardPool[slotIndex] or lastAnchor
        elseif rowStartAnchor then
            lastAnchor = rowStartAnchor
        end
        perRow = columns
        slotW = ComputeSlotWidth(perRow)
        col = 0
        rowStartAnchor = nil
    end

    local function PlaceSlot(rf, forceNewRow)
        if forceNewRow and col ~= 0 then
            col = 0
            lastAnchor = rowStartAnchor or rewardPool[slotIndex - 1] or lastAnchor
        end
        rf:ClearAllPoints()
        rf:SetWidth(slotW)
        if col == 0 then
            rf:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -6)
            rowStartAnchor = rf
        else
            rf:SetPoint("LEFT", rewardPool[slotIndex - 1], "RIGHT", gap, 0)
        end
        rf:Show()
        col = col + 1
        if col >= perRow then
            col = 0
            lastAnchor = rowStartAnchor or rf
        end
    end

    for i = 1, numChoices do
        slotIndex = slotIndex + 1
        local rf = rewardPool[slotIndex]
        if not rf then break end

        -- Currency-choice quests (e.g. "Den of Nalorakk" rep-token pick) reuse
        -- their currencyID as the index passed to GetQuestItemInfo("choice", i),
        -- so the item path coincidentally returns a Classic-era grey item that
        -- happens to share the numeric ID. The currency API is authoritative
        -- for these slots — query it first and prefer its data when present.
        local curInfo = nil
        if C_QuestOffer and C_QuestOffer.GetQuestRewardCurrencyInfo then
            local okCC, infoCC = pcall(C_QuestOffer.GetQuestRewardCurrencyInfo, "choice", i)
            if okCC and type(infoCC) == "table"
               and type(infoCC.name) == "string" and infoCC.name ~= ""
               and infoCC.currencyID then
                curInfo = infoCC
            end
        end

        local name, texture, numItems, quality
        local tipType = "choice"

        if curInfo then
            name     = curInfo.name
            texture  = curInfo.texture
            numItems = curInfo.totalRewardAmount or curInfo.baseRewardAmount or 0
            quality  = curInfo.quality or 1
            tipType  = "currency"
            rf._currencyID = curInfo.currencyID
        else
            local isUsable
            name, texture, numItems, quality, isUsable = SafeCall(GetQuestItemInfo, "choice", i)
            rf._currencyID = nil

            -- Same async-load fallback as the loot loop below: when the quest API
            -- hands back a missing/Common quality, augment with a direct item-info
            -- lookup so the icon border matches the item's true rarity.
            local link = nil
            if type(GetQuestItemLink) == "function" then
                local okL, l = pcall(GetQuestItemLink, "choice", i)
                if okL then link = l end
            end
            if type(link) == "string" and type(GetItemInfo) == "function" then
                local itemID = tonumber((link:match("item:(%d+)") or ""))
                if itemID then
                    local okGI, liveName, _, liveQuality, _, _, _, _, _, _, liveTexture = pcall(GetItemInfo, itemID)
                    if okGI then
                        if (not name or name == "") and type(liveName) == "string" then name = liveName end
                        if (not quality or quality < 2) and type(liveQuality) == "number" then quality = liveQuality end
                        if (not texture or texture == "") and liveTexture then texture = liveTexture end
                    end
                end
            end
        end

        local qc = GetQualityColor(quality or 1)

        SetupRewardSlot(rf, texture, name or "Loading...", qc[1], qc[2], qc[3],
            numItems and numItems > 1 and numItems or "", qc[1], qc[2], qc[3], tipType, i)
        if tipType ~= "currency" then
            SetRewardItemLevelLine(rf, "choice", i)
        end
        PlaceSlot(rf)

        if numChoices > 1 then
            local choiceIdx = i
            rf:SetScript("OnClick", function()
                State.chosenReward = choiceIdx
                for j = 1, numChoices do
                    local rr = rewardPool[j]
                    if j == choiceIdx then
                        rr._glow:Show()
                        rr:SetBackdropBorderColor(C.border[1], C.border[2], C.border[3], 0.6)
                    else
                        rr._glow:Hide()
                        rr:SetBackdropBorderColor(0, 0, 0, 0)
                    end
                end
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_SELECT or 856)
            end)
        elseif numChoices == 1 then
            State.chosenReward = 1
        end
    end

    local function PlaceSubLabel(labelFS, text)
        if col ~= 0 then
            col = 0
            lastAnchor = rowStartAnchor or rewardPool[slotIndex] or lastAnchor
        elseif rowStartAnchor then
            lastAnchor = rowStartAnchor
        end
        labelFS:ClearAllPoints()
        labelFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -10)
        labelFS:SetText(text)
        labelFS:Show()
        lastAnchor = labelFS
        rowStartAnchor = nil
    end

    if numRewards > 0 then
        SetRewardColumns(2)
        PlaceSubLabel(lootSubLabel, "Loot")

        for i = 1, numRewards do
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if not rf then break end

            local name, texture, numItems, quality = SafeCall(GetQuestItemInfo, "reward", i)

            if _G.MidnightUI_DebugSource then
                _G.MidnightUI_DebugSource("QuestRewards",
                    "  reward[" .. i .. "] GetQuestItemInfo →"
                    .. " name=" .. tostring(name)
                    .. " texture=" .. tostring(texture)
                    .. " numItems=" .. tostring(numItems)
                    .. " quality=" .. tostring(quality))
            end

            -- Quest reward APIs often return nil/incorrect quality for items the local
            -- client hasn't cached yet (common on weekly resets, fresh-from-server quests,
            -- or any NPC quest the player hasn't seen before this session). Pull the item
            -- link so we can fall back to a direct item-info lookup which uses the same
            -- cache the WoW UI uses for everything else.
            local link = nil
            if type(GetQuestItemLink) == "function" then
                local okL, l = pcall(GetQuestItemLink, "reward", i)
                if okL then link = l end
            end
            if type(link) == "string" and type(GetItemInfo) == "function" then
                local itemID = tonumber((link:match("item:(%d+)") or ""))
                if itemID then
                    local okGI, liveName, _, liveQuality, _, _, _, _, _, _, liveTexture = pcall(GetItemInfo, itemID)
                    if _G.MidnightUI_DebugSource then
                        _G.MidnightUI_DebugSource("QuestRewards",
                            "  reward[" .. i .. "] link=\"" .. tostring(link) .. "\""
                            .. " itemID=" .. tostring(itemID)
                            .. " GetItemInfo →"
                            .. " liveName=" .. tostring(liveName)
                            .. " liveQuality=" .. tostring(liveQuality)
                            .. " liveTexture=" .. tostring(liveTexture)
                            .. " okGI=" .. tostring(okGI))
                    end
                    if okGI then
                        if (not name or name == "") and type(liveName) == "string" then name = liveName end
                        if (not quality or quality < 2) and type(liveQuality) == "number" then quality = liveQuality end
                        if (not texture or texture == "") and liveTexture then texture = liveTexture end
                    end
                elseif _G.MidnightUI_DebugSource then
                    _G.MidnightUI_DebugSource("QuestRewards",
                        "  reward[" .. i .. "] link=\"" .. tostring(link)
                        .. "\" — no itemID could be parsed")
                end
            elseif _G.MidnightUI_DebugSource then
                _G.MidnightUI_DebugSource("QuestRewards",
                    "  reward[" .. i .. "] GetQuestItemLink returned non-string"
                    .. " (type=" .. type(link) .. ") — fallback skipped")
            end

            local qc = GetQualityColor(quality or 1)

            if _G.MidnightUI_DebugSource then
                _G.MidnightUI_DebugSource("QuestRewards",
                    "  reward[" .. i .. "] FINAL DRAW"
                    .. " name=" .. tostring(name or "Loading...")
                    .. " texture=" .. tostring(texture)
                    .. " quality=" .. tostring(quality)
                    .. " borderRGB=(" .. tostring(qc[1]) .. "," .. tostring(qc[2]) .. "," .. tostring(qc[3]) .. ")")
            end

            SetupRewardSlot(rf, texture, name or "Loading...", qc[1], qc[2], qc[3],
                numItems and numItems > 1 and numItems or "", qc[1], qc[2], qc[3], "reward", i)
            SetRewardItemLevelLine(rf, "reward", i)
            PlaceSlot(rf)
        end
    end

    local hasCurrency = (xp > 0) or (money > 0) or (#currencyList > 0)
    if hasCurrency then
        SetRewardColumns(2)
        PlaceSubLabel(currencySubLabel, "Currency")

        if xp > 0 then
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if rf then
                local xpLabel = "|cff8080ff" .. xp .. "|r XP"
                local maxXP = SafeCall(UnitXPMax, "player") or 1
                if maxXP > 0 then
                    xpLabel = xpLabel .. string.format("  |cff888888(%.1f%%)|r", (xp / maxXP) * 100)
                end
                SetupRewardSlot(rf,
                    "Interface\\Icons\\XP_Icon",
                    xpLabel, 0.80, 0.75, 1.0, "", 0.45, 0.40, 0.70)
                PlaceSlot(rf)
            end
        end

        if money > 0 then
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if rf then
                local moneyStr = FormatMoney(money) or (math.floor(money / 10000) .. " Gold")
                SetupRewardSlot(rf,
                    "Interface\\Icons\\INV_Misc_Coin_01",
                    moneyStr, 0.94, 0.86, 0.56, "", 0.62, 0.54, 0.36)
                PlaceSlot(rf)
            end
        end

        for _, info in ipairs(currencyList) do
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if not rf then break end

            local amount = info.totalRewardAmount or info.baseRewardAmount or 0
            local countText = amount > 0 and tostring(amount) or ""
            SetupRewardSlot(rf,
                info.texture or info.iconFileID or "Interface\\Icons\\INV_Misc_Coin_01",
                info.name, 0.70, 0.85, 1.0, countText, 0.40, 0.65, 0.90)
            PlaceSlot(rf)
        end
    end

    local hasRep = (#repList > 0) or (#currencyRepList > 0)
    if hasRep then
        SetRewardColumns(2)
        PlaceSubLabel(repSubLabel, "Reputation")

        for _, info in ipairs(currencyRepList) do
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if not rf then break end

            local amount = info.totalRewardAmount or info.baseRewardAmount or 0
            local countText = amount > 0 and tostring(amount) or ""
            SetupRewardSlot(rf,
                info.texture or info.iconFileID or "Interface\\Icons\\Achievement_Reputation_01",
                info.name, 0.55, 0.80, 1.0, countText, 0.35, 0.60, 0.85)
            PlaceSlot(rf)
        end

        for _, rep in ipairs(repList) do
            slotIndex = slotIndex + 1
            local rf = rewardPool[slotIndex]
            if not rf then break end

            local countText = (rep.amount and rep.amount > 0) and tostring(rep.amount) or ""
            SetupRewardSlot(rf,
                rep.icon or "Interface\\Icons\\Achievement_Reputation_01",
                rep.name, 0.55, 0.80, 1.0, countText, 0.35, 0.60, 0.85)
            PlaceSlot(rf)
        end
    end

    local lastReward = rewardPool[slotIndex]
    if lastReward and lastReward:IsShown() then
        return lastReward
    end
    return rewardDivider
end

function UpdateScrollContentHeight()
    local maxBottom = 0
    local children = { scrollContent:GetChildren() }
    for _, child in ipairs(children) do
        local okShown, isShown = false, false
        if child and type(child.IsShown) == "function" then
            okShown, isShown = pcall(child.IsShown, child)
        end
        if okShown and isShown and type(child.GetPoint) == "function" then
            local okPoint, _, _, _, _, offsetY = pcall(child.GetPoint, child, 1)
            local okHeight, height = false, nil
            if type(child.GetHeight) == "function" then
                okHeight, height = pcall(child.GetHeight, child)
            end
            if okPoint and okHeight then
                local okBottom, bottom = pcall(function()
                    return -offsetY + height
                end)
                if okBottom and type(bottom) == "number" then
                    local okCompare, isLower = pcall(function()
                        return maxBottom < bottom
                    end)
                    if okCompare and isLower then maxBottom = bottom end
                end
            end
        end
    end
    local regions = { scrollContent:GetRegions() }
    for _, region in ipairs(regions) do
        local okShown, isShown = false, false
        if region and type(region.IsShown) == "function" then
            okShown, isShown = pcall(region.IsShown, region)
        end
        if okShown and isShown and region.GetStringHeight then
            local okPoint, _, _, _, _, offsetY = pcall(region.GetPoint, region, 1)
            if okPoint and type(offsetY) == "number" then
                local okHeight, h = pcall(region.GetStringHeight, region)
                if okHeight and type(h) == "number" then
                    local okBottom, bottom = pcall(function()
                        return -offsetY + h
                    end)
                    if okBottom and type(bottom) == "number" then
                        local okCompare, isLower = pcall(function()
                            return maxBottom < bottom
                        end)
                        if okCompare and isLower then maxBottom = bottom end
                    end
                end
            end
        end
    end

    pcall(scrollContent.SetHeight, scrollContent, math.max(maxBottom + 20, 1))
end

local function PopulateGreeting()
    ResetContent()

    local greetText = SafeCall(GetGreetingText) or ""
    local numActive = SafeCall(GetNumActiveQuests) or 0
    local numAvail  = SafeCall(GetNumAvailableQuests) or 0

    bodyTextFS:SetText(greetText)
    StartTypewriter(greetText)
    local rowIndex  = 0
    local lastAnchor = bodyTextFS

    if numActive > 0 then
        objHeaderFS:ClearAllPoints()
        objHeaderFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -20)
        objHeaderFS:SetText("Active Quests")
        objHeaderFS:Show()
        objDivider:ClearAllPoints()
        objDivider:SetPoint("TOPLEFT", objHeaderFS, "BOTTOMLEFT", 0, -4)
        objDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
        objDivider:Show()
        lastAnchor = objDivider

        for i = 1, numActive do
            rowIndex = rowIndex + 1
            local row = greetingPool[rowIndex]
            if not row then break end

            local title = SafeCall(GetActiveTitle, i) or "Quest"
            local isComplete = false
            if IsActiveQuestComplete then
                isComplete = SafeCall(IsActiveQuestComplete, i) or false
            end

            row._icon:Show()
            if row._iconAtlas then row._iconAtlas:Hide() end
            row._icon:SetText(isComplete and "?" or "?")
            if isComplete then
                row._icon:SetTextColor(C.questReady[1], C.questReady[2], C.questReady[3])
            else
                row._icon:SetTextColor(C.questActive[1], C.questActive[2], C.questActive[3])
            end
            row._title:SetText(title)
            row._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])

            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -2)
            row:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
            row:Show()
            FitGreetingRowHeight(row)
            lastAnchor = row

            local idx = i
            row:SetScript("OnClick", function()
                SafeCall(SelectActiveQuest, idx)
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_SELECT or 856)
            end)
        end
    end

    if numAvail > 0 then
        rewardHeaderFS:ClearAllPoints()
        rewardHeaderFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -20)
        rewardHeaderFS:SetText("Available Quests")
        rewardHeaderFS:Show()
        rewardDivider:ClearAllPoints()
        rewardDivider:SetPoint("TOPLEFT", rewardHeaderFS, "BOTTOMLEFT", 0, -4)
        rewardDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
        rewardDivider:Show()
        lastAnchor = rewardDivider

        for i = 1, numAvail do
            rowIndex = rowIndex + 1
            local row = greetingPool[rowIndex]
            if not row then break end

            local title = SafeCall(GetAvailableTitle, i) or "Quest"

            SetRowAvailableQuestIcon(row)
            row._title:SetText(title)
            row._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])

            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -2)
            row:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
            row:Show()
            FitGreetingRowHeight(row)
            lastAnchor = row

            local idx = i
            row:SetScript("OnClick", function()
                SafeCall(SelectAvailableQuest, idx)
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_SELECT or 856)
            end)
        end
    end

    HideAllButtons()
    btnGoodbye:ClearAllPoints()
    btnGoodbye:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
    btnGoodbye:Show()

    C_Timer.After(0.05, UpdateScrollContentHeight)
end

local function PopulateGossip()
    ResetContent()
    State.currentMode = "gossip"
    local npcName = SafeCall(UnitName, "npc") or ""

    local gossipText = nil
    if type(C_GossipInfo) == "table" and type(C_GossipInfo.GetText) == "function" then
        local okText, textValue = pcall(C_GossipInfo.GetText)
        if okText and type(textValue) == "string" then
            gossipText = textValue
        end
    end
    if IsEmptyTextSafe(gossipText) and type(GetGossipText) == "function" then
        gossipText = SafeCall(GetGossipText) or ""
    end
    if IsEmptyTextSafe(gossipText) then
        gossipText = SafeCall(GetGreetingText) or ""
    end
    if gossipText == nil then
        gossipText = ""
    end

    local activeQuests = {}
    local availableQuests = {}
    local gossipOptions = {}
    local gossipApiReady = false

    if type(C_GossipInfo) == "table" then
        if type(C_GossipInfo.GetActiveQuests) == "function" then
            local okActive, activeList = pcall(C_GossipInfo.GetActiveQuests)
            if okActive and type(activeList) == "table" then
                activeQuests = activeList
                gossipApiReady = true
            end
        end
        if type(C_GossipInfo.GetAvailableQuests) == "function" then
            local okAvail, availList = pcall(C_GossipInfo.GetAvailableQuests)
            if okAvail and type(availList) == "table" then
                availableQuests = availList
                gossipApiReady = true
            end
        end
        if type(C_GossipInfo.GetOptions) == "function" then
            local okOpts, optionsList = pcall(C_GossipInfo.GetOptions)
            if okOpts and type(optionsList) == "table" then
                gossipOptions = optionsList
                gossipApiReady = true
            end
        end
    end

    if #activeQuests == 0 then
        local numActive = SafeCall(GetNumActiveQuests) or 0
        for i = 1, numActive do
            activeQuests[#activeQuests + 1] = {
                title = SafeCall(GetActiveTitle, i),
                isComplete = (IsActiveQuestComplete and SafeCall(IsActiveQuestComplete, i)) or false,
                _legacyIndex = i,
            }
        end
    end
    if #availableQuests == 0 then
        local numAvail = SafeCall(GetNumAvailableQuests) or 0
        for i = 1, numAvail do
            availableQuests[#availableQuests + 1] = {
                title = SafeCall(GetAvailableTitle, i),
                _legacyIndex = i,
            }
        end
    end
    if #gossipOptions == 0 and type(GetGossipOptions) == "function" then
        local legacy = { SafeCall(GetGossipOptions) }
        local optionIndex = 0
        for i = 1, #legacy, 2 do
            local optionText = legacy[i]
            if type(optionText) == "string" and IsNonEmptyTextSafe(optionText) then
                optionIndex = optionIndex + 1
                gossipOptions[#gossipOptions + 1] = {
                    name = optionText,
                    _legacyIndex = optionIndex,
                }
            end
        end
    end

    local hasChoices = (#activeQuests + #availableQuests + #gossipOptions) > 0
    local gossipTextSource = "live"
    if IsNonEmptyTextSafe(gossipText) and SafeLowerText(gossipText) then
        State.lastGossipText = gossipText
        State.lastGossipNpc = npcName
        State.lastGossipTextAt = (GetTime and GetTime()) or 0
    else
        gossipTextSource = "empty"
        local now = (GetTime and GetTime()) or 0
        local age = now - (State.lastGossipTextAt or 0)
        local hasRecentCache = IsNonEmptyTextSafe(State.lastGossipText)
            and State.lastGossipNpc == npcName
            and age >= 0 and age <= 30
        if hasRecentCache then
            gossipText = State.lastGossipText
            gossipTextSource = "cached"
        end
    end

    bodyTextFS:SetText(gossipText)
    StartTypewriter(gossipText)
    local rowIndex = 0
    local lastAnchor = bodyTextFS
    local activeHeaderText = _G.ACTIVE_QUESTS or "Active Quests"
    local availableHeaderText = _G.AVAILABLE_QUESTS or "Available Quests"
    local optionsHeaderText = _G.GOSSIP_OPTIONS or "Conversation"

    if #activeQuests > 0 then
        objHeaderFS:ClearAllPoints()
        objHeaderFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -20)
        objHeaderFS:SetText(activeHeaderText)
        objHeaderFS:Show()
        objDivider:ClearAllPoints()
        objDivider:SetPoint("TOPLEFT", objHeaderFS, "BOTTOMLEFT", 0, -4)
        objDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
        objDivider:Show()
        lastAnchor = objDivider

        for i = 1, #activeQuests do
            rowIndex = rowIndex + 1
            local row = greetingPool[rowIndex]
            if not row then break end
            local entry = activeQuests[i] or {}
            local title = entry.title or "Quest"
            local isComplete = entry.isComplete and true or false
            local questID = tonumber(entry.questID) or tonumber(entry.questId)

            row._icon:Show()
            if row._iconAtlas then row._iconAtlas:Hide() end
            row._icon:SetText("?")
            if isComplete then
                row._icon:SetTextColor(C.questReady[1], C.questReady[2], C.questReady[3])
            else
                row._icon:SetTextColor(C.questActive[1], C.questActive[2], C.questActive[3])
            end
            row._title:SetText(title)
            row._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -2)
            row:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
            row:Show()
            FitGreetingRowHeight(row)
            lastAnchor = row

            local legacyIndex = tonumber(entry._legacyIndex) or i
            row:SetScript("OnClick", function()
                local okSelect = false
                if type(C_GossipInfo) == "table"
                   and type(C_GossipInfo.SelectActiveQuest) == "function"
                   and questID then
                    okSelect = pcall(C_GossipInfo.SelectActiveQuest, questID)
                end
                if not okSelect and type(SelectActiveQuest) == "function" then
                    okSelect = pcall(SelectActiveQuest, legacyIndex)
                end
                if not okSelect and type(SelectGossipActiveQuest) == "function" then
                    okSelect = pcall(SelectGossipActiveQuest, legacyIndex)
                end
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_SELECT or 856)
            end)
        end
    end

    if #availableQuests > 0 then
        rewardHeaderFS:ClearAllPoints()
        rewardHeaderFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -20)
        rewardHeaderFS:SetText(availableHeaderText)
        rewardHeaderFS:Show()
        rewardDivider:ClearAllPoints()
        rewardDivider:SetPoint("TOPLEFT", rewardHeaderFS, "BOTTOMLEFT", 0, -4)
        rewardDivider:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
        rewardDivider:Show()
        lastAnchor = rewardDivider

        for i = 1, #availableQuests do
            rowIndex = rowIndex + 1
            local row = greetingPool[rowIndex]
            if not row then break end
            local entry = availableQuests[i] or {}
            local title = entry.title or "Quest"
            local questID = tonumber(entry.questID) or tonumber(entry.questId)

            SetRowAvailableQuestIcon(row)
            row._title:SetText(title)
            row._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -2)
            row:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
            row:Show()
            FitGreetingRowHeight(row)
            lastAnchor = row

            local legacyIndex = tonumber(entry._legacyIndex) or i
            row:SetScript("OnClick", function()
                local okSelect = false
                if type(C_GossipInfo) == "table"
                   and type(C_GossipInfo.SelectAvailableQuest) == "function"
                   and questID then
                    okSelect = pcall(C_GossipInfo.SelectAvailableQuest, questID)
                end
                if not okSelect and type(SelectAvailableQuest) == "function" then
                    okSelect = pcall(SelectAvailableQuest, legacyIndex)
                end
                if not okSelect and type(SelectGossipAvailableQuest) == "function" then
                    okSelect = pcall(SelectGossipAvailableQuest, legacyIndex)
                end
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_SELECT or 856)
            end)
        end
    end

    if #gossipOptions > 0 then
        reqHeaderFS:ClearAllPoints()
        reqHeaderFS:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -20)
        reqHeaderFS:SetText(optionsHeaderText)
        reqHeaderFS:Show()
        lastAnchor = reqHeaderFS

        for i = 1, #gossipOptions do
            rowIndex = rowIndex + 1
            local row = greetingPool[rowIndex]
            if not row then break end
            local entry = gossipOptions[i] or {}
            local optionText = entry.name or entry.text or "Option"
            local optionID = tonumber(entry.gossipOptionID) or tonumber(entry.optionID)
            local legacyIndex = tonumber(entry._legacyIndex) or i

            SetRowGossipOptionIcon(row)
            row._title:SetText(optionText)
            row._title:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3])
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", lastAnchor, "BOTTOMLEFT", 0, -2)
            row:SetPoint("RIGHT", scrollContent, "RIGHT", -8, 0)
            row:Show()
            FitGreetingRowHeight(row)
            lastAnchor = row

            row:SetScript("OnClick", function()
                local okSelect = false
                if type(C_GossipInfo) == "table"
                   and type(C_GossipInfo.SelectOption) == "function"
                   and optionID then
                    okSelect = pcall(C_GossipInfo.SelectOption, optionID)
                end
                if not okSelect and type(SelectGossipOption) == "function" then
                    okSelect = pcall(SelectGossipOption, legacyIndex)
                end
                pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_MAINMENU_OPTION_CHECKBOX_ON or 856)
            end)
        end
    end

    HideAllButtons()
    btnGoodbye:ClearAllPoints()
    btnGoodbye:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
    btnGoodbye:Show()

    C_Timer.After(0.05, UpdateScrollContentHeight)
    return gossipText, hasChoices, gossipTextSource
end

local function PopulateDetail()
    ResetContent()
    if State.currentMode ~= "detail" then
        State.lastDetailTitle = nil
        State.lastDetailText = nil
    end
    State.currentMode = "detail"

    local title, questText = ResolveStableModeText(
        "lastDetailTitle",
        "lastDetailText",
        SafeCall(GetTitleText),
        SafeCall(GetQuestText),
        "Quest"
    )

    titleText:SetText(title)
    StartTypewriter(questText)

    local anchor = bodyTextFS
    anchor = PopulateObjectives(anchor)
    anchor = PopulateRewards(anchor, "detail")

    HideAllButtons()
    btnAccept:Show()
    btnDecline:Show()

    if QuestGetAutoAccept and SafeCall(QuestGetAutoAccept) then
        btnAccept._label:SetText("Continue")
        btnAccept:SetScript("OnClick", function()
            if AcknowledgeAutoAcceptQuest then SafeCall(AcknowledgeAutoAcceptQuest) end
            SafeCall(CloseQuest)
        end)
        btnDecline:Hide()
    else
        btnAccept._label:SetText("Accept Quest")
        btnAccept:SetScript("OnClick", function()
            local questName = SafeCall(GetTitleText) or ""
            SafeCall(AcceptQuest)
            pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LOG_OPEN or 844)
            TriggerQuestAcceptedBanner(questName)
        end)
    end

    C_Timer.After(0.05, UpdateScrollContentHeight)
end

local function PopulateProgress()
    ResetContent()
    if State.currentMode ~= "progress" then
        State.lastProgressTitle = nil
        State.lastProgressText = nil
    end
    State.currentMode = "progress"

    local title, progText = ResolveStableModeText(
        "lastProgressTitle",
        "lastProgressText",
        SafeCall(GetTitleText),
        SafeCall(GetProgressText),
        "Quest"
    )

    titleText:SetText(title)
    StartTypewriter(progText)

    local anchor = bodyTextFS
    anchor = PopulateRequiredItems(anchor)

    HideAllButtons()
    local completable = SafeCall(IsQuestCompletable)
    if completable then
        btnContinue:Show()
        btnGoodbye:ClearAllPoints()
        btnGoodbye:SetPoint("RIGHT", btnContinue, "LEFT", -10, 0)
        btnGoodbye:Show()
    else
        btnGoodbye:ClearAllPoints()
        btnGoodbye:SetPoint("BOTTOMRIGHT", contentPanel, "BOTTOMRIGHT", -CFG.contentPadding, 14)
        btnGoodbye:Show()
    end

    C_Timer.After(0.05, UpdateScrollContentHeight)
end

local function PopulateComplete()
    ResetContent()
    if State.currentMode ~= "complete" then
        State.lastCompleteTitle = nil
        State.lastCompleteText = nil
    end
    State.currentMode = "complete"

    local title, rewardText = ResolveStableModeText(
        "lastCompleteTitle",
        "lastCompleteText",
        SafeCall(GetTitleText),
        SafeCall(GetRewardText),
        "Quest"
    )

    titleText:SetText(title)
    StartTypewriter(rewardText)

    local anchor = bodyTextFS
    anchor = PopulateRewards(anchor, "complete")

    HideAllButtons()
    btnComplete:Show()
    btnGoodbye:ClearAllPoints()
    btnGoodbye:SetPoint("RIGHT", btnComplete, "LEFT", -10, 0)
    btnGoodbye:Show()

    local numChoices = SafeCall(GetNumQuestChoices) or 0
    if numChoices == 1 then
        State.chosenReward = 1
    end

    C_Timer.After(0.05, UpdateScrollContentHeight)
end

local function ShowQuestFrame()
    if QI and QI.ApplyFontSizes then
        QI.ApplyFontSizes()
    end

    local alreadyShown = QI:IsShown() and State.fadeAlpha > 0

    SetupModel()
    RestoreContentAnchors()
    if not alreadyShown then
        QI:SetAlpha(0)
    end
    QI:Show()
    if QI.Raise then
        QI:Raise()
    end

    local fallbackW = CFG.frameWidth - CFG.modelWidth - (CFG.contentPadding * 2)
    local w = SafeFrameNumber(scrollFrame, scrollFrame.GetWidth) or fallbackW
    pcall(scrollContent.SetWidth, scrollContent, w)

    C_Timer.After(0, function()
        local w2 = SafeFrameNumber(scrollFrame, scrollFrame.GetWidth) or fallbackW
        pcall(scrollContent.SetWidth, scrollContent, w2)
        UpdateScrollContentHeight()
    end)

    if alreadyShown then
        State.fadeAlpha = 1
        State.fadeDir = 0
        State.fadeDone = nil
        QI:SetAlpha(1)
    else
        State.fadeAlpha = 0
        State.fadeDir = 1
        State.fadeDone = nil
        pcall(PlaySound, SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_OPEN or 844)
    end
end

local function HideQuestFrame(immediate)
    if immediate then
        QI:Hide()
        QI:SetAlpha(0)
        State.fadeDir = 0
        State.fadeAlpha = 0
        return
    end
    if not QI:IsShown() then return end
    State.fadeDir = -1
    State.fadeDone = function()
        QI:Hide()
        QI:SetAlpha(0)
    end
end

-- ============================================================
-- SECTION: OnUpdate — Fade + Typewriter + Glow Pulse
-- READS: State.fadeDir/fadeAlpha, typewriterAtoms/cursor,
--   CFG timing constants
-- WRITES: QI:SetAlpha, body text reveal, glow texture alpha
-- OnUpdate runs every frame while QI is shown.
-- Three concurrent animations:
--   1. Fade in/out: State.fadeDir (+1/-1) drives alpha 0→1 or
--      1→0; sets QI:SetAlpha; triggers callback on complete.
--   2. Typewriter: reveals typewriterAtoms one atom per tick
--      at CFG.typewriterSpeed chars/sec; updates body text.
--   3. Glow pulse: a sinusoidal alpha pulse on the backdrop
--      glow texture; period ~2 s, amplitude 0.06–0.28.
-- ============================================================
QI:SetScript("OnUpdate", function(self, elapsed)
    if not elapsed or elapsed <= 0 then return end

    if State.fadeDir ~= 0 then
        local dur = State.fadeDir > 0 and CFG.fadeInDuration or CFG.fadeOutDuration
        State.fadeAlpha = State.fadeAlpha + (State.fadeDir * elapsed / dur)
        if State.fadeAlpha >= 1 then
            State.fadeAlpha = 1
            State.fadeDir = 0
        elseif State.fadeAlpha <= 0 then
            State.fadeAlpha = 0
            State.fadeDir = 0
            if State.fadeDone then
                State.fadeDone()
                State.fadeDone = nil
            end
            return
        end
        self:SetAlpha(Smoothstep(State.fadeAlpha))
    end

    if not State.typewriterDone then
        if State.talkAnimWanted and npcModel:IsShown() then
            State.talkAnimRetry = State.talkAnimRetry - elapsed
            if State.talkAnimRetry <= 0 then
                if not State.talkAnimID then
                    local resolvedID, resolvedIndex, resolvedSupported = ResolveNPCTalkAnimation()
                    State.talkAnimID = resolvedID
                    State.talkAnimIndex = resolvedIndex or 1
                    State.talkAnimNoState = not resolvedSupported
                end
                local isActive, currentAnimID, reason = IsNPCTalkAnimationActive()
                if isActive then
                    State.talkAnimApplied = true
                    if State.talkAnimNoState or reason == "no_GetAnimation" then
                        State.talkAnimRefreshes = (State.talkAnimRefreshes or 0) + 1
                        if (State.talkAnimRefreshes % 2) == 0 then
                            State.talkAnimIndex = State.talkAnimIndex + 1
                            if State.talkAnimIndex > #NPC_ANIM_TALK_CANDIDATES then
                                State.talkAnimIndex = 1
                            end
                            State.talkAnimID = NPC_ANIM_TALK_CANDIDATES[State.talkAnimIndex]
                        end
                        local refreshed = SetNPCAnimationSafe(State.talkAnimID, "tick-refresh")
                        if refreshed then
                            State.talkAnimRetry = NPC_ANIM_NOSTATE_REAPPLY_SECONDS
                            local afterAnimID, afterReason = GetNPCAnimationSafe()
                            local refreshReason = ((State.talkAnimRefreshes % 2) == 0)
                                and "reapplied_rotated" or "reapplied_same"
                        else
                            State.talkAnimApplied = false
                            State.talkAnimRetry = NPC_ANIM_APPLY_RETRY_SECONDS
                            local failAnimID, failReason = GetNPCAnimationSafe()
                        end
                    else
                        State.talkAnimRetry = NPC_ANIM_STATE_POLL_SECONDS
                    end
                else
                    local applied = SetNPCAnimationSafe(State.talkAnimID, "tick")
                    if applied then
                        State.talkAnimApplied = true
                        State.talkAnimRefreshes = 0
                        if State.talkAnimNoState or reason == "no_GetAnimation" then
                            State.talkAnimRetry = NPC_ANIM_NOSTATE_REAPPLY_SECONDS
                        else
                            State.talkAnimRetry = NPC_ANIM_STATE_POLL_SECONDS
                        end
                        local afterAnimID, afterReason = GetNPCAnimationSafe()
                    else
                        State.talkAnimApplied = false
                        State.talkAnimRetry = NPC_ANIM_APPLY_RETRY_SECONDS
                        local failAnimID, failReason = GetNPCAnimationSafe()
                    end
                end
            end
        end
        if State.typewriterDelay > 0 then
            State.typewriterDelay = State.typewriterDelay - elapsed
        else
            State.typewriterTimer = State.typewriterTimer + elapsed
            local charsToShow = math.floor(State.typewriterTimer * CFG.typewriterRate)
            if charsToShow > State.typewriterVisCur then
                State.typewriterVisCur = math.min(charsToShow, State.typewriterVisTotal)
                local display = Typewriter.Build(State.typewriterAtoms, State.typewriterVisCur)
                bodyTextFS:SetText(display)

                C_Timer.After(0, function()
                    local okTextH, textH = pcall(bodyTextFS.GetStringHeight, bodyTextFS)
                    local okScrollH, scrollH = pcall(scrollFrame.GetHeight, scrollFrame)
                    if okTextH and okScrollH and type(textH) == "number" and type(scrollH) == "number" then
                        local okCompare, shouldScroll = pcall(function()
                            return textH > scrollH
                        end)
                        if okCompare and shouldScroll then
                            local okTarget, target = pcall(function()
                                return textH - scrollH
                            end)
                            if okTarget and type(target) == "number" then
                                State.scrollTarget = target
                            end
                        end
                    end
                    UpdateScrollContentHeight()
                end)
            end
            if State.typewriterVisCur >= State.typewriterVisTotal then
                StopTypewriter("done")
                bodyTextFS:SetText(Typewriter.Build(State.typewriterAtoms, State.typewriterVisTotal))
                C_Timer.After(0.05, UpdateScrollContentHeight)
            end
        end
    end

    local okScrollStep, shouldScroll = pcall(function()
        return math.abs(State.scrollTarget - State.scrollCurrent) > 0.5
    end)
    if okScrollStep and shouldScroll then
        local okNewScroll, newScroll = pcall(function()
            return State.scrollCurrent
                + (State.scrollTarget - State.scrollCurrent) * math.min(1, elapsed * 10)
        end)
        if okNewScroll and type(newScroll) == "number" then
            State.scrollCurrent = newScroll
            pcall(scrollFrame.SetVerticalScroll, scrollFrame, State.scrollCurrent)
        end
    end

    local maxScroll = SafeFrameNumber(scrollFrame, scrollFrame.GetVerticalScrollRange)
    local scrollH = SafeFrameNumber(scrollFrame, scrollFrame.GetHeight)
    if maxScroll and scrollH then
        scrollTrack:Show()
        scrollThumb:Show()
        local ratio = scrollH / (scrollH + maxScroll)
        local thumbH = math.max(20, scrollH * ratio)
        scrollThumb:SetHeight(thumbH)
        local pos = State.scrollCurrent / maxScroll
        local travel = scrollH - thumbH
        scrollThumb:ClearAllPoints()
        scrollThumb:SetPoint("TOPRIGHT", scrollFrame, "TOPRIGHT", -1, -(pos * travel))
    else
        scrollTrack:Hide()
        scrollThumb:Hide()
    end

    if modelPanel:IsShown() and npcModel:IsShown() then
        State.modelDrift = State.modelDrift + elapsed
        local sway = math.sin(State.modelDrift * 0.6) * 0.015
        npcModel:SetFacing(CFG.modelFacing + sway)

        local glowPulse = 0.14 + math.sin(State.modelDrift * 1.2) * 0.04
        glowCenter:SetVertexColor(
            C.glowAmbient[1], C.glowAmbient[2], C.glowAmbient[3],
            math.max(0.06, math.min(0.28, glowPulse)))
    end
end)

-- ============================================================
-- SECTION: Event Handler + Banner + Public API + Font Settings
-- READS: event payloads, State, MidnightUISettings
-- WRITES: State.currentMode, bannerFrame state, QI visibility
-- EventFrame OnEvent:
--   QUEST_DETAIL/PROGRESS/COMPLETE → populate + show QI.
--   GOSSIP_SHOW/CLOSED → gossip panel or hide.
--   PLAYER_LOGIN → SuppressDefaultQuestFrame.
-- bannerFrame: "Quest Accepted" floating notification anchored
--   to top of UIParent. Plays fade-in (0.4s) → hold (1.5s)
--   → fade-out (0.5s) via C_Timer.After chain.
-- Public API:
--   QI.GetState() → State.currentMode
--   QI.ForceClose() → HideQuestFrame(true)
--   QI.ApplyFontSizes = ApplyQuestFontSizes
-- ApplyQuestFontSizes reads MidnightUISettings.QuestInterface
--   for user-configured body/title font sizes; called from
--   Settings_UI after slider changes.
-- ============================================================
local EventFrame = CreateFrame("Frame")

local function SuppressDefaultQuestFrame()
    if QuestFrame then
        QuestFrame:SetAlpha(0)
        QuestFrame:EnableMouse(false)
        if not QuestFrame._muiStashed then
            QuestFrame._muiStashed = true
            QuestFrame:ClearAllPoints()
            QuestFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -9999, 9999)
        end
    end
end

local function RestoreDefaultQuestFrame()
    if QuestFrame and QuestFrame._muiStashed then
        QuestFrame._muiStashed = nil
        QuestFrame:SetAlpha(1)
        QuestFrame:EnableMouse(true)
    end
end

local function SuppressDefaultGossipFrame()
    if type(GossipFrame) == "table" and type(GossipFrame.SetAlpha) == "function" then
        GossipFrame:SetAlpha(0)
        if type(GossipFrame.EnableMouse) == "function" then
            GossipFrame:EnableMouse(false)
        end
        if not GossipFrame._muiStashed
            and type(GossipFrame.ClearAllPoints) == "function"
            and type(GossipFrame.SetPoint) == "function" then
            GossipFrame._muiStashed = true
            GossipFrame._muiStashedPoints = {}
            if type(GossipFrame.GetNumPoints) == "function" and type(GossipFrame.GetPoint) == "function" then
                for i = 1, GossipFrame:GetNumPoints() do
                    local point, relativeTo, relativePoint, xOfs, yOfs = GossipFrame:GetPoint(i)
                    GossipFrame._muiStashedPoints[i] = { point, relativeTo, relativePoint, xOfs, yOfs }
                end
            end
            GossipFrame:ClearAllPoints()
            GossipFrame:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -9999, 9999)
        end
    end
end

local function RestoreDefaultGossipFrame()
    if type(GossipFrame) == "table" then
        if GossipFrame._muiStashed then
            GossipFrame._muiStashed = nil
            if type(GossipFrame.ClearAllPoints) == "function" and type(GossipFrame.SetPoint) == "function" then
                GossipFrame:ClearAllPoints()
                local restored = false
                local points = GossipFrame._muiStashedPoints
                if type(points) == "table" then
                    for i = 1, #points do
                        local p = points[i]
                        if type(p) == "table" and p[1] then
                            local okPoint
                            if p[2] then
                                okPoint = pcall(GossipFrame.SetPoint, GossipFrame, p[1], p[2], p[3] or p[1], p[4] or 0, p[5] or 0)
                            else
                                okPoint = pcall(GossipFrame.SetPoint, GossipFrame, p[1], UIParent, p[3] or p[1], p[4] or 0, p[5] or 0)
                            end
                            restored = restored or okPoint
                        end
                    end
                end
                if not restored then
                    GossipFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
                end
            end
            GossipFrame._muiStashedPoints = nil
        end
        if type(GossipFrame.SetAlpha) == "function" then
            GossipFrame:SetAlpha(1)
        end
        if type(GossipFrame.EnableMouse) == "function" then
            GossipFrame:EnableMouse(true)
        end
    end
end

local GOSSIP_REFRESH_DELAYS = { 0.15, 0.40, 0.80, 1.20, 1.80, 2.40 }

local function QueueGossipRefresh(reason, attempt, nonce)
    local idx = tonumber(attempt) or 1
    local delay = GOSSIP_REFRESH_DELAYS[idx]
    if not delay then
        return
    end
    C_Timer.After(delay, function()
        if nonce ~= State.gossipRefreshNonce then return end
        if State.currentMode ~= "gossip" then return end
        if not QI:IsShown() then return end

        local _, _, textSource = PopulateGossip()
        ShowQuestFrame()
        SuppressDefaultGossipFrame()
        if textSource ~= "live" then
            QueueGossipRefresh(reason, idx + 1, nonce)
        end
    end)
end

local function OnQuestDetail()
    if State.declining then
        State.declining = nil
        SafeCall(CloseQuest)
        return
    end
    PopulateDetail()
    ShowQuestFrame()
    SuppressDefaultQuestFrame()
end

local function OnQuestProgress()
    if State.declining then
        State.declining = nil
        SafeCall(CloseQuest)
        return
    end
    PopulateProgress()
    ShowQuestFrame()
    SuppressDefaultQuestFrame()
end

local function OnQuestComplete()
    if State.declining then
        State.declining = nil
        SafeCall(CloseQuest)
        return
    end
    PopulateComplete()
    ShowQuestFrame()
    SuppressDefaultQuestFrame()
end

local function OnQuestGreeting()
    if State.declining then
        State.declining = nil
        SafeCall(CloseQuest)
        return
    end
    State.currentMode = "greeting"
    titleText:SetText(SafeCall(UnitName, "npc") or "")
    PopulateGreeting()
    ShowQuestFrame()
    SuppressDefaultQuestFrame()
end

local function OnGossipShow()
    if State.declining then
        State.declining = nil
        if type(C_GossipInfo) == "table" and type(C_GossipInfo.CloseGossip) == "function" then
            pcall(C_GossipInfo.CloseGossip)
        elseif type(CloseGossip) == "function" then
            pcall(CloseGossip)
        else
            SafeCall(CloseQuest)
        end
        return
    end

    State.useBlizzardGossip = ShouldUseBlizzardGossipFrame()
    if State.useBlizzardGossip then
        State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
        RestoreDefaultGossipFrame()
        if State.currentMode == "gossip" or QI:IsShown() then
            HideQuestFrame(true)
        end
        State.currentMode = nil
        ForceTypewriterDone()
        State.fadeDir = 0
        return
    end

    if C_GossipInfo and C_GossipInfo.GetOptions and C_GossipInfo.SelectOption then
        local ok, options = pcall(C_GossipInfo.GetOptions)
        local numActive = C_GossipInfo.GetNumActiveQuests and C_GossipInfo.GetNumActiveQuests() or 0
        local numAvail = C_GossipInfo.GetNumAvailableQuests and C_GossipInfo.GetNumAvailableQuests() or 0
        local optCount = (ok and options) and #options or -1
        local isDelveSupplies = IsDelveSuppliesGossip(ok and options or nil)
        if numActive == 0 and numAvail == 0 then
            if optCount == 0 and not isDelveSupplies then
                State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
                RestoreDefaultGossipFrame()
                if State.currentMode == "gossip" or QI:IsShown() then
                    HideQuestFrame(true)
                end
                State.currentMode = nil
                ForceTypewriterDone()
                State.fadeDir = 0
                return
            elseif optCount == 1 and not isDelveSupplies then
                pcall(C_GossipInfo.SelectOption, options[1].gossipOptionID or 1)
                return
            end
        end
    end
    State.useBlizzardGossip = ShouldUseBlizzardGossipFrame()
    if State.useBlizzardGossip then
        State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
        RestoreDefaultGossipFrame()
        if State.currentMode == "gossip" or QI:IsShown() then
            HideQuestFrame(true)
        end
        State.currentMode = nil
        ForceTypewriterDone()
        State.fadeDir = 0
        return
    end
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
    local refreshNonce = State.gossipRefreshNonce
    titleText:SetText(SafeCall(UnitName, "npc") or "")
    local _, _, textSource = PopulateGossip()
    ShowQuestFrame()
    SuppressDefaultGossipFrame()
    if textSource ~= "live" then
        QueueGossipRefresh("show", 1, refreshNonce)
    end
end

local function OnCinematicStart()
    if GossipFrame and GossipFrame:IsShown() then
        State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
    end
end

local function OnCinematicStop()
    local s = _G.MidnightUISettings
    if type(s) == "table" and type(s.General) == "table"
       and s.General.useBlizzardQuestingInterface == true then
        return
    end
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1

    if State.currentMode == "gossip" or QI:IsShown() then
        RestoreDefaultGossipFrame()
        HideQuestFrame(true)
        State.currentMode = nil
        ForceTypewriterDone()
    end
end

local function OnMovieStart()
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1

    if State.currentMode == "gossip" or QI:IsShown() then
        RestoreDefaultGossipFrame()
        HideQuestFrame(true)
        State.currentMode = nil
        ForceTypewriterDone()
    end
end

local function OnMovieStop()
    OnCinematicStop()
end

local function OnGossipClosed()
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
    State.useBlizzardGossip = false
    RestoreDefaultGossipFrame()
    if State.currentMode == "gossip" then
        HideQuestFrame(false)
    end
end

local function OnQuestFinished()
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
    RestoreDefaultQuestFrame()
    RestoreDefaultGossipFrame()
    HideQuestFrame(false)
    if State.declining then
        C_Timer.After(0.5, function() State.declining = nil end)
    end
end

local function OnQuestItemUpdate()
    if not QI:IsShown() then return end
    if State.currentMode == "detail" then
        PopulateDetail()
    elseif State.currentMode == "complete" then
        PopulateComplete()
    elseif State.currentMode == "progress" then
        PopulateProgress()
    end
end

EventFrame:SetScript("OnEvent", function(self, event, ...)
    if event ~= "PLAYER_LOGIN" and UseBlizzardQuestingInterface() then
        if QI:IsShown() or State.currentMode ~= nil then
            State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
            RestoreDefaultQuestFrame()
            RestoreDefaultGossipFrame()
            HideQuestFrame(true)
            State.currentMode = nil
            ForceTypewriterDone()
            State.fadeDir = 0
        end
        return
    end

    if event == "QUEST_DETAIL" then
        OnQuestDetail()
    elseif event == "QUEST_PROGRESS" then
        OnQuestProgress()
    elseif event == "QUEST_COMPLETE" then
        OnQuestComplete()
    elseif event == "QUEST_GREETING" then
        OnQuestGreeting()
    elseif event == "QUEST_FINISHED" then
        OnQuestFinished()
    elseif event == "QUEST_ITEM_UPDATE" then
        OnQuestItemUpdate()
    elseif event == "GOSSIP_SHOW" then
        OnGossipShow()
    elseif event == "GOSSIP_CLOSED" then
        OnGossipClosed()
    elseif event == "CINEMATIC_START" then
        OnCinematicStart()
    elseif event == "CINEMATIC_STOP" then
        OnCinematicStop()
    elseif event == "PLAY_MOVIE" then
        OnMovieStart(...)
    elseif event == "STOP_MOVIE" then
        OnMovieStop(...)
    elseif event == "PLAYER_LOGIN" then
        self:UnregisterEvent("PLAYER_LOGIN")
        if UseBlizzardQuestingInterface() then
            RestoreDefaultQuestFrame()
            RestoreDefaultGossipFrame()
            HideQuestFrame(true)
            State.currentMode = nil
            ForceTypewriterDone()
            State.fadeDir = 0

            self:UnregisterEvent("QUEST_DETAIL")
            self:UnregisterEvent("QUEST_PROGRESS")
            self:UnregisterEvent("QUEST_COMPLETE")
            self:UnregisterEvent("QUEST_GREETING")
            self:UnregisterEvent("QUEST_FINISHED")
            self:UnregisterEvent("QUEST_ITEM_UPDATE")
            self:UnregisterEvent("GOSSIP_SHOW")
            self:UnregisterEvent("GOSSIP_CLOSED")
            self:UnregisterEvent("CINEMATIC_START")
            self:UnregisterEvent("CINEMATIC_STOP")
            self:UnregisterEvent("PLAY_MOVIE")
            self:UnregisterEvent("STOP_MOVIE")
            return
        end

        if QuestFrame then
            QuestFrame:HookScript("OnShow", function(f)
                local s = _G.MidnightUISettings
                if type(s) == "table" and type(s.General) == "table"
                   and s.General.useBlizzardQuestingInterface == true then
                    return
                end
                SuppressDefaultQuestFrame()
            end)
        end

        if GossipFrame then
            GossipFrame:HookScript("OnShow", function(f)
                local s = _G.MidnightUISettings
                if type(s) == "table" and type(s.General) == "table"
                   and s.General.useBlizzardQuestingInterface == true then
                    return
                end
                if ShouldUseBlizzardGossipFrame() then
                    State.useBlizzardGossip = true
                    RestoreDefaultGossipFrame()
                    if State.currentMode == "gossip" or QI:IsShown() then
                        HideQuestFrame(true)
                    end
                    return
                end
                State.useBlizzardGossip = false
                SuppressDefaultGossipFrame()
            end)
        end
    end
end)

EventFrame:RegisterEvent("PLAYER_LOGIN")
EventFrame:RegisterEvent("QUEST_DETAIL")
EventFrame:RegisterEvent("QUEST_PROGRESS")
EventFrame:RegisterEvent("QUEST_COMPLETE")
EventFrame:RegisterEvent("QUEST_GREETING")
EventFrame:RegisterEvent("QUEST_FINISHED")
EventFrame:RegisterEvent("QUEST_ITEM_UPDATE")
EventFrame:RegisterEvent("GOSSIP_SHOW")
EventFrame:RegisterEvent("GOSSIP_CLOSED")
EventFrame:RegisterEvent("CINEMATIC_START")
EventFrame:RegisterEvent("CINEMATIC_STOP")
EventFrame:RegisterEvent("PLAY_MOVIE")
EventFrame:RegisterEvent("STOP_MOVIE")

QI:SetScript("OnShow", function()
    local found = false
    for _, name in ipairs(UISpecialFrames) do
        if name == "MidnightUI_QuestInterface" then
            found = true
            break
        end
    end
    if not found then
        table.insert(UISpecialFrames, "MidnightUI_QuestInterface")
    end
end)

QI:SetScript("OnHide", function()
    ResetContent()
    State.gossipRefreshNonce = (State.gossipRefreshNonce or 0) + 1
    RestoreDefaultQuestFrame()
    RestoreDefaultGossipFrame()
    State.currentMode = nil
    ForceTypewriterDone()
    State.fadeDir = 0
end)


local classBannerAtlas = {
    HUNTER      = "UI-Centaur-Highlight-Bottom",
    ROGUE       = "UI-Centaur-Highlight-Bottom",
    EVOKER      = "UI-Dream-Highlight-Bottom",
    MONK        = "UI-Dream-Highlight-Bottom",
    DEATHKNIGHT = "UI-Expedition-Highlight-Bottom",
    PALADIN     = "UI-Expedition-Highlight-Bottom",
    WARRIOR     = "UI-Niffen-Highlight-Bottom",
    DRUID       = "UI-Niffen-Highlight-Bottom",
    MAGE        = "ui-plunderstorm-highlight-bottom",
    SHAMAN      = "UI-Tuskarr-Highlight-Bottom",
    DEMONHUNTER = "UI-Valdrakken-Highlight-Bottom",
    PRIEST      = "UI-Valdrakken-Highlight-Bottom",
    WARLOCK     = "UI-Valdrakken-Highlight-Bottom",
}
local function GetBannerAtlas()
    local _, pc = UnitClass("player")
    return classBannerAtlas[pc] or "UI-Valdrakken-Highlight-Bottom"
end

local bannerFrame = CreateFrame("Frame", nil, UIParent)
bannerFrame:SetSize(798, 228)
bannerFrame:SetPoint("TOP", UIParent, "TOP", 0, -72)
bannerFrame:SetFrameStrata("TOOLTIP")
bannerFrame:SetAlpha(0)
bannerFrame:Hide()

local bannerBg = bannerFrame:CreateTexture(nil, "BACKGROUND")
bannerBg:SetAllPoints()
bannerBg:SetAlpha(0.65)

local bannerText = bannerFrame:CreateFontString(nil, "OVERLAY")
TrySetFont(bannerText, TITLE_FONT, 28, "OUTLINE")
bannerText:SetPoint("CENTER", bannerFrame, "CENTER", 0, 8)
bannerText:SetText("Quest Accepted")
bannerText:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
bannerText:SetShadowColor(0, 0, 0, 0.8)
bannerText:SetShadowOffset(2, -2)

local bannerSubtitle = bannerFrame:CreateFontString(nil, "OVERLAY")
TrySetFont(bannerSubtitle, BODY_FONT, 15, "OUTLINE")
bannerSubtitle:SetPoint("TOP", bannerText, "BOTTOM", 0, -14)
bannerSubtitle:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3], 0.9)
bannerSubtitle:SetShadowColor(0, 0, 0, 0.8)
bannerSubtitle:SetShadowOffset(1, -1)
bannerSubtitle:SetText("")

local bannerState = {
    active   = false,
    timer    = 0,
    holdTime = 1.8,
    fadeIn   = 0.35,
    fadeOut  = 0.60,
    phase    = "idle",
}

function ShowQuestAcceptedBanner(questName)
    bannerState.active = true
    bannerState.timer = 0
    bannerState.phase = "fadein"
    bannerFrame:SetAlpha(0)
    bannerFrame:Show()
    bannerSubtitle:SetText(questName or "")
    if bannerBg.SetAtlas then
        pcall(bannerBg.SetAtlas, bannerBg, GetBannerAtlas(), false)
    end
end

bannerFrame:SetScript("OnUpdate", function(self, elapsed)
    if not bannerState.active then return end
    bannerState.timer = bannerState.timer + elapsed

    if bannerState.phase == "fadein" then
        local p = math.min(1, bannerState.timer / bannerState.fadeIn)
        self:SetAlpha(Smoothstep(p))
        if p >= 1 then
            bannerState.phase = "hold"
            bannerState.timer = 0
        end
    elseif bannerState.phase == "hold" then
        self:SetAlpha(1)
        if bannerState.timer >= bannerState.holdTime then
            bannerState.phase = "fadeout"
            bannerState.timer = 0
        end
    elseif bannerState.phase == "fadeout" then
        local p = math.min(1, bannerState.timer / bannerState.fadeOut)
        self:SetAlpha(1 - Smoothstep(p))
        if p >= 1 then
            bannerState.active = false
            bannerState.phase = "idle"
            self:SetAlpha(0)
            self:Hide()
        end
    end
end)


local completeBannerFrame = CreateFrame("Frame", nil, UIParent)
completeBannerFrame:SetSize(798, 228)
completeBannerFrame:SetPoint("TOP", UIParent, "TOP", 0, -72)
completeBannerFrame:SetFrameStrata("TOOLTIP")
completeBannerFrame:SetAlpha(0)
completeBannerFrame:Hide()

local completeBannerBg = completeBannerFrame:CreateTexture(nil, "BACKGROUND")
completeBannerBg:SetAllPoints()
completeBannerBg:SetAlpha(0.8)

local completeBannerText = completeBannerFrame:CreateFontString(nil, "OVERLAY")
TrySetFont(completeBannerText, TITLE_FONT, 28, "OUTLINE")
completeBannerText:SetPoint("CENTER", completeBannerFrame, "CENTER", 0, 8)
completeBannerText:SetText("Quest Complete")
completeBannerText:SetTextColor(C.titleText[1], C.titleText[2], C.titleText[3])
completeBannerText:SetShadowColor(0, 0, 0, 0.8)
completeBannerText:SetShadowOffset(2, -2)

local completeBannerSubtitle = completeBannerFrame:CreateFontString(nil, "OVERLAY")
TrySetFont(completeBannerSubtitle, BODY_FONT, 15, "OUTLINE")
completeBannerSubtitle:SetPoint("TOP", completeBannerText, "BOTTOM", 0, -6)
completeBannerSubtitle:SetTextColor(C.bodyText[1], C.bodyText[2], C.bodyText[3], 0.9)
completeBannerSubtitle:SetShadowColor(0, 0, 0, 0.8)
completeBannerSubtitle:SetShadowOffset(1, -1)
completeBannerSubtitle:SetText("")

local completeBannerState = {
    active   = false,
    timer    = 0,
    holdTime = 1.8,
    fadeIn   = 0.35,
    fadeOut  = 0.60,
    phase    = "idle",
}

function ShowQuestCompleteBanner(questName)
    completeBannerState.active = true
    completeBannerState.timer = 0
    completeBannerState.phase = "fadein"
    completeBannerFrame:SetAlpha(0)
    completeBannerFrame:Show()
    completeBannerSubtitle:SetText(questName or "")
    if completeBannerBg.SetAtlas then
        pcall(completeBannerBg.SetAtlas, completeBannerBg, GetBannerAtlas(), false)
    end
end

completeBannerFrame:SetScript("OnUpdate", function(self, elapsed)
    if not completeBannerState.active then return end
    completeBannerState.timer = completeBannerState.timer + elapsed

    if completeBannerState.phase == "fadein" then
        local p = math.min(1, completeBannerState.timer / completeBannerState.fadeIn)
        self:SetAlpha(Smoothstep(p))
        if p >= 1 then
            completeBannerState.phase = "hold"
            completeBannerState.timer = 0
        end
    elseif completeBannerState.phase == "hold" then
        self:SetAlpha(1)
        if completeBannerState.timer >= completeBannerState.holdTime then
            completeBannerState.phase = "fadeout"
            completeBannerState.timer = 0
        end
    elseif completeBannerState.phase == "fadeout" then
        local p = math.min(1, completeBannerState.timer / completeBannerState.fadeOut)
        self:SetAlpha(1 - Smoothstep(p))
        if p >= 1 then
            completeBannerState.active = false
            completeBannerState.phase = "idle"
            self:SetAlpha(0)
            self:Hide()
        end
    end
end)

QI.GetState    = function() return State.currentMode end
QI.ForceClose  = function() HideQuestFrame(true) end

local function ApplyQuestFontSizes()
    local s = _G.MidnightUISettings and _G.MidnightUISettings.QuestInterface
    if type(s) ~= "table" then return end

    local newTitle     = tonumber(s.titleFontSize)     or CFG.titleSize
    local newBody      = tonumber(s.bodyFontSize)      or CFG.bodySize
    local newSection   = tonumber(s.sectionFontSize)   or CFG.sectionSize
    local newObjective = tonumber(s.objectiveFontSize) or CFG.objectiveSize

    if newTitle     < 8 then newTitle     = 8 end; if newTitle     > 48 then newTitle     = 48 end
    if newBody      < 8 then newBody      = 8 end; if newBody      > 48 then newBody      = 48 end
    if newSection   < 8 then newSection   = 8 end; if newSection   > 48 then newSection   = 48 end
    if newObjective < 8 then newObjective = 8 end; if newObjective > 48 then newObjective = 48 end

    CFG.titleSize     = newTitle
    CFG.bodySize      = newBody
    CFG.sectionSize   = newSection
    CFG.objectiveSize = newObjective
    CFG.rewardRowHeight = math.max(CFG.rewardIconSize + 8, (newObjective * 2) + 14)

    local function SizeFor(kind)
        if kind == "title"     then return newTitle     end
        if kind == "section"   then return newSection   end
        if kind == "objective" then return newObjective end
        return newBody
    end

    for _, entry in ipairs(FontRegistry) do
        if entry.fs and entry.fs.SetFont then
            local size = SizeFor(entry.kind)
            if entry.fs._muiFontSizeOffset then
                size = math.max(8, size + entry.fs._muiFontSizeOffset)
            end
            pcall(entry.fs.SetFont, entry.fs, entry.font, size, entry.flags)
        end
    end

    for i = 1, #rewardPool do
        if rewardPool[i] and rewardPool[i].SetHeight then
            rewardPool[i]:SetHeight(CFG.rewardRowHeight)
        end
    end

    if QI and QI.IsShown and QI:IsShown() then
        C_Timer.After(0, function()
            if State.currentMode == "detail" then
                PopulateDetail()
            elseif State.currentMode == "progress" then
                PopulateProgress()
            elseif State.currentMode == "complete" then
                PopulateComplete()
            elseif State.currentMode == "greeting" then
                PopulateGreeting()
            elseif State.currentMode == "gossip" then
                PopulateGossip()
            else
                UpdateScrollContentHeight()
            end
        end)
    end
end

QI.ApplyFontSizes = ApplyQuestFontSizes
_G.MidnightUI_ApplyQuestInterfaceFontSizes = ApplyQuestFontSizes

ApplyQuestFontSizes()
