-- AutoQuestLearner Tracker Module
-- Rebranded from pfQuest tracker.lua

local fontsize = 12
local panelheight = 16
local entryheight = 20

-- Flicker diagnostic instrumentation. After several theory-based fixes
-- to hover/refresh handling made no observable difference, this
-- replaces further guessing with actual data: a call counter on every
-- suspect function, dumped as plain text via /aql flickerdebug. If
-- something is firing far more than a stationary hover should ever
-- produce, this will show it directly instead of needing to be seen.
AutoQuestLearner_FlickerDebug = { active = false, counts = {} }
local function FlickerTick(name)
  if not AutoQuestLearner_FlickerDebug.active then return end
  local c = AutoQuestLearner_FlickerDebug.counts
  c[name] = (c[name] or 0) + 1
end

local lastTooltipFrame, lastTooltipTime = nil, 0

local function HideTooltip()
  GameTooltip:Hide()
  lastTooltipFrame = nil
end

local function ShowTooltip()
  FlickerTick("ShowTooltip_called")
  if this.tooltip then
    -- If this exact row triggered a tooltip rebuild within the last
    -- instant, skip the full clear-and-rebuild -- GameTooltip is
    -- already showing the right content, and redoing it serves no
    -- purpose except risking a visible flash if this is firing more
    -- often than a genuine "entered a new row" event should.
    local now = GetTime()
    if lastTooltipFrame == this and (now - lastTooltipTime) < 0.05 then
      FlickerTick("ShowTooltip_debounced")
      return
    end
    FlickerTick("ShowTooltip_rebuilt")
    lastTooltipFrame = this
    lastTooltipTime = now

    GameTooltip:ClearLines()
    GameTooltip_SetDefaultAnchor(GameTooltip, this)
    if this.text then
      GameTooltip:SetText(this.text:GetText())
      GameTooltip:SetText(this.text:GetText(), this.text:GetTextColor())
    else
      GameTooltip:SetText("|cff00ff00AutoQuestLearner|r")
    end

    if this.node and this.node.questid then
      if AutoQuestLearnerDB and AutoQuestLearnerData.quests and AutoQuestLearnerData.quests["loc"] and AutoQuestLearnerData.quests["loc"][this.node.questid] and AutoQuestLearnerData.quests["loc"][this.node.questid]["O"] then
        GameTooltip:AddLine(AutoQuestLearnerData.quests["loc"][this.node.questid]["O"], 1,1,1,1)
        GameTooltip:AddLine(" ")
      end

      local qlogid = AutoQuestLearner.questlog and AutoQuestLearner.questlog[this.node.questid] and AutoQuestLearner.questlog[this.node.questid].qlogid
      if qlogid then
        local objectives = GetNumQuestLeaderBoards(qlogid)
        if objectives and objectives > 0 then
          for i=1, objectives, 1 do
            local text, _, done = GetQuestLogLeaderBoard(i, qlogid)
            local _, _, obj, cur, req = strfind(gsub(text, "\239\188\154", ":"), "(.*):%s*([%d]+)%s*/%s*([%d]+)")
            if done then
              GameTooltip:AddLine(" - " .. text, 0,1,0)
            elseif cur and req then
              local r,g,b = AutoQuestLearnerMap.tooltip:GetColor(cur, req)
              GameTooltip:AddLine(" - " .. text, r,g,b)
            else
              GameTooltip:AddLine(" - " .. text, 1,0,0)
            end
          end
          GameTooltip:AddLine(" ")
        end
      end
    end

    GameTooltip:AddLine(this.tooltip, 1,1,1)
    GameTooltip:Show()
  end
end

local expand_states = {}

AutoQuestLearnerTracker = CreateFrame("Frame", "AutoQuestLearnerMapTracker", UIParent)
AutoQuestLearnerTracker:Hide()
AutoQuestLearnerTracker:SetPoint("LEFT", UIParent, "LEFT", 0, 0)
AutoQuestLearnerTracker:SetWidth(200)
AutoQuestLearnerTracker:SetHeight(400)
AutoQuestLearnerTracker:SetMovable(true)
AutoQuestLearnerTracker:SetResizable(true)
AutoQuestLearnerTracker:SetMinResize(150, 80)
AutoQuestLearnerTracker:SetMaxResize(500, 900)
AutoQuestLearnerTracker:EnableMouse(true)
AutoQuestLearnerTracker:SetClampedToScreen(true)

------------------------------------------------------------
-- Scrollable content area
--
-- The tracker used to grow to exactly fit however many quests were
-- tracked, with no upper bound -- a long quest list could take up
-- most of the screen. Now the tracker frame itself is a fixed (but
-- user-resizable) viewport; quest buttons live in a scrollable content
-- frame inside it, with a slider down the right edge when there's more
-- content than fits.
------------------------------------------------------------
AutoQuestLearnerTracker.scrollframe = CreateFrame("ScrollFrame", "AutoQuestLearnerTrackerScroll",
  AutoQuestLearnerTracker, "UIPanelScrollFrameTemplate")
AutoQuestLearnerTracker.scrollframe:SetPoint("TOPLEFT", AutoQuestLearnerTracker, "TOPLEFT", 0, -panelheight)
AutoQuestLearnerTracker.scrollframe:SetPoint("BOTTOMRIGHT", AutoQuestLearnerTracker, "BOTTOMRIGHT", -22, 16)

AutoQuestLearnerTracker.content = CreateFrame("Frame", "AutoQuestLearnerTrackerContent", AutoQuestLearnerTracker.scrollframe)
AutoQuestLearnerTracker.content:SetWidth(1)
AutoQuestLearnerTracker.content:SetHeight(1)
AutoQuestLearnerTracker.content:SetPoint("TOPLEFT", 0, 0)
AutoQuestLearnerTracker.scrollframe:SetScrollChild(AutoQuestLearnerTracker.content)

