-- UI/TutorialOverlay.lua
-- What Farm? :: Guided tutorial overlay (modal + spotlight) that sits over the HelpWindow.
-- Advanced rewrite: dirtied updates, cached spotlight rect, safer widget fallbacks, cleaner state buckets.

local ADDON_NAME, ns = ...
local WF = ns.WF

WF.UI = WF.UI or {}
WF.UI.TutorialOverlay = WF.UI.TutorialOverlay or {}
local TO = WF.UI.TutorialOverlay

local Widgets = WF.UI.Widgets
local Theme   = WF.UI.Theme

-- -------------------------------------------------------
-- Helpers
-- -------------------------------------------------------
local function IsTable(t) return type(t) == "table" end

local function C4(t, fallback)
  if IsTable(t) then return t[1], t[2], t[3], t[4] end
  if IsTable(fallback) then return fallback[1], fallback[2], fallback[3], fallback[4] end
  return 1, 1, 1, 1
end

local function Clamp(v, lo, hi)
  v = tonumber(v) or lo
  if v < lo then return lo end
  if v > hi then return hi end
  return v
end

local function RoundInt(v)
  v = tonumber(v) or 0
  if v >= 0 then
    return math.floor(v + 0.5)
  end
  return math.ceil(v - 0.5)
end

local function SafeCall(fn, ...)
  if type(fn) ~= "function" then return end
  local ok = pcall(fn, ...)
  return ok
end

local function IsFrame(obj)
  return type(obj) == "table"
     and type(obj.GetObjectType) == "function"
     and type(obj.IsObjectType) == "function"
end

local function NormalizeParentFrame(parentFrame)
  if IsFrame(parentFrame) then return parentFrame end

  if type(parentFrame) == "table" then
    local candidate = parentFrame.frame
      or parentFrame.Frame
      or parentFrame.root
      or parentFrame.Root
      or parentFrame._frame
      or parentFrame._root
    if IsFrame(candidate) then return candidate end
  end

  return UIParent
end

local function GetSetting(key, default)
  if WF and WF.DB and WF.DB.GetSetting then
    local v = WF.DB:GetSetting(key)
    if v ~= nil then return v end
  end
  return default
end

local function HasSetting(key)
  if WF and WF.DB and WF.DB.GetSetting then
    return WF.DB:GetSetting(key) ~= nil
  end
  return false
end

local function SetSetting(key, value)
  if WF and WF.DB and WF.DB.SetSetting then
    WF.DB:SetSetting(key, value)
  end
end

local function GetActiveProfile()
  if WF and WF.DB and WF.DB.GetActiveProfileName then
    return tostring(WF.DB:GetActiveProfileName() or "Default")
  end
  return "Default"
end

local function ApplyProfile(name)
  if WF and WF.DB and WF.DB.ApplyProfile then
    SafeCall(WF.DB.ApplyProfile, WF.DB, name)
  end
end

local function SetProviderVendor()
  if WF and WF.DB and WF.DB.SetSetting then
    WF.DB:SetSetting("priceSource", "vendor")
  end
end

local function SetProviderAuctionator()
  if WF and WF.DB and WF.DB.SetSetting then
    WF.DB:SetSetting("priceSource", "auctionator")
  end
end

local function SetProviderTSM(priceString)
  if WF and WF.DB and WF.DB.SetSetting then
    WF.DB:SetSetting("priceSource", "tsm")
    if priceString and priceString ~= "" then
      WF.DB:SetSetting("tsmPriceString", priceString)
    end
  end
end

local function RepriceAndRefresh()
  -- Reprice current session (if any) using the newly selected provider/string.
  if WF and WF.Tracker and WF.Tracker.RepriceCurrent then
    SafeCall(WF.Tracker.RepriceCurrent, WF.Tracker, true)
  end

  -- Refresh main UI layout/content.
  if WF and WF.UI and WF.UI.Main and WF.UI.Main.Refresh then
    SafeCall(WF.UI.Main.Refresh, WF.UI.Main)
  end

  -- Refresh help overview (cheap) so tutorial step previews stay in sync.
  if WF and WF.UI and WF.UI.Help and WF.UI.Help.RefreshOverview then
    SafeCall(WF.UI.Help.RefreshOverview, WF.UI.Help)
  end

  -- IMPORTANT: the header "Source: ..." chip does NOT necessarily update on a generic Refresh.
  -- It has its own exposed refresh method; call it so the label changes immediately.
  if WF and WF.UI and WF.UI.Main and WF.UI.Main.RefreshHeaderPriceSource then
    SafeCall(WF.UI.Main.RefreshHeaderPriceSource, WF.UI.Main)
  end
end

local function GetProviderState()
  local prov = "vendor"
  local tsmStr = nil

  if WF and WF.Price and WF.Price.GetProvider then
    local p = tostring(WF.Price:GetProvider() or "vendor")
    if p:match("^tsm:") then
      prov = "tsm"
      tsmStr = p:match("^tsm:(.+)$")
    else
      prov = p
    end
  else
    prov = tostring(GetSetting("priceSource", "vendor") or "vendor")
    if prov == "tsm" then
      tsmStr = tostring(GetSetting("tsmPriceString", "") or "")
    end
  end

  return prov, tsmStr
end


local function GetFarmWindowRefs()
  local mainUI = WF and WF.UI and WF.UI.Main or nil
  if type(mainUI) ~= "table" then
    return nil, nil
  end

  local frame = mainUI.frame
    or mainUI.Frame
    or mainUI.window
    or mainUI.Window
    or mainUI.root
    or mainUI.Root
    or mainUI.mainFrame
    or mainUI.MainFrame

  if not IsFrame(frame) then
    frame = nil
  end

  return mainUI, frame
end

local function CallMainUI(mainUI, name)
  if type(mainUI) ~= "table" then return false end
  local fn = mainUI[name]
  if type(fn) ~= "function" then return false end
  return pcall(fn, mainUI)
end

local function GetFarmWindowDeepField(root, path)
  local cur = root
  if type(path) ~= "table" then return nil end
  for i = 1, #path do
    if type(cur) ~= "table" then return nil end
    cur = cur[path[i]]
    if cur == nil then return nil end
  end
  return cur
end

local function FindFarmWindowTarget(which)
  local mainUI, frame = GetFarmWindowRefs()
  if type(mainUI) ~= "table" then
    return frame
  end

  if which == "layoutOptions" then
    local candidates = {
      mainUI.layoutButton,
      mainUI.layoutBtn,
      mainUI.bLayout,
      mainUI.btnLayout,
      mainUI.layoutDropdown,
      mainUI.layoutDropDown,
      mainUI.layoutOptions,
      mainUI.layoutOptionsButton,
      mainUI.headerLayoutButton,
      mainUI.headerLayoutBtn,
      mainUI.header and mainUI.header.layoutButton,
      mainUI.header and mainUI.header.layoutBtn,
      mainUI.header and mainUI.header.layoutDropdown,
      mainUI.header and mainUI.header.layoutDropDown,
      mainUI.header and mainUI.header.layoutOptions,
      mainUI.header and mainUI.header.layoutOptionsButton,
      GetFarmWindowDeepField(mainUI, {"header", "layout"}),
      GetFarmWindowDeepField(mainUI, {"header", "layoutControl"}),
      GetFarmWindowDeepField(mainUI, {"header", "layoutMenu"}),
      GetFarmWindowDeepField(mainUI, {"controls", "layout"}),
      GetFarmWindowDeepField(mainUI, {"controls", "layoutButton"}),
      GetFarmWindowDeepField(mainUI, {"buttons", "layout"}),
      GetFarmWindowDeepField(mainUI, {"buttons", "layoutButton"}),
      GetFarmWindowDeepField(mainUI, {"headerButtons", "layout"}),
      GetFarmWindowDeepField(mainUI, {"headerButtons", "layoutButton"}),
      GetFarmWindowDeepField(mainUI, {"quickButtons", "layout"}),
      GetFarmWindowDeepField(mainUI, {"quickButtons", "layoutButton"}),
      GetFarmWindowDeepField(mainUI, {"refs", "layout"}),
      GetFarmWindowDeepField(mainUI, {"refs", "layoutButton"}),
      GetFarmWindowDeepField(mainUI, {"_refs", "layout"}),
      GetFarmWindowDeepField(mainUI, {"_refs", "layoutButton"}),
    }

    for i = 1, #candidates do
      if IsFrame(candidates[i]) then
        return candidates[i]
      end
    end
  end

  return frame
end

local function EnsureFarmWindowOpen()
  local mainUI, frame = GetFarmWindowRefs()

  if frame and frame.IsShown and frame:IsShown() then
    if frame.Raise then
      SafeCall(frame.Raise, frame)
    end
    return frame, true, false
  end

  if type(mainUI) ~= "table" then
    return frame, false, false
  end

  local ordered = {
    "Open",
    "Show",
    "ShowWindow",
    "OpenWindow",
    "ShowMainWindow",
    "OpenMainWindow",
    "EnsureShown",
    "EnsureVisible",
  }

  for i = 1, #ordered do
    CallMainUI(mainUI, ordered[i])
    mainUI, frame = GetFarmWindowRefs()
    if frame and frame.Show and frame.IsShown and not frame:IsShown() then
      SafeCall(frame.Show, frame)
    end
    if frame and frame.IsShown and frame:IsShown() then
      if frame.Raise then
        SafeCall(frame.Raise, frame)
      end
      return frame, true, true
    end
  end

  if frame and frame.Show then
    SafeCall(frame.Show, frame)
  end

  if frame and frame.SetShown then
    SafeCall(frame.SetShown, frame, true)
  end

  if frame and frame.IsShown and not frame:IsShown() and type(mainUI.Toggle) == "function" then
    CallMainUI(mainUI, "Toggle")
  end

  mainUI, frame = GetFarmWindowRefs()
  if frame and frame.Show and frame.IsShown and not frame:IsShown() then
    SafeCall(frame.Show, frame)
  end
  if frame and frame.Raise then
    SafeCall(frame.Raise, frame)
  end

  local shown = (frame and frame.IsShown and frame:IsShown()) and true or false
  return frame, shown, shown and true or false
end

local function EnsureFarmWindowClosed()
  local mainUI, frame = GetFarmWindowRefs()

  if not frame or not frame.IsShown or not frame:IsShown() then
    return frame, false
  end

  local ordered = {
    "Close",
    "Hide",
    "CloseWindow",
    "HideWindow",
    "HideMainWindow",
    "CloseMainWindow",
  }

  for i = 1, #ordered do
    CallMainUI(mainUI, ordered[i])
    mainUI, frame = GetFarmWindowRefs()
    if frame and frame.IsShown and not frame:IsShown() then
      return frame, true
    end
  end

  if frame and frame.Hide then
    SafeCall(frame.Hide, frame)
  end

  if frame and frame.SetShown then
    SafeCall(frame.SetShown, frame, false)
  end

  if frame and frame.IsShown and frame:IsShown() and type(mainUI) == "table" and type(mainUI.Toggle) == "function" then
    CallMainUI(mainUI, "Toggle")
  end

  mainUI, frame = GetFarmWindowRefs()
  return frame, (frame and frame.IsShown and not frame:IsShown()) and true or false
end

local function GetCompetitiveWindowRefs()
  local mainUI = WF and WF.UI and WF.UI.Main or nil
  if type(mainUI) ~= "table" then
    return nil, nil
  end

  local frame = mainUI.compFrame
    or mainUI.competitiveFrame
    or mainUI.CompetitiveFrame
    or mainUI.competitiveWindow
    or mainUI.CompetitiveWindow

  if not IsFrame(frame) then
    frame = nil
  end

  return mainUI, frame
end

local function EnsureCompetitiveWindowOpen()
  local mainUI, frame = GetCompetitiveWindowRefs()

  if frame and frame.IsShown and frame:IsShown() then
    if frame.Raise then
      SafeCall(frame.Raise, frame)
    end
    return frame, true, false
  end

  if type(mainUI) ~= "table" then
    return frame, false, false
  end

  if not frame and type(mainUI.Create) == "function" then
    SafeCall(mainUI.Create, mainUI)
    mainUI, frame = GetCompetitiveWindowRefs()
  end

  if frame and frame.Show then
    SafeCall(frame.Show, frame)
  elseif type(mainUI.ToggleCompetitiveWindow) == "function" then
    SafeCall(mainUI.ToggleCompetitiveWindow, mainUI)
    mainUI, frame = GetCompetitiveWindowRefs()
  end

  if frame and frame.IsShown and frame:IsShown() then
    if type(mainUI._EnsureCompetitiveComm) == "function" then
      SafeCall(mainUI._EnsureCompetitiveComm, mainUI)
    end
    if type(mainUI.RefreshCompetitivePanel) == "function" then
      SafeCall(mainUI.RefreshCompetitivePanel, mainUI, true)
    end
    if type(mainUI.RefreshCompetitiveLootPanel) == "function" then
      SafeCall(mainUI.RefreshCompetitiveLootPanel, mainUI, true)
    end
    if frame.Raise then
      SafeCall(frame.Raise, frame)
    end
    return frame, true, true
  end

  return frame, false, false
end

local function EnsureCompetitiveWindowClosed()
  local mainUI, frame = GetCompetitiveWindowRefs()

  if not frame or not frame.IsShown or not frame:IsShown() then
    return frame, false
  end

  if frame.Hide then
    SafeCall(frame.Hide, frame)
  end

  if frame.SetShown and frame.IsShown and frame:IsShown() then
    SafeCall(frame.SetShown, frame, false)
  end

  if frame.IsShown and frame:IsShown() and type(mainUI) == "table" and type(mainUI.ToggleCompetitiveWindow) == "function" then
    SafeCall(mainUI.ToggleCompetitiveWindow, mainUI)
  end

  mainUI, frame = GetCompetitiveWindowRefs()
  return frame, (frame and frame.IsShown and not frame:IsShown()) and true or false
end

local function FindCompetitiveWindowTarget(which)
  local mainUI, frame = GetCompetitiveWindowRefs()
  if type(mainUI) ~= "table" then
    return frame
  end

  local comp = type(mainUI._comp) == "table" and mainUI._comp or nil
  which = tostring(which or "")

  local candidates
  if which == "invite" then
    candidates = {
      comp and comp.inviteCard,
      comp and comp.invBox,
      comp and comp.bInvite,
    }
  elseif which == "session" then
    candidates = {
      comp and comp.sessionCard,
      comp and comp.summarySession,
      comp and comp.summaryStatus,
    }
  elseif which == "controls" then
    candidates = {
      comp and comp.bStartChallenge,
      comp and comp.bPauseMatch,
      comp and comp.bQuitMatch,
    }
  elseif which == "loot" then
    candidates = {
      comp and comp.lootScroll,
      comp and comp.lootChild,
    }
  else
    candidates = {
      mainUI.compPanel,
      comp and comp.rosterScroll,
      comp and comp.lbScroll,
      frame,
    }
  end

  for i = 1, #(candidates or {}) do
    if IsFrame(candidates[i]) then
      return candidates[i]
    end
  end

  return frame
end

local function EnsureFrameShown(frame)
  if not IsFrame(frame) then return false end

  if frame.Show then
    SafeCall(frame.Show, frame)
  end

  if frame.SetShown and frame.IsShown and not frame:IsShown() then
    SafeCall(frame.SetShown, frame, true)
  end

  if frame.Raise and frame.IsShown and frame:IsShown() then
    SafeCall(frame.Raise, frame)
  end

  return (frame.IsShown and frame:IsShown()) and true or false
end

local function EnsureFrameHidden(frame)
  if not IsFrame(frame) then return false end

  if frame.Hide then
    SafeCall(frame.Hide, frame)
  end

  if frame.SetShown and frame.IsShown and frame:IsShown() then
    SafeCall(frame.SetShown, frame, false)
  end

  return (frame.IsShown and not frame:IsShown()) and true or false
end

local function StepFocusKind(step)
  if type(step) ~= "table" or type(step.focus) ~= "table" then
    return ""
  end
  return tostring(step.focus.kind or "")
end

local function IsFarmWindowStep(step)
  return StepFocusKind(step) == "farmWindow"
end

local function IsCompetitiveWindowStep(step)
  return StepFocusKind(step) == "competitiveWindow"
end

local function IsDetachedWindowStep(step)
  local kind = StepFocusKind(step)
  return kind == "farmWindow" or kind == "competitiveWindow"
end

local function IsOverviewHeadlineStep(step)
  if type(step) ~= "table" then return false end

  local short = tostring(step.short or "")
  local title = tostring(step.title or "")
  if short == "Overview" then
    return true
  end

  return title:match("^Overview:") ~= nil
end

-- -------------------------------------------------------
-- Flags (persist per tutorial instance)
-- stored as string like "101001..." to avoid table serialization assumptions
-- -------------------------------------------------------
local function FlagsFromString(s, n)
  local out = {}
  s = tostring(s or "")
  for i = 1, n do
    out[i] = (s:sub(i, i) == "1")
  end
  return out
end

local function FlagsToString(flags, n)
  local t = {}
  for i = 1, n do
    t[i] = (flags and flags[i]) and "1" or "0"
  end
  return table.concat(t, "")
end

-- -------------------------------------------------------
-- Theme colors (cached per open; allows runtime theme changes)
-- -------------------------------------------------------
local function ThemeColor(name, fallback)
  if Theme and Theme[name] then
    return Theme[name]
  end
  return fallback
end

local function CacheColors(self)
  self._col = self._col or {}

  self._col.ACCENT = ThemeColor("accent", { 0.35, 0.78, 1.00, 1.00 })
  self._col.TEXT   = ThemeColor("text",   { 0.92, 0.92, 0.92, 1.00 })
  self._col.MUTED  = ThemeColor("muted",  { 0.65, 0.65, 0.65, 1.00 })

  local bg = ThemeColor("bg", { 0.06, 0.06, 0.06, 1.00 })
  local panel = ThemeColor("panel", { 0.09, 0.09, 0.09, 1.00 })
  local border = ThemeColor("border", { 0.22, 0.22, 0.22, 1.00 })

  -- Force the tutorial chrome fully opaque so the addon doesn't bleed through the panel.
  self._col.BG     = { bg[1] or 0.06, bg[2] or 0.06, bg[3] or 0.06, 1.00 }
  self._col.PANEL  = { panel[1] or 0.09, panel[2] or 0.09, panel[3] or 0.09, 1.00 }
  self._col.BORDER = { border[1] or 0.22, border[2] or 0.22, border[3] or 0.22, 1.00 }
end

local function SetBtnActive(btn, active, colAccent, colBorder)
  if not btn then return end

  -- Default behavior: border tint (used by normal buttons)
  if btn.SetBackdropBorderColor then
    if active then
      btn:SetBackdropBorderColor(C4(colAccent, colAccent))
    else
      btn:SetBackdropBorderColor(C4(colBorder, colBorder))
    end
  end

  -- Nav step buttons: act like "marker tabs" with a thicker left accent bar
  if btn._wfKind == "nav" and btn._marker then
    btn._wfSelected = active and true or false

    if active then
      btn._marker:SetVertexColor(C4(colAccent, colAccent))
      btn._marker:SetAlpha(1.00)
      btn._marker:SetWidth(8)
    else
      if btn._wfDone then
        btn._marker:SetVertexColor(C4(colAccent, colAccent))
        btn._marker:SetAlpha(0.35)
        btn._marker:SetWidth(5)
      else
        btn._marker:SetVertexColor(C4(colBorder, colBorder))
        btn._marker:SetAlpha(0.55)
        btn._marker:SetWidth(5)
      end
    end


    -- Selected step: make label slightly larger for clearer focus.
    if btn._labelFS and btn._labelFS.GetFont and btn._labelFS.SetFont then
      if not btn._wfBaseFont then
        local fp, sz, fl = btn._labelFS:GetFont()
        if fp and sz then
          btn._wfBaseFont = { fp, sz, fl }
        end
      end

      if btn._wfBaseFont then
        local fp, sz, fl = btn._wfBaseFont[1], btn._wfBaseFont[2], btn._wfBaseFont[3]
        if active then
          btn._labelFS:SetFont(fp, sz + 2, fl)
        else
          btn._labelFS:SetFont(fp, sz, fl)
        end
      end
    end
    -- Subtle background shift for the selected row
    if btn.SetBackdropColor then
      if active then
        btn:SetBackdropColor(0.10, 0.10, 0.10, 0.90)
      else
        btn:SetBackdropColor(0.06, 0.06, 0.06, 0.55)
      end
    end
  end
end


-- -------------------------------------------------------
-- UI builders (with safe widget fallback)
-- -------------------------------------------------------
local function MakePanel(parent, bg, border)
  local f = CreateFrame("Frame", nil, parent, "BackdropTemplate")
  f:SetBackdrop({
    bgFile = "Interface/Buttons/WHITE8x8",
    edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
    edgeSize = 12,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
  })
  f:SetBackdropColor(C4(bg, bg))
  f:SetBackdropBorderColor(C4(border, border))
  return f
end

local function MakeButtonLike(parent, text, w, h, colText, colBorder)
  local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
  b:SetSize(w or 100, h or 22)
  b:SetBackdrop({
    bgFile = "Interface/Buttons/WHITE8x8",
    edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
    edgeSize = 12,
    insets = { left = 2, right = 2, top = 2, bottom = 2 },
  })
  b:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
  b:SetBackdropBorderColor(C4(colBorder, colBorder))
  b:EnableMouse(true)

  local fs = b:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
  fs:SetPoint("LEFT", 10, 0)
  fs:SetTextColor(C4(colText, colText))
  fs:SetText(text or "")
  b._labelFS = fs
  b.label = fs -- unify with Widgets:Button

  b:SetScript("OnEnter", function()
    b:SetBackdropColor(0.10, 0.10, 0.10, 0.92)
  end)
  b:SetScript("OnLeave", function()
    b:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
  end)

  return b
end

local function UIButton(self, parent, text, w, h)
  if Widgets and Widgets.Button then
    local b = Widgets:Button(parent, text, w, h)
    -- Assume Widgets buttons have .label fontstring; if not, keep safe
    if not b.label and b._labelFS then b.label = b._labelFS end
    return b
  end
  CacheColors(self)
  return MakeButtonLike(parent, text, w, h, self._col.TEXT, self._col.BORDER)
end

local function SkinCloseButton(btn)
  if Widgets and Widgets.SkinCloseButton then
    Widgets:SkinCloseButton(btn)
  end
end

local function SkinScrollBar(sb)
  if Widgets and Widgets.SkinScrollBar and sb then
    Widgets:SkinScrollBar(sb)
  end
end