-- The user's actual intended scroll position, tracked separately from
-- the scrollbar's own live value (see UpdateScrollRange below for why).
AutoQuestLearnerTracker.userScrollValue = 0

-- Keep the scrollbar's range in sync with how much content actually
-- exists vs. how much of it is visible.
--
-- BUGFIX: this used to just clamp the scrollbar's CURRENT value down if
-- it exceeded the newly-computed maxScroll. That's a one-way operation
-- -- it never restores a HIGHER value once more room becomes available
-- again. The problem: the full layout (and this function) runs once
-- PER TRACKED QUEST during a refresh (see ButtonEvent), not once per
-- refresh as a whole -- so partway through refreshing several quests,
-- the content is briefly much shorter than its true final size, this
-- sees an artificially tiny maxScroll, and clamps the scroll position
-- down to match. That clamp then never gets reversed once the rest of
-- the quests are added back and the real (larger) maxScroll is known --
-- the net effect being the tracker visibly snapping back to the top
-- essentially every time it refreshes, which is often. Fixed by always
-- re-applying the user's actual intended scroll position (tracked
-- independently, only changed when the user themselves scrolls) against
-- whatever the CURRENT maxScroll is, rather than trusting the slider's
-- live value, which can get clobbered mid-refresh by an intermediate call.
function AutoQuestLearnerTracker:UpdateScrollRange()
  local viewHeight = AutoQuestLearnerTracker.scrollframe:GetHeight()
  local contentHeight = AutoQuestLearnerTracker.content:GetHeight()
  local maxScroll = max(0, contentHeight - viewHeight)

  local bar = getglobal("AutoQuestLearnerTrackerScrollScrollBar")
  if bar then
    bar:SetMinMaxValues(0, maxScroll)
    bar:SetValue(min(AutoQuestLearnerTracker.userScrollValue or 0, maxScroll))
    if maxScroll <= 0 then
      bar:Hide()
    else
      bar:Show()
    end
  end
end

-- Resize grip in the bottom-right corner.
AutoQuestLearnerTracker.resizeGrip = CreateFrame("Button", nil, AutoQuestLearnerTracker)
AutoQuestLearnerTracker.resizeGrip:SetWidth(16)
AutoQuestLearnerTracker.resizeGrip:SetHeight(16)
AutoQuestLearnerTracker.resizeGrip:SetPoint("BOTTOMRIGHT", AutoQuestLearnerTracker, "BOTTOMRIGHT", 0, 0)
AutoQuestLearnerTracker.resizeGrip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
AutoQuestLearnerTracker.resizeGrip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
AutoQuestLearnerTracker.resizeGrip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
AutoQuestLearnerTracker.resizeGrip:SetScript("OnMouseDown", function()
  if not AutoQuestLearnerDB.config.lock then
    AutoQuestLearnerTracker:StartSizing("BOTTOMRIGHT")
  end
end)
AutoQuestLearnerTracker.resizeGrip:SetScript("OnMouseUp", function()
  AutoQuestLearnerTracker:StopMovingOrSizing()
  AutoQuestLearnerDB.config.trackersize = {
    AutoQuestLearnerTracker:GetWidth(),
    AutoQuestLearnerTracker:GetHeight(),
  }
  AutoQuestLearnerTracker:UpdateScrollRange()
end)

AutoQuestLearnerTracker.scrollframe:EnableMouseWheel(true)
AutoQuestLearnerTracker.scrollframe:SetScript("OnMouseWheel", function()
  local bar = getglobal("AutoQuestLearnerTrackerScrollScrollBar")
  if not bar then return end
  local step = 30
  local minVal, maxVal = bar:GetMinMaxValues()
  local newVal = bar:GetValue() - (arg1 or 0) * step
  if newVal < minVal then newVal = minVal end
  if newVal > maxVal then newVal = maxVal end
  AutoQuestLearnerTracker.userScrollValue = newVal
  bar:SetValue(newVal)
end)

-- Also capture direct scrollbar dragging, not just the mouse wheel --
-- HookScript adds this alongside the template's own default handler
-- (which actually performs the visual scroll) rather than replacing it.
do
  local bar = getglobal("AutoQuestLearnerTrackerScrollScrollBar")
  if bar then
    bar:HookScript("OnValueChanged", function()
      AutoQuestLearnerTracker.userScrollValue = this:GetValue()
    end)
  end
end

AutoQuestLearnerTracker:RegisterEvent("PLAYER_ENTERING_WORLD")
AutoQuestLearnerTracker:SetScript("OnEvent", function()
  -- update font sizes according to config
  fontsize = tonumber(AutoQuestLearnerDB.config.trackerfontsize) or 12
  entryheight = ceil(fontsize*1.6)

  -- restore saved size, if any
  local size = AutoQuestLearnerDB.config.trackersize
  if size and type(size[1]) == "number" and type(size[2]) == "number" then
    AutoQuestLearnerTracker:SetWidth(size[1])
    AutoQuestLearnerTracker:SetHeight(size[2])
  end

  -- restore tracker state
  if AutoQuestLearnerDB.config.showtracker and AutoQuestLearnerDB.config.showtracker == "0" then
    this:Hide()
  else
    this:Show()
  end
end)

AutoQuestLearnerTracker:SetScript("OnMouseDown",function()
  if not AutoQuestLearnerDB.config.lock then
    this:StartMoving()
  end
end)

AutoQuestLearnerTracker:SetScript("OnMouseUp",function()
  this:StopMovingOrSizing()
  -- GetPoint() returns point, relativeTo, relativePoint, xOfs, yOfs --
  -- skip relativeTo/relativePoint, we only want the point and offsets.
  local anchor, _, _, x, y = this:GetPoint()
  this:ClearAllPoints()
  this:SetPoint(anchor, UIParent, anchor, x, y)

  -- save position
  AutoQuestLearnerDB.config.trackerpos = { anchor, x, y }
end)

AutoQuestLearnerTracker:SetScript("OnUpdate", function()
  -- Throttled to 30 times a second -- still smooth enough for the fade
  -- animation to look continuous (well above the ~24fps threshold
  -- where motion reads as fluid to begin with), while meaningfully
  -- cutting the per-frame cost of this whole block versus running
  -- completely unthrottled at 60+fps.
  if (this.panelUpdateTick or 0) > GetTime() then return end
  this.panelUpdateTick = GetTime() + 0.033

  if WorldMapFrame:IsShown() then
    if this.strata ~= "FULLSCREEN_DIALOG" then
      this:SetFrameStrata("FULLSCREEN_DIALOG")
      this.strata = "FULLSCREEN_DIALOG"
    end
  else
    if this.strata ~= "BACKGROUND" then
      this:SetFrameStrata("BACKGROUND")
      this.strata = "BACKGROUND"
    end
  end

  local alpha = this.backdrop:GetAlpha()
  local content = AutoQuestLearnerTracker.buttons[1] and not AutoQuestLearnerTracker.buttons[1].empty and true or nil
  -- Checked against both the outer tracker frame and the scrollframe
  -- (where the actual quest buttons live) -- a gap between the two
  -- hit-test boundaries, particularly near the scrollbar's reserved
  -- edge space, would otherwise make the fade target rapidly flip
  -- between visible and fading while the mouse sits in that gap,
  -- visible as a flicker while hovering over the quest list.
  local rawIsOver = MouseIsOver(this) or (AutoQuestLearnerTracker.scrollframe and MouseIsOver(AutoQuestLearnerTracker.scrollframe))

  -- Hysteresis: this polls every single tick with no smoothing, unlike
  -- the discrete OnEnter/OnLeave-based tooltip/highlight system (fixed
  -- separately). If the raw hover reading flips at an ambiguous
  -- boundary, this used to react instantly every tick, making the goal
  -- alpha alternate and the background visibly oscillate. Now the
  -- effective state only changes after several consecutive ticks agree,
  -- absorbing brief flip-flopping instead of reacting to each one.
  this.hoverConfirmCount = this.hoverConfirmCount or 0
  if rawIsOver == this.effectiveIsOver then
    this.hoverConfirmCount = 0
  else
    FlickerTick("PanelFade_rawFlip")
    this.hoverConfirmCount = this.hoverConfirmCount + 1
    if this.hoverConfirmCount >= 3 then
      FlickerTick("PanelFade_effectiveChange")
      this.effectiveIsOver = rawIsOver
      this.hoverConfirmCount = 0
    end
  end
  if this.effectiveIsOver == nil then this.effectiveIsOver = rawIsOver end
  local isOver = this.effectiveIsOver

  local goal = ( content and not isOver ) and 0 or not content and not isOver and 0.5 or 1
  if ceil(alpha*10) ~= ceil(goal*10)then
    FlickerTick("PanelFade_alphaStep")
    this.backdrop:SetAlpha(alpha + ((goal - alpha) > 0 and .1 or (goal - alpha) < 0 and -.1 or 0))
  end
end)

AutoQuestLearnerTracker:SetScript("OnShow", function()
  AutoQuestLearnerDB.config.showtracker = "1"

  -- load tracker position if exists. Guard against corrupted entries
  -- saved by an earlier buggy version (which stored a frame reference
  -- and a string where x/y numbers should have been).
  local pos = AutoQuestLearnerDB.config.trackerpos
  if pos and type(pos[1]) == "string" and type(pos[2]) == "number" and type(pos[3]) == "number" then
    this:ClearAllPoints()
    this:SetPoint(pos[1], UIParent, pos[1], pos[2], pos[3])
  elseif pos then
    -- Corrupted from before the GetPoint() fix -- discard it.
    AutoQuestLearnerDB.config.trackerpos = nil
  end
end)

AutoQuestLearnerTracker:SetScript("OnHide", function()
  AutoQuestLearnerDB.config.showtracker = "0"
end)

AutoQuestLearnerTracker.buttons = {}
AutoQuestLearnerTracker.mode = "QUEST_TRACKING"

AutoQuestLearnerTracker.backdrop = CreateFrame("Frame", nil, AutoQuestLearnerTracker)
AutoQuestLearnerTracker.backdrop:SetAllPoints(AutoQuestLearnerTracker)
AutoQuestLearnerTracker.backdrop.bg = AutoQuestLearnerTracker.backdrop:CreateTexture(nil, "BACKGROUND")
AutoQuestLearnerTracker.backdrop.bg:SetTexture(0,0,0,.2)
AutoQuestLearnerTracker.backdrop.bg:SetAllPoints()