local function SetButtonTooltip(btn, title, lines)
  if not btn then return end

  local tooltipLines = {}
  if type(lines) == "table" then
    for i = 1, #lines do
      tooltipLines[#tooltipLines + 1] = tostring(lines[i] or "")
    end
  elseif lines ~= nil then
    tooltipLines[1] = tostring(lines)
  end

  btn:HookScript("OnEnter", function(self)
    local tooltip = GameTooltip
    if not tooltip then return end

    tooltip:SetOwner(self, "ANCHOR_TOP")
    tooltip:ClearLines()

    if title and title ~= "" then
      tooltip:AddLine(tostring(title), 0.35, 0.78, 1.00, true)
    end

    for i = 1, #tooltipLines do
      local line = tostring(tooltipLines[i] or "")
      if line == "" then
        tooltip:AddLine(" ")
      else
        tooltip:AddLine(line, 0.92, 0.92, 0.92, true)
      end
    end

    tooltip:Show()
  end)

  btn:HookScript("OnLeave", function()
    if GameTooltip then
      GameTooltip:Hide()
    end
  end)

  btn:HookScript("OnHide", function()
    if GameTooltip then
      GameTooltip:Hide()
    end
  end)
end

-- -------------------------------------------------------
-- Help UI focus discovery (best-effort; safe if structure changes)
-- -------------------------------------------------------
local function TryGetHelpTabButton(helpUI, key)
  if not helpUI or not key then return nil end

  if helpUI.tabButtons and helpUI.tabButtons[key] then
    return helpUI.tabButtons[key]
  end
  if helpUI.tabs and helpUI.tabs[key] then
    local t = helpUI.tabs[key]
    if t.button then return t.button end
    if t.tabButton then return t.tabButton end
  end
  if helpUI._tabs and helpUI._tabs[key] then
    local t = helpUI._tabs[key]
    if t.button then return t.button end
    if t.tabButton then return t.tabButton end
  end
  if helpUI._tabButtons and helpUI._tabButtons[key] then
    return helpUI._tabButtons[key]
  end

  return nil
end

-- -------------------------------------------------------
-- Tutorial steps
-- -------------------------------------------------------
local TSM_QUICK = {
  { "Min Buyout (DBMinBuyout)", "dbminbuyout" },
  { "Region Market Avg (DBRegionMarketAvg)", "dbregionmarketavg" },
  { "Market Value (DBMarket)", "dbmarket" },
}

local NAV_HINT_LEFT  = "|TInterface\\Buttons\\UI-SpellbookIcon-PrevPage-Up:16:16:0:0|t"
local NAV_HINT_RIGHT = "|TInterface\\Buttons\\UI-SpellbookIcon-NextPage-Up:16:16:0:0|t"
local CHOOSER_LOGO_TEXTURE = "Interface\\AddOns\\WhatFarm\\Media\\WF_ChooserLogo.tga"
local CHOOSER_LOGO_NATIVE_W = 196
local CHOOSER_LOGO_NATIVE_H = 100
local CHOOSER_LOGO_BOTTOM_OFFSET = 10

local function MakeTextureCrisp(tex)
  if not tex then return end

  if tex.SetTexCoord then
    tex:SetTexCoord(0, 1, 0, 1)
  end

  -- Keep the chooser logo locked to the pixel grid. This avoids the soft/warped look
  -- that can happen when the tutorial panel lands on fractional UI pixels.
  if tex.SetSnapToPixelGrid then
    tex:SetSnapToPixelGrid(true)
  end
  if tex.SetTexelSnappingBias then
    tex:SetTexelSnappingBias(0)
  end
end

local STEPS = {
  {
    group = "Start",
    short = "Start",
    title = "Choose how the addon is displayed",
    intro = "This tutorial is here to make your first real session feel intentional instead of overwhelming.",
    goal = "Pick the tutorial path that matches how much detail you want before release.",
    why = "Fast keeps the tutorial focused on the pages and details that matter most so you can get moving faster. Advanced is the longer version for users who want the addon explained inside and out before their first real session.",
    doNow = "Choose Fast if you want the shorter version that covers what you need to know. Choose Advanced if you want the longer walkthrough that explains the addon more thoroughly from top to bottom.",
    body = {
      "Advanced is the full guided experience. It spends more time on what each page does, why it matters, and how the addon fits together as a complete workflow.",
      "Fast is the shorter path. It stays focused on the pages, decisions, and setup points that matter most before you start farming.",
      "Both paths can be rerun later from Help, so this is only choosing how much explanation you want right now.",
    },
    tip = "If this is your first time with What Farm?, Advanced is the safer first run.",
    wants = "tutorialChoice",
    fast = true,
  },
  {
    key = "Overview",
    group = "Welcome",
    groupStep = 2,
    groupTotal = 2,
    short = "Tutorial",
    title = "Welcome: Resizing Windows",
    goal = "Understand the addons resize language before you start judging page layouts.",
    why = "Most of What Farm can be adjusted to your screen instead of forcing one fixed size. A page can feel cramped, clean, or way too heavy depending on how big the window is.",
    doNow = "Use the corner gripper on this tutorial window now. Anywhere you see that same resize handle, treat it as a sign that the window can be adjusted.",
    actionLabel = "Do this",
    body = {
      "This tutorial panel is the easiest place to teach that behavior first.",
	  "",
      "The same resize language carries through the addon, especially on the main window and the pages you will actually spend time in.",
	  "",
      "A few smaller or special purpose windows may not always follow that rule, but the general idea is simple; if you see the gripper, you're able to resize the frame.",
    },
    tip = "Do not judge a page too fast until you have sized it the way you want.",
    focusLabel = false,
    focus = { kind = "tutorialGrip", pad = 10, side = "TOP", text = "Use this gripper to resize the tutorial panel. The same resize handle shows up across most of What Farm." },
  },
  {
    key = "Overview",
    group = "Welcome",
    groupStep = 2,
    groupTotal = 2,
    short = "Tutorial",
    title = "Welcome: Resizing Windows",
    goal = "Apply that same resize behavior to the settings window.",
    doNow = "Use this gripper to resize the What Farm settings window now. Set it to a size and position that feels natural for your play space.",
    actionLabel = "Do this",
    tip = "Get the settings frame feeling right first. Then judge the pages inside it.",
    focusLabel = false,
    focus = { kind = "parentGrip", pad = 10, side = "TOP", text = "Now move or resize the main What Farm window. This step is about adjusting the addon itself, not the tutorial panel." },
  },
{
  key = "Overview",
  group = "Welcome",
  groupStep = 1,
  groupTotal = 2,
  short = "Welcome",
  title = "Welcome to What Farm",
  fast = true,
  intro = "Welcome to What Farm. This addon helps you track farms, value loot, compare routes, and review what actually happened after a run.",
  body = {
    "We got tired of bouncing between outdated addons, old macros, and spreadsheets just to track results, so What Farm keeps all of that in one place.",
    "",
    "Before you start a run, you set it up properly. Choose the right Mode for the kind of farm you're doing, then choose the right Price Source for how you want loot to be valued. Once those are set, the addon tracks the session using that context.",
    "",
    "After the run, Overview gives you the quick read, Farms helps you compare and organize routes, History lets you inspect past sessions in detail, and Settings controls what gets counted and how.",
    "",
    "That part matters because the same farm can look completely different depending on how it was priced, what kind of run it was, and what data was allowed into the session.",
    "",
    "This tutorial walks you through the main pages so the workflow makes sense from the start, and your first real session feels clear instead of overwhelming.",
  },
  tip = "You can use your arrow keys (left and right) to navigate the addon.",
},
  {
    key = "Overview",
    short = "Mode",
    title = "Choose your Mode",
    fast = true,
    goal = "Tell What Farm what kind of run this is.",
    why = "Mode sets the context for the run. Transmog, mats, and raw gold are not the same kind of farm, so they should not be tracked or judged the same way. The profile you pick changes the rules behind the session so the addon fits the run instead of forcing everything into one bucket.",
    doNow = "Choose the profile that matches what type of farm you'd like to start off with.",
    body = {
      "Hover a profile if you need a quick reminder.",
    },
    checklist = {
      { text = "Pick a Mode below", doneKey = "pickedMode" },
    },
    tip = "If you're recording the wrong items, or what you want to record isn't recording, check out your mode first.",
    wants = "mode",
    focusLabel = false,
    focus = { kind = "overlay", which = "modeRow", pad = 8, side = "TOP", text = "Choose the Mode that matches the run. This decides which profile rules the session uses." },
  },
  {
    key = "Overview",
    short = "Source",
    title = "Choose your Price Source",
    fast = true,
    goal = "Tell What Farm where item value should come from.",
    why = "Source changes how the addon values what you loot. The same run can look grounded, inflated, or way lower than expected depending on whether you use Vendor, Auctionator, or TSM. If the source is wrong, the run is going to read wrong.",
    doNow = "Pick the source that matches how you want to judge the run.",
    body = {
      "Hover a source if you need a quick reminder.",
      "If you use TSM, hover the quick buttons too.",
    },
    checklist = {
      { text = "Pick a Source below", doneKey = "pickedSource" },
      { text = "If using TSM, choose a sane quick string", doneKey = "pickedTSMQuick", optional = true },
    },
    tip = "If your items values don't look correct, check your price source.",
    wants = "source",
    focusLabel = false,
    focus = { kind = "overlay", which = "sourceRow", pad = 8, side = "TOP", text = "Choose the Price Source that makes sense for the run. This is where the addon gets item values from." },
  },
{
  key = "Overview",
  group = "Farm Window",
  groupStep = 1,
  groupTotal = 3,
  short = "Farm Window",
  title = "Farm Window: Session Tracker",
  fast = true,
  goal = "Understand the window you will actually be using during a run.",
  why = "This is where you manage the session while it is happening. You can watch the run build in real time, control it without backing out, and get to the stuff you may need without bouncing around the addon.",
  doNow = "Look through the farm window and get familiar with the session controls, loot table, and quick-access buttons so you know exactly where to manage the run once it starts.",
body = {
  "The header keeps your current session in front of you and gives you quick access to other parts of the addon during the run.",
  "",
  "The loot table updates live as items come in, showing what you looted, how many you have gotten, the single item price, and the total value for each item.",
  "",
  "While a session is running, you can right click the Item column header to manually add an item you missed.",
  "",
  "You can also right click a tracked item to delete it from the current session if something was captured by mistake.",
  "",
  "This is also where you start, stop, or reset the session.",
  "",
  "You can switch Modes and Sources here, change layouts, reset dungeons, open Settings, and open the Compare and Competitive windows without leaving the tracker.",
  "",
  "When the run ends, it can save to your History automatically as long as it meets the requirements you set in Settings.",
},
  tip = "This is basically your command center while you farm.",
  focusLabel = false,
  focus = { kind = "farmWindow", pad = 12, side = "LEFT", text = "This is the live session tracker. Use it to start, stop, reset, review loot, and jump to related windows while you farm." },
},
{
  key = "Overview",
  group = "Farm Window",
  groupStep = 2,
  groupTotal = 3,
  short = "Farm Window",
  title = "Farm Window: Layout Shortcuts",
  fast = true,
  goal = "Know what the layout control really does.",
  why = "That layout control is not just there to change how the tracker looks. It also gives you two fast shortcuts into the Competitive and Compare windows, which is easy to miss if nobody tells you.",
  doNow = "Use the layout control like this: Shift click - change layout. Left click - open Compare. Right click - open Competitive.",
  body = {
    "The layout control handles more than one thing, so it is worth learning early.",
    "",
    "Shift click - change the Farm Window layout.",
    "",
    "Left click - open Compare.",
    "",
    "Right click - open Competitive.",
    "",
    "The gear icon opens Settings from the Farm Window, so you can adjust tracking rules, pricing, save requirements, notifications, and window behavior without leaving the tracker.",
    "",
    "Once you know those shortcuts, the farm window feels a lot faster because you do not have to back out just to get to the related tools.",
  },
  tip = "Easy button to ignore, useful button once you know the shortcuts.",
  focusLabel = false,
  focus = { kind = "farmWindow", which = "layoutOptions", pad = 10, side = "LEFT", text = "Shift click - change layout. Left click - open Compare. Right click - open Competitive." },
},
{
  key = "Overview",
  group = "Farm Window",
  groupStep = 3,
  groupTotal = 3,
  short = "Competitive",
  title = "Farm Window: Competitive",
  fast = true,
  goal = "Understand when to use Competitive instead of a normal solo session.",
  why = "Competitive is for live farming challenges with other players. It gives the host a shared session, a leaderboard, invite and join tools, synced match controls, and a loot view so everyone can see how the race is shaping up.",
  doNow = "Open Competitive from the Farm Window layout control when you want to host or join a farming race. The host should pick the profile and price source before starting, because those become the official rules for the match.",
  body = {
    "Competitive is not just another layout. It is a separate window for group farming races.",
    "",
    "The host creates the session, invites players, and starts the match. When the match starts, What Farm uses the host's active profile and price source as the official scoring rules so everyone is being judged under the same setup.",
    "",
    "The Leaderboard tab shows players, session state, host, profile, scoring, and match controls. The Loot tab shows the competitive loot view so you can see what is carrying the score.",
    "",
    "During an active Competitive match, normal Farm Window controls are protected so a participant cannot silently start, stop, or reset their own separate run and desync the match.",
  },
  tip = "Host sets the rules. Competitive keeps the race under those rules.",
  focusLabel = false,
  focus = { kind = "competitiveWindow", which = "leaderboard", pad = 12, side = "LEFT", text = "Competitive is the live challenge window. Use Leaderboard for players and match controls, and Loot for the shared loot breakdown." },
},
{
  key = "Overview",
  group = "Overview",
  groupStep = 1,
  groupTotal = 4,
  short = "Overview",
  title = "Overview: GPH or Looted Value",
  fast = true,
  goal = "Understand what this card is showing and when you should flip it.",
  why = "This card changes how you read the page. GPH is better for farms with more predictable value, like mats or other steady repeatable farms. Looted Value makes more sense for transmog, where drops are RNG-based and the value is tied more to what you pulled than what you realistically made right away.",
  doNow = "Flip this card between GPH and Looted Value depending on the kind of farm you are looking at. This flips the metric for the whole addon.",
  body = {
    "The Last 5 avg card is not locked to one metric. It can change how the addon views the run.",
    "",
    "GPH is the cleaner read when the farm has more stable and predictable value. That makes it a better fit for mats farms or anything where the return is more consistent from run to run.",
    "",
    "Looted Value is usually the better read for transmog. Transmog is RNG heavy, sales are delayed, and even a high-value item is still only potential value until it actually sells.",
    "",
    "So if a transmog run looks weak in GPH, that does not always mean the run was bad. Sometimes GPH is just the wrong lens for that kind of farm.",
    "",
    "Use the metric that fits the farm. Overview is supposed to help you read the run faster, not force every route into the same type of number.",
  },
  tip = "Use GPH for predictable farms. Use Looted Value for item-heavy farms like transmog.",
  focusLabel = false,
  focus = { kind = "layout", page = "ov", path = {"cards", 1}, pad = 10, side = "RIGHT", text = "This card can flip between GPH and Looted Value depending on how you want to judge the farm." },
},{
  key = "Overview",
  group = "Overview",
  groupStep = 2,
  groupTotal = 4,
  short = "Recent Runs",
  title = "Overview: Recent Runs",
  fast = true,
  goal = "Learn how to quickly check out the session you just recorded.",
  why = "One lucky drop can make a farm look way better than it really is. One scuffed run can do the opposite. This panel helps you look at the recent sessions instead of getting baited by one run.",
  doNow = "Use this panel when you want to compare recent runs by farm, mode, metric, date, or duration.",
  body = {
    "This is the quick read for how the farm has been looking lately.",
    "",
    "You can tell pretty fast whether a route has been holding up or if one run is doing all the talking.",
    "",
    "It is not here to break the run down for you. It is just here to show the recent sessions clearly so you can get a better feel for what has actually been happening.",
    "",
    "And if you want to narrow it down even more, right-click the Mode column to filter by profiles.",
  },
  tip = "A farm you can trust beats a farm that only popped off once.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "ov",
    path = {"recentRuns"},
    pad = 10,
    side = "RIGHT",
    text = "Use Recent Runs to compare sessions, and right-click the Mode column when you want to filter by profile."
  },
},
{
  key = "Overview",
  group = "Overview",
  groupStep = 3,
  groupTotal = 4,
  short = "Utilities",
  title = "Overview: Utilities",
  fast = true,
  why = "This side is where Overview keeps the extra useful stuff. It is not for breaking one run down. It is more for quick tools, extra context, and a few utility widgets that help while you are deciding what to run or checking where your gold is at.",
  doNow = "Use this side when you want to roll a farm, check what is performing, look at token value, check bag worth, or run the farm timer.",
  body = {
    "Random Farm pulls from your farm library and can roll something fully at random or based on your filters.",
    "",
    "When you pick one, it can waypoint the farm and load that farm into your session so the tracker knows where you are supposed to be farming. If you leave the farm area, the session location clears instead of pretending you are still there.",
    "",
    "Top Farms is the quick ranking section. It flips between GPH and LIV when you change the metric using the main card, so it stays in line with how you are currently judging the page.",
    "",
    "WoW Token tracks the current token price and shows how much gold per day you would need to farm to afford one in a month.",
    "",
    "Bag Worth lets you check the value of what is sitting in your bags, bank, and guild bank. If you hold Shift, Refresh Worth changes to Details so you can see what got counted, what got skipped, and why.",
    "",
    "Farm Timer is a custom looping timer mainly meant for group farming. It gives warnings as the timer gets close, then helps call out things like when to kill, how long to kill for, when to loot, and the grace period.",
  },
  tip = "This side is less about one run, more about utility.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "ov",
    path = {"right"},
    pad = 10,
    side = "LEFT",
    text = "This side holds the utility widgets like Random Farm, Top Farms, WoW Token, Bag Worth, and the Farm Timer."
  },
},
{
  key = "Overview",
  group = "Overview",
  groupStep = 4,
  groupTotal = 4,
  short = "About",
  title = "Overview: About",
  fast = true,
  goal = "Know where the built-in explanation lives when you need a refresher.",
  why = "About is the quick reference area. It explains how the main pieces fit together without making you rerun the full tutorial every time you forget a shortcut or a rule.",
  doNow = "Use the About box when you want the plain-English version of sessions, History, Farms, pricing, profiles, Bag Worth, Notepad, and the farm window shortcuts.",
  body = {
    "Each dropdown is meant to be a quick reminder, not a wall of documentation.",
    "",
    "Open the section that matches what you are trying to understand, read the quick breakdown, then get back to farming.",
    "",
    "This is where the addon explains itself after the tutorial is over.",
  },
  tip = "When something feels off, About is usually faster than guessing or digging through every setting.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "ov",
    path = {"about"},
    pad = 10,
    side = "RIGHT",
    text = "Use About as the quick reference box after the tutorial. It keeps the important explanations close without rerunning the full walkthrough."
  },
},
{
  key = "Farms",
  short = "Farms",
  title = "Farms: Finding a Farm",
  fast = true,
  goal = "Get to the right farm without digging through the whole library blind.",
  why = "This page only feels good when it helps you narrow things down fast. Search and filters are what make the library actually useful instead of just being a big list.",
  doNow = "Use search and the top filters to narrow the library by what you are actually trying to run.",
  body = {
    "Search is best when you already know the farm, zone, or keyword you want.",
    "",
    "The filters help when you do not know the exact farm yet, but you do know the kind of route you are looking for.",
    "",
    "Expansion narrows the list by expansion, Type narrows it by the kind of farm, and Group helps you cut it down even further inside that bucket.",
    "",
    "That makes it easier to move from a huge list to a short set of believable options without the page feeling noisy.",
    "",
    "Resync is there for when the farm library gets a little weird. If you record a run, delete a run, or the averages do not look like they updated right, Resync forces the farm averages to refresh.",
    "",
    "The cleaner your farm library is, the faster this page becomes.",
  },
  tip = "Filtering is usually way faster than scrolling once your library starts filling up.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "farms",
    path = {"topArea"},
    pad = 12,
    side = "BOTTOM",
    text = "Use this top area to search, filter by expansion, type, and group, or resync farm averages when the library needs a refresh."
  },
},
 {
  key = "Farms",
  short = "Library",
  title = "Farms: Farm Library",
  fast = true,
  goal = "Use the farm library to keep track of and compare your averages.",
  why = "The library is there to help you judge farms quickly. It stores things like the zone, coordinates, gold per hour, and other useful data so you can compare routes without opening every farm one by one.",
  doNow = "Scan the list first, then open the right side only when a farm actually looks worth your time.",
  body = {
    "This list is your fast comparison layer. It should help you narrow things down before you start digging deeper.",
    "",
    "The Details column has a tooltip too, so if you need a quick reminder of what the farm is for, you can get that without opening it.",
    "",
    "The Zone column shows coordinates, which makes it easier to see where the route actually lives at a glance instead of just knowing the zone name.",
    "",
    "If you right-click the Gold Per Hour column, you can view that farm's average in a specific mode. That means you can do stuff like make a Farm Set 1 and Farm Set 2, then compare the exact averages between those speed sets instead of mashing everything together.",
    "",
    "If you right-click a farm, you get the farm actions like Share, Edit, Waypoints, Delete, Archive, and average editing.",
    "",
    "Archiving is there for farms you do not really want in your active library anymore, but still want to keep the data for.",
    "",
    "The more honest your farm data is, the better this page gets.",
  },
  tip = "Good comparison is not just big numbers. It is knowing what you are actually comparing.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "farms",
    path = {"listPanel"},
    pad = 10,
    side = "RIGHT",
    text = "This list is your farm comparison layer. Scan routes by details, zone, coordinates, and averages before opening the full breakdown."
  },
},
{
  key = "Farms",
  short = "Details",
  title = "Farms: Details and Sharing",
  fast = true,
  goal = "Understand what the farm actually is, what it is trying to do, and how it has really been performing.",
  why = "This side is where the farm stops being just a name in the library. It shows you the actual data behind it so you can tell what the route is, what it is aimed at, and whether it is really worth your time.",
  doNow = "Open a farm and use this side to check its data, preview, top items, tags, and action buttons before you decide what to do with it.",
  body = {
    "This section is built to show you the farm's exact data, not just vague summary stuff.",
    "",
    "It shows things like the full zone, aim, type, expected gold value, coordinates, expansion, real GPH, and when the farm was last updated.",
    "",
    "It also shows the farm's top items based on value, not just count, so the loot section tells you what is actually carrying the route.",
    "",
    "Above the top loot section, there is a preview area where a farm can show its route using imported PNG images.",
    "",
    "You also have the main action buttons here, like Edit, Share, Waypoint, and Random Farm.",
    "",
    "On top of that, this side includes the farm's tags and description, so you still have the context and notes that make the route make sense.",
  },
  tip = "This side is there to show you the full picture, not just the farm name.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "farms",
    path = {"right"},
    pad = 10,
    side = "LEFT",
    text = "This panel shows the selected farm's full data, preview, top loot, tags, notes, and action buttons."
  },
},
{
  key = "History",
  short = "History",
  title = "History: Session History",
  fast = true,
  goal = "Use History when you want the full session list instead of just a quick summary.",
  why = "This section is your full history. It stores your recorded runs so you can search them, sort them, clean them up, and inspect exactly what has been happening over time instead of guessing off averages.",
  doNow = "Use search, column sorting, and mode filtering to narrow the list down to the runs you actually want to look at.",
  body = {
    "This is the full session history for the addon.",
    "",
    "You can filter and sort it by clicking the column headers, and you can use the search bar when you already know what run or farm you are trying to find.",
    "",
    "If you right-click the Mode column, you can filter the list by profile. That makes it easier to compare runs from specific setups instead of mashing everything together.",
    "",
    "Left-clicking Clear History wipes the full history. Right-clicking it opens more specific cleanup options, like deleting runs under a certain time or under a certain LIV or GPH.",
    "",
    "Here, and in Recent Runs, you can rename and share runs.",
    "",
    "But History also lets you merge runs together, which is useful when two sessions really belong as one run.",
    "",
    "The merge list also has a Shift behavior for narrowing things down further, which helps when you are trying to combine related runs faster.",
  },
  tip = "When you want the exact run, not the summary, this is the page.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "history",
    path = {"left"},
    pad = 10,
    side = "RIGHT",
    text = "This is the full run list. Search, sort, filter by Mode, clear bad data, or select a session for deeper review."
  },
},
{
  key = "History",
  short = "Session Details",
  title = "History: Session Details",
  fast = true,
  goal = "Use this side when you want to see exactly what happened in a run, not just the headline.",
  why = "This section is the deeper breakdown for a selected session. It shows the actual session data, the loot that built the result, and the extra controls you need when you want to inspect or clean up what the run is showing.",
  doNow = "Select a run and use this side to read the session stats, review the loot, and manage anything that should not be part of the final picture.",
  body = {
    "This panel shows the run at a more exact level.",
    "",
    "It includes the recorded date, the time the session was logged, the duration, items looted, item types, raw gold looted, looted item value, and the run's actual gold per hour.",
    "",
    "It also shows the session's actual top loot, so you can see which items were really carrying the result instead of just looking at the final total.",
    "",
    "If you right-click an item in the top loot list, you can delete it from the session view, ignore it, or do both.",
    "",
    "If you right-click the Top Loot header, you can view the loot that has been ignored or deleted.",
    "",
    "You can also share the session from here, including both the full version and the shorter version depending on how much detail you want to send.",
  },
  tip = "This is the side you use when you want the actual makeup of the run, not just the number it ended on.",
  focusLabel = false,
  focus = {
    kind = "layout",
    page = "history",
    path = {"right"},
    pad = 10,
    side = "LEFT",
    text = "This side shows the selected run's exact stats and top loot, plus the controls for sharing or cleaning up the session."
  },
},
  {
    key = "Settings",
    group = "Settings",
    groupStep = 1,
    groupTotal = 6,
    short = "Window",
    title = "Settings: Window",
    goal = "Set up how the Farm Window looks and behaves before you start judging the tracker.",
    why = "Window settings control the tracker you actually use during a run. A layout that feels cramped or missing data can make the addon feel worse than it is, so this section lets you shape the Farm Window around your setup.",
    doNow = "Check the Window section and choose the layout, pill setup, keybind behavior, and scale that fit how you farm.",
    body = {
      "Window Layout changes the Farm Window style, and the Pill options choose which value cards appear in the tracker, like Time, Estimated GPH, or Looted Item Value.",
      "",
      "The fourth pill option lets you show more data at once when you want the tracker to carry more information without opening another page.",
      "",
      "This section also controls quality of life options like the minimap button, double tap keybind, auto start, auto resume on loot, locking mode changes while recording, auto splitting when the mode changes, and UI Scale.",
      "",
      "These settings do not change what a farm is worth. They change how the tracker behaves while you are using it.",
    },
    tip = "Get the Farm Window comfortable first. You will see it more than almost anything else.",
    settingsTab = "General",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "Window", pad = 10, side = "RIGHT", text = "These controls shape the Farm Window layout, pills, keybind behavior, auto start behavior, and scale." },
  },
  {
    key = "Settings",
    group = "Settings",
    groupStep = 2,
    groupTotal = 6,
    short = "History & Cleanup",
    title = "Settings: History and Cleanup",
    fast = true,
    goal = "Know where to go when the data needs maintenance instead of pretending every session is perfect.",
    why = "Even well built addons need cleanup tools. Test runs, short sessions, bad pricing, and legacy data all accumulate over time. These controls let you deal with them without throwing everything out.",
    doNow = "Check your minimum duration and GPH thresholds now so the addon does not save sessions that do not belong in your long term averages.",
    body = {
      "The minimum duration setting is one of the most useful filters here. If a session runs for less than a set amount of time, it does not save. This keeps test runs, quick checks, and accidental starts out of your history automatically.",
      "",
      "Minimum GPH works the same way but filters on performance instead of time. A run that crashes your average because something went wrong in the first two minutes does not need to be in your history.",
      "",
      "Sessions to keep controls how many runs the addon stores before it starts pruning the oldest ones. This matters more over time as the session list grows.",
      "",
      "Prune now and Clear history are your manual tools when the automatic filters were not set right from the start. Clear wipes everything. Prune removes sessions that fall below your current thresholds.",
      "",
      "Good maintenance tools let you fix the data instead of abandoning it.",
    },
    tip = "Set the minimum duration early. It is the single easiest way to keep your averages clean.",
    settingsTab = "General",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "History", pad = 10, side = "RIGHT", text = "Use these controls to protect your long term data quality from noise and bad sessions." },
  },
  {
    key = "Settings",
    group = "Settings",
    groupStep = 3,
    groupTotal = 6,
    short = "Loot Rules",
    title = "Settings: Loot Tracking",
    fast = true,
    goal = "Decide what loot should count so your long term averages stay believable.",
    why = "Loose capture rules create noisy data. Tight, intentional rules make the addon feel dramatically smarter because junk runs and irrelevant loot stop polluting your history.",
    doNow = "Use the Loot Tracking section to set your minimum value threshold, quality floor, and category toggles before your first real session.",
    body = {
      "This is one of the most important trust surfaces in the entire addon because it decides whether your history becomes useful or messy over time.",
      "",
      "Minimum value filters out low copper clutter that would otherwise inflate your item count without adding meaningful data. Minimum quality keeps grey and white filler out of the loot picture when you do not care about it.",
      "",
      "Category toggles let you tell the addon exactly what kind of loot belongs in a session. If you are doing a transmog farm, you probably do not want consumables or crafting reagents pulling the numbers. If you are farming mats, gear is probably noise.",
      "",
      "The Soulbound toggle is worth checking too. Some farms produce a lot of BoP drops that have vendor value but no real market value, and including those can make a session look better than it actually was.",
      "",
      "It is much easier to prevent bad data from entering than it is to mentally filter it out later.",
    },
    tip = "Good data is usually the result of good boundaries.",
    settingsTab = "General",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "Loot tracking", pad = 10, side = "RIGHT", text = "These rules decide what kind of loot and session data gets trusted." },
  },
  {
    key = "Settings",
    group = "Settings",
    groupStep = 4,
    groupTotal = 6,
    short = "Notifications",
    title = "Settings: Notifications",
    fast = true,
    goal = "Set up loot notifications so high value drops get your attention without spamming you.",
    why = "A well tuned notification tells you when something meaningful dropped without interrupting the flow of the run. A poorly tuned one fires constantly and gets ignored.",
    doNow = "Set your minimum value threshold and choose how you want to be notified so the alerts stay meaningful instead of becoming background noise.",
    body = {
      "Notifications fire when an item you loot crosses the gold value threshold you set here.",
      "",
      "You can control whether the alert shows in chat, plays a sound, or triggers a raid warning toast. Each of those can be toggled independently so you get exactly the combination that fits how you farm.",
      "",
      "The sound picker lets you choose something subtle or something obvious depending on whether you want a quiet cue or a hard interrupt when a good drop lands.",
      "",
      "The threshold is the most important setting here. Set it too low and every piece of gear fires it. Set it right and it only fires when something actually worth noticing drops.",
      "",
      "Use the Test button to preview how the notification will look and sound before you commit to the settings.",
    },
    tip = "Tune the threshold to your farm. A 5g threshold on a transmog run is going to fire constantly.",
    settingsTab = "General",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "Notifications", pad = 10, side = "RIGHT", text = "These controls decide when and how What Farm? alerts you to valuable drops." },
  },
  {
    key = "Settings",
    group = "Settings",
    groupStep = 5,
    groupTotal = 6,
    short = "Pricing",
    title = "Settings: Pricing",
    fast = true,
    goal = "Make the addon's value logic match the way you actually want to judge farms.",
    why = "Pricing is one of the biggest trust points in the entire addon. When it is wrong, every page feels suspect. When it is aligned, the whole addon feels clean immediately.",
    doNow = "Use the Pricing section to set your default price source and TSM string so values mean something consistent across every session.",
    body = {
      "This is where you make Vendor, Auctionator, or TSM feel deliberate instead of accidental.",
      "",
      "Vendor is the safest baseline. It never changes, never inflates, and always reflects what you can immediately convert an item into. It undersells market reality, but it is honest.",
      "",
      "Auctionator uses recent scan data from your local auction house. It is more useful than vendor for items you actually plan to sell, but it depends on your scans being current.",
      "",
      "TSM gives you access to regional and historical pricing, which is the most powerful option if you already use TSM for your gold making workflow. The price string you pick here matters a lot. DBRegionMarketAvg is a reasonable starting point for most people.",
      "",
      "If values suddenly look too high, too low, or too volatile, this section is the first place to inspect. A single pricing choice can completely change how a session reads, especially on item-heavy farms.",
    },
    tip = "If the values are not believable, nothing else feels stable.",
    settingsTab = "General",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "Pricing", pad = 10, side = "RIGHT", text = "This section controls how What Farm? turns loot into displayed value." },
  },
  {
    key = "Settings",
    group = "Settings",
    groupStep = 6,
    groupTotal = 6,
    short = "Profiles",
    title = "Settings: Profiles",
    fast = true,
    goal = "Use profiles to switch intent without rebuilding your settings every time.",
    why = "Profiles reduce friction and keep your data cleaner. Instead of rethinking every toggle for every route, you swap to the mindset that matches the run and keep moving.",
    doNow = "Open the Profiles tab and set up at least one profile that matches a farm type you run regularly. Even a basic one is better than always using the defaults.",
    body = {
      "Profiles are one of the easiest ways to make the addon feel lighter in day-to-day use.",
      "",
      "Each profile can represent a different standard for what should count, how value should be judged, and what kind of session you are trying to measure. A transmog profile might exclude mats and consumables. A raw gold profile might use vendor pricing. A mats profile might use TSM with a regional market string.",
      "",
      "Switching profiles takes one click. Once you have a few set up, choosing the right context for a run stops feeling like a chore.",
      "",
      "The profile editor on the right side shows you a live preview of the rules and lets you adjust individual settings without touching the active profile until you apply it.",
      "",
      "They are especially helpful when you regularly bounce between very different farm types. A well built profile system turns setup friction into one click.",
    },
    tip = "Profiles make good habits easier to repeat.",
    settingsTab = "Profiles",
    focusLabel = false,
    focus = { kind = "layout", page = "profiles", path = {"listPanel"}, pad = 10, side = "RIGHT", text = "Use this page to switch and manage saved farming setups." },
  },
  {
    key = "Notepad",
    short = "Notepad",
    title = "Notepad: keep route knowledge close",
    goal = "Save route notes, reminders, and ideas where they are still connected to your workflow.",
    why = "Great farms are not just data. They also come with route tricks, setup reminders, spawn notes, and small lessons you do not want to lose between sessions.",
    doNow = "Use Notepad whenever you want to preserve the kind of knowledge that normally gets forgotten after a good run.",
    body = {
      "This is the place for route reminders, farm specific observations, setup notes, and loose ideas worth revisiting later.",
      "",
      "Keeping those notes inside the addon makes the library and your session history more useful over time. The knowledge that made a run click is worth as much as the data it produced.",
    },
    tip = "The best route notes are usually written right after the session that taught them.",
    focusLabel = false,
    focus = { kind = "layout", page = "notepad", path = {"editorPanel"}, pad = 10, side = "LEFT", text = "Use this editor area to save route notes, reminders, item links, and anything else you do not want to forget." },
  },
  {
    key = "Overview",
    short = "Finish",
    title = "You are ready to do a clean session",
    fast = true,
    goal = "Leave the tutorial with a clear mental model of how the major pieces fit together.",
    why = "Once Mode, Source, Overview, Farms, History, and Settings all make sense together, What Farm? stops feeling like a pile of features and starts feeling intentional.",
    doNow = "Close the tutorial and do one clean session. Choose the right Mode, choose the right Source, run the farm, then review Overview and History right after.",
    body = {
      "You now know where to set intent, where value comes from, where to compare farms, where to audit runs, and where to tighten the rules behind your data.",
      "That is enough to get useful results immediately, and enough context to understand why the addon is behaving the way it is.",
      "Reopen the tutorial any time from Help if you want a refresher or want to run the other path.",
    },
    tip = "Your first clean session is where the addon starts feeling obvious.",
    wants = "finish",
    focusLabel = false,
    focus = { kind = "helpFrame", pad = 12, side = "RIGHT", text = "You now have the full workflow: set intent, run the farm, then review the truth behind the numbers." },
  },
  {
    key = "Overview",
    short = "Welcome",
    title = "Fast: Welcome to What Farm",
    fastOnly = true,
    fastOrder = 1,
    intro = "Welcome to What Farm. Fast mode gives you the clean first run path without turning the tutorial into the full manual.",
    goal = "Understand the basic flow before your first real session.",
    why = "The addon works best when the setup, session, and review pieces all make sense together. You pick the kind of run, pick how loot should be valued, record the session, then review what happened.",
    doNow = "Use this fast path to learn enough to run one clean session, then come back for Advanced when you want the deeper page by page walkthrough.",
    body = {
      "The core loop is simple: choose Mode, choose Source, run the farm from the Farm Window, then review Overview, Farms, and History.",
      "",
      "Settings controls the rules behind the data, like what gets counted, when sessions save, how pricing works, and how the Farm Window behaves.",
      "",
      "Fast mode keeps this focused on what you need before the addon starts feeling natural.",
    },
    tip = "Use your left and right arrow keys to move through the tutorial.",
  },
  {
    key = "Overview",
    short = "Mode",
    title = "Fast: Choose your Mode",
    fastOnly = true,
    fastOrder = 2,
    goal = "Tell What Farm what kind of run this is.",
    why = "Mode sets the farming setup behind the session. Transmog, mats, and raw gold should not always use the same rules, so Mode helps the addon judge the run under the right setup.",
    doNow = "Pick the Mode that matches the farm you are about to run.",
    body = {
      "Think of Mode as the intent for the session.",
      "",
      "A raw gold setup can care about immediate value. A transmog setup can care more about looted item value. A mats setup may use different item filters and pricing.",
      "",
      "When the Mode is wrong, the session can still record, but the data may not mean what you think it means.",
    },
    checklist = {
      { text = "Pick a Mode below", doneKey = "pickedMode" },
    },
    tip = "If the addon is counting the wrong kind of loot, check the Mode first.",
    wants = "mode",
    focusLabel = false,
    focus = { kind = "overlay", which = "modeRow", pad = 8, side = "TOP", text = "Choose the Mode that matches the run. This decides which profile rules the session uses." },
  },
  {
    key = "Overview",
    short = "Source",
    title = "Fast: Choose your Price Source",
    fastOnly = true,
    fastOrder = 3,
    goal = "Tell What Farm where item value should come from.",
    why = "Price Source changes how the addon values loot. The same session can look conservative, market based, or completely wrong depending on whether Vendor, Auctionator, or TSM is the right fit for the farm.",
    doNow = "Pick the source that matches how you want this run judged.",
    body = {
      "Vendor is the safest immediate value.",
      "",
      "Auctionator uses your auction scan data, so it is better when you care about what items may sell for on your realm.",
      "",
      "TSM is the strongest option for market based gold making if you already use TSM and have a price string you trust.",
      "",
      "Market value does not mean the item already sold. It is an estimate based on the source you selected.",
    },
    checklist = {
      { text = "Pick a Source below", doneKey = "pickedSource" },
      { text = "If using TSM, choose a sane quick string", doneKey = "pickedTSMQuick", optional = true },
    },
    tip = "If item values look insane, check Source before blaming the session.",
    wants = "source",
    focusLabel = false,
    focus = { kind = "overlay", which = "sourceRow", pad = 8, side = "TOP", text = "Choose the Price Source that makes sense for the run. This is where the addon gets item values from." },
  },
  {
    key = "Overview",
    group = "Farm Window",
    groupStep = 1,
    groupTotal = 3,
    short = "Farm Window",
    title = "Fast: Farm Window",
    fastOnly = true,
    fastOrder = 4,
    goal = "Know where the live session is controlled.",
    why = "The Farm Window is the tracker you actually use while farming. It shows the active farm, timer, raw gold, selected profile, session controls, and the loot window with tracked items and values.",
    doNow = "Look over the Farm Window before you start the run so the controls and loot table are not a surprise mid session.",
    body = {
      "Start begins the session. In Compact layout, Start can also toggle between Start, Pause, and Resume.",
      "",
      "Shift click Start hard stops and saves immediately instead of pausing. Shift click Stop ends the run and saves it straight to History as long as the session meets the requirements in Settings.",
      "",
      "While a session is running, right click the Item column header to manually add an item you missed.",
      "",
      "You can also right click a tracked item to delete it from the current run if something was captured by mistake.",
    },
    tip = "This is your command center while you farm.",
    focusLabel = false,
    focus = { kind = "farmWindow", pad = 12, side = "LEFT", text = "This is the live session tracker. Use it to start, stop, review loot, fix missed items, and save the run to History." },
  },
  {
    key = "Overview",
    group = "Farm Window",
    groupStep = 2,
    groupTotal = 3,
    short = "Window Tools",
    title = "Fast: Farm Window Shortcuts",
    fastOnly = true,
    fastOrder = 5,
    goal = "Learn the Farm Window buttons that save the most time.",
    why = "A few controls in the Farm Window do more than they look like they do. Knowing them early makes the tracker feel faster and keeps you from hunting through other pages mid run.",
    doNow = "Remember the layout control shortcuts and the Settings gear before you start your first clean session.",
    body = {
      "Shift click layout - change the Farm Window layout.",
      "",
      "Left click layout - open Compare.",
      "",
      "Right click layout - open Competitive.",
      "",
      "The gear icon opens Settings from the Farm Window.",
      "",
      "Right click the profile bar to cycle profiles backwards instead of forwards.",
    },
    tip = "The layout icon is more than a layout button.",
    focusLabel = false,
    focus = { kind = "farmWindow", which = "layoutOptions", pad = 10, side = "LEFT", text = "Shift click - change layout. Left click - open Compare. Right click - open Competitive." },
  },
  {
    key = "Overview",
    group = "Farm Window",
    groupStep = 3,
    groupTotal = 3,
    short = "Competitive",
    title = "Fast: Farm Window Competitive",
    fastOnly = true,
    fastOrder = 6,
    goal = "Know what the Competitive window is for before you need it.",
    why = "Competitive lets you turn a farm into a shared challenge instead of everyone running separate sessions and arguing over the results later.",
    doNow = "Use Competitive when you want a host-controlled farming race with invites, a leaderboard, synced start and end controls, and shared scoring rules.",
    body = {
      "Open Competitive by right clicking the Farm Window layout control.",
      "",
      "The host picks the active profile and price source before starting. Those become the official rules for scoring the match.",
      "",
      "Leaderboard is for players, session state, and match controls. Loot is for seeing the items behind the score.",
      "",
      "Once a Competitive match is active, main Farm Window controls are locked where needed so the shared session does not desync.",
    },
    tip = "Use Competitive when the run needs a winner, not just a saved session.",
    focusLabel = false,
    focus = { kind = "competitiveWindow", which = "leaderboard", pad = 12, side = "LEFT", text = "Competitive is the live challenge window for invites, leaderboard, match controls, and shared loot." },
  },
  {
    key = "Overview",
    short = "Overview",
    title = "Fast: Overview",
    fastOnly = true,
    fastOrder = 7,
    goal = "Use Overview as the first place to read recent results.",
    why = "Overview brings the main value cards, Recent Runs, Top Farms, and utility tools together in one place. It is not the full audit page, but it is the quickest place to see what has been happening.",
    doNow = "Use Overview after a run to check the latest result, compare recent sessions, and pick which metric fits the farm.",
    body = {
      "The top card flips between GPH and Looted Item Value.",
      "",
      "Use GPH for predictable farms like mats or steady repeatable routes. Use Looted Item Value when the farm is more RNG heavy, like transmog, recipes, pets, mounts, or gear.",
      "",
      "Recent Runs shows your latest saved sessions without opening the full History page. Right click the Mode column when you want to filter by profile.",
      "",
      "The right side has utilities like Random Farm, Top Farms, WoW Token, Bag Worth, and Farm Timer.",
    },
    tip = "Use the metric that matches the farm instead of forcing every route into GPH.",
    focusLabel = false,
    focus = { kind = "layout", page = "ov", path = {"cards", 1}, pad = 10, side = "RIGHT", text = "Flip this card between GPH and Looted Item Value depending on how you want to judge the farm." },
  },
  {
    key = "Farms",
    short = "Farms",
    title = "Fast: Farms",
    fastOnly = true,
    fastOrder = 8,
    goal = "Use the Farms page as your route library.",
    why = "Farms is where routes become organized instead of just being remembered. Search and filters help you narrow the list, and the details side gives you the information you need before starting a route.",
    doNow = "Use search, Expansion, Type, and Group filters to find a route that matches what you want to run.",
    body = {
      "The farm list helps you compare routes by zone, coordinates, details, and averages.",
      "",
      "Right click a farm row for actions like Edit, Share, Waypoint, Archive, Delete, and average tools.",
      "",
      "Right click the GPH column header to view averages for a specific profile, so different setups do not get mashed together.",
      "",
      "Use Resync when farm averages need to be recalculated from your actual History.",
    },
    tip = "The better your History data is, the more useful Farms becomes.",
    focusLabel = false,
    focus = { kind = "layout", page = "farms", path = {"listPanel"}, pad = 10, side = "RIGHT", text = "Use the farm list to search, filter, compare averages, and manage routes." },
  },
  {
    key = "History",
    short = "History",
    title = "Fast: History",
    fastOnly = true,
    fastOrder = 9,
    goal = "Know where the saved run actually lives.",
    why = "History is the audit page. Overview can summarize a run, but History is where you inspect the saved session, clean up bad items, merge related runs, and understand what drove the number.",
    doNow = "After your first session saves, open History and click the run to review the full breakdown.",
    body = {
      "The left side is the full run list. You can search, sort, filter by profile from the Mode column, rename, share, merge, or clean up sessions.",
      "",
      "The right side shows the selected run's details, including date, duration, item count, item types, raw gold, looted value, actual GPH, and top loot.",
      "",
      "Right click an item in the top loot list to delete it from the session view, ignore it, or both.",
      "",
      "Right click Clear History for targeted cleanup instead of wiping everything at once.",
    },
    tip = "When a number looks wrong, History is where you prove why.",
    focusLabel = false,
    focus = { kind = "layout", page = "history", path = {"left"}, pad = 10, side = "RIGHT", text = "History is the full saved run list. Select a session to inspect, clean up, merge, rename, or share it." },
  },
  {
    key = "Settings",
    short = "Settings",
    title = "Fast: Settings",
    fastOnly = true,
    fastOrder = 10,
    goal = "Know which settings protect your data before the first real run.",
    why = "Settings decides how the addon records, filters, prices, alerts, displays, and cleans up data. When something feels wrong, it is usually because one of these rules does not match the kind of farm you are running.",
    doNow = "Before your first clean session, check Window, History, Loot Tracking, Notifications, Profiles, and Pricing at least once.",
    body = {
      "Window controls Farm Window layout, pills, minimap button, double tap keybind, auto start, auto resume on loot, lock mode while recording, auto split, and UI Scale.",
      "",
      "History controls the requirements a session has to meet before it saves, like minimum duration, minimum GPH, minimum LIV, and pruning.",
      "",
      "Loot Tracking controls what gets captured, including minimum value, quality floor, category toggles, and soulbound filtering.",
      "",
      "Notifications controls valuable drop alerts, including threshold, chat, sound, raid warning toast, and testing.",
      "",
      "Profiles let you save different farming setups. Pricing decides whether values come from Vendor, Auctionator, or TSM.",
    },
    tip = "Settings is where you fix rules. History is where you fix saved runs.",
    focusLabel = false,
    focus = { kind = "settingsSection", title = "History", pad = 10, side = "RIGHT", text = "Settings controls the rules behind tracking, saving, pricing, notifications, profiles, and window behavior." },
  },
  {
    key = "Overview",
    short = "Finish",
    title = "Fast: Ready for a Clean Session",
    fastOnly = true,
    fastOrder = 11,
    goal = "Leave Fast mode ready to record one clean run.",
    why = "You now know the minimum workflow: set Mode, set Source, run the Farm Window, review Overview, check Farms and History, then tighten Settings when the rules need adjustment.",
    doNow = "Close the tutorial and do one clean session. Pick the right Mode and Source first, then review the saved run in Overview and History when you are done.",
    body = {
      "Do not judge the addon from a test run with messy settings.",
      "",
      "Run one real farm with the right setup, let it save properly, then inspect what happened.",
      "",
      "Advanced is there when you want the deeper explanation for every page.",
    },
    tip = "Your first clean session is where the addon starts making sense.",
    wants = "finish",
    focusLabel = false,
    focus = { kind = "helpFrame", pad = 12, side = "RIGHT", text = "Fast mode is done. Set intent, record the session, then review the truth behind the numbers." },
  },
}