do -- button panel
  AutoQuestLearnerTracker.panel = CreateFrame("Frame", nil, AutoQuestLearnerTracker.backdrop)
  AutoQuestLearnerTracker.panel:SetPoint("TOPLEFT", 0, 0)
  AutoQuestLearnerTracker.panel:SetPoint("TOPRIGHT", 0, 0)
  AutoQuestLearnerTracker.panel:SetHeight(panelheight)

  local anchors = {}
  local buttons = {}
  local function CreateButton(icon, anchor, tooltip, func)
    anchors[anchor] = anchors[anchor] and anchors[anchor] + 1 or 0
    local pos = 1+(panelheight+1)*anchors[anchor]
    pos = anchor == "TOPLEFT" and pos or pos*-1
    local func = func

    local b = CreateFrame("Button", nil, AutoQuestLearnerTracker.panel)
    b.tooltip = tooltip
    b.icon = b:CreateTexture(nil, "BACKGROUND")
    b.icon:SetAllPoints()
    b.icon:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\tracker_"..icon)
    if table.getn(buttons) == 0 then b.icon:SetVertexColor(.2,1,.8) end

    b:SetPoint(anchor, pos, -1)
    b:SetWidth(panelheight-2)
    b:SetHeight(panelheight-2)

    b:SetScript("OnEnter", ShowTooltip)
    b:SetScript("OnLeave", HideTooltip)

    if anchor == "TOPLEFT" then
      table.insert(buttons, b)
      b:SetScript("OnClick", function()
        if func then func() end
        for id, button in pairs(buttons) do
          button.icon:SetVertexColor(1,1,1)
        end
        this.icon:SetVertexColor(.2,1,.8)
      end)
    else
      b:SetScript("OnClick", func)
    end

    return b
  end

  AutoQuestLearnerTracker.btnquest = CreateButton("quests", "TOPLEFT", "Show Current Quests", function()
    AutoQuestLearnerTracker.mode = "QUEST_TRACKING"
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
  end)

  AutoQuestLearnerTracker.btndatabase = CreateButton("database", "TOPLEFT", "Show Database Results", function()
    AutoQuestLearnerTracker.mode = "DATABASE_TRACKING"
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
  end)

  AutoQuestLearnerTracker.btngiver = CreateButton("giver", "TOPLEFT", "Show Quest Givers", function()
    AutoQuestLearnerTracker.mode = "GIVER_TRACKING"
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
  end)

  -- Action buttons for slash commands
  AutoQuestLearnerTracker.btnscan = CreateButton("available", "TOPRIGHT", "Force Proximity Scan", function()
    if AutoQuestLearner and AutoQuestLearner.ProximityScan then
      AutoQuestLearner.ProximityScan()
      print("|cff00ff00AutoQuestLearner:|r Proximity scan complete.")
    end
  end)

  AutoQuestLearnerTracker.btngossip = CreateButton("complete", "TOPRIGHT", "Reprocess Gossip Window", function()
    if AutoQuestLearner and AutoQuestLearner.LearnFromGossip then
      AutoQuestLearner.LearnFromGossip()
    end
  end)

  AutoQuestLearnerTracker.btnconfig = CreateButton("settings", "TOPRIGHT", "Open Settings", function()
    if AutoQuestLearnerConfig then AutoQuestLearnerConfig:Show() end
  end)

  AutoQuestLearnerTracker.btnmap = CreateButton("fav", "TOPRIGHT", "Update Map Nodes", function()
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
    print("|cff00ff00AutoQuestLearner:|r Map nodes updated.")
  end)

  AutoQuestLearnerTracker.btnclean = CreateButton("clean", "TOPRIGHT", "Clean Database Results", function()
    if AutoQuestLearnerMap then AutoQuestLearnerMap:DeleteNode("AUTOQLDB") end
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
  end)

  AutoQuestLearnerTracker.btnclose = CreateButton("close", "TOPRIGHT", "Close Tracker", function()
    DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00AutoQuestLearner|r Tracker is now hidden. Type `/aql tracker` to show.")
    AutoQuestLearnerTracker:Hide()
  end)
end

local leaveToken = 0
local leaveDelayFrame = CreateFrame("Frame")

function AutoQuestLearnerTracker.ButtonEnter()
  FlickerTick("ButtonEnter")
  -- Cancel any pending delayed-leave (see ButtonLeave below) -- a
  -- genuine re-entry, however it happened, means whatever was about to
  -- clear the highlight/hide the tooltip should not go through.
  leaveToken = leaveToken + 1
  if AutoQuestLearnerMap then AutoQuestLearnerMap.highlight = this.title end
  ShowTooltip()
end

function AutoQuestLearnerTracker.ButtonLeave()
  FlickerTick("ButtonLeave")
  -- BUGFIX: this used to clear the highlight and hide the tooltip
  -- immediately. Hit-test boundaries near the edge of a row can flip
  -- between "in" and "out" from frame to frame due to ordinary
  -- sub-pixel rounding -- this doesn't require the cursor to actually
  -- be moving, just sitting somewhere ambiguous -- which means
  -- OnEnter/OnLeave can rapidly alternate even with a genuinely
  -- motionless cursor. Reacting to every single OnLeave instantly made
  -- that instability directly visible as a flicker. Now the actual
  -- clear/hide is delayed slightly and only goes through if nothing
  -- re-entered in that window (checked via the token above) -- a real
  -- re-entry cancels it, so brief flip-flopping gets absorbed into a
  -- stable state instead of flashing on screen.
  local myToken = leaveToken + 1
  leaveToken = myToken
  local elapsed = 0
  leaveDelayFrame:SetScript("OnUpdate", function(self, e)
    elapsed = elapsed + (e or 0)
    if elapsed < 0.08 then return end
    self:SetScript("OnUpdate", nil)
    if leaveToken ~= myToken then return end -- something re-entered since
    if AutoQuestLearnerMap then AutoQuestLearnerMap.highlight = nil end
    HideTooltip()
  end)
end

function AutoQuestLearnerTracker.ButtonUpdate()
  -- BUGFIX: this ran completely unthrottled -- bound directly as
  -- OnUpdate with no rate limit, meaning it fired on every single
  -- render frame, PER TRACKED QUEST. With several quests tracked at
  -- 60fps, that's hundreds of calls a second for something that only
  -- checks a config value and a highlight state, neither of which
  -- changes anywhere near that often. Throttled to 10 times a second --
  -- still far more than fast enough for a color/alpha change to look
  -- instant to a human eye, but a real, direct reduction in a per-frame
  -- cost that scaled with tracked-quest count.
  if (this.updateTick or 0) > GetTime() then return end
  this.updateTick = GetTime() + 0.1

  local alpha = tonumber((AutoQuestLearnerDB.config.trackeralpha or .2)) or .2

  if not this.alpha or this.alpha ~= alpha then
    this.bg:SetTexture(0,0,0,alpha)
    this.bg:SetAlpha(alpha)
    this.alpha = alpha
  end

  if AutoQuestLearnerMap and AutoQuestLearnerMap.highlight and AutoQuestLearnerMap.highlight == this.title then
    if not this.highlight then
      FlickerTick("ButtonUpdate_highlightON")
      this.bg:SetTexture(1,1,1,math.max(.2, alpha))
      this.bg:SetAlpha(math.max(.5, alpha))
      this.highlight = true
    end
  elseif this.highlight then
    FlickerTick("ButtonUpdate_highlightOFF")
    this.bg:SetTexture(0,0,0,alpha)
    this.bg:SetAlpha(alpha)
    this.highlight = nil
  end
end

function AutoQuestLearnerTracker.ButtonClick()
  if arg1 == "RightButton" and IsAltKeyDown() then
    -- Debounce: guards against a single physical right-click somehow
    -- registering as two OnClick events in very quick succession (seen
    -- on some clients/input setups) -- without this, that would toggle
    -- the pin on and then immediately back off, visible as a flash.
    local now = GetTime()
    if AutoQuestLearnerTracker.lastPinClickTime and (now - AutoQuestLearnerTracker.lastPinClickTime) < 0.3 then
        return
    end
    AutoQuestLearnerTracker.lastPinClickTime = now

    -- Pin/unpin this quest as the arrow's fixed target, overriding
    -- auto-select-closest until unpinned. Click the same quest again
    -- to release the pin and go back to automatic selection.
    if AutoQuestLearner.pinnedQuestID == this.questid then
      AutoQuestLearner.pinnedQuestID = nil
      UIErrorsFrame:AddMessage(string.format("|cffffcc00[%s]|r unpinned -- arrow back to auto-select.", this.title), 1,1,1)
    else
      AutoQuestLearner.pinnedQuestID = this.questid
      if AutoQuestLearner_HasAnyKnownLocation and not AutoQuestLearner_HasAnyKnownLocation(this.questid, this.title) then
        UIErrorsFrame:AddMessage(string.format("|cffffcc00[%s]|r pinned, but nothing is known about it yet -- the arrow will stay hidden until a location is learned (or you unpin it).", this.title), 1,.6,.2)
      else
        UIErrorsFrame:AddMessage(string.format("|cffffcc00[%s]|r pinned -- arrow will track this quest until unpinned (Alt+Right-Click again to release).", this.title), 1,1,1)
      end
    end
  elseif arg1 == "RightButton" then
    if AutoQuestLearner.questlog then
      for questid, data in pairs(AutoQuestLearner.questlog) do
        if data.title == this.title then
          -- show questlog
          HideUIPanel(QuestLogFrame)
          SelectQuestLogEntry(data.qlogid)
          ShowUIPanel(QuestLogFrame)
          break
        end
      end
    end
  elseif IsAltKeyDown() then
    AutoQuestLearner_SetTomTomWaypoint(this.questid, this.title)
  elseif IsShiftKeyDown() then
    -- mark as done if node is quest and not in questlog
    if this.node.questid and not this.node.qlogid then
      -- mark as done in history
      AutoQuestLearnerDB.history = AutoQuestLearnerDB.history or {}
      AutoQuestLearnerDB.history[this.node.questid] = { time(), UnitLevel("player") }
      UIErrorsFrame:AddMessage(string.format("The Quest |cffffcc00[%s]|r (id:%s) is now marked as done.", this.title, this.node.questid), 1,1,1)
    end

    if AutoQuestLearnerMap then AutoQuestLearnerMap:DeleteNode(this.node.addon, this.title) end
    if AutoQuestLearnerMap then AutoQuestLearnerMap:UpdateNodes() end
  elseif IsControlKeyDown() and not WorldMapFrame:IsShown() then
    -- show world map
    if ToggleWorldMap then
      ToggleWorldMap()
    else
      WorldMapFrame:Show()
    end
  elseif expand_states[this.title] == 0 then
    expand_states[this.title] = 1
    AutoQuestLearnerTracker.ButtonEvent(this)
  elseif expand_states[this.title] == 1 then
    expand_states[this.title] = 0
    AutoQuestLearnerTracker.ButtonEvent(this)
  end
end

local function trackersort(a,b)
  if a.empty then
    return false
  elseif ( a.tracked and 1 or -1 ) ~= (b.tracked and 1 or -1) then
    return ( a.tracked and 1 or -1 ) > (b.tracked and 1 or -1)
  elseif ( a.level or -1 ) ~= ( b.level or -1 ) then
    return (a.level or -1) > (b.level or -1)
  elseif ( a.perc or -1 ) ~= ( b.perc or -1 ) then
    return (a.perc or -1) > (b.perc or -1)
  elseif ( a.title or "" ) ~= ( b.title or "" ) then
    return ( a.title or "" ) < ( b.title or "" )
  else
    return false
  end
end