local function StepCount() return #STEPS end

local function BuildStepSequence(mode)
  local seq = {}
  local wantFast = (tostring(mode or "advanced") == "fast")

  local function add(absIdx)
    local step = STEPS[absIdx]
    if not step then return end
    if step.wants == "tutorialChoice" then return end
    if step.fastOnly then return end
    seq[#seq + 1] = absIdx
  end

  if wantFast then
    local fast = {}
    for i = 1, #STEPS do
      local step = STEPS[i]
      if step and step.fastOnly then
        fast[#fast + 1] = {
          idx = i,
          order = tonumber(step.fastOrder) or i,
        }
      end
    end

    table.sort(fast, function(a, b)
      if a.order == b.order then
        return a.idx < b.idx
      end
      return a.order < b.order
    end)

    for i = 1, #fast do
      seq[#seq + 1] = fast[i].idx
    end
  else
    -- Advanced should still start with a proper welcome page before the resize steps.
    add(4)
    add(2)
    add(3)
    for i = 5, #STEPS do
      add(i)
    end
  end

  if #seq == 0 then
    for i = 1, #STEPS do
      add(i)
    end
  end

  return seq
end

local function NavGroupLabel(step, idx)
  if not step then
    return "Step " .. tostring(idx or "?")
  end
  if step.group and tostring(step.group) ~= "" then
    return tostring(step.group)
  end

  local short = tostring(step.short or "")
  if short == "Recent Runs" then return "Overview" end
  if short == "Library" or short == "Details" then return "Farms" end
  if short == "Session Details" then return "History" end
  if short == "Pricing" then return "Settings" end

  if short ~= "" then return short end
  return tostring(step.title or ("Step " .. tostring(idx or "?")))
end

local function ComputeNavGroupsForSequence(sequence)
  local byStep = {}
  local order = {}
  local lookup = {}

  for displayIdx = 1, #(sequence or {}) do
    local absIdx = sequence[displayIdx]
    local step = STEPS[absIdx]
    local label = NavGroupLabel(step, absIdx)
    local g = lookup[label]
    if not g then
      g = {
        key = label,
        label = label,
        first = displayIdx,
        last = displayIdx,
        steps = {},
      }
      lookup[label] = g
      order[#order + 1] = g
    else
      g.last = displayIdx
    end

    g.steps[#g.steps + 1] = absIdx
    byStep[displayIdx] = g
  end

  return byStep, order, lookup
end

local NAV_GROUPS_BY_STEP, NAV_GROUP_ORDER, NAV_GROUP_LOOKUP = ComputeNavGroupsForSequence(BuildStepSequence("advanced"))

local function NavGroupForIndex(idx)
  idx = tonumber(idx) or 1
  if idx < 1 then idx = 1 end
  if idx > #NAV_GROUPS_BY_STEP then idx = #NAV_GROUPS_BY_STEP end
  return NAV_GROUPS_BY_STEP[idx]
end

local function GroupHasAnyDone(flags, group)
  if type(group) ~= "table" or type(group.steps) ~= "table" then return false end
  for i = 1, #group.steps do
    if flags[group.steps[i]] then
      return true
    end
  end
  return false
end

local function GroupAllDone(flags, group)
  if type(group) ~= "table" or type(group.steps) ~= "table" or #group.steps == 0 then return false end
  for i = 1, #group.steps do
    if not flags[group.steps[i]] then
      return false
    end
  end
  return true
end

local TUTORIAL_MIN_W = 540
local TUTORIAL_MIN_H = 420
local TUTORIAL_MAX_W_CAP = 760
local CHOOSER_ARROW_Y_OFFSET = -1
local CHOOSER_ARROW_HINT_BOTTOM_OFFSET = 38
local CHOOSER_ARROW_MIN_INSET = 100
local CHOOSER_ARROW_MAX_INSET = 140

local function GetTutorialPanelBounds(parentFrame)
  local upw = (UIParent and UIParent.GetWidth and UIParent:GetWidth()) or 1600
  local uph = (UIParent and UIParent.GetHeight and UIParent:GetHeight()) or 1000
  local minW = TUTORIAL_MIN_W
  local minH = TUTORIAL_MIN_H
  local maxW = math.max(minW, math.min(TUTORIAL_MAX_W_CAP, math.floor(upw - 40)))
  local maxH = math.max(minH, math.floor(uph - 40))

  if parentFrame and parentFrame.GetHeight then
    local ph = tonumber(parentFrame:GetHeight() or 0) or 0
    if ph > 0 then
      maxH = math.max(minH, math.min(maxH, math.floor(ph - 20)))
    end
  end

  return minW, minH, maxW, maxH
end

local function ClampTutorialPanelSize(parentFrame, w, h)
  local minW, minH, maxW, maxH = GetTutorialPanelBounds(parentFrame)
  w = tonumber(w) or minW
  h = tonumber(h) or minH
  if Widgets and Widgets.SnapSize then
    w, h = Widgets:SnapSize(UIParent, w, h)
  end
  w = Clamp(RoundInt(w), minW, maxW)
  h = Clamp(RoundInt(h), minH, maxH)
  return w, h, minW, minH, maxW, maxH
end

local function StepText(step)
  if not step then return "" end

  local out = {}
  local BLUE = "|cff5ecbff"
  local RESET = "|r"
  local MUTED = "|cff9fa6ad"
  local SOFT_WHITE = "|cffd9e0e8"

  local function addSection(label, text)
    text = tostring(text or "")
    if text == "" then return end
    out[#out + 1] = BLUE .. label .. RESET
    out[#out + 1] = text
    out[#out + 1] = ""
  end

  if step.intro and step.intro ~= "" then
    out[#out + 1] = SOFT_WHITE .. tostring(step.intro) .. RESET
    out[#out + 1] = ""
  end

  local focusText = step.focus and step.focus.text
  if type(focusText) == "string" and focusText ~= "" then
    local focusLabel = step.focusLabel
    if focusLabel ~= false then
      if focusLabel == "" then
        out[#out + 1] = focusText
        out[#out + 1] = ""
      else
        addSection(tostring(focusLabel or "On screen"), focusText)
      end
    end
  end

  addSection(tostring(step.goalLabel or "Goal"), step.goal)
  addSection(tostring(step.whyLabel or "Why it matters"), step.why)
  addSection(tostring(step.actionLabel or "Do this"), step.doNow)

  local body = step.body
  if type(body) == "string" then
    body = { body }
  end
  if type(body) == "table" and #body > 0 then
    for i = 1, #body do
      out[#out + 1] = tostring(body[i] or "")
    end
    out[#out + 1] = ""
  end

  if step.tip and step.tip ~= "" then
    out[#out + 1] = MUTED .. "Tip: " .. RESET .. tostring(step.tip)
  end

  while #out > 0 and tostring(out[#out] or "") == "" do
    table.remove(out, #out)
  end
  return table.concat(out, "\n")
end



-- -------------------------------------------------------
-- Spotlight geometry
-- -------------------------------------------------------
local function RectFromFrameRelative(root, fr)
  if not root or not fr or not fr.GetLeft then return nil end
  if not fr:IsShown() then return nil end

  local l, b, w, h = fr:GetLeft(), fr:GetBottom(), fr:GetWidth(), fr:GetHeight()
  if not l or not b or not w or not h then return nil end

  local fs = fr:GetEffectiveScale() or 1
  local rs = root:GetEffectiveScale() or 1

  local rl, rb = root:GetLeft(), root:GetBottom()
  if not rl or not rb then return nil end

  local lpx = l * fs
  local bpx = b * fs
  local rpx = (l + w) * fs
  local tpx = (b + h) * fs

  local rlpx = rl * rs
  local rbpx = rb * rs

  local left   = (lpx - rlpx) / rs
  local bottom = (bpx - rbpx) / rs
  local right  = (rpx - rlpx) / rs
  local top    = (tpx - rbpx) / rs

  return left, bottom, right, top
end

local function NearlyEqual(a, b, eps)
  eps = eps or 0.5
  if a == nil or b == nil then return false end
  return math.abs(a - b) <= eps
end

local function RectEqual(r1, r2)
  if not r1 or not r2 then return false end
  return NearlyEqual(r1.l, r2.l) and NearlyEqual(r1.b, r2.b) and NearlyEqual(r1.r, r2.r) and NearlyEqual(r1.t, r2.t)
end

-- -------------------------------------------------------
-- Dirty engine
-- -------------------------------------------------------
function TO:_InitState()
  self._ui = self._ui or {}
  self._dirty = self._dirty or {}
  self._session = self._session or { interact = {} }
  self._spot = self._spot or { active = false }
  self._chooserActive = self._chooserActive and true or false
  CacheColors(self)
end

function TO:_HasTutorialChoice()
  -- The chooser should only be considered completed after the user actually
  -- clicks Advanced or Fast. Older builds could leave tutorialMode or
  -- tutorialStep in saved variables before a real choice was made, which made
  -- fresh/beta users open on a random tutorial page instead of the selector.
  return GetSetting("tutorialChoiceMade", nil) == true
end

function TO:_IsChoosing()
  return self._chooserActive and true or false
end

function TO:_GetTutorialMode()
  local mode = tostring(self._tutorialMode or GetSetting("tutorialMode", "advanced") or "advanced")
  if mode ~= "fast" then
    mode = "advanced"
  end
  return mode
end

function TO:_SetTutorialMode(mode, persist)
  mode = tostring(mode or "advanced")
  if mode ~= "fast" then
    mode = "advanced"
  end

  self._tutorialMode = mode
  if persist ~= false then
    SetSetting("tutorialMode", mode)
  end

  self._sequence = BuildStepSequence(mode)
  self._navGroupsByStep, self._navGroupOrder, self._navGroupLookup = ComputeNavGroupsForSequence(self._sequence)

  if self._BuildNavButtons then
    self:_BuildNavButtons()
  end
end

function TO:_StepCount()
  local seq = self._sequence
  if not seq or #seq == 0 then
    self:_SetTutorialMode(self:_GetTutorialMode())
    seq = self._sequence
  end
  return (seq and #seq) or 1
end

function TO:_GetAbsoluteStepIndex(displayIdx)
  local count = self:_StepCount()
  displayIdx = Clamp(displayIdx, 1, count)
  local seq = self._sequence or BuildStepSequence(self:_GetTutorialMode())
  return seq[displayIdx] or displayIdx
end

function TO:_GetStep(displayIdx)
  return STEPS[self:_GetAbsoluteStepIndex(displayIdx)]
end

function TO:_GetNavGroupForIndex(displayIdx)
  local count = self:_StepCount()
  displayIdx = Clamp(displayIdx, 1, count)
  return self._navGroupsByStep and self._navGroupsByStep[displayIdx] or nil
end

function TO:_ShouldKeepOverlayWhenParentHides()
  if not self._session or not self._session.autoHiddenTutorialParent then
    return false
  end

  if self:_IsChoosing() then
    return false
  end

  return IsDetachedWindowStep(self:_GetStep(tonumber(self._idx) or 1)) and true or false
end

function TO:_HideTutorialParentForDetachedWindow()
  self:_InitState()

  local ui = self._ui
  local parent = ui and ui.parent or nil
  if not IsFrame(parent) then return false end

  if self._session then
    -- Set this before hiding the parent so the parent's OnHide hook knows
    -- the tutorial overlay should remain visible during detached window steps.
    self._session.autoHiddenTutorialParent = true
  end

  if not parent.IsShown or not parent:IsShown() then
    return true
  end

  if self._PinPanelToScreen then
    self:_PinPanelToScreen()
  end

  local hidden = EnsureFrameHidden(parent)
  if not hidden and self._session then
    self._session.autoHiddenTutorialParent = nil
  end

  if ui and ui.root and ui.root._wf_sync then
    ui.root._wf_sync()
  end

  self:_Dirty("layout")
  self:_Dirty("spot")
  return hidden
end

function TO:_RestoreTutorialParentAfterDetachedWindow()
  self:_InitState()

  if not self._session or not self._session.autoHiddenTutorialParent then
    return false
  end

  local ui = self._ui
  local parent = ui and ui.parent or nil
  self._session.autoHiddenTutorialParent = nil

  if not IsFrame(parent) then
    return false
  end

  local shown = EnsureFrameShown(parent)
  if not shown then
    return false
  end

  C_Timer.After(0, function()
    local root = self._ui and self._ui.root
    if not root or not root.IsShown or not root:IsShown() then return end

    -- Do NOT redock the tutorial panel when returning from a detached window step.
    -- We pinned it to screen space before hiding the parent specifically so the
    -- panel would stay where the user left it. Re-docking here snaps it back to
    -- the parent window and feels like the tutorial position reset.
    if self._EnsurePanelVisible then
      self:_EnsurePanelVisible()
    end
    if root._wf_sync then
      root._wf_sync()
    end

    self:_Dirty("layout")
    self:_Dirty("spot")
    if self._QueueSpotRefresh then
      self:_QueueSpotRefresh()
    end
  end)

  return true
end


-- -------------------------------------------------------
-- Checklist clearing
-- Prevents one-frame flashes where prior step checklist lingers when navigating quickly.
-- -------------------------------------------------------
function TO:_ClearChecklistUI()
  self:_InitState()
  local ui = self._ui
  if not ui then return end

  if ui.checkTitle and ui.checkTitle.Hide then ui.checkTitle:Hide() end
  if ui.checkWrap  and ui.checkWrap.Hide  then ui.checkWrap:Hide() end

  local lines = ui.checkLines
  if lines then
    for i = 1, #lines do
      local fs = lines[i]
      if fs then
        if fs.SetText then fs:SetText("") end
        if fs.Hide then fs:Hide() end
      end
    end
  end
end


-- -------------------------------------------------------
-- Global inline focus helper (fallback)
-- Ensures _ApplyInlineFocus is always available even if Build() was cached/short-circuited.
-- Uses stored UI refs (self._ui.inlineFocus, etc.).
-- -------------------------------------------------------
function TO:_ApplyInlineFocus(targetFrame, pad, force)
  self:_InitState()
  local ui = self._ui
  if not ui or not ui.root or not ui.panel then return end
  if not ui.inlineFocus then return end

  if not targetFrame or not targetFrame.IsShown or not targetFrame:IsShown() then
    if self._ClearSpotlight then
      self:_ClearSpotlight()
    elseif ui.inlineFocus then
      ui.inlineFocus:Hide()
      if ui.inlinePulse then ui.inlinePulse:Stop() end
    end
    return
  end

  pad = tonumber(pad) or 8
  force = (force ~= false)

  self._spot = self._spot or {}
  local prevTarget = self._spot.target
  local prevPad = self._spot.pad
  local prevText = tostring(self._spot.text or "")
  local nextText = tostring(self._spot.text or "")
  local needsAnchor = force
    or self._spot.kind ~= "inline"
    or prevTarget ~= targetFrame
    or prevPad ~= pad
    or not ui.inlineFocus:IsShown()

  local needsCallout = force or prevText ~= nextText or needsAnchor

  self._spot.active = true
  self._spot.kind = "inline"
  self._spot.target = targetFrame
  self._spot.pad = pad
  self._spot.text = nextText

  if ui.blockAll then ui.blockAll:Show() end
  if ui.spotTop then ui.spotTop:Hide() end
  if ui.spotBot then ui.spotBot:Hide() end
  if ui.spotLeft then ui.spotLeft:Hide() end
  if ui.spotRight then ui.spotRight:Hide() end
  if ui.holeBorder then ui.holeBorder:Hide() end
  if ui.holePulse then ui.holePulse:Stop() end

  if needsAnchor then
    ui.inlineFocus:ClearAllPoints()
    ui.inlineFocus:SetPoint("TOPLEFT", targetFrame, "TOPLEFT", -pad, pad)
    ui.inlineFocus:SetPoint("BOTTOMRIGHT", targetFrame, "BOTTOMRIGHT", pad, -pad)
  end
  ui.inlineFocus:Show()

  if ui.callout and ui.calloutText then
    local msg = nextText
    if msg ~= "" then
      ui.calloutText:SetText(msg)
      if needsCallout then
        local calloutAnchor = ui.inlineFocus
        if ui.sourceRow and targetFrame == ui.sourceRow and ui.tsmRow and ui.tsmRow.IsShown and ui.tsmRow:IsShown() then
          calloutAnchor = ui.tsmRow
        end

        ui.callout:ClearAllPoints()
        ui.callout:SetPoint("TOPLEFT", calloutAnchor, "BOTTOMLEFT", 0, -8)
      end
      ui.callout:Show()
    else
      ui.callout:Hide()
    end
  end

  if ui.inlinePulse then
    if force or needsAnchor or not ui.inlinePulse:IsPlaying() then
      ui.inlinePulse:Stop()
      ui.inlinePulse:Play()
    end
  end
end

-- -------------------------------------------------------
-- Global spotlight clear helper (fallback)
-- Ensures _ClearSpotlight is always available even if Build() was cached/short-circuited.
-- Uses stored UI refs (self._ui.*).
-- -------------------------------------------------------
function TO:_ClearSpotlight()
  self:_InitState()
  self._spot = self._spot or {}
  self._spot.active = false
  self._spot.target = nil
  self._spot.pad = nil
  self._spot.side = nil
  self._spot.text = nil
  self._spot.rect = nil
  self._spot.kind = nil
  self._spot._inlinePlayed = nil

  local ui = self._ui
  if not ui then return end

  if ui.blockAll then ui.blockAll:Show() end
  if ui.spotTop then ui.spotTop:Hide() end
  if ui.spotBot then ui.spotBot:Hide() end
  if ui.spotLeft then ui.spotLeft:Hide() end
  if ui.spotRight then ui.spotRight:Hide() end
  if ui.holeBorder then ui.holeBorder:Hide() end
  if ui.callout then ui.callout:Hide() end
  if ui.holePulse and ui.holePulse.Stop then ui.holePulse:Stop() end

  if ui.inlineFocus then ui.inlineFocus:Hide() end
  if ui.inlinePulse and ui.inlinePulse.Stop then ui.inlinePulse:Stop() end
end
function TO:_LoadPanelPrefs()
  self:_InitState()
  local ui = self._ui
  if not ui or not ui.panel or not ui.root then return end

  local w = tonumber(GetSetting("tutorialOverlayW", nil))
  local h = tonumber(GetSetting("tutorialOverlayH", nil))
  if w and h and w > 0 and h > 0 then
    local cw, ch = ClampTutorialPanelSize(ui.parent or ui.root, w, h)
    ui._userSized = true
    ui.panel:SetSize(cw, ch)
  end
end

function TO:_PinPanelToScreen()
  self:_InitState()

  local ui = self._ui
  if not ui or not ui.panel or not ui.root then return false end

  local panel = ui.panel
  local root = ui.root

  if not panel.GetLeft or not panel.GetTop then
    return false
  end

  local left, bottom, right, top = RectFromFrameRelative(root, panel)
  if not left or not top then
    return false
  end

  panel:ClearAllPoints()
  panel:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", RoundInt(left), RoundInt(top))
  ui._dockSide = "FLOAT"

  if self._RefreshDockVisuals then
    self:_RefreshDockVisuals()
  end

  return true
end


function TO:_ResetPanelPrefs()
  SetSetting("tutorialOverlayPoint", nil)
  SetSetting("tutorialOverlayRelPoint", nil)
  SetSetting("tutorialOverlayX", nil)
  SetSetting("tutorialOverlayY", nil)
  SetSetting("tutorialOverlayW", nil)
  SetSetting("tutorialOverlayH", nil)
end

function TO:_DockPanelBesideRoot(savePrefs)
  self:_InitState()
  local ui = self._ui
  if not ui or not ui.panel or not ui.root then return end

  local step = nil
  if not self:_IsChoosing() then
    step = self:_GetStep(tonumber(self._idx) or 1)
  end

  if step and step.focus and tostring(step.focus.kind or "") == "parentGrip" then
    self:_PinPanelToScreen()
    return
  end

  local panel = ui.panel
  local root = ui.root
  local anchor = ui.parent or (root and root._wf_anchor) or root

  local upw = UIParent:GetWidth() or 0
  local panelW = panel:GetWidth() or 620
  local anchorRight = (anchor and anchor.GetRight and anchor:GetRight()) or (root.GetRight and root:GetRight()) or 0
  local anchorLeft = (anchor and anchor.GetLeft and anchor:GetLeft()) or (root.GetLeft and root:GetLeft()) or 0
  local rightRoom = upw - anchorRight
  local leftRoom = anchorLeft

  panel:ClearAllPoints()
  if rightRoom >= (panelW + 30) then
    panel:SetPoint("TOPLEFT", anchor, "TOPRIGHT", 14, -8)
    ui._dockSide = "RIGHT"
  elseif leftRoom >= (panelW + 30) then
    panel:SetPoint("TOPRIGHT", anchor, "TOPLEFT", -14, -8)
    ui._dockSide = "LEFT"
  else
    panel:SetPoint("TOPRIGHT", anchor, "TOPRIGHT", -8, -8)
    ui._dockSide = "INSIDE"
  end

  if self._RefreshDockVisuals then
    self:_RefreshDockVisuals()
  end

  if savePrefs ~= false then
    self:_SavePanelPrefs()
  end
end

function TO:_EnsurePanelVisible()

  local ui = self._ui
  if not ui or not ui.root or not ui.panel then return end

  -- Run next frame so geometry is valid.
  C_Timer.After(0, function()
    if not ui.root or not ui.root:IsShown() then return end

    local l, r, t, b = ui.panel:GetLeft(), ui.panel:GetRight(), ui.panel:GetTop(), ui.panel:GetBottom()
    local upw, uph = UIParent:GetWidth(), UIParent:GetHeight()

    -- If we can't read geometry, just re-center.
    if not l or not r or not t or not b or not upw or not uph or upw < 10 or uph < 10 then
      self:_DockPanelBesideRoot(false)
      self:_ResetPanelPrefs()
      self:_SavePanelPrefs()
      return
    end

    -- If it's entirely off-screen, reset it.
    local off = (r < 0) or (l > upw) or (t < 0) or (b > uph)
    if off then
      self:_DockPanelBesideRoot(false)
      self:_ResetPanelPrefs()
      self:_SavePanelPrefs()
      return
    end
  end)
end
function TO:_SavePanelPrefs()
  local ui = self._ui
  if not ui or not ui.panel then return end

  local w, h = ui.panel:GetSize()
  if w and h then
    SetSetting("tutorialOverlayW", math.floor(w + 0.5))
    SetSetting("tutorialOverlayH", math.floor(h + 0.5))
  end
end

function TO:_RefreshPanelInteractivity()
  self:_InitState()

  local ui = self._ui
  if not ui or not ui.panel then return end

  local step = nil
  if not self:_IsChoosing() then
    step = self:_GetStep(tonumber(self._idx) or 1)
  end

  local lockTutorialPanel = step
    and step.focus
    and tostring(step.focus.kind or "") == "parentGrip"

  ui._panelDragLocked = lockTutorialPanel and true or false
  ui._panelResizeLocked = lockTutorialPanel and true or false

  if lockTutorialPanel then
    self:_PinPanelToScreen()
  end

  if ui.panel.SetMovable then
    ui.panel:SetMovable(not ui._panelDragLocked)
  end
  if ui.panel.SetResizable then
    ui.panel:SetResizable(not ui._panelResizeLocked)
  end

  if ui._panelDragLocked or ui._panelResizeLocked then
    ui.panel._wfLiveSizing = nil
    if ui.panel.StopMovingOrSizing then
      ui.panel:StopMovingOrSizing()
    end
  end

  if ui.resizeGrip then
    ui.resizeGrip:EnableMouse(not ui._panelResizeLocked)
    ui.resizeGrip:SetAlpha(ui._panelResizeLocked and 0.20 or 1.00)
  end
end

function TO:_RefreshDockVisuals()
  local ui = self._ui
  if not ui or not ui.panel then return end

  local side = tostring(ui._dockSide or "RIGHT")

  if ui.resizeGrip then
    ui.resizeGrip:ClearAllPoints()
    if side == "LEFT" then
      ui.resizeGrip:SetPoint("BOTTOMLEFT", 4, 4)
    else
      ui.resizeGrip:SetPoint("BOTTOMRIGHT", -4, 4)
    end
  end

  if ui.tutorialGripProxy then
    ui.tutorialGripProxy:ClearAllPoints()
    if side == "LEFT" then
      ui.tutorialGripProxy:SetPoint("BOTTOMLEFT", ui.panel, "BOTTOMLEFT", 2, 2)
    else
      ui.tutorialGripProxy:SetPoint("BOTTOMRIGHT", ui.panel, "BOTTOMRIGHT", -2, 2)
    end
  end

  if ui._SyncGripProxies then
    ui._SyncGripProxies()
  end
end



function TO:_Dirty(kind)
  self._dirty[kind] = true
end

function TO:_ClearDirty()
  for k in pairs(self._dirty) do
    self._dirty[k] = nil
  end
end

-- -------------------------------------------------------
-- Build overlay UI
-- -------------------------------------------------------
local function MakeBlocker(parent, alpha)
  local f = CreateFrame("Frame", nil, parent)
  f:EnableMouse(true)
  f:SetScript("OnMouseDown", function() end)

  f:SetFrameStrata(parent:GetFrameStrata())
  f:SetFrameLevel((parent:GetFrameLevel() or 0) + 1)

  local t = f:CreateTexture(nil, "BACKGROUND")
  t:SetAllPoints(f)
  t:SetTexture("Interface/Buttons/WHITE8x8")
  t:SetVertexColor(0, 0, 0, alpha or 0.42)

  f._tex = t
  f:Hide()
  return f
end

local function Build(self, parentFrame, helpUI)
  self:_InitState()

  if self._ui.built and self._ui.parent == parentFrame then
    return
  end

  -- Rebuild if parent changed
  if self._ui.root and self._ui.root.GetParent and self._ui.root:GetParent() ~= UIParent then
    self._ui.root:Hide()
    self._ui.root:SetParent(nil)
    self._ui.root = nil
    self._ui.built = nil
  end

  if self._ui.built then return end
  self._ui.built = true
  self._ui.parent = parentFrame
  self._ui.helpUI = helpUI

  CacheColors(self)

  local col = self._col

  local root = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
  root:ClearAllPoints()
  root:SetAllPoints(UIParent)

  -- keep an anchor reference for docking beside the actual WhatFarm window
  root._wf_anchor = parentFrame
  root._wf_sync = function() end

  root:SetFrameStrata("FULLSCREEN_DIALOG")
  root:SetFrameLevel(90)
  root:Hide()
  root:EnableKeyboard(true)
  if root.SetPropagateKeyboardInput then
    root:SetPropagateKeyboardInput(false)
  end

  -- Blockers
  local blockAll  = MakeBlocker(root, 0.56); blockAll:SetAllPoints(root)
  local spotTop   = MakeBlocker(root, 0.56)
  local spotBot   = MakeBlocker(root, 0.56)
  local spotLeft  = MakeBlocker(root, 0.56)
  local spotRight = MakeBlocker(root, 0.56)

  local baseLevel = root:GetFrameLevel() or 0
  blockAll:SetFrameLevel(baseLevel + 1)
  spotTop:SetFrameLevel(baseLevel + 1)
  spotBot:SetFrameLevel(baseLevel + 1)
  spotLeft:SetFrameLevel(baseLevel + 1)
  spotRight:SetFrameLevel(baseLevel + 1)

  -- Hole border
  local holeBorder = CreateFrame("Frame", nil, root, "BackdropTemplate")
  holeBorder:SetFrameStrata(root:GetFrameStrata())
  holeBorder:SetFrameLevel(baseLevel + 2)
  holeBorder:SetBackdrop({
    bgFile = "Interface/Tooltips/UI-Tooltip-Background",
    edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
    edgeSize = 16,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
  })
  holeBorder:SetBackdropColor(0, 0, 0, 0)
  holeBorder:SetBackdropBorderColor(0.36, 0.82, 1.00, 1.00)
  holeBorder:SetAlpha(1.00)
  holeBorder:EnableMouse(false)
  holeBorder:Hide()

  local pulse = holeBorder:CreateAnimationGroup()
  local a1 = pulse:CreateAnimation("Alpha")
  a1:SetFromAlpha(0.35); a1:SetToAlpha(1.00); a1:SetDuration(0.55); a1:SetOrder(1)
  local a2 = pulse:CreateAnimation("Alpha")
  a2:SetFromAlpha(1.00); a2:SetToAlpha(0.35); a2:SetDuration(0.55); a2:SetOrder(2)
  pulse:SetLooping("REPEAT")

  -- Callout bubble
  local callout = MakePanel(root, col.BG, col.BORDER)
  callout:SetFrameStrata(root:GetFrameStrata())
  callout:SetFrameLevel(baseLevel + 30)
  callout:SetSize(260, 56)
  callout:Hide()

  local calloutSolid = callout:CreateTexture(nil, "BACKGROUND")
  calloutSolid:SetAllPoints(callout)
  calloutSolid:SetTexture("Interface/Buttons/WHITE8x8")
  calloutSolid:SetVertexColor(0.04, 0.04, 0.04, 1.00)

  local calloutText = callout:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
  calloutText:SetPoint("TOPLEFT", 12, -10)
  calloutText:SetWidth(236)

  -- Inline focus ring (used for overlay targets INSIDE the tutorial panel)
  -- This avoids coordinate math / spotlight holes and prevents duplicate "Pick ..." text.
  local inlineFocus = CreateFrame("Frame", nil, root, "BackdropTemplate")
  inlineFocus:SetFrameStrata(root:GetFrameStrata())
  inlineFocus:SetFrameLevel(baseLevel + 45) -- above panel (base + 20) and callout (base + 30)
  inlineFocus:SetBackdrop({
    bgFile = "Interface/Tooltips/UI-Tooltip-Background",
    edgeFile = "Interface/Tooltips/UI-Tooltip-Border",
    edgeSize = 14,
    insets = { left = 3, right = 3, top = 3, bottom = 3 },
  })
  inlineFocus:SetBackdropColor(0, 0, 0, 0)
  inlineFocus:SetBackdropBorderColor(C4(col.ACCENT, col.ACCENT))
  inlineFocus:EnableMouse(false)
  inlineFocus:Hide()

  local inlinePulse = inlineFocus:CreateAnimationGroup()
  local ia1 = inlinePulse:CreateAnimation("Alpha")
  ia1:SetFromAlpha(0.35); ia1:SetToAlpha(1.00); ia1:SetDuration(0.55); ia1:SetOrder(1)
  local ia2 = inlinePulse:CreateAnimation("Alpha")
  ia2:SetFromAlpha(1.00); ia2:SetToAlpha(0.35); ia2:SetDuration(0.55); ia2:SetOrder(2)
  inlinePulse:SetLooping("REPEAT")


  -- store refs for global focus helpers
  self._ui.inlineFocus = inlineFocus
  self._ui.inlinePulse = inlinePulse
  calloutText:SetPoint("BOTTOMRIGHT", -10, 8)
  calloutText:SetJustifyH("LEFT")
  calloutText:SetJustifyV("MIDDLE")
  calloutText:SetTextColor(C4(col.TEXT, col.TEXT))
  calloutText:SetText("")

  local parentGripProxy = CreateFrame("Frame", nil, root)
  parentGripProxy:SetSize(28, 28)
  parentGripProxy:EnableMouse(false)

  local tutorialGripProxy = CreateFrame("Frame", nil, root)
  tutorialGripProxy:SetSize(28, 28)
  tutorialGripProxy:EnableMouse(false)

  -- Main panel
  local panel = MakePanel(root, col.PANEL, col.BORDER)
  panel:SetFrameStrata(root:GetFrameStrata())
  panel:SetFrameLevel(baseLevel + 20)

  local panelSolid = panel:CreateTexture(nil, "BACKGROUND")
  panelSolid:SetAllPoints(panel)
  panelSolid:SetTexture("Interface/Buttons/WHITE8x8")
  panelSolid:SetVertexColor(0.03, 0.03, 0.03, 1.00)
  panel:EnableMouse(true)

  panel:SetMovable(true)
  panel:SetResizable(true)
  panel:SetClampedToScreen(true)

  local function ApplyTutorialResizeBounds()
    local minW, minH, maxW, maxH = GetTutorialPanelBounds(parentFrame)
    if panel.SetResizeBounds then
      panel:SetResizeBounds(minW, minH, maxW, maxH)
    else
      if panel.SetMinResize then panel:SetMinResize(minW, minH) end
      if panel.SetMaxResize then panel:SetMaxResize(maxW, maxH) end
    end
    return minW, minH, maxW, maxH
  end

  ApplyTutorialResizeBounds()

  panel:HookScript("OnSizeChanged", function(_, w, h)
    self._ui._userSized = true

    -- Match the rest of the addon: do not run full layout work while the
    -- corner grip is actively resizing. Anchors can stretch the visible frame
    -- live, then we do the expensive text/spotlight/layout pass once on release.
    -- This prevents slower clients from losing font strings/header widgets
    -- during rapid resize events.
    if panel._wfSizing or panel._wfLiveSizing then
      return
    end

    if panel._wfSizingClamp then return end

    local rw = RoundInt(w)
    local rh = RoundInt(h)
    local cw, ch = ClampTutorialPanelSize(parentFrame, rw, rh)
    if cw ~= rw or ch ~= rh then
      panel._wfSizingClamp = true
      panel:SetSize(cw, ch)
      panel._wfSizingClamp = nil
      return
    end

    self:_Dirty("layout")
  end)
  -- Header
  local header = MakePanel(panel, col.BG, col.BORDER)
  header:SetPoint("TOPLEFT", 8, -8)
  header:SetPoint("TOPRIGHT", -8, -8)
  header:SetHeight(36)
  header:SetBackdropColor(0.03, 0.03, 0.03, 1.00)

  local titleBrandFS = header:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlight")
  titleBrandFS:SetPoint("LEFT", 10, 0)
  titleBrandFS:SetTextColor(C4(col.TEXT, col.TEXT))
  titleBrandFS:SetText("WHAT FARM?")

  local titleSubFS = header:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  titleSubFS:SetPoint("LEFT", titleBrandFS, "RIGHT", 6, -1)
  titleSubFS:SetTextColor(C4(col.MUTED, col.MUTED))
  titleSubFS:SetText("Tutorial")

  local progressFS = header:CreateFontString(nil, "OVERLAY", "WF_GameFontDisable")
  progressFS:SetPoint("RIGHT", -40, 0)
  progressFS:SetTextColor(C4(col.MUTED, col.MUTED))
  progressFS:SetText("")

  local hintFS = header:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  hintFS:SetPoint("RIGHT", -40, 0)
  hintFS:SetTextColor(C4(col.MUTED, col.MUTED))
  hintFS:SetText("")
hintFS:Hide()

  local close = CreateFrame("Button", nil, header, "UIPanelCloseButton")
  close:SetPoint("RIGHT", -6, 0)
  SkinCloseButton(close)
  close:SetScript("OnClick", function() TO:Close() end)

  local progressBar = CreateFrame("Frame", nil, panel)
  progressBar:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -6)
  progressBar:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, -6)
  progressBar:SetHeight(8)

  local progressBG = progressBar:CreateTexture(nil, "BACKGROUND")
  progressBG:SetAllPoints(progressBar)
  progressBG:SetTexture("Interface/Buttons/WHITE8x8")
  progressBG:SetVertexColor(1, 1, 1, 0.08)

  local progressFill = progressBar:CreateTexture(nil, "ARTWORK")
  progressFill:SetTexture("Interface/Buttons/WHITE8x8")
  progressFill:SetPoint("TOPLEFT", progressBar, "TOPLEFT", 0, 0)
  progressFill:SetPoint("BOTTOMLEFT", progressBar, "BOTTOMLEFT", 0, 0)
  progressFill:SetWidth(1)
  progressFill:SetVertexColor(C4(col.ACCENT, col.ACCENT))

  -- Drag window by the header
  header:EnableMouse(true)
  header:RegisterForDrag("LeftButton")
  header:SetScript("OnDragStart", function()
    if self._ui and self._ui._panelDragLocked then
      return
    end
    panel:StartMoving()
  end)
  header:SetScript("OnDragStop", function()
    if self._ui and self._ui._panelDragLocked then
      return
    end
    panel:StopMovingOrSizing()
    self:_SavePanelPrefs()
    self:_Dirty("layout")
  end)
  -- Sidebar (step nav)
  local navWrap = MakePanel(panel, col.PANEL, col.BORDER)
  navWrap:SetBackdropColor(0.035, 0.035, 0.035, 1.00)
  navWrap:SetPoint("TOPLEFT", progressBar, "BOTTOMLEFT", 0, -8)
  navWrap:SetPoint("BOTTOMLEFT", 8, 52)
  navWrap:SetWidth(150)

  local navTitle = navWrap:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  navTitle:SetPoint("TOPLEFT", 10, -10)
  navTitle:SetTextColor(C4(col.MUTED, col.MUTED))
  navTitle:SetText("Tour")

  -- Step navigation never needs to scroll at the current minimum tutorial height,
  -- and the template scrollbar was the thing visibly flickering during live resize.
  -- Keep the column as a simple clipped frame with a fixed integer width instead.
  local navContent = CreateFrame("Frame", nil, navWrap)
  navContent:SetPoint("TOPLEFT", 8, -28)
  navContent:SetPoint("TOPRIGHT", -8, -28)
  navContent:SetPoint("BOTTOMLEFT", 8, 10)
  navContent:SetPoint("BOTTOMRIGHT", -8, 10)
  navContent:SetClipsChildren(true)

  local NAV_BUTTON_W = 122

  local function SyncNavSCWidth()
    if self._ui and self._ui.navButtons then
      for i = 1, #self._ui.navButtons do
        local btn = self._ui.navButtons[i]
        if btn and btn.SetWidth then
          btn:SetWidth(NAV_BUTTON_W)
        end
      end
    end
  end
  SyncNavSCWidth()

  -- Body
  local body = CreateFrame("Frame", nil, panel)
  body:SetPoint("TOPLEFT", navWrap, "TOPRIGHT", 10, 0)
  body:SetPoint("TOPRIGHT", -8, -52)
  body:SetPoint("BOTTOMRIGHT", -8, 52)
  body:SetPoint("BOTTOMLEFT", navWrap, "BOTTOMRIGHT", 10, 52)

  local bodyBG = MakePanel(body, col.PANEL, col.BORDER)
  bodyBG:SetBackdropColor(0.035, 0.035, 0.035, 1.00)
  bodyBG:SetAllPoints(body)

  local chooserScreen = CreateFrame("Frame", nil, panel)
  chooserScreen:SetPoint("TOPLEFT", progressBar, "BOTTOMLEFT", 0, -8)
  chooserScreen:SetPoint("TOPRIGHT", progressBar, "BOTTOMRIGHT", 0, -8)
  chooserScreen:SetPoint("BOTTOMLEFT", 8, 8)
  chooserScreen:SetPoint("BOTTOMRIGHT", -8, 8)
  chooserScreen:Hide()

  local chooserBG = MakePanel(chooserScreen, col.PANEL, col.BORDER)
  chooserBG:SetAllPoints(chooserScreen)
  chooserBG:SetBackdropColor(0.035, 0.035, 0.035, 1.00)

  local chooserTitle = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontNormalLarge")
  chooserTitle:SetPoint("TOP", 0, -34)
  chooserTitle:SetTextColor(C4(col.ACCENT, col.ACCENT))
  chooserTitle:SetText("Choose how the addon is displayed")

  local chooserDesc = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlight")
  chooserDesc:SetPoint("TOP", chooserTitle, "BOTTOM", 0, -18)
  chooserDesc:SetWidth(540)
  chooserDesc:SetJustifyH("CENTER")
  chooserDesc:SetJustifyV("TOP")
  chooserDesc:SetTextColor(C4(col.TEXT, col.TEXT))
  chooserDesc:SetText("Pick the path that fits how you want to learn. Fast focuses on the pages and decisions that matter most, so you can get started quickly. Advanced is a longer, more detailed walkthrough that explains how the addon works from end to end.")

  local chooserSub = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontDisable")
  chooserSub:SetPoint("TOP", chooserDesc, "BOTTOM", 0, -18)
  chooserSub:SetTextColor(C4(col.MUTED, col.MUTED))
  chooserSub:SetText("You can reopen Tutorial from Help any time and run the other path later.")

  local chooserArrowHint = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontDisable")
  chooserArrowHint:SetPoint("BOTTOM", chooserBG, "BOTTOM", 0, CHOOSER_ARROW_HINT_BOTTOM_OFFSET)
  chooserArrowHint:SetSize(460, 18)
  chooserArrowHint:SetJustifyH("CENTER")
  chooserArrowHint:SetJustifyV("MIDDLE")
  if chooserArrowHint.SetWordWrap then chooserArrowHint:SetWordWrap(false) end
  if chooserArrowHint.SetNonSpaceWrap then chooserArrowHint:SetNonSpaceWrap(false) end
  if chooserArrowHint.SetIndentedWordWrap then chooserArrowHint:SetIndentedWordWrap(false) end
  if chooserArrowHint.SetMaxLines then chooserArrowHint:SetMaxLines(1) end
  chooserArrowHint:SetTextColor(C4(col.MUTED, col.MUTED))
  chooserArrowHint:SetText("Use arrow keys to navigate the tutorial.")

  -- Beta release: no chooser arrow textures. They were resizing/reanchoring with the frame.
  local chooserArrowLeft = nil
  local chooserArrowLeftGlow = nil
  local chooserArrowRight = nil
  local chooserArrowRightGlow = nil

  local chooserAdvancedBtn = UIButton(self, chooserBG, "Advanced", 170, 30)
  chooserAdvancedBtn:SetPoint("TOP", chooserSub, "BOTTOM", -94, -30)

  local chooserFastBtn = UIButton(self, chooserBG, "Fast", 170, 30)
  chooserFastBtn:SetPoint("LEFT", chooserAdvancedBtn, "RIGHT", 18, 0)

  local chooserAdvancedBody = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
  chooserAdvancedBody:SetPoint("TOP", chooserAdvancedBtn, "BOTTOM", 0, -14)
  chooserAdvancedBody:SetWidth(190)
  chooserAdvancedBody:SetJustifyH("CENTER")
  chooserAdvancedBody:SetJustifyV("TOP")
  chooserAdvancedBody:SetTextColor(C4(col.TEXT, col.TEXT))
  chooserAdvancedBody:SetText("Best for users who want the full walkthrough. Covers every major page, explains how things work, and gives you the full picture before you start farming.")

  local chooserFastBody = chooserBG:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
  chooserFastBody:SetPoint("TOP", chooserFastBtn, "BOTTOM", 0, -14)
  chooserFastBody:SetWidth(190)
  chooserFastBody:SetJustifyH("CENTER")
  chooserFastBody:SetJustifyV("TOP")
  chooserFastBody:SetTextColor(C4(col.TEXT, col.TEXT))
  chooserFastBody:SetText("Best for experienced users. Covers the important pages, the core decisions, and what you need to know so you can get started quickly.")

  -- Beta release: remove chooser logo/fallback/warmup textures so they cannot shift on resize.
  local chooserLogoFallback = nil
  local chooserLogo = nil
  local chooserLogoWarmup = nil

  local scroll = CreateFrame("ScrollFrame", nil, body, "UIPanelScrollFrameTemplate")
  SkinScrollBar(scroll.ScrollBar)
  scroll:SetPoint("TOPLEFT", 0, 0)
  scroll:SetPoint("BOTTOMRIGHT", -22, 0)

  local sc = CreateFrame("Frame", nil, scroll)
  sc:SetPoint("TOPLEFT", 0, 0)
  sc:SetSize(1, 1)
  scroll:SetScrollChild(sc)

  local stepTitle = sc:CreateFontString(nil, "OVERLAY", "WF_GameFontNormalLarge")
  stepTitle:SetPoint("TOPLEFT", 8, -6)
  stepTitle:SetTextColor(C4(col.ACCENT, col.ACCENT))
  stepTitle:SetText("")

  local stepText = sc:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlight")
  stepText:SetPoint("TOPLEFT", stepTitle, "BOTTOMLEFT", 0, -10)
  stepText:SetTextColor(C4(col.TEXT, col.TEXT))
  stepText:SetJustifyH("LEFT")
  stepText:SetJustifyV("TOP")
  stepText:SetText("")
  stepText:SetWidth(460)

  -- Checklist
  local checkWrap = CreateFrame("Frame", nil, sc)
  checkWrap:SetPoint("TOPLEFT", stepText, "BOTTOMLEFT", 0, -16)
  checkWrap:SetPoint("TOPRIGHT", -8, 0)
  checkWrap:SetHeight(72)
  checkWrap:Hide()

  local checkTitle = checkWrap:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  checkTitle:SetPoint("TOPLEFT", 0, 0)
  checkTitle:SetTextColor(C4(col.MUTED, col.MUTED))
  checkTitle:SetText("Checklist")

  local checkLines = {}

  -- Store checklist refs for global clearing (prevents one-frame flashes during navigation)

  self._ui.checkLines = checkLines

  self._ui.checkTitle = checkTitle

  self._ui.checkWrap  = checkWrap

  for i = 1, 3 do
    local fs = checkWrap:CreateFontString(nil, "OVERLAY", "WF_GameFontHighlightSmall")
    fs:SetPoint("TOPLEFT", 10, -8 - (i * 18))
    fs:SetTextColor(C4(col.TEXT, col.TEXT))
    fs:SetText("")
    checkLines[i] = fs
  end

  -- Actions
  local ACTION_ROW_H = 28
  local ACTION_ROW_GAP = 8
  local ACTION_WRAP_GAP = 10

  local actionWrap = CreateFrame("Frame", nil, sc)
  actionWrap:SetPoint("TOPLEFT", stepText, "BOTTOMLEFT", 0, -ACTION_WRAP_GAP)
  actionWrap:SetWidth(430)
  actionWrap:SetHeight(ACTION_ROW_H)
  actionWrap:Hide()

  local modeRow = CreateFrame("Frame", nil, actionWrap)
  modeRow:SetPoint("TOPLEFT", 0, 0)
  modeRow:SetSize(430, ACTION_ROW_H)
  modeRow:Hide()

  local modeLbl = modeRow:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  modeLbl:SetPoint("LEFT", 0, 0)
  modeLbl:SetTextColor(C4(col.MUTED, col.MUTED))
  modeLbl:SetText("Mode:")

  local bDefault = UIButton(self, modeRow, "Default", 78, 22);   bDefault:SetPoint("LEFT", modeLbl, "RIGHT", 10, 0)
  local bMats    = UIButton(self, modeRow, "Mats", 70, 22);      bMats:SetPoint("LEFT", bDefault, "RIGHT", 8, 0)
  local bTransmog = UIButton(self, modeRow, "Transmog", 92, 22); bTransmog:SetPoint("LEFT", bMats, "RIGHT", 8, 0)
  local bRaw     = UIButton(self, modeRow, "Raw Gold", 84, 22);  bRaw:SetPoint("LEFT", bTransmog, "RIGHT", 8, 0)

  SetButtonTooltip(bDefault, "Default", {
    "Flexible catch all profile when you want a broader setup without switching into a specialized mindset first.",
  })
  SetButtonTooltip(bMats, "Mats", {
    "For material focused routes.",
  })
  SetButtonTooltip(bTransmog, "Transmog", {
    "For transmog focused sessions",
  })
  SetButtonTooltip(bRaw, "Raw Gold", {
    "For raw gold drop or vendor sale farms.",
  })

  local sourceRow = CreateFrame("Frame", nil, actionWrap)
  sourceRow:SetPoint("TOPLEFT", 0, -(ACTION_ROW_H + ACTION_ROW_GAP))
  sourceRow:SetSize(380, ACTION_ROW_H)
  sourceRow:Hide()

  local srcLbl = sourceRow:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  srcLbl:SetPoint("LEFT", 0, 0)
  srcLbl:SetTextColor(C4(col.MUTED, col.MUTED))
  srcLbl:SetText("Source:")

  local bVendor = UIButton(self, sourceRow, "Vendor", 78, 22);      bVendor:SetPoint("LEFT", srcLbl, "RIGHT", 10, 0)
  local bTSM    = UIButton(self, sourceRow, "TSM", 60, 22);         bTSM:SetPoint("LEFT", bVendor, "RIGHT", 8, 0)
  local bAuc    = UIButton(self, sourceRow, "Auctionator", 98, 22); bAuc:SetPoint("LEFT", bTSM, "RIGHT", 8, 0)

  SetButtonTooltip(bVendor, "Vendor", {
    "Default when no addons are enabled.",
	"",
    "Uses vendor value, it is highly recommended you use either TradeSkillMaster or Auctionator to get the correct item values.",
  })
  SetButtonTooltip(bAuc, "Auctionator", {
    "Simple auction house estimate.",
	"",
    "Good when you want a market based read without going full TSM. Remember to update your Auction House data to avoid stale prices.",
  })
  SetButtonTooltip(bTSM, "TSM", {
    "Most control, most risk.",
	"",
    "Excellent when you know what your price string is doing.",
	"",
    "Also the easiest way to make your run look weird as hell if the string is bad.",
  })

  local tsmRow = CreateFrame("Frame", nil, actionWrap)
  tsmRow:SetPoint("TOPLEFT", 0, -((ACTION_ROW_H * 2) + (ACTION_ROW_GAP * 2)))
  tsmRow:SetSize(430, ACTION_ROW_H)
  tsmRow:Hide()

  local tsmLbl = tsmRow:CreateFontString(nil, "OVERLAY", "WF_GameFontDisableSmall")
  tsmLbl:SetPoint("LEFT", 0, 0)
  tsmLbl:SetTextColor(C4(col.MUTED, col.MUTED))
  tsmLbl:SetText("TSM Quick:")

  local tsmBtns = {}
  local prev = tsmLbl
  for i = 1, #TSM_QUICK do
    local full = tostring(TSM_QUICK[i][1] or "")
    local val = TSM_QUICK[i][2]

    -- Button labels: show the actual option name (no unicode, no 1/2/3).
    -- Derive compact label from the friendly string: remove "(...)" and spaces, normalize names.
    local label = full
    label = label:gsub("%s*%b()", "")                 -- drop parenthetical
    label = label:gsub("Region Market Avg", "RegionAvg")
    label = label:gsub("Market Value", "Market")
    label = label:gsub("Min Buyout", "MinBuyout")
    label = label:gsub("%s+", "")                     -- no spaces
    if label == "" then label = tostring(val or "") end

    local b = UIButton(self, tsmRow, label, 96, 22)
    if b.label and b.label.SetText then
      b.label:SetText(label)
    end
    b:SetPoint("LEFT", prev, "RIGHT", (prev == tsmLbl) and 10 or 6, 0)
    b._tsmString = val

    if val == "dbminbuyout" then
      SetButtonTooltip(b, "TSM: MinBuyout", {
        "Most aggressive of the quick options.",
		"",
        "Can make a run look juicy fast because it follows the current lowest buyout.",
      })
    elseif val == "dbmarket" then
      SetButtonTooltip(b, "TSM: DBMarket", {
        "Realm market value.",
		"",
        "Usually a decent middle-ground when you want something market-based without going as hard as MinBuyout.",
      })
    elseif val == "dbregionmarketavg" then
      SetButtonTooltip(b, "TSM: DBRegionMarketAvg", {
        "Regional average value.",
		"",
        "Usually steadier and less twitchy than realm-specific pricing.",
      })
    end

    tsmBtns[#tsmBtns + 1] = b
    prev = b
  end

  local function RelayoutActionRows()
    local anchorTarget = stepText
    local anchorOffset = -ACTION_WRAP_GAP

    if checkWrap and checkWrap.IsShown and checkWrap:IsShown() then
      anchorTarget = checkWrap
      anchorOffset = -12
    end

    actionWrap:ClearAllPoints()
    actionWrap:SetPoint("TOPLEFT", anchorTarget, "BOTTOMLEFT", 0, anchorOffset)
    actionWrap:SetWidth(math.max(modeRow:GetWidth() or 0, sourceRow:GetWidth() or 0, tsmRow:GetWidth() or 0, 1))

    local y = 0
    local visibleCount = 0

    local function PlaceRow(row)
      if not row then return end
      row:ClearAllPoints()
      if row:IsShown() then
        row:SetPoint("TOPLEFT", actionWrap, "TOPLEFT", 0, y)
        y = y - (ACTION_ROW_H + ACTION_ROW_GAP)
        visibleCount = visibleCount + 1
      end
    end

    PlaceRow(modeRow)
    PlaceRow(sourceRow)
    PlaceRow(tsmRow)

    local totalH = 1
    if visibleCount > 0 then
      totalH = (visibleCount * ACTION_ROW_H) + ((visibleCount - 1) * ACTION_ROW_GAP)
    end
    actionWrap:SetHeight(totalH)
  end

  self._RelayoutActionRows = RelayoutActionRows

  -- Hint removed: button labels are self-explanatory.
  -- Footer
  local footer = CreateFrame("Frame", nil, panel)
  footer:SetPoint("BOTTOMLEFT", 8, 8)
  footer:SetPoint("BOTTOMRIGHT", -8, 8)
  footer:SetHeight(34)


  -- Resize grip (bottom-right)
  local resizeGrip = CreateFrame("Button", nil, panel)
  resizeGrip:SetSize(16, 16)
  resizeGrip:SetPoint("BOTTOMRIGHT", -4, 4)
  resizeGrip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
  resizeGrip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
  resizeGrip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
  resizeGrip:EnableMouse(true)

  resizeGrip._wfActive = false
  resizeGrip._wfSizingStarted = false
  resizeGrip._wfDownW = nil
  resizeGrip._wfDownH = nil
  resizeGrip._wfDownX = nil
  resizeGrip._wfDownY = nil
  resizeGrip._wfMinW = nil
  resizeGrip._wfMinH = nil
  resizeGrip._wfMaxW = nil
  resizeGrip._wfMaxH = nil

  local TUTORIAL_DRAG_THRESHOLD = 3

  local function Tutorial_GetScaledCursorPosition()
    local x, y = GetCursorPosition()
    local scale = 1
    if UIParent and UIParent.GetEffectiveScale then
      scale = UIParent:GetEffectiveScale() or 1
    end
    if scale == 0 then
      scale = 1
    end
    return (x or 0) / scale, (y or 0) / scale
  end

  local function Tutorial_Clamp(v, minV, maxV)
    v = tonumber(v) or 0
    if minV and v < minV then v = minV end
    if maxV and maxV > 0 and v > maxV then v = maxV end
    return v
  end

  local function Tutorial_ReadResizeBounds()
    local minW, minH = 360, 300
    local maxW, maxH = nil, nil

    if panel and panel.GetResizeBounds then
      local ok, a, b, c, d = pcall(panel.GetResizeBounds, panel)
      if ok then
        minW = tonumber(a) or minW
        minH = tonumber(b) or minH
        maxW = tonumber(c) or maxW
        maxH = tonumber(d) or maxH
      end
    elseif panel and panel.GetMinResize then
      local ok, a, b = pcall(panel.GetMinResize, panel)
      if ok then
        minW = tonumber(a) or minW
        minH = tonumber(b) or minH
      end
      if panel.GetMaxResize then
        local okMax, c, d = pcall(panel.GetMaxResize, panel)
        if okMax then
          maxW = tonumber(c) or maxW
          maxH = tonumber(d) or maxH
        end
      end
    end

    if not maxW and UIParent and UIParent.GetWidth then
      maxW = math.max(minW, math.floor((UIParent:GetWidth() or 0) - 20))
    end
    if not maxH and UIParent and UIParent.GetHeight then
      maxH = math.max(minH, math.floor((UIParent:GetHeight() or 0) - 20))
    end

    if maxW and maxW > 0 and maxW < minW then maxW = minW end
    if maxH and maxH > 0 and maxH < minH then maxH = minH end

    return minW, minH, maxW, maxH
  end

  local function Tutorial_ClearGripState(btn)
    btn = btn or resizeGrip
    if btn and btn.SetScript then
      btn:SetScript("OnUpdate", nil)
    end
    if panel then
      panel._wfLiveSizing = nil
      panel._wfSizing = nil
    end
    if Widgets and Widgets.HideResizeOverlay then
      Widgets:HideResizeOverlay(panel)
    end
    if btn then
      btn._wfActive = false
      btn._wfSizingStarted = false
      btn._wfDownW = nil
      btn._wfDownH = nil
      btn._wfDownX = nil
      btn._wfDownY = nil
      btn._wfMinW = nil
      btn._wfMinH = nil
      btn._wfMaxW = nil
      btn._wfMaxH = nil
    end
  end

  local function Tutorial_BeginResize(btn)
    if not btn then return end
    if btn._wfSizingStarted then return end
    if self._ui and self._ui._panelResizeLocked then return end
    if not (panel and panel.SetSize) then return end

    if panel.StopMovingOrSizing then
      panel:StopMovingOrSizing()
    end

    btn._wfSizingStarted = true
    panel._wfLiveSizing = true
    panel._wfSizing = true
    btn._wfMinW, btn._wfMinH, btn._wfMaxW, btn._wfMaxH = Tutorial_ReadResizeBounds()

    if Widgets and Widgets.ShowResizeOverlay then
      Widgets:ShowResizeOverlay(panel, "TUTORIAL BEING RESIZED")
    end
  end

  local function Tutorial_ApplyResize(btn)
    if not (btn and btn._wfActive) then return end
    if not (panel and panel.SetSize) then return end

    local xNow, yNow = Tutorial_GetScaledCursorPosition()
    local dx = (xNow or 0) - (btn._wfDownX or 0)
    local dy = (yNow or 0) - (btn._wfDownY or 0)

    if not btn._wfSizingStarted then
      if math.abs(dx) < TUTORIAL_DRAG_THRESHOLD and math.abs(dy) < TUTORIAL_DRAG_THRESHOLD then
        return
      end
      Tutorial_BeginResize(btn)
    end

    if not btn._wfSizingStarted then return end

    local side = tostring((self._ui and self._ui._dockSide) or "RIGHT")
    local nextW
    if side == "LEFT" then
      nextW = (btn._wfDownW or panel:GetWidth() or 360) - dx
    else
      nextW = (btn._wfDownW or panel:GetWidth() or 360) + dx
    end
    local nextH = (btn._wfDownH or panel:GetHeight() or 300) - dy

    nextW = Tutorial_Clamp(nextW, btn._wfMinW or 360, btn._wfMaxW)
    nextH = Tutorial_Clamp(nextH, btn._wfMinH or 300, btn._wfMaxH)
    nextW = math.floor(nextW + 0.5)
    nextH = math.floor(nextH + 0.5)

    local curW, curH = panel:GetSize()
    if math.abs((curW or 0) - nextW) >= 1 or math.abs((curH or 0) - nextH) >= 1 then
      panel:SetSize(nextW, nextH)
      if self._ui and self._ui._SyncNavSCWidth then
        self._ui._SyncNavSCWidth()
      end
    end
  end

  local function Tutorial_FinishResize(btn)
    btn = btn or resizeGrip
    if not btn then return end
    if not btn._wfActive then return end

    local didResize = btn._wfSizingStarted and true or false
    if btn.SetScript then
      btn:SetScript("OnUpdate", nil)
    end

    if panel and panel.StopMovingOrSizing then
      panel:StopMovingOrSizing()
    end

    if didResize then
      -- Finalize to the same snapped/bounded size model used elsewhere.
      local fw, fh = panel:GetSize()
      local cw, ch = ClampTutorialPanelSize(parentFrame, fw, fh)
      if math.abs((fw or 0) - cw) >= 1 or math.abs((fh or 0) - ch) >= 1 then
        panel:SetSize(cw, ch)
      end

      if Widgets and Widgets.RefreshPixelPerfect then
        Widgets:RefreshPixelPerfect(panel)
      end
      if self._ui and self._ui._SyncNavSCWidth then
        self._ui._SyncNavSCWidth()
      end
      self:_SavePanelPrefs()
      self:_Dirty("layout")
      self:_Dirty("spot")
    end

    Tutorial_ClearGripState(btn)
  end

  resizeGrip:SetScript("OnMouseDown", function(btn, button)
    if button ~= "LeftButton" then return end
    if self._ui and self._ui._panelResizeLocked then
      Tutorial_ClearGripState(btn)
      return
    end

    if panel and panel.StopMovingOrSizing then
      panel:StopMovingOrSizing()
    end

    btn._wfActive = true
    btn._wfSizingStarted = false
    btn._wfDownW, btn._wfDownH = panel:GetSize()
    btn._wfDownX, btn._wfDownY = Tutorial_GetScaledCursorPosition()
    btn._wfMinW, btn._wfMinH, btn._wfMaxW, btn._wfMaxH = Tutorial_ReadResizeBounds()

    btn:SetScript("OnUpdate", function(g)
      if not (g and g._wfActive) then return end

      if IsMouseButtonDown and not IsMouseButtonDown("LeftButton") then
        Tutorial_FinishResize(g)
        return
      end

      Tutorial_ApplyResize(g)
    end)
  end)

  resizeGrip:SetScript("OnMouseUp", function(btn, button)
    if button ~= "LeftButton" then return end
    if self._ui and self._ui._panelResizeLocked then
      Tutorial_ClearGripState(btn)
      return
    end

    Tutorial_FinishResize(btn)
  end)

  resizeGrip:SetScript("OnHide", function(btn)
    if panel and panel.StopMovingOrSizing then
      panel:StopMovingOrSizing()
    end
    Tutorial_ClearGripState(btn)
  end)

  local function SyncGripProxies()
    local parentAnchor = (self._ui and self._ui.parent) or parentFrame or root._wf_anchor or nil

    if parentAnchor and parentAnchor.IsShown and parentAnchor:IsShown() then
      parentGripProxy:ClearAllPoints()
      parentGripProxy:SetPoint("BOTTOMRIGHT", parentAnchor, "BOTTOMRIGHT", -6, 6)
      parentGripProxy:Show()
    else
      parentGripProxy:Hide()
    end

    if resizeGrip and resizeGrip.IsShown and resizeGrip:IsShown() then
      tutorialGripProxy:ClearAllPoints()
      tutorialGripProxy:SetPoint("CENTER", resizeGrip, "CENTER", 0, 0)
      tutorialGripProxy:Show()
    else
      tutorialGripProxy:Hide()
    end
  end

  root._wf_sync = SyncGripProxies

  local bBack    = UIButton(self, footer, "Back", 80, 24);   bBack:SetPoint("LEFT", 0, 0)
  local bNext    = UIButton(self, footer, "Next", 80, 24);   bNext:SetPoint("RIGHT", 0, 0)
  local bFinish  = UIButton(self, footer, "Finish", 90, 24); bFinish:SetPoint("RIGHT", bNext, "LEFT", -10, 0); bFinish:Hide()
  local bRestart = UIButton(self, footer, "Restart", 90, 24); bRestart:SetPoint("LEFT", bBack, "RIGHT", 10, 0)

  -- -------------------------------------------------------
  -- Store refs
  -- -------------------------------------------------------
  self._ui.root = root
  self._ui.panel = panel
  self._ui.progressFS = progressFS
  self._ui.progressBar = progressBar
  self._ui.progressFill = progressFill
  self._ui.stepTitle = stepTitle
  self._ui.stepText = stepText
  self._ui.scroll = scroll
  self._ui.sc = sc
  self._ui.bodyBG = bodyBG
  self._ui.chooserScreen = chooserScreen
  self._ui.chooserBG = chooserBG
  self._ui.chooserTitle = chooserTitle
  self._ui.chooserDesc = chooserDesc
  self._ui.chooserSub = chooserSub
  self._ui.chooserArrowHint = chooserArrowHint
  self._ui.chooserArrowLeft = chooserArrowLeft
  self._ui.chooserArrowLeftGlow = chooserArrowLeftGlow
  self._ui.chooserArrowRight = chooserArrowRight
  self._ui.chooserArrowRightGlow = chooserArrowRightGlow
  self._ui.chooserAdvancedBtn = chooserAdvancedBtn
  self._ui.chooserFastBtn = chooserFastBtn
  self._ui.chooserAdvancedBody = chooserAdvancedBody
  self._ui.chooserFastBody = chooserFastBody
  self._ui.chooserLogoFallback = chooserLogoFallback
  self._ui.chooserLogo = chooserLogo
  self._ui.chooserLogoWarmup = chooserLogoWarmup

  self._ui.navWrap = navWrap
  self._ui.navContent = navContent
  self._ui._SyncNavSCWidth = SyncNavSCWidth
  self._ui.navButtons = {}

  self._ui.checkWrap = checkWrap
  self._ui.checkLines = checkLines

  self._ui.actionWrap = actionWrap
  self._ui.modeRow = modeRow
  self._ui.sourceRow = sourceRow
  self._ui.tsmRow = tsmRow

  self._ui.bBack = bBack
  self._ui.bNext = bNext
  self._ui.bFinish = bFinish
  self._ui.bRestart = bRestart
  self._ui.body = body
  self._ui.footer = footer
  self._ui.header = header
  self._ui.resizeGrip = resizeGrip
  self._ui.parentGripProxy = parentGripProxy
  self._ui.tutorialGripProxy = tutorialGripProxy
  self._ui._SyncGripProxies = SyncGripProxies
  self._ui.blockAll = blockAll
  self._ui.spotTop = spotTop
  self._ui.spotBot = spotBot
  self._ui.spotLeft = spotLeft
  self._ui.spotRight = spotRight
  self._ui.holeBorder = holeBorder
  self._ui.holePulse = pulse
  self._ui.callout = callout
  self._ui.calloutText = calloutText

  if self._ui._SyncGripProxies then
    self._ui._SyncGripProxies()
  end
  if self._RefreshPanelInteractivity then
    self:_RefreshPanelInteractivity()
  end

  -- -------------------------------------------------------
  -- Step nav buttons
  -- -------------------------------------------------------
  local function BuildNavButtons()
    for i = 1, #self._ui.navButtons do
      local b = self._ui.navButtons[i]
      if b then
        b:Hide()
        b:SetParent(nil)
      end
    end
    self._ui.navButtons = {}

    local y = -2
    local navGroups = self._navGroupOrder or NAV_GROUP_ORDER
    for i = 1, #navGroups do
      local group = navGroups[i]
      local label = group.label or ("Step " .. i)
      local b = MakeButtonLike(navContent, label, NAV_BUTTON_W, 26, col.TEXT, col.BORDER)
      b._wfKind = "nav"
      b._wfNavGroup = group

      -- Cleaner list-row styling (no heavy tooltip border)
      b:SetBackdrop({
        bgFile = "Interface/Buttons/WHITE8x8",
        edgeFile = "Interface/Buttons/WHITE8x8",
        edgeSize = 1,
        insets = { left = 1, right = 1, top = 1, bottom = 1 },
      })
      b:SetBackdropColor(0.06, 0.06, 0.06, 0.55)
      b:SetBackdropBorderColor(C4(col.BORDER, col.BORDER))

      -- Left "marker tab" bar (this is what gets highlighted)
      local mk = b:CreateTexture(nil, "ARTWORK")
      mk:SetTexture("Interface/Buttons/WHITE8x8")
      mk:SetPoint("TOPLEFT", 1, -2)
      mk:SetPoint("BOTTOMLEFT", 1, 2)
      mk:SetWidth(2)
      mk:SetVertexColor(C4(col.BORDER, col.BORDER))
      mk:SetAlpha(0.55)
      b._marker = mk

      -- Move label right so it doesn't collide with marker
      if b._labelFS then
        b._labelFS:ClearAllPoints()
        b._labelFS:SetPoint("LEFT", 14, 0)
      end

      -- Hover should not override selected styling
      b:SetScript("OnEnter", function()
        if b._wfSelected then
          b:SetBackdropColor(0.12, 0.12, 0.12, 0.95)
        else
          b:SetBackdropColor(0.09, 0.09, 0.09, 0.80)
        end
      end)
      b:SetScript("OnLeave", function()
        if b._wfSelected then
          b:SetBackdropColor(0.10, 0.10, 0.10, 0.90)
        else
          b:SetBackdropColor(0.06, 0.06, 0.06, 0.55)
        end
      end)

      b:ClearAllPoints()
      b:SetPoint("TOPLEFT", 0, y)
      b:SetWidth(NAV_BUTTON_W)
      y = y - 30

      local check = b:CreateFontString(nil, "OVERLAY", "WF_GameFontNormalSmall")
      check:SetPoint("RIGHT", -8, 0)
      check:SetTextColor(C4(col.ACCENT, col.ACCENT))
      check:SetText("")
      b._checkFS = check

      local checkTex = b:CreateTexture(nil, "OVERLAY")
      checkTex:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
      checkTex:SetPoint("RIGHT", -6, 0)
      checkTex:SetSize(14, 14)
      if checkTex.SetDesaturated then
        checkTex:SetDesaturated(true)
      end
      checkTex:SetVertexColor(C4(col.ACCENT, col.ACCENT))
      checkTex:Hide()
      b._checkTex = checkTex

      b:SetScript("OnClick", function()
        TO:GoTo(group.first)
      end)

      self._ui.navButtons[i] = b
    end

    if navContent and navContent.SetHeight then
      local navGroups = self._navGroupOrder or NAV_GROUP_ORDER
      navContent:SetHeight(math.max(1, 10 + (#navGroups * 30)))
    end
  end

  BuildNavButtons()
  self._BuildNavButtons = BuildNavButtons
  SyncNavSCWidth()

  -- -------------------------------------------------------
  -- Render + refresh helpers
  -- -------------------------------------------------------
  function self:_RefreshModeButtons()
    local active = GetActiveProfile()
    SetBtnActive(bTransmog, active == "Transmog", col.ACCENT, col.BORDER)
    SetBtnActive(bMats,    active == "Mats",    col.ACCENT, col.BORDER)
    SetBtnActive(bRaw,     active == "Raw Gold", col.ACCENT, col.BORDER)
    SetBtnActive(bDefault, active == "Default", col.ACCENT, col.BORDER)
  end

  function self:_RefreshSourceButtons()
    local prov, tsmStr = GetProviderState()

    SetBtnActive(bVendor, prov == "vendor", col.ACCENT, col.BORDER)
    SetBtnActive(bTSM,    prov == "tsm",    col.ACCENT, col.BORDER)
    SetBtnActive(bAuc,    prov == "auctionator", col.ACCENT, col.BORDER)

    -- Only show TSM Quick while we're on the Source step and TSM is selected.
    local showTSMQuick = false
    if sourceRow and sourceRow.IsShown and sourceRow:IsShown() then
      showTSMQuick = (prov == "tsm")
    end

    local tsmChanged = false
    if tsmRow then
      local wasShown = tsmRow:IsShown()
      tsmRow:SetShown(showTSMQuick)
      tsmChanged = (wasShown ~= showTSMQuick)
    end

    if showTSMQuick then
      for i = 1, #tsmBtns do
        local bb = tsmBtns[i]
        SetBtnActive(bb, (tsmStr ~= nil and bb._tsmString == tsmStr), col.ACCENT, col.BORDER)
      end
    end

    if RelayoutActionRows then
      RelayoutActionRows()
    end
    if tsmChanged then
      self:_Dirty("layout")
      self:_Dirty("spot")
    end
  end
  function self:_RenderChecklist(step)
    local suppressForActionStep = step and (step.wants == "mode" or step.wants == "source")
    if suppressForActionStep or not step or not step.checklist or type(step.checklist) ~= "table" then
      checkWrap:Hide()
      for i = 1, #checkLines do
        checkLines[i]:SetText("")
        checkLines[i]:Hide()
      end
      if RelayoutActionRows then
        RelayoutActionRows()
      end
      self:_Dirty("layout")
      self:_Dirty("spot")
      return
    end

    checkWrap:Show()
    for i = 1, #checkLines do
      checkLines[i]:SetText("")
      checkLines[i]:Hide()
    end

    local shown = 0
    local line = 1
    for i = 1, #step.checklist do
      local item = step.checklist[i]
      local key = item.doneKey
      local done = false
      if key and self._session and self._session.interact then
        done = self._session.interact[key] == true
      end

      local prefix = "- "
      local txt = prefix .. tostring(item.text or "")
      if item.optional then
        txt = txt .. " (optional)"
      end

      if checkLines[line] then
        checkLines[line]:SetText(txt)
        checkLines[line]:SetTextColor(C4(col.TEXT, col.TEXT))
        checkLines[line]:Show()
        shown = line
      end

      line = line + 1
      if line > #checkLines then break end
    end

    if shown > 0 then
      checkWrap:SetHeight(26 + (shown * 18))
      checkWrap:Show()
    else
      checkWrap:Hide()
    end

    if RelayoutActionRows then
      RelayoutActionRows()
    end
    self:_Dirty("layout")
    self:_Dirty("spot")
  end

  function self:_ChecklistSatisfied(step)
    if not step or not step.checklist or not self._session or not self._session.interact then
      return false
    end
    for i = 1, #step.checklist do
      local it = step.checklist[i]
      if not it.optional then
        if not it.doneKey or self._session.interact[it.doneKey] ~= true then
          return false
        end
      end
    end
    return true
  end

  function self:_RefreshNav()
    local idx = tonumber(self._idx) or 1
    local flags = self._flags or {}
    local curGroup = self:_GetNavGroupForIndex(idx)

    for i = 1, #self._ui.navButtons do
      local b = self._ui.navButtons[i]
      if b then
        local g = b._wfNavGroup
        local isCurrent = (curGroup ~= nil and g == curGroup)
        local anyDone = GroupHasAnyDone(flags, g)
        local allDone = GroupAllDone(flags, g)

        b._wfDone = allDone
        SetBtnActive(b, isCurrent, col.ACCENT, col.BORDER)

        if b._checkFS then
          b._checkFS:SetText("")
        end
        if b._checkTex then
          b._checkTex:Hide()
        end

        if allDone then
          if b._checkTex then
            b._checkTex:Show()
          elseif b._checkFS then
            b._checkFS:SetText("•")
          end
        elseif isCurrent or anyDone then
          if b._checkFS then
            b._checkFS:SetText("•")
          end
        end
      end
    end
  end


  -- -------------------------------------------------------
  -- Spotlight control (cached rect + only updates when changed)
  -- -------------------------------------------------------
  function self:_ResetSpotMissing()
    self._spot = self._spot or {}
    self._spot.missCount = 0
    self._spot.holdUntil = nil
  end

  function self:_HoldSpotlightUntilResolved(seconds)
    self._spot = self._spot or {}
    self._spot.holdUntil = (GetTime and GetTime() or 0) + (tonumber(seconds) or 0.45)
    self._spot.missCount = 0
  end

  function self:_ShouldKeepSpotlightOnMissing()
    self._spot = self._spot or {}
    local now = (GetTime and GetTime() or 0)
    if self._spot.holdUntil and now < self._spot.holdUntil then
      return true
    end
    self._spot.missCount = (tonumber(self._spot.missCount) or 0) + 1
    return self._spot.missCount <= 6
  end

  function self:_ClearSpotlight()
    self._spot.active = false
    self._spot.target = nil
    self._spot.pad = nil
    self._spot.side = nil
    self._spot.text = nil
    self._spot.rect = nil
    self._spot.kind = nil
  self._spot._inlinePlayed = nil
    self._spot.missCount = 0
    self._spot.holdUntil = nil

    blockAll:Show()
    spotTop:Hide()
    spotBot:Hide()
    spotLeft:Hide()
    spotRight:Hide()
    holeBorder:Hide()
    callout:Hide()
    if pulse then pulse:Stop() end

    inlineFocus:Hide()
    if inlinePulse then inlinePulse:Stop() end
  end

  local function GetFrameScreenRect(frame)
    if not frame or not frame.GetLeft or not frame.GetBottom or not frame.GetWidth or not frame.GetHeight then
      return nil
    end
    if frame.IsShown and not frame:IsShown() then
      return nil
    end

    local left = frame:GetLeft()
    local bottom = frame:GetBottom()
    local width = frame:GetWidth()
    local height = frame:GetHeight()
    if not left or not bottom or not width or not height then
      return nil
    end

    return left, bottom, left + width, bottom + height
  end

  local function GetCalloutAnchor(side)
    side = tostring(side or "RIGHT"):upper()
    if side == "LEFT" then
      return "RIGHT", "LEFT", -10, 0
    elseif side == "TOP" then
      return "BOTTOM", "TOP", 0, 10
    elseif side == "BOTTOM" then
      return "TOP", "BOTTOM", 0, -10
    end
    return "LEFT", "RIGHT", 10, 0
  end

  local function CandidateSides(preferred)
    preferred = tostring(preferred or "RIGHT"):upper()
    local seen = {}
    local order = {}
    local function add(side)
      side = tostring(side or ""):upper()
      if side ~= "" and not seen[side] then
        seen[side] = true
        order[#order + 1] = side
      end
    end

    add(preferred)
    if preferred == "LEFT" then
      add("RIGHT")
      add("TOP")
      add("BOTTOM")
    elseif preferred == "TOP" then
      add("BOTTOM")
      add("RIGHT")
      add("LEFT")
    elseif preferred == "BOTTOM" then
      add("TOP")
      add("RIGHT")
      add("LEFT")
    else
      add("LEFT")
      add("TOP")
      add("BOTTOM")
    end

    return order
  end

  local function PlaceCalloutSmart(anchorFrame, preferredSide, text)
    if not callout or not calloutText or not anchorFrame then return end
    text = tostring(text or "")
    if text == "" then
      callout:Hide()
      return
    end

    calloutText:SetText(text)
    local textH = math.ceil(calloutText:GetStringHeight() or 0)
    callout:SetHeight(math.max(56, textH + 20))

    local screenW = (UIParent and UIParent.GetWidth and UIParent:GetWidth()) or 0
    local screenH = (UIParent and UIParent.GetHeight and UIParent:GetHeight()) or 0
    local margin = 12
    local gap = 10
    local calloutW = math.ceil(callout:GetWidth() or 260)
    local calloutH = math.ceil(callout:GetHeight() or 56)
    local left, bottom, right, top = GetFrameScreenRect(anchorFrame)
    if not left then
      callout:Hide()
      return
    end

    local room = {
      LEFT = left - margin,
      RIGHT = screenW - right - margin,
      TOP = screenH - top - margin,
      BOTTOM = bottom - margin,
    }

    local chosen = tostring(preferredSide or "RIGHT"):upper()
    local sides = CandidateSides(chosen)
    for i = 1, #sides do
      local side = sides[i]
      local need = ((side == "LEFT" or side == "RIGHT") and calloutW or calloutH) + gap
      if (room[side] or -99999) >= need then
        chosen = side
        break
      end
    end

    local point, relPoint, xOfs, yOfs = GetCalloutAnchor(chosen)
    callout:ClearAllPoints()
    callout:SetPoint(point, anchorFrame, relPoint, xOfs, yOfs)
    callout:Show()

    local cl, cb, cr, ct = GetFrameScreenRect(callout)
    if cl then
      local dx, dy = 0, 0
      if cl < margin then
        dx = margin - cl
      elseif cr > (screenW - margin) then
        dx = (screenW - margin) - cr
      end

      if cb < margin then
        dy = margin - cb
      elseif ct > (screenH - margin) then
        dy = (screenH - margin) - ct
      end

      if dx ~= 0 or dy ~= 0 then
        callout:ClearAllPoints()
        callout:SetPoint(point, anchorFrame, relPoint, xOfs + dx, yOfs + dy)
      end
    end
  end

  function self:_PlaceCalloutForSpot(side, text)
    PlaceCalloutSmart(holeBorder, side, text)
  end

  function self:_QueueSpotRefresh()
    self._spotRetryToken = (tonumber(self._spotRetryToken) or 0) + 1
    local token = self._spotRetryToken
    local delays = { 0, 0.03, 0.08, 0.16, 0.30, 0.45 }
    for i = 1, #delays do
      local d = delays[i]
      C_Timer.After(d, function()
        if token ~= self._spotRetryToken then return end
        if not self._ui or not self._ui.root or not self._ui.root:IsShown() then return end
        self:_Dirty("spot")
      end)
    end
  end

  function self:_ApplyInlineFocus(targetFrame, pad, force)
    if not targetFrame or not targetFrame.IsShown or not targetFrame:IsShown() then
      if self:_ShouldKeepSpotlightOnMissing() then return end
      self:_ClearSpotlight()
      return
    end

    pad = tonumber(pad) or 8
    force = (force ~= false)
    self:_ResetSpotMissing()

    local prevTarget = self._spot.target
    local prevPad = self._spot.pad
    local prevText = tostring(self._spot.text or "")
    local nextText = tostring(self._spot.text or "")

    local needsAnchor = force
      or self._spot.kind ~= "inline"
      or prevTarget ~= targetFrame
      or prevPad ~= pad
      or not inlineFocus:IsShown()

    local needsPulseRestart = force or needsAnchor or not inlinePulse or not inlinePulse:IsPlaying()
    local needsCallout = force or prevText ~= nextText or needsAnchor

    self._spot.active = true
    self._spot.kind = "inline"
    self._spot.target = targetFrame
    self._spot.pad = pad
    self._spot.side = nil
    self._spot.text = nextText
    self._spot.rect = nil

    blockAll:Show()
    spotTop:Hide()
    spotBot:Hide()
    spotLeft:Hide()
    spotRight:Hide()
    holeBorder:Hide()
    if pulse then pulse:Stop() end

    if needsAnchor then
      inlineFocus:ClearAllPoints()
      inlineFocus:SetPoint("TOPLEFT", targetFrame, "TOPLEFT", -pad, pad)
      inlineFocus:SetPoint("BOTTOMRIGHT", targetFrame, "BOTTOMRIGHT", pad, -pad)
    end
    inlineFocus:Show()

    if nextText ~= "" then
      if needsCallout then
        local calloutAnchor = inlineFocus
        if targetFrame == sourceRow and tsmRow and tsmRow.IsShown and tsmRow:IsShown() then
          calloutAnchor = tsmRow
        end

        PlaceCalloutSmart(calloutAnchor, "BOTTOM", nextText)
      else
        callout:Show()
      end
    else
      callout:Hide()
    end

    if needsPulseRestart and inlinePulse then
      inlinePulse:Stop()
      inlinePulse:Play()
    end
  end

  function self:_ApplySpotlight(targetFrame, pad, side, callTxt, force)
    if inlineFocus and inlineFocus.Hide then inlineFocus:Hide() end
    if inlinePulse and inlinePulse.Stop then inlinePulse:Stop() end

    if not targetFrame or not targetFrame.IsShown or not targetFrame:IsShown() then
      if self:_ShouldKeepSpotlightOnMissing() then return end
      self:_ClearSpotlight()
      return
    end

    local left, bottom, right, top = RectFromFrameRelative(root, targetFrame)
    if not left then
      if self:_ShouldKeepSpotlightOnMissing() then return end
      self:_ClearSpotlight()
      return
    end

    pad = tonumber(pad) or 10
    left = left - pad
    bottom = bottom - pad
    right = right + pad
    top = top + pad

    local rw = root:GetWidth() or 800
    local rh = root:GetHeight() or 600

    left = Clamp(left, 0, rw)
    right = Clamp(right, 0, rw)
    bottom = Clamp(bottom, 0, rh)
    top = Clamp(top, 0, rh)

    local newRect = { l = left, b = bottom, r = right, t = top }
    if not force and self._spot.rect and RectEqual(self._spot.rect, newRect) and self._spot.side == side and self._spot.text == callTxt then
      return
    end

    self:_ResetSpotMissing()
    self._spot.rect = newRect

    blockAll:Hide()

    spotTop:ClearAllPoints()
    spotTop:SetPoint("TOPLEFT", root, "TOPLEFT", 0, 0)
    spotTop:SetPoint("BOTTOMRIGHT", root, "BOTTOMLEFT", rw, top)
    spotTop:Show()

    spotBot:ClearAllPoints()
    spotBot:SetPoint("TOPLEFT", root, "BOTTOMLEFT", 0, bottom)
    spotBot:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", 0, 0)
    spotBot:Show()

    spotLeft:ClearAllPoints()
    spotLeft:SetPoint("TOPLEFT", root, "BOTTOMLEFT", 0, top)
    spotLeft:SetPoint("BOTTOMRIGHT", root, "BOTTOMLEFT", left, bottom)
    spotLeft:Show()

    spotRight:ClearAllPoints()
    spotRight:SetPoint("TOPLEFT", root, "BOTTOMLEFT", right, top)
    spotRight:SetPoint("BOTTOMRIGHT", root, "BOTTOMRIGHT", 0, bottom)
    spotRight:Show()

    holeBorder:ClearAllPoints()
    holeBorder:SetPoint("BOTTOMLEFT", root, "BOTTOMLEFT", left, bottom)
    holeBorder:SetPoint("TOPRIGHT", root, "BOTTOMLEFT", right, top)
    holeBorder:Show()
    if pulse and not pulse:IsPlaying() then
      pulse:Play()
    end

    self:_PlaceCalloutForSpot(side, callTxt)

    self._spot.active = true
    self._spot.kind = "spotlight"
    self._spot.target = targetFrame
    self._spot.pad = pad
    self._spot.side = side
    self._spot.text = callTxt
  end

  -- -------------------------------------------------------
  -- Focus resolver
  -- -------------------------------------------------------
  local function GetDeepField(t, path)
    local cur = t
    if type(path) ~= "table" then return nil end
    for i = 1, #path do
      if type(cur) ~= "table" then return nil end
      cur = cur[path[i]]
      if cur == nil then return nil end
    end
    return cur
  end

  local function FindSettingsSection(helpUIRef, title)
    local layout = helpUIRef and helpUIRef._layout and helpUIRef._layout.settings
    local sections = layout and layout.sections
    if type(sections) ~= "table" then return nil end
    local needle = tostring(title or ""):lower()
    for i = 1, #sections do
      local sec = sections[i]
      if type(sec) == "table" and tostring(sec.title or ""):lower() == needle then
        return sec.wrap or sec.header or sec.content or nil
      end
    end
    return nil
  end

  function self:_ResolveFocus(step, helpUIRef)
    if not step or not step.focus then return nil end
    local f = step.focus

    if f.kind == "helpTab" then
      return TryGetHelpTabButton(helpUIRef, f.key), f.pad, f.side, f.text
    end

    if f.kind == "helpFrame" then
      local frame = (helpUIRef and helpUIRef.frame) or self._ui.parent or nil
      return frame, f.pad, f.side, f.text
    end

    if f.kind == "farmWindow" then
      local frame, shown = EnsureFarmWindowOpen()
      if shown then
        local target = frame
        if f.which then
          target = FindFarmWindowTarget(f.which) or frame
        end
        return target, f.pad, f.side, f.text
      end
      return nil, f.pad, f.side, f.text
    end

    if f.kind == "competitiveWindow" then
      local frame, shown = EnsureCompetitiveWindowOpen()
      if shown then
        local target = frame
        if f.which then
          target = FindCompetitiveWindowTarget(f.which) or frame
        end
        return target, f.pad, f.side, f.text
      end
      return nil, f.pad, f.side, f.text
    end

    if f.kind == "tutorialGrip" then
      return (self._ui and self._ui.tutorialGripProxy) or resizeGrip, f.pad, f.side, f.text
    end

    if f.kind == "parentGrip" then
      return (helpUIRef and helpUIRef.frame) or self._ui.parent or nil, f.pad, f.side, f.text
    end

    if f.kind == "overlay" then
      if f.which == "modeRow" then
        return modeRow, f.pad, f.side, f.text
      end
      if f.which == "sourceRow" then
        return sourceRow, f.pad, f.side, f.text
      end
      if f.which == "resizeGrip" then
        return resizeGrip, f.pad, f.side, f.text
      end
    end

    if f.kind == "layout" then
      local layout = helpUIRef and helpUIRef._layout and helpUIRef._layout[f.page or ""]
      local target = layout
      if type(layout) == "table" and type(f.path) == "table" then
        target = GetDeepField(layout, f.path)
      elseif type(layout) == "table" and f.which then
        target = layout[f.which]
      end
      if IsFrame(target) then
        return target, f.pad, f.side, f.text
      end
    end

    if f.kind == "settingsSection" then
      local layout = helpUIRef and helpUIRef._layout and helpUIRef._layout.settings
      if layout and layout.expandSection then
        SafeCall(layout.expandSection, f.title)
      end
      local target = FindSettingsSection(helpUIRef, f.title)
      if IsFrame(target) then
        return target, f.pad, f.side, f.text
      end
    end

    return nil
  end

  function self:_AutoPlacePanel(step, helpUIRef)
    -- Stable by design now: do not jump the tutorial box every time focus changes.
    -- The panel keeps its saved/manual position so multi-step tab tours feel anchored.
    return
  end

  function self:_SetChooserLayout(isChooser)
    local ui = self._ui
    if not ui then return end

    isChooser = isChooser and true or false

    if ui.chooserScreen then ui.chooserScreen:SetShown(isChooser) end
    if ui.chooserArrowLeft then ui.chooserArrowLeft:Hide() end
    if ui.chooserArrowLeftGlow then ui.chooserArrowLeftGlow:Hide() end
    if ui.chooserArrowRight then ui.chooserArrowRight:Hide() end
    if ui.chooserArrowRightGlow then ui.chooserArrowRightGlow:Hide() end
    if ui.chooserLogo then ui.chooserLogo:Hide() end
    if ui.chooserLogoFallback then ui.chooserLogoFallback:Hide() end
    if ui.chooserLogoWarmup then ui.chooserLogoWarmup:Hide() end
    if ui.navWrap then ui.navWrap:SetShown(not isChooser) end
    if ui.body then ui.body:SetShown(not isChooser) end
    if ui.footer then ui.footer:SetShown(not isChooser) end
    if ui.progressBar then ui.progressBar:SetShown(not isChooser) end
    if ui.progressFS then if isChooser then ui.progressFS:Hide() else ui.progressFS:Show() end end
    if ui.resizeGrip then ui.resizeGrip:SetShown(not isChooser) end

    if ui.scroll then ui.scroll:SetShown(not isChooser) end
    if ui.bodyBG then ui.bodyBG:SetShown(not isChooser) end

    if isChooser then
      if ui.actionWrap then ui.actionWrap:Hide() end
      if ui.modeRow then ui.modeRow:Hide() end
      if ui.sourceRow then ui.sourceRow:Hide() end
      if ui.tsmRow then ui.tsmRow:Hide() end
      if ui.checkWrap then ui.checkWrap:Hide() end
    end

    if ui._SyncGripProxies then
      ui._SyncGripProxies()
    end
    if self._RefreshPanelInteractivity then
      self:_RefreshPanelInteractivity()
    end
  end

  -- -------------------------------------------------------
  -- Layout (dirty-driven)
  -- -------------------------------------------------------
  function self:_Layout()
    if not root:IsShown() then return end

    local pw = root:GetWidth() or 900
    local ph = root:GetHeight() or 650

    local minW, minH, maxW, maxH = GetTutorialPanelBounds(parentFrame)

    local w = panel:GetWidth() or 620
    local h = panel:GetHeight() or 520

    ApplyTutorialResizeBounds()

    -- Default sizing unless the user has manually resized
    if not self._ui._userSized then
      local targetW = Clamp(math.floor(pw * 0.30), minW, math.min(640, maxW))
      local targetH = Clamp(math.floor(ph * 0.72), math.max(minH, 440), math.min(620, maxH))
      w, h = ClampTutorialPanelSize(parentFrame, targetW, targetH)
      panel:SetSize(w, h)
    else
      local cw, ch = ClampTutorialPanelSize(parentFrame, w, h)
      if cw ~= w or ch ~= h then
        panel:SetSize(cw, ch)
      end
      w, h = cw, ch
    end

    local isChooser = self:_IsChoosing()
    if self._SetChooserLayout then
      self:_SetChooserLayout(isChooser)
    end

    if isChooser then
      local choiceSpread = Clamp(math.floor((w - 540) * 0.20) + 98, 98, 126)
      local choiceBodyW = Clamp(math.floor((w - 240) * 0.28), 150, 188)
      -- Logo removed for beta release; nothing logo-related is laid out here.

      if self._ui.chooserDesc then
        self._ui.chooserDesc:SetWidth(math.max(320, w - 140))
      end

      if self._ui.chooserArrowHint then
        self._ui.chooserArrowHint:ClearAllPoints()
        self._ui.chooserArrowHint:SetPoint("BOTTOM", self._ui.chooserBG or self._ui.chooserScreen, "BOTTOM", 0, CHOOSER_ARROW_HINT_BOTTOM_OFFSET)
        self._ui.chooserArrowHint:SetSize(460, 18)
        if self._ui.chooserArrowHint.SetWordWrap then self._ui.chooserArrowHint:SetWordWrap(false) end
        if self._ui.chooserArrowHint.SetNonSpaceWrap then self._ui.chooserArrowHint:SetNonSpaceWrap(false) end
        if self._ui.chooserArrowHint.SetIndentedWordWrap then self._ui.chooserArrowHint:SetIndentedWordWrap(false) end
        if self._ui.chooserArrowHint.SetMaxLines then self._ui.chooserArrowHint:SetMaxLines(1) end
        self._ui.chooserArrowHint:SetText("Use arrow keys to navigate the tutorial.")
      end

      if self._ui.chooserArrowLeft then self._ui.chooserArrowLeft:Hide() end
      if self._ui.chooserArrowLeftGlow then self._ui.chooserArrowLeftGlow:Hide() end
      if self._ui.chooserArrowRight then self._ui.chooserArrowRight:Hide() end
      if self._ui.chooserArrowRightGlow then self._ui.chooserArrowRightGlow:Hide() end

      local choiceAnchor = self._ui.chooserSub

      if self._ui.chooserAdvancedBtn and choiceAnchor then
        self._ui.chooserAdvancedBtn:ClearAllPoints()
        self._ui.chooserAdvancedBtn:SetPoint("TOP", choiceAnchor, "BOTTOM", -choiceSpread, -30)
      end

      if self._ui.chooserFastBtn and choiceAnchor then
        self._ui.chooserFastBtn:ClearAllPoints()
        self._ui.chooserFastBtn:SetPoint("TOP", choiceAnchor, "BOTTOM", choiceSpread, -30)
      end

      if self._ui.chooserAdvancedBody then
        self._ui.chooserAdvancedBody:SetWidth(choiceBodyW)
      end

      if self._ui.chooserFastBody then
        self._ui.chooserFastBody:SetWidth(choiceBodyW)
      end

      if self._ui.chooserLogoFallback then self._ui.chooserLogoFallback:Hide() end
      if self._ui.chooserLogo then self._ui.chooserLogo:Hide() end
      if self._ui.chooserLogoWarmup then self._ui.chooserLogoWarmup:Hide() end

      return
    end

    local bodyW = (self._ui.body and self._ui.body.GetWidth and self._ui.body:GetWidth()) or ((w - 16) - navWrap:GetWidth() - 10)
    bodyW = RoundInt(bodyW)
    local textW = RoundInt(math.max(300, bodyW - 42))
    stepText:SetWidth(textW)
    sc:SetWidth(RoundInt(math.max(textW + 22, bodyW - 22)))

    if RelayoutActionRows then
      RelayoutActionRows()
    end

    local baseH = 16
      + (stepTitle:GetStringHeight() or 0)
      + 14
      + (stepText:GetStringHeight() or 0)
      + 16

    if checkWrap:IsShown() then
      baseH = baseH + (checkWrap:GetHeight() or 0) + 14
    end
    if actionWrap:IsShown() then
      baseH = baseH + (actionWrap:GetHeight() or 0) + 14
    end

    sc:SetHeight(math.max(1, baseH + 60))

    if self._ui.progressBar and self._ui.progressFill and self._ui.progressBar.GetWidth then
      local fullW = tonumber(self._ui.progressBar:GetWidth() or 0) or 0
      if fullW > 0 and self._ui.progressFill.SetWidth then
        self._ui.progressFill:SetWidth(math.max(1, RoundInt(fullW * (tonumber(self._idx) or 1) / self:_StepCount())))
      end
    end
  end

  root:SetScript("OnShow", function()
    if root._wf_sync then root._wf_sync() end
    self:_Dirty("layout")
    self:_Dirty("spot")
    self:_Dirty("nav")
    self:_Dirty("check")
  end)

  root:SetScript("OnSizeChanged", function()
    ApplyTutorialResizeBounds()
    if root._wf_sync then root._wf_sync() end
    self:_Dirty("layout")
    self:_Dirty("spot")
  end)

  -- parent hooks (guarded to avoid stacking hooks on re-open same parent)
  if self._ui.hookedParent ~= parentFrame then
    self._ui.hookedParent = parentFrame

    parentFrame:HookScript("OnHide", function()
      if self._ShouldKeepOverlayWhenParentHides and self:_ShouldKeepOverlayWhenParentHides() then
        if root._wf_sync then root._wf_sync() end
        self:_Dirty("layout")
        self:_Dirty("spot")
        return
      end
      if root:IsShown() then root:Hide() end
    end)

    parentFrame:HookScript("OnSizeChanged", function()
      ApplyTutorialResizeBounds()
      if root._wf_sync then root._wf_sync() end
      if root:IsShown() then
        self:_Dirty("layout")
        self:_Dirty("spot")
      end
    end)
  end

  -- -------------------------------------------------------
  -- Keyboard
  -- -------------------------------------------------------
  root:SetScript("OnKeyDown", function(_, key)
    if key == "ESCAPE" then
      TO:Close()
      return
    end

    if TO._IsChoosing and TO:_IsChoosing() then
      return
    end

    if key == "LEFT" then
      if (tonumber(TO._idx) or 1) <= 1 then
        TO:_ShowChooser()
      else
        TO:Prev()
      end
      return
    end
    if key == "RIGHT" then
      TO:Next()
      return
    end
    if key == "ENTER" then
      TO:Next()
      return
    end
  end)

  -- -------------------------------------------------------
  -- Interactions
  -- -------------------------------------------------------
  local function MarkInteract(key)
    self._session.interact[key] = true

    local cur = tonumber(self._idx) or 1
    local step = self:_GetStep(cur)
    if step and self._ChecklistSatisfied and self:_ChecklistSatisfied(step) then
      self:_MarkDone(cur)
    end

    self:_Dirty("check")
    self:_Dirty("nav")
  end

  bTransmog:SetScript("OnClick", function()
    ApplyProfile("Transmog")
    MarkInteract("pickedMode")
    self:_RefreshModeButtons()
    RepriceAndRefresh()
  end)
  bMats:SetScript("OnClick", function()
    ApplyProfile("Mats")
    MarkInteract("pickedMode")
    self:_RefreshModeButtons()
    RepriceAndRefresh()
  end)
  bRaw:SetScript("OnClick", function()
    ApplyProfile("Raw Gold")
    MarkInteract("pickedMode")
    self:_RefreshModeButtons()
    RepriceAndRefresh()
  end)
  bDefault:SetScript("OnClick", function()
    ApplyProfile("Default")
    MarkInteract("pickedMode")
    self:_RefreshModeButtons()
    RepriceAndRefresh()
  end)

  bVendor:SetScript("OnClick", function()
    SetProviderVendor()
    MarkInteract("pickedSource")
    RepriceAndRefresh()
    self:_RefreshSourceButtons()
  end)

  bAuc:SetScript("OnClick", function()
    SetProviderAuctionator()
    MarkInteract("pickedSource")
    RepriceAndRefresh()
    self:_RefreshSourceButtons()
  end)

  bTSM:SetScript("OnClick", function()
    SetProviderTSM(nil)
    MarkInteract("pickedSource")
    RepriceAndRefresh()
    self:_RefreshSourceButtons()
  end)

  for i = 1, #tsmBtns do
    local b = tsmBtns[i]
    b:SetScript("OnClick", function()
      SetProviderTSM(b._tsmString)
      MarkInteract("pickedSource")
      MarkInteract("pickedTSMQuick")
      RepriceAndRefresh()
      self:_RefreshSourceButtons()
    end)
  end

  local function ChooseTutorial(mode)
    self:_SetTutorialMode(mode)
    self._chooserActive = false
    self._session.interact = self._session.interact or {}
    self._session.interact.pickedTutorialChoice = true
    SetSetting("tutorialChoiceMade", true)
    SetSetting("tutorialStep", 1)
    self:_Dirty("nav")
    self:_ApplyStep(self._ui.helpUI, 1)
  end

  chooserAdvancedBtn:SetScript("OnClick", function()
    ChooseTutorial("advanced")
  end)

  chooserFastBtn:SetScript("OnClick", function()
    ChooseTutorial("fast")
  end)

  -- -------------------------------------------------------
  -- Footer button scripts
  -- -------------------------------------------------------
  bFinish:SetScript("OnClick", function() TO:Finish() end)
  bBack:SetScript("OnClick", function() TO:Prev() end)
  bNext:SetScript("OnClick", function() TO:Next() end)

  bRestart:SetScript("OnClick", function()
    TO:Restart()
  end)

  -- -------------------------------------------------------
  -- Tick loop (advanced): throttled + dirty-driven
  -- - keeps spotlight aligned (only when active)
  -- - refreshes checklist/nav only when dirty
  -- - layout only when dirty
  -- -------------------------------------------------------
  local acc = 0
  root:SetScript("OnUpdate", function(_, elapsed)
    acc = acc + (elapsed or 0)
    if acc < 0.04 then return end
    acc = 0

    if not root:IsShown() then return end

    -- Focus ring maintenance:
    -- Spotlight targets outside the tutorial need periodic rect checks.
    -- Inline targets are already anchored to their widgets, so reapplying them here
    -- just causes visual churn and makes the pulse look choppy.
    if not self._dirty.spot and self._spot.active and self._spot.target and self._spot.target.IsShown and self._spot.target:IsShown() then
      if self._spot.kind ~= "inline" then
        self:_ApplySpotlight(self._spot.target, self._spot.pad, self._spot.side, self._spot.text, false)
      end
    end

    if self._dirty.check then
      local step = self:_GetStep(tonumber(self._idx) or 1)
      self:_RenderChecklist(step)
      self._dirty.check = nil
    end

    if self._dirty.nav then
      self:_RefreshNav()
      self._dirty.nav = nil
    end

    if self._dirty.layout then
      self:_Layout()
      local step = self:_GetStep(tonumber(self._idx) or 1)
      if self._AutoPlacePanel then self:_AutoPlacePanel(step, self._ui.helpUI) end
      self._dirty.layout = nil
      self:_Dirty("spot")
    end

    if self._dirty.spot then
      local step = self:_GetStep(tonumber(self._idx) or 1)
      local focus = step and step.focus
      local target, pad, side, callTxt = self:_ResolveFocus(step, self._ui.helpUI)

      if target then
        if focus and (focus.kind == "overlay" or focus.kind == "tutorialGrip") then
          if self._spot then self._spot.text = callTxt end
          if self._ApplyInlineFocus then self:_ApplyInlineFocus(target, pad, true) end
        else
          self:_ApplySpotlight(target, pad, side, callTxt, true)
        end
      else
        if focus then
          if focus.kind == "overlay" or focus.kind == "tutorialGrip" then
            if self._ApplyInlineFocus then self:_ApplyInlineFocus(nil, nil, false) end
          else
            if self._ApplySpotlight then self:_ApplySpotlight(nil, nil, nil, nil, false) end
          end
        else
          self:_ClearSpotlight()
        end
      end
      self._dirty.spot = nil
    end
  end)
end

-- -------------------------------------------------------
-- Step logic
-- -------------------------------------------------------
function TO:_ClearFocusNow()
  if self._ClearSpotlight then
    self:_ClearSpotlight()
    return
  end
  local ui = self._ui
  if not ui then return end
  if ui.inlineFocus then ui.inlineFocus:Hide() end
  if ui.inlinePulse then ui.inlinePulse:Stop() end
  if ui.callout then ui.callout:Hide() end
end

function TO:_LoadFlags()
  local n = StepCount()
  local s = GetSetting("tutorialFlags", "")
  self._flags = FlagsFromString(s, n)
end

function TO:_SaveFlags()
  local n = StepCount()
  local s = FlagsToString(self._flags, n)
  SetSetting("tutorialFlags", s)
end

function TO:_MarkDone(idx)
  local absIdx = self:_GetAbsoluteStepIndex(idx)
  absIdx = Clamp(absIdx, 1, StepCount())
  self._flags = self._flags or FlagsFromString(GetSetting("tutorialFlags", ""), StepCount())
  self._flags[absIdx] = true
  self:_SaveFlags()
end

function TO:_ShowChooser()
  self:_InitState()

  if self._session then
    self._session.openToken = (self._session.openToken or 0) + 1
  end
  self._spotRetryToken = (tonumber(self._spotRetryToken) or 0) + 1

  self._chooserActive = true
  self._idx = 1
  self:_ClearFocusNow()
  if self._ClearChecklistUI then self:_ClearChecklistUI() end

  local ui = self._ui
  if ui then
    if ui.stepTitle then ui.stepTitle:SetText("") end
    if ui.stepText then ui.stepText:SetText("") end
    if ui.scroll and ui.scroll.SetVerticalScroll then
      ui.scroll:SetVerticalScroll(0)
    end

    -- Hard-hide step-specific rows immediately so we never flash the
    -- previously viewed step while switching back to the chooser.
    if ui.tsmRow then ui.tsmRow:Hide() end
    if ui.sourceRow then ui.sourceRow:Hide() end
    if ui.modeRow then ui.modeRow:Hide() end
    if ui.actionWrap then
      ui.actionWrap:SetAlpha(0)
      ui.actionWrap:Hide()
    end
  end

  if self._SetChooserLayout then
    self:_SetChooserLayout(true)
  end

  -- Apply the chooser layout immediately when the overlay is already shown.
  -- Relying only on the next dirty/layout pass is what causes the occasional
  -- one-frame Welcome flash on first open or when paging back to the chooser.
  if self._ui and self._ui.root and self._ui.root:IsShown() then
    if self._Layout then self:_Layout() end
    if self._RefreshNav then self:_RefreshNav() end
  end

  self:_Dirty("layout")
  self:_Dirty("spot")
  self:_Dirty("nav")
  self:_Dirty("check")
end

function TO:_ApplyStep(helpUI, idx)
  self:_InitState()
  self._chooserActive = false

  if self._spot then
    self._spot.holdUntil = nil
    self._spot.missCount = 0
    self._spot.active = false
    self._spot.target = nil
    self._spot.kind = nil
    self._spot.pad = nil
    self._spot.side = nil
    self._spot.text = nil
    self._spot.rect = nil
  end
  self:_ClearFocusNow()

  idx = Clamp(idx, 1, self:_StepCount())

  local prevStep = self:_GetStep(tonumber(self._idx) or idx)
  local nextStep = self:_GetStep(idx)

  if IsDetachedWindowStep(prevStep) and not IsDetachedWindowStep(nextStep) then
    self:_RestoreTutorialParentAfterDetachedWindow()
  end

  if IsCompetitiveWindowStep(prevStep) and not IsCompetitiveWindowStep(nextStep) then
    EnsureCompetitiveWindowClosed()
    if self._session then
      self._session.autoOpenedCompetitiveWindow = nil
    end
  end

  if IsDetachedWindowStep(prevStep)
    and not IsDetachedWindowStep(nextStep)
  then
    EnsureFarmWindowClosed()
    if self._session then
      self._session.autoOpenedFarmWindow = nil
    end
  end

  self._idx = idx

  SetSetting("tutorialStep", idx)
  if self._ClearChecklistUI then self:_ClearChecklistUI() end

  local ui = self._ui

  -- Pre-hide action rows BEFORE any tab switching / text updates.
  -- This prevents a one-frame flash of Mode/Source controls when navigating (e.g., Mode -> Welcome).
  if ui then
    if ui.tsmRow then ui.tsmRow:Hide() end
    if ui.sourceRow then ui.sourceRow:Hide() end
    if ui.modeRow then ui.modeRow:Hide() end
    if ui.actionWrap then ui.actionWrap:Hide() end
    if ui.actionWrap then ui.actionWrap:SetAlpha(0) end
  end

  local step = nextStep
  if not step then return end

  if IsFarmWindowStep(step) then
    local _, shown, openedNow = EnsureFarmWindowOpen()
    if shown and openedNow and self._session then
      self._session.autoOpenedFarmWindow = true
    end
    if shown then
      self:_HideTutorialParentForDetachedWindow()
    end
  elseif IsCompetitiveWindowStep(step) then
    -- Farm Window 3/3 should show only the Competitive window.
    -- The previous Farm Window tutorial steps may have auto-opened the tracker,
    -- so close it before opening Competitive.
    EnsureFarmWindowClosed()
    if self._session then
      self._session.autoOpenedFarmWindow = nil
    end

    local _, shown, openedNow = EnsureCompetitiveWindowOpen()
    if shown and openedNow and self._session then
      self._session.autoOpenedCompetitiveWindow = true
    end
    if shown then
      self:_HideTutorialParentForDetachedWindow()
    end
  elseif self._session then
    self._session.autoOpenedFarmWindow = nil
    self._session.autoOpenedCompetitiveWindow = nil
  end

  -- switch help tab only when the parent tab actually changes.
  -- This lets multiple tutorial steps live inside one WhatFarm tab without forcing a tab flip.
  if helpUI and helpUI.Show and step.key then
    local curKey = tostring(helpUI.currentKey or "")
    local nextKey = tostring(step.key or "")
    if curKey ~= nextKey then
      SafeCall(helpUI.Show, helpUI, step.key)
    end
  end
  if self._SetChooserLayout then
    self:_SetChooserLayout(false)
  end

  if step.settingsTab and helpUI and helpUI._layout and helpUI._layout.settings and helpUI._layout.settings.showTab then
    SafeCall(helpUI._layout.settings.showTab, helpUI._layout.settings, step.settingsTab)
  end
  CacheColors(self)

  if ui.stepTitle then ui.stepTitle:SetText(step.title or "") end
  if ui.stepText then
    local panelW = (ui.panel and ui.panel.GetWidth and ui.panel:GetWidth()) or 620
    local navW = (ui.navWrap and ui.navWrap.GetWidth and ui.navWrap:GetWidth()) or 150
    local bodyW = (ui.body and ui.body.GetWidth and ui.body:GetWidth()) or ((panelW - 16) - navW - 10)
    bodyW = RoundInt(bodyW)
    ui.stepText:SetWidth(RoundInt(math.max(300, bodyW - 42)))
    ui.stepText:SetText(StepText(step))
  end
  if ui.progressFS then
    local group = self:_GetNavGroupForIndex(idx)
    local text = ("Step %d / %d"):format(idx, self:_StepCount())
    if group then
      local pos = tonumber(step.groupStep)
      local count = tonumber(step.groupTotal)

      if not pos or pos < 1 then
        pos = (idx - (group.first or idx)) + 1
      end
      if not count or count < pos then
        count = math.max(pos, ((group.last or idx) - (group.first or idx)) + 1)
      end

      if count > 1 then
        text = text .. ("  •  %s %d / %d"):format(tostring(group.label or "Section"), pos, count)
      end
    end
    ui.progressFS:SetText(text)
  end
  if ui.progressBar and ui.progressFill and ui.progressBar.GetWidth then
    local fullW = tonumber(ui.progressBar:GetWidth() or 0) or 0
    if fullW > 0 and ui.progressFill.SetWidth then
      ui.progressFill:SetWidth(math.max(1, math.floor((fullW * idx / self:_StepCount()) + 0.5)))
    end
  end

  if self._RenderChecklist then
    self:_RenderChecklist(step)
  end

  local wants = step.wants or ""
  local showActions = (wants == "mode" or wants == "source")
  if ui.actionWrap then
    ui.actionWrap:SetAlpha(showActions and 1 or 0)
    ui.actionWrap:SetShown(showActions)
    if not showActions then ui.actionWrap:Hide() end
  end
  if ui.modeRow then
    ui.modeRow:SetAlpha((wants == "mode") and 1 or 0)
    ui.modeRow:SetShown(wants == "mode")
    if wants ~= "mode" then ui.modeRow:Hide() end
  end
  if ui.sourceRow then
    ui.sourceRow:SetAlpha((wants == "source") and 1 or 0)
    ui.sourceRow:SetShown(wants == "source")
    if wants ~= "source" then ui.sourceRow:Hide() end
  end

  if ui.tsmRow then
    local prov = select(1, GetProviderState())
    local showTSMQuick = (wants == "source" and prov == "tsm")
    ui.tsmRow:SetAlpha(showTSMQuick and 1 or 0)
    ui.tsmRow:SetShown(showTSMQuick)
    if not showTSMQuick then ui.tsmRow:Hide() end
  end

  if self._RelayoutActionRows then
    self._RelayoutActionRows()
  end
  if self._RefreshPanelInteractivity then
    self:_RefreshPanelInteractivity()
  end

  if ui.bFinish then ui.bFinish:SetShown(wants == "finish") end
  if ui.bNext then ui.bNext:SetShown(wants ~= "finish") end
  if ui.bBack then
    ui.bBack:SetShown(true)
    ui.bBack:SetEnabled(true)
    local backText = (idx <= 1) and "Choose" or "Back"
    if ui.bBack.label and ui.bBack.label.SetText then
      ui.bBack.label:SetText(backText)
    elseif ui.bBack._labelFS and ui.bBack._labelFS.SetText then
      ui.bBack._labelFS:SetText(backText)
    end
  end
  if ui.bRestart then ui.bRestart:SetShown(true) end

  if self._RefreshModeButtons then self:_RefreshModeButtons() end
  if self._RefreshSourceButtons then self:_RefreshSourceButtons() end
  if self._Layout then self:_Layout() end
  -- spotlight + checklist + layout are now dirty-driven
  self:_Dirty("check")
  -- Apply focus immediately (the ticker updates every 0.08s; deferring causes visible flashes).
  do
    local focus = step and step.focus
    local target, pad, side, callTxt = self:_ResolveFocus(step, helpUI)
    if target then
      if focus and (focus.kind == "overlay" or focus.kind == "tutorialGrip") then
        if self._spot then self._spot.text = callTxt end
        if self._ApplyInlineFocus then self:_ApplyInlineFocus(target, pad, true) end
      else
        if self._ApplySpotlight then self:_ApplySpotlight(target, pad, side, callTxt, true) end
      end

      -- Scroll the settings panel so the spotlighted section is visible.
      if focus and focus.kind == "settingsSection" then
        local layout = helpUI and helpUI._layout and helpUI._layout.settings
        local settingsScroll = layout and layout.scroll
        local settingsSC = layout and layout.content
        if settingsScroll and settingsSC and IsFrame(target) then
          C_Timer.After(0.05, function()
            if not (settingsScroll and settingsSC and IsFrame(target)) then return end
            local scH = settingsSC:GetHeight() or 0
            local svH = settingsScroll:GetHeight() or 0
            if scH <= svH then return end -- no scrolling needed
            local scTop = settingsSC:GetTop() or 0
            local targetTop = target:GetTop() or 0
            if scTop == 0 and targetTop == 0 then return end
            -- Aim to put the section near 25% from the top of the visible area
            local offsetFromTop = scTop - targetTop
            local desired = math.max(0, offsetFromTop - math.floor(svH * 0.25))
            local maxScroll = math.max(0, scH - svH)
            settingsScroll:SetVerticalScroll(math.min(desired, maxScroll))
          end)
        end
      end
    else
      local hasFocus = step and step.focus
      if not hasFocus and self._ClearSpotlight then self:_ClearSpotlight() end
    end
  end

  self:_Dirty("layout")
  self:_Dirty("nav")
  self:_Dirty("spot")
  if self._QueueSpotRefresh then self:_QueueSpotRefresh() end

  if ui.scroll and ui.scroll.SetVerticalScroll then
    ui.scroll:SetVerticalScroll(0)
  end
end

function TO:IsShown()
  return self._ui and self._ui.root and self._ui.root:IsShown()
end

-- -------------------------------------------------------
-- Stabilize open: force layout after parent becomes visible
-- -------------------------------------------------------
function TO:_StabilizeAfterOpen(helpUI, idx)
  if not self._ui or not self._ui.root then return end
  self._session = self._session or { interact = {} }

  self._session.openToken = (self._session.openToken or 0) + 1
  local token = self._session.openToken
  local tries = 0

  local function Tick()
    if not self._session or token ~= self._session.openToken then return end
    local ui = self._ui
    local root = ui and ui.root
    if not root then return end

    tries = tries + 1

    if not root:IsShown() or (root:GetWidth() or 0) < 10 or (root:GetHeight() or 0) < 10 then
      if tries < 12 then
        C_Timer.After(0, Tick)
      end
      return
    end

    if root._wf_sync then root._wf_sync() end
    if root.Raise then root:Raise() end

    if self:_IsChoosing() then
      if self._SetChooserLayout then self:_SetChooserLayout(true) end
      if self._Layout then self:_Layout() end
      self:_Dirty("layout")
      self:_Dirty("spot")
      if tries < 2 then
        C_Timer.After(0, Tick)
      end
      return
    end

    if self._Layout then self:_Layout() end
    if self._RefreshNav then self:_RefreshNav() end

    local step = self:_GetStep(tonumber(idx) or tonumber(self._idx) or 1)
    if step and step.focus and step.focus.kind == "farmWindow" then
      EnsureFarmWindowOpen()
    end
    if self._RenderChecklist then
      self:_RenderChecklist(step)
    end

    self:_Dirty("layout")
    self:_Dirty("spot")
    self:_Dirty("nav")
    self:_Dirty("check")
    if self._QueueSpotRefresh then
      self:_QueueSpotRefresh()
    end

    if tries < 2 then
      C_Timer.After(0, Tick)
    end
  end

  C_Timer.After(0, Tick)
end

function TO:Open(parentFrame, helpUI, startIdx)
  parentFrame = NormalizeParentFrame(parentFrame)
  if not parentFrame then return end

  if parentFrame.Raise then parentFrame:Raise() end

  self:_InitState()
  self._ui.helpUI = helpUI or self._ui.helpUI

  Build(self, parentFrame, self._ui.helpUI)
  if not self._ui or not self._ui.root then return end

  local hadChoice = self:_HasTutorialChoice()
  self:_SetTutorialMode(hadChoice and self:_GetTutorialMode() or "advanced", hadChoice)
  self:_LoadFlags()

  local idx = tonumber(startIdx)
  if not idx then
    if hadChoice then
      idx = tonumber(GetSetting("tutorialStep", 1)) or 1
    else
      idx = 1
    end
  end
  idx = Clamp(idx or 1, 1, self:_StepCount())

  self:_LoadPanelPrefs()
  self:_DockPanelBesideRoot(false)

  local root = self._ui.root

  if self._ClearSpotlight then
    self:_ClearSpotlight()
  end

  if not hadChoice then
    -- Prime the chooser state before the root becomes visible so the overlay
    -- never briefly renders the last visited tutorial page on first open.
    self._chooserActive = true
    self._idx = 1
    if self._ClearChecklistUI then self:_ClearChecklistUI() end

    local ui = self._ui
    if ui then
      if ui.stepTitle then ui.stepTitle:SetText("") end
      if ui.stepText then ui.stepText:SetText("") end
      if ui.scroll and ui.scroll.SetVerticalScroll then
        ui.scroll:SetVerticalScroll(0)
      end
      if ui.tsmRow then ui.tsmRow:Hide() end
      if ui.sourceRow then ui.sourceRow:Hide() end
      if ui.modeRow then ui.modeRow:Hide() end
      if ui.actionWrap then
        ui.actionWrap:SetAlpha(0)
        ui.actionWrap:Hide()
      end
    end

    if self._SetChooserLayout then
      self:_SetChooserLayout(true)
    end
  end

  root:Show()
  if root.Raise then root:Raise() end

  if hadChoice then
    self:_ApplyStep(self._ui.helpUI, idx)
  else
    self._idx = idx
    self:_ShowChooser()
  end

  C_Timer.After(0, function()
    if self._ui and self._ui.root and self._ui.root:IsShown() then
      self:_DockPanelBesideRoot(false)
      self:_EnsurePanelVisible()
    end
  end)

  self:_StabilizeAfterOpen(self._ui.helpUI, idx)
end

function TO:Close()
  self:_InitState()

  -- Respect a manual close. Until the tutorial is finished, the Help window used
  -- to auto-open this overlay every time because tutorialDone stayed false.
  -- Keep the manual Tutorial button usable, but stop the automatic popup after X.
  SetSetting("tutorialAutoDismissed", true)

  local curStep = self:_GetStep(tonumber(self._idx) or 1)
  if IsCompetitiveWindowStep(curStep) then
    EnsureCompetitiveWindowClosed()
  end
  if self._session then
    self._session.openToken = (self._session.openToken or 0) + 1
    self._session.autoHiddenTutorialParent = nil
    self._session.autoOpenedFarmWindow = nil
    self._session.autoOpenedCompetitiveWindow = nil
  end
  self._spotRetryToken = (tonumber(self._spotRetryToken) or 0) + 1
  -- Restore settings section collapse state that the tutorial may have changed.
  local helpUI = self._ui and self._ui.helpUI
  local settingsLayout = helpUI and helpUI._layout and helpUI._layout.settings
  if settingsLayout and settingsLayout.restoreSectionCollapse then
    SafeCall(settingsLayout.restoreSectionCollapse)
  end
  local root = self._ui and self._ui.root
  if root then
    if root.SetAlpha then root:SetAlpha(1) end
    root._wfOpening = nil
    root:Hide()
  end
end

-- Compatible with existing HelpWindow calls:
-- TO:Toggle(f, header, UI, ctx)
function TO:Toggle(parentFrame, header, helpUI, ctx)
  self._ctx = ctx -- reserved for future contextual focus expansion
  if self:IsShown() then
    self:Close()
  else
    self:Open(parentFrame, helpUI, nil)
  end
end

function TO:GoTo(idx)
  if not self._ui or not self._ui.helpUI then return end
  self:_ApplyStep(self._ui.helpUI, idx)
end

function TO:Next()
  if not self._ui or not self._ui.helpUI then return end




  if self._ClearChecklistUI then self:_ClearChecklistUI() end
  if self._spot then self._spot.holdUntil = nil self._spot.missCount = 0 end
  -- Hard-hide action rows immediately to prevent one-frame flashes during navigation.
  do
    local ui = self._ui
    if ui then
      if ui.tsmRow then ui.tsmRow:Hide() end
      if ui.sourceRow then ui.sourceRow:Hide() end
      if ui.modeRow then ui.modeRow:Hide() end
      if ui.actionWrap then ui.actionWrap:SetAlpha(0) ui.actionWrap:Hide() end
    end
  end

  if self:_IsChoosing() then return end

  -- Mark the CURRENT step as done whenever the user advances.
  local cur = tonumber(self._idx) or 1
  self:_MarkDone(cur)
  self:_Dirty("nav")

  self:_ApplyStep(self._ui.helpUI, cur + 1)
end

function TO:Prev()
  if not self._ui or not self._ui.helpUI then return end



  if self._ClearChecklistUI then self:_ClearChecklistUI() end
  if self._spot then self._spot.holdUntil = nil self._spot.missCount = 0 end
  -- Hard-hide action rows immediately to prevent one-frame flashes during navigation.
  do
    local ui = self._ui
    if ui then
      if ui.tsmRow then ui.tsmRow:Hide() end
      if ui.sourceRow then ui.sourceRow:Hide() end
      if ui.modeRow then ui.modeRow:Hide() end
      if ui.actionWrap then ui.actionWrap:SetAlpha(0) ui.actionWrap:Hide() end
    end
  end
  if self:_IsChoosing() then return end
  local cur = tonumber(self._idx) or 1
  if cur <= 1 then
    self:_ShowChooser()
    return
  end
  self:_ApplyStep(self._ui.helpUI, cur - 1)
end

function TO:Restart()
  self:_InitState()
  SetSetting("tutorialAutoDismissed", nil)
  self._chooserActive = false
  self._session.interact = {}
  self._flags = FlagsFromString("", StepCount())
  self:_SaveFlags()
  SetSetting("tutorialStep", 1)
  if self:IsShown() and self._ui and self._ui.helpUI then
    self:_ApplyStep(self._ui.helpUI, 1)
  end
end

function TO:Finish()
  SetSetting("tutorialDone", true)
  SetSetting("tutorialAutoDismissed", nil)
  SetSetting("tutorialStep", 1)
  -- leave flags intact so returning users see checkmarks
  self:Close()
end