function AutoQuestLearnerTracker.ButtonEvent(self)
  local self   = self or this
  local title  = self.title
  local node   = self.node
  local id     = self.id
  local qid    = self.questid

  self:SetHeight(0)

  -- we got an event on a hidden button
  if not title then return end
  if self.empty then return end

  self:SetHeight(entryheight)

  -- initialize and hide all objectives
  self.objectives = self.objectives or {}
  for id, obj in pairs(self.objectives) do obj:Hide() end

  -- update button icon
  if node.texture then
    self.icon:SetTexture(node.texture)

    local r, g, b = unpack(node.vertex or {0,0,0})
    if r > 0 or g > 0 or b > 0 then
      self.icon:SetVertexColor(unpack(node.vertex))
    else
      self.icon:SetVertexColor(1,1,1,1)
    end
  else
    self.icon:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\node")
    self.icon:SetVertexColor(AutoQuestLearnerMap and AutoQuestLearnerMap.str2rgb(title) or str2rgb(title))
  end

  if AutoQuestLearnerTracker.mode == "QUEST_TRACKING" then
    local qlogid = AutoQuestLearner.questlog and AutoQuestLearner.questlog[qid] and AutoQuestLearner.questlog[qid].qlogid or 0
    local qtitle, level, tag, header, collapsed, complete = AutoQuestLearner_GetQuestLogTitle(qlogid)
    if not qlogid or not qtitle then return end
    local objectives = GetNumQuestLeaderBoards(qlogid)
    local watched = IsQuestWatched(qlogid)
    local cur,max = 0,0
    local percent = 0

    -- write expand state
    if not expand_states[title] then
      expand_states[title] = AutoQuestLearnerDB.config.trackerexpand == "1" and 1 or 0
    end

    local expanded = expand_states[title] == 1 and true or nil

    if objectives and objectives > 0 then
      for i=1, objectives, 1 do
        local text, _, done = GetQuestLogLeaderBoard(i, qlogid)
        local _, _, obj, objNum, objNeeded = strfind(gsub(text, "\239\188\154", ":"), "(.*):%s*([%d]+)%s*/%s*([%d]+)")
        if objNum and objNeeded then
          max = max + objNeeded
          cur = cur + objNum
        elseif not done then
          max = max + 1
        end
      end
    end

    if cur == max or complete then
      cur, max = 1, 1
      percent = 100
    else
      percent = cur/max*100
    end

    -- expand button to show objectives
    if objectives and (expanded or ( percent > 0 and percent < 100 )) then
      -- BUGFIX: consecutive objective lines were spaced exactly
      -- `fontsize` pixels apart, and the button height budgeted exactly
      -- `fontsize` per line -- but real rendered text height runs
      -- taller than the nominal font size (ascender/descender space),
      -- so EVERY line boundary throughout a multi-objective quest was
      -- undersized, not just the margin below the last line. A quest
      -- with more objective lines stacks proportionally more of these
      -- undersized boundaries into the same vertical space -- which is
      -- exactly why a character with several multi-objective quests
      -- active at once (a denser tracker) showed far more flickering
      -- than one with simpler quests: more total edges, each one worn
      -- thinner than it looked. lineSpacing now scales generously with
      -- font size instead of a bare 1:1 ratio (matching the same
      -- proportional padding already used for the header row's own
      -- height, entryheight = fontsize*1.6), and the button height
      -- calculation uses the same value so it stays consistent with
      -- where the lines actually get positioned below.
      local lineSpacing = fontsize * 1.3
      self:SetHeight(entryheight + objectives * lineSpacing + 8)

      for i=1, objectives, 1 do
        local text, _, done = GetQuestLogLeaderBoard(i, qlogid)
        local _, _, obj, objNum, objNeeded = strfind(gsub(text, "\239\188\154", ":"), "(.*):%s*([%d]+)%s*/%s*([%d]+)")

        if not self.objectives[i] then
          self.objectives[i] = self:CreateFontString(nil, "HIGH", "GameFontNormal")
          self.objectives[i]:SetFont(STANDARD_TEXT_FONT, fontsize)
          self.objectives[i]:SetJustifyH("LEFT")
          self.objectives[i]:SetPoint("TOPLEFT", 20, -lineSpacing*i-6)
          self.objectives[i]:SetPoint("TOPRIGHT", -10, -lineSpacing*i-6)
          -- Force single-line: without this, a long objective (e.g.
          -- "Bring an Example Collar to Neeru Fireblade in Orgrimmar")
          -- wraps onto a second visual line, but the button's height
          -- calculation below only ever budgets one line per
          -- objective -- causing the next tracker entry to overlap
          -- into where that wrapped second line actually renders.
          self.objectives[i]:SetWordWrap(false)
        end

        if objNum and objNeeded then
          local r,g,b = AutoQuestLearnerMap.tooltip:GetColor(objNum, objNeeded)
          self.objectives[i]:SetTextColor(r+.2, g+.2, b+.2)
          self.objectives[i]:SetText(string.format("|cffffffff- %s:|r %s/%s", obj, objNum, objNeeded))
        else
          self.objectives[i]:SetTextColor(.8,.8,.8)
          self.objectives[i]:SetText("|cffffffff- " .. text)
        end

        self.objectives[i]:Show()
      end
    end

    local r,g,b = AutoQuestLearnerMap.tooltip:GetColor(cur, max)
    local colorperc = string.format("|cff%02x%02x%02x", r*255, g*255, b*255)
    local showlevel = AutoQuestLearnerDB.config.trackerlevel == "1" and "[" .. ( level or "??" ) .. ( tag and "+" or "") .. "] " or ""

    self.tracked = watched
    self.perc = percent
    self.text:SetText(string.format("%s%s |cffaaaaaa(%s%s%%|cffaaaaaa)|r", showlevel, title or "", colorperc or "", ceil(percent)))
    -- Quest title text color: yellow.
    self.text:SetTextColor(1, 1, 0)
    self.tooltip = "|cff00ff00<Click>|r Unfold/Fold Objectives\n|cff00ff00<Right-Click>|r Show In QuestLog\n|cff00ff00<Ctrl-Click>|r Show Map\n|cff00ff00<Shift-Click>|r Hide Nodes"
  elseif AutoQuestLearnerTracker.mode == "GIVER_TRACKING" then
    local level = node.qlvl or node.level or UnitLevel("player")
    local color = GetQuestDifficultyColor(level)

    -- red quests
    if node.qmin and node.qmin > UnitLevel("player") then
      color = { r = 1, g = 0, b = 0 }
    end

    local showlevel = AutoQuestLearnerDB.config.trackerlevel == "1" and "[" .. ( level or "??" ) .. "] " or ""
    self.text:SetTextColor(color.r, color.g, color.b)
    self.text:SetText(showlevel .. title)
    self.level = tonumber(level)
    self.tooltip = "|cff00ff00<Ctrl-Click>|r Show Map\n|cff00ff00<Shift-Click>|r Mark As Done"
  elseif AutoQuestLearnerTracker.mode == "DATABASE_TRACKING" then
    self.text:SetText(title)
    self.text:SetTextColor(1,1,1,1)
    self.text:SetTextColor(AutoQuestLearnerMap and AutoQuestLearnerMap.str2rgb(title) or str2rgb(title))
    self.tooltip = "|cff00ff00<Ctrl-Click>|r Show Map\n|cff00ff00<Shift-Click>|r Hide Nodes"
  end

  -- sort all tracker entries
  table.sort(AutoQuestLearnerTracker.buttons, trackersort)

  self:Show()

  -- Deferred during a batch refresh (see Reset() below) -- this used to
  -- run unconditionally here, meaning the full layout (and the
  -- scrollbar range it computes) recalculated once per quest button
  -- while Reset() was still re-adding them one at a time, seeing an
  -- artificially small content size partway through and clamping the
  -- scroll position down to match -- a clamp that never got undone once
  -- the rest of the quests were added back. Reset() now explicitly
  -- triggers exactly one layout pass after all buttons are back, once
  -- the true final size is known.
  if not AutoQuestLearnerTracker.batching then
    AutoQuestLearnerTracker:RelayoutAndSync()
  end
end

-- The actual button positioning, content sizing, and scrollbar range
-- update -- split out from ButtonEvent so it can be called exactly once
-- per refresh (from Reset(), after all buttons are re-added) instead of
-- once per individual button.
function AutoQuestLearnerTracker:RelayoutAndSync()
  FlickerTick("RelayoutAndSync")
  -- Lay out buttons inside the scrollable content frame. The content
  -- frame grows to fit however many quests are tracked (same as
  -- before), but AutoQuestLearnerTracker itself (the visible viewport)
  -- no longer grows to match -- it stays at whatever fixed/user-resized
  -- size it already has, with a scrollbar picking up the difference.
  local height = 0
  local width = 100

  for bid, button in pairs(AutoQuestLearnerTracker.buttons) do
    button:ClearAllPoints()
    button:SetPoint("TOPRIGHT", AutoQuestLearnerTracker.content, "TOPRIGHT", 0, -height)
    button:SetPoint("TOPLEFT", AutoQuestLearnerTracker.content, "TOPLEFT", 0, -height)
    if not button.empty then
      height = height + button:GetHeight()

      if button.text:GetStringWidth() > width then
        width = button.text:GetStringWidth()
      end

      for id, objective in pairs(button.objectives) do
        if objective:IsShown() and objective:GetStringWidth() > width then
          width = objective:GetStringWidth()
        end
      end
    end
  end

  width = min(width, 300) + 30
  AutoQuestLearnerTracker.content:SetHeight(max(height, 1))
  AutoQuestLearnerTracker.content:SetWidth(width)

  -- Only auto-size the tracker's own width the first time, before the
  -- user has ever manually resized it. Once a saved size exists, it's
  -- entirely under the user's control via the resize grip.
  if not AutoQuestLearnerDB.config.trackersize then
    AutoQuestLearnerTracker:SetWidth(width + 22)
  end

  AutoQuestLearnerTracker:UpdateScrollRange()
end

function AutoQuestLearnerTracker.ButtonAdd(title, node)
  if not title or not node then return end

  local questid = title
  if AutoQuestLearner.questlog then
    for qid, data in pairs(AutoQuestLearner.questlog) do
      if data.title == title then
        questid = qid
        break
      end
    end
  end

  if AutoQuestLearnerTracker.mode == "QUEST_TRACKING" then
    if node.addon ~= "AUTOQUESTLEARNER" then return end
    if not AutoQuestLearner.questlog or not AutoQuestLearner.questlog[questid] then return end
  elseif AutoQuestLearnerTracker.mode == "GIVER_TRACKING" then
    if node.addon ~= "AUTOQUESTLEARNER" then return end
    if AutoQuestLearner.questlog and AutoQuestLearner.questlog[questid] then return end
    if not node.layer or node.layer > 2 then return end
  elseif AutoQuestLearnerTracker.mode == "DATABASE_TRACKING" then
    if node.addon ~= "AUTOQLDB" then return end
  end

  local id

  -- skip duplicate titles
  for bid, button in pairs(AutoQuestLearnerTracker.buttons) do
    if button.title and button.title == title then
      if node.dummy or not node.texture then
        id = bid
        break
      elseif node.cluster and ( not button.node or button.node.texture ) then
        id = bid
      else
        return
      end
    end
  end

  if not id then
    id = table.getn(AutoQuestLearnerTracker.buttons)+1

    for bid, button in pairs(AutoQuestLearnerTracker.buttons) do
      if button.empty then id = bid break end
    end
  end

  if id > 25 then return end

  if not AutoQuestLearnerTracker.buttons[id] then
    AutoQuestLearnerTracker.buttons[id] = CreateFrame("Button", "AutoQuestLearnerMapButton"..id, AutoQuestLearnerTracker.content)
    AutoQuestLearnerTracker.buttons[id]:SetHeight(entryheight)

    AutoQuestLearnerTracker.buttons[id].bg = AutoQuestLearnerTracker.buttons[id]:CreateTexture(nil, "BACKGROUND")
    AutoQuestLearnerTracker.buttons[id].bg:SetTexture(1,1,1,.2)
    AutoQuestLearnerTracker.buttons[id].bg:SetAllPoints()
    AutoQuestLearnerTracker.buttons[id].bg:SetAlpha(0)

    AutoQuestLearnerTracker.buttons[id].text = AutoQuestLearnerTracker.buttons[id]:CreateFontString("AutoQuestLearnerIDButton", "HIGH", "GameFontNormal")
    AutoQuestLearnerTracker.buttons[id].text:SetFont(STANDARD_TEXT_FONT, fontsize)
    AutoQuestLearnerTracker.buttons[id].text:SetJustifyH("LEFT")
    AutoQuestLearnerTracker.buttons[id].text:SetPoint("TOPLEFT", 16, -4)
    AutoQuestLearnerTracker.buttons[id].text:SetPoint("TOPRIGHT", -10, -4)
    AutoQuestLearnerTracker.buttons[id].text:SetWordWrap(false)

    AutoQuestLearnerTracker.buttons[id].icon = AutoQuestLearnerTracker.buttons[id]:CreateTexture(nil, "BORDER")
    AutoQuestLearnerTracker.buttons[id].icon:SetPoint("TOPLEFT", 2, -4)
    AutoQuestLearnerTracker.buttons[id].icon:SetWidth(12)
    AutoQuestLearnerTracker.buttons[id].icon:SetHeight(12)

    AutoQuestLearnerTracker.buttons[id]:RegisterEvent("QUEST_WATCH_UPDATE")
    AutoQuestLearnerTracker.buttons[id]:RegisterEvent("QUEST_LOG_UPDATE")
    AutoQuestLearnerTracker.buttons[id]:RegisterEvent("QUEST_FINISHED")

    AutoQuestLearnerTracker.buttons[id]:RegisterForClicks("LeftButtonUp", "RightButtonUp")

    AutoQuestLearnerTracker.buttons[id]:SetScript("OnEnter", AutoQuestLearnerTracker.ButtonEnter)
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnLeave", AutoQuestLearnerTracker.ButtonLeave)
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnUpdate", AutoQuestLearnerTracker.ButtonUpdate)
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnEvent", AutoQuestLearnerTracker.ButtonEvent)
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnClick", AutoQuestLearnerTracker.ButtonClick)

    -- Buttons cover most of the tracker's surface, so a plain drag on
    -- the tracker background rarely has anywhere to grab. Hold Shift
    -- and click any quest entry to drag the whole tracker instead.
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnMouseDown", function()
      if IsShiftKeyDown() and not AutoQuestLearnerDB.config.lock then
        AutoQuestLearnerTracker:StartMoving()
      end
    end)
    AutoQuestLearnerTracker.buttons[id]:SetScript("OnMouseUp", function()
      AutoQuestLearnerTracker:StopMovingOrSizing()
      local anchor, _, _, x, y = AutoQuestLearnerTracker:GetPoint()
      AutoQuestLearnerTracker:ClearAllPoints()
      AutoQuestLearnerTracker:SetPoint(anchor, UIParent, anchor, x, y)
      AutoQuestLearnerDB.config.trackerpos = { anchor, x, y }
    end)
  end

  AutoQuestLearnerTracker.buttons[id].empty = nil
  AutoQuestLearnerTracker.buttons[id].title = title
  AutoQuestLearnerTracker.buttons[id].node = node
  AutoQuestLearnerTracker.buttons[id].questid = questid

  AutoQuestLearnerTracker.ButtonEvent(AutoQuestLearnerTracker.buttons[id])
end

function AutoQuestLearnerTracker.Reset()
  FlickerTick("Reset")
  AutoQuestLearnerTracker.batching = true
  AutoQuestLearnerTracker.content:SetHeight(1)
  for id, button in pairs(AutoQuestLearnerTracker.buttons) do
    button.level = nil
    button.title = nil
    button.perc = nil
    button.empty = true
    button:SetHeight(0)
    button:Hide()
  end

  -- add tracked quests
  local _, numQuests = GetNumQuestLogEntries()
  local found = 0

  for qlogid=1,40 do
    local title, level, tag, header, collapsed, complete = AutoQuestLearner_GetQuestLogTitle(qlogid)
    if title and not header then
      -- Show every quest in the log by default. Previously this only
      -- added quests that were explicitly "watched" via Blizzard's
      -- track-quest system, which meant the tracker stayed empty for
      -- anyone who hadn't manually tracked each quest.
      local img = "Interface\\AddOns\\AutoQuestLearner\\img\\node"
      AutoQuestLearnerTracker.ButtonAdd(title, { dummy = true, addon = "AUTOQUESTLEARNER", texture = img })

      found = found + 1
      if found >= numQuests then
        break
      end
    end
  end

  AutoQuestLearnerTracker.batching = false
  AutoQuestLearnerTracker:RelayoutAndSync()
end

-- Helper function for color generation
-- math.mod does not exist on this client -- use manual modulo instead.
local function mod(a, b)
  return a - math.floor(a / b) * b
end

local function str2rgb(text)
  if not text then return 1, 1, 1 end
  local counter = 1
  local l = string.len(text)
  for i = 1, l, 3 do
    counter = mod(counter*8161, 4294967279) +
        (string.byte(text,i)*16776193) +
        ((string.byte(text,i+1) or (l-i+256))*8372226) +
        ((string.byte(text,i+2) or (l-i+256))*3932164)
  end
  local hash = mod(mod(counter, 4294967291),16777216)
  local r = (hash - (mod(hash,65536))) / 65536
  local g = ((hash - r*65536) - ( mod((hash - r*65536),256)) ) / 256
  local b = hash - r*65536 - g*256
  return r / 255, g / 255, b / 255
end

AutoQuestLearnerTracker.tooltip = {}
AutoQuestLearnerTracker.tooltip.GetColor = function(min, max)
  local max = max or 1
  local min = min or max or 1

  local perc = min / max
  local r1, g1, b1, r2, g2, b2
  if perc <= 0.5 then
    perc = perc * 2
    r1, g1, b1 = 1, 0, 0
    r2, g2, b2 = 1, 1, 0
  else
    perc = perc * 2 - 1
    r1, g1, b1 = 1, 1, 0
    r2, g2, b2 = 0, 1, 0
  end
  r = r1 + (r2 - r1)*perc
  g = g1 + (g2 - g1)*perc
  b = b1 + (b2 - b1)*perc

  return r, g, b
end

-- make global available
AutoQuestLearner = AutoQuestLearner or CreateFrame("Frame")
AutoQuestLearner.tracker = AutoQuestLearnerTracker
