-- WarBoard_Gossip.lua
-- "Fake gossip" for NPCs that normally have no dialogue of their own.
--
-- We can't intercept or replace what clicking a world unit does (that path is protected). But if
-- an NPC has no native click behavior, clicking it simply TARGETS it — so we react to the target
-- change and spawn our OWN dialogue frame. Nothing here goes through the server: the frame is a
-- plain UI panel we build ourselves, its options run local Lua only. Two pieces:
--   1. CURSOR  — while mousing over a registered NPC, swap the cursor to the "speak" icon (or the
--                greyed "can't reach" icon when out of interact range), the way the client does.
--   2. FRAME   — when a registered NPC becomes the target, open a gossip-styled frame with a
--                greeting + clickable options, each running a local onSelect handler.
--
-- Register more NPCs with WB.FakeGossip.Register(npcID, cfg) or by adding to the NPCS table below.

local addonName, WB = ...
WB.FakeGossip = WB.FakeGossip or {}
local FG = WB.FakeGossip

-- ── Cursor ────────────────────────────────────────────────────────────────────
-- We do NOT touch the system cursor. Changing it with SetCursor over the 3D world flickers (the
-- engine re-asserts the world cursor every frame) and the hardware cursor can't be hidden. Instead
-- we draw our OWN speak icon that FOLLOWS the mouse while hovering the NPC — the flicker-free
-- technique cursor-FX addons use (position a texture at GetCursorPosition each frame). The icon is
-- built + driven in the CURSOR section below.
local INTERACT_RANGE_INDEX = 3   -- CheckInteractDistance: 3 ≈ ~10yd (tightest the API allows)

-- True while `unit` is within interact/talk range. Index 3 (~10yd) is the closest an addon can get
-- to the real ~5yd gossip range — there's no finer distance API for arbitrary units.
local function InInteractRange(unit)
    return CheckInteractDistance(unit, INTERACT_RANGE_INDEX) and true or false
end

local StartCloseWatch, StopCloseWatch   -- forward decls: the walk-out-of-range close monitor

local GOLD = { r = 1.00, g = 0.82, b = 0.00 }   -- tooltip "<Speak>" hint color

-- ── Registered NPCs ─────────────────────────────────────────────────────────────
-- [npcID] = {
--   faction  = "Horde" | "Alliance" | nil (any),
--   title    = name shown in the frame's title bar,
--   greeting = the big greeting paragraph, or a function returning one,
--   options  = { { text = "...", onSelect = function() ... end }, ... }, or a function returning one,
-- }
local NPCS = {}

function FG.Register(npcID, cfg)
    if type(npcID) == "number" and type(cfg) == "table" then
        NPCS[npcID] = cfg
    end
end

local function SupplyRunState()
    if WB.SupplyRun and WB.SupplyRun.LoadState then WB.SupplyRun:LoadState() end
    return (WB.SupplyRun and WB.SupplyRun.state) or "idle"
end

local function IsSupplyRunSettled()
    local st = SupplyRunState()
    return st == "complete" or st == "failed"
end

-- Am I carrying stolen enemy cargo? Turned in at the delivery quartermaster with NO daily limit,
-- so it must be offered even when today's daily run is already complete/failed.
local function HasStolenCargo()
    return (WB.Cargo and WB.Cargo.IsCarrying and WB.Cargo.IsCarrying()) and true or false
end

-- Am I actively carrying THIS faction's daily shipment right now?
local function OnActiveRun()
    return (WB.SupplyRun and WB.SupplyRun.HasLumber and WB.SupplyRun:HasLumber()) and true or false
end

local GORTHAK_COMPLETE =
    "You got the shipment through. Good.\n\n"
    .. "Tweedle has the lumber, and the Alliance lost their chance to choke us out today. "
    .. "Rest while you can, soldier. We'll have more to move once the next shipment is ready.\n\n"
    .. "Come back tomorrow."

local GORTHAK_FAILED =
    "The shipment was lost?\n\n"
    .. "Bah. That lumber was meant for Hellscream's Watch, not the mud. The Alliance will "
    .. "press harder now that they know our routes can be broken.\n\n"
    .. "You'll get another chance when the next shipment is ready. Come back tomorrow, and do better."

local TWEEDLE_COMPLETE =
    "There's the courier of the hour.\n\n"
    .. "That Warsong lumber came through just in time. We're already putting it to work, and "
    .. "Hellscream's Watch will stand stronger because of it.\n\n"
    .. "The Horde won't forget this delivery."

local TWEEDLE_FAILED =
    "So the shipment didn't make it.\n\n"
    .. "That's going to hurt us. We needed that lumber for repairs, barricades, and about a dozen "
    .. "other things I don't have enough hands for.\n\n"
    .. "Tell Gorthak we'll make do for now, but Hellscream's Watch can't hold forever on promises."

local FALDREAS_COMPLETE =
    "You have done well, friend.\n\n"
    .. "The shipment reached Forest Song safely, and Apprentice Boulian can continue his work "
    .. "without delay. Every beam placed helps us hold this ground against the Horde's advance.\n\n"
    .. "Return tomorrow, if you are willing. Ashenvale still has need of steady hands."

local FALDREAS_FAILED =
    "The shipment was lost?\n\n"
    .. "That is troubling. Forest Song needed those materials, and the Horde will not wait for us "
    .. "to recover.\n\n"
    .. "Rest for now. When the next shipment is prepared, we will need someone willing to try again."

local BOULIAN_COMPLETE =
    "The ironwood beams arrived safely. Excellent.\n\n"
    .. "With these, we can reinforce Forest Song's supports before the Horde presses any deeper "
    .. "into Ashenvale.\n\n"
    .. "You have my thanks. This work may not look heroic, but it keeps our people standing."

local BOULIAN_FAILED =
    "No shipment?\n\n"
    .. "That will delay the reinforcements. We can salvage what we have, but Forest Song will "
    .. "remain exposed longer than I would like.\n\n"
    .. "Please tell Faldreas I will adjust the plans until another delivery can be made."

-- Overseer Gorthak — Horde-only fake dialogue. Options are placeholders for now.
-- Gorthak's second page — the "orders" briefing, reached from "What are the orders?".
local GORTHAK_ORDERS = {
    greeting = "Once you take this shipment, there's no turning back.\n\n"
            .. "Get it to Hellscream's Watch without dying. Stay on foot. The load is too heavy "
            .. "to carry by mount, so keep moving and stay alert for Alliance blades. If they kill "
            .. "you, the shipment is lost, and the Alliance campaign grows stronger for it.\n\n"
            .. "Keep moving, soldier. The Horde is counting on this shipment.",
    options = {
        -- Accept — begin the Supply Run.
        { text = "I understand. Load me up.", icon = "SmallQuestBang",
          onSelect = function()
              if WB.SupplyRun and WB.SupplyRun.Begin then WB.SupplyRun:Begin() end
              FG:Close()
          end },
        -- Decline — just closes the window.
        { text = "Not yet.", icon = "Map-MarkedDefeated",
          onSelect = function() FG:Close() end },
    },
}

FG.Register(17304, {
    faction  = "Horde",
    title    = "Overseer Gorthak",
    greeting = function()
        local st = SupplyRunState()
        if st == "complete" then return GORTHAK_COMPLETE end
        if st == "failed" then return GORTHAK_FAILED end
        return "You picked a fine time to show up, soldier.\n\n"
            .. "We've got lumber ready to move, but the Alliance is pressing in on the camp. They're "
            .. "choking every route out, and we need this shipment through before they overrun us.\n\n"
            .. "Tweedle at Hellscream's Watch is waiting for the delivery. If that lumber doesn't "
            .. "reach him, the Horde's war machine slows with it.\n\n"
            .. "Think you can handle the run?"
    end,
    options  = function()
        if IsSupplyRunSettled() then return {} end
        return {
        -- Advance to the orders briefing (page 2).
        { text = "What are the orders?", icon = "crosshair_speak_32",
          onSelect = function() FG:RenderPage(GORTHAK_ORDERS) end },
        }
    end,
})

-- Shared turn-in bullet for the delivery NPCs (Tweedle / Boulian): shows the "can't turn in" icon
-- until the player is carrying something (the daily shipment OR stolen cargo), then the turn-in icon.
local function SupplyTurnInIcon()
    local carrying = (WB.SupplyRun and WB.SupplyRun.HasLumber and WB.SupplyRun:HasLumber())
                  or (WB.Cargo and WB.Cargo.IsCarrying and WB.Cargo.IsCarrying())
    return carrying and "Islands-QuestTurnin" or "Islands-QuestDisable"
end

-- Shared turn-in action: deliver the daily shipment (if on a run), else turn in stolen cargo
-- (no daily cooldown), else a "nothing to turn in" nudge.
local function SupplyTurnIn(who, item)
    if WB.SupplyRun and WB.SupplyRun.HasLumber and WB.SupplyRun:HasLumber() then
        WB.SupplyRun:Complete()
        FG:Close()
    elseif WB.Cargo and WB.Cargo.IsCarrying and WB.Cargo.IsCarrying() then
        WB.Cargo.TurnIn()
        FG:Close()
    else
        print((WB.TAG .. " (%s) You're not carrying any %s."):format(who or "?", item or "cargo"))
    end
end

FG.Register(34395, {
    faction  = "Horde",
    title    = "Tweedle",
    greeting = function()
        -- Stolen enemy cargo has no daily limit, so it takes priority over the settled daily state.
        if HasStolenCargo() and not OnActiveRun() then
            return "Enemy supplies? You cracked open a shipment and hauled it back here? Ha!\n\n"
                .. "Every crate we take off the Alliance is one their war effort never sees. Hand it "
                .. "over — Hellscream's Watch will put it to good use."
        end
        local st = SupplyRunState()
        if st == "complete" then return TWEEDLE_COMPLETE end
        if st == "failed" then return TWEEDLE_FAILED end
        return "Hey, you! You got that lumber shipment from Warsong or not?\n\n"
            .. "Gorthak said someone was hauling it through, but with the Alliance crawling all over "
            .. "the roads, I wasn't betting on it. If you've got the goods, hand 'em over. "
            .. "Hellscream's Watch needs that lumber yesterday."
    end,
    options  = function()
        -- Offer the turn-in while on a run OR carrying stolen cargo; only an empty-handed, settled
        -- daily run shows nothing.
        if IsSupplyRunSettled() and not HasStolenCargo() then return {} end
        local text = OnActiveRun() and "I've brought the lumber." or "Hand over the seized supplies."
        return {
        { text = text, icon = SupplyTurnInIcon,
          onSelect = function() SupplyTurnIn("Tweedle", "lumber") end },
        }
    end,
})

-- ═══════════════════════════════ ALLIANCE SUPPLY RUN ═══════════════════════════════

-- Faldreas' second page — the orders briefing, reached from "What needs to be done?".
local FALDREAS_ORDERS = {
    greeting = "Once you take this shipment, you must see it safely to Forest Song.\n\n"
            .. "Deliver the ironwood beams to Apprentice Boulian without dying. Stay on foot. The "
            .. "load is too heavy to carry by mount, so move carefully and watch for Horde patrols. "
            .. "If they kill you, the shipment is lost, and their campaign grows stronger for it.\n\n"
            .. "Go with purpose. Forest Song is counting on you.",
    options = {
        -- Accept — begin the Supply Run.
        { text = "I understand. I'll carry the shipment.", icon = "SmallQuestBang",
          onSelect = function()
              if WB.SupplyRun and WB.SupplyRun.Begin then WB.SupplyRun:Begin() end
              FG:Close()
          end },
        -- Decline — just closes the window.
        { text = "Not yet.", icon = "Map-MarkedDefeated",
          onSelect = function() FG:Close() end },
    },
}

FG.Register(3996, {
    faction  = "Alliance",
    title    = "Faldreas Goeth'Shael",
    greeting = function()
        local st = SupplyRunState()
        if st == "complete" then return FALDREAS_COMPLETE end
        if st == "failed" then return FALDREAS_FAILED end
        return "You arrive at a troubled hour, friend.\n\n"
            .. "The Horde continues to tear through Ashenvale, and Forest Song must be reinforced "
            .. "before their advance reaches it. We have prepared ironwood beams for Apprentice "
            .. "Boulian, but the roads are no longer safe.\n\n"
            .. "Boulian needs these materials to strengthen the outpost's foundations. Without them, "
            .. "Forest Song will remain exposed.\n\n"
            .. "Will you carry the shipment through?"
    end,
    options  = function()
        if IsSupplyRunSettled() then return {} end
        return {
        -- Advance to the orders briefing (page 2).
        { text = "What needs to be done?", icon = "crosshair_speak_32",
          onSelect = function() FG:RenderPage(FALDREAS_ORDERS) end },
        }
    end,
})

FG.Register(17409, {
    faction  = "Alliance",
    title    = "Apprentice Boulian",
    greeting = function()
        -- Stolen enemy cargo has no daily limit, so it takes priority over the settled daily state.
        if HasStolenCargo() and not OnActiveRun() then
            return "You recovered these from the Horde?\n\n"
                .. "Well done. Every shipment we reclaim is one less for their war effort. Hand them "
                .. "over — I'll see Forest Song makes good use of the materials."
        end
        local st = SupplyRunState()
        if st == "complete" then return BOULIAN_COMPLETE end
        if st == "failed" then return BOULIAN_FAILED end
        return "Are those the ironwood beams?\n\n"
            .. "Good. We were beginning to worry the shipment had been lost on the road. With these "
            .. "materials, we can reinforce the supports and keep Forest Song standing against the "
            .. "Horde's advance.\n\n"
            .. "Hand over the shipment, and I'll see it put to use at once."
    end,
    options  = function()
        if IsSupplyRunSettled() and not HasStolenCargo() then return {} end
        local text = OnActiveRun() and "I've brought the ironwood beams." or "Hand over the seized supplies."
        return {
        { text = text, icon = SupplyTurnInIcon,
          onSelect = function() SupplyTurnIn("Boulian", "beams") end },
        }
    end,
})

-- ═══════════════════════════ BREAKING BADLANDS (foot race) ═══════════════════════════
-- Demolitions Analyst II (npc 50512) at the Badlands start line. Both factions. Gated to the race
-- start radius (WB.Race.AtStart) because that npc id is shared by copies near another town.

-- Page 2 — the rules briefing, reached from "What are the rules?".
local RACE_RULES = {
    greeting = "Here's the deal, runner.\n\n"
            .. "Once the timer starts, you reach the finish line before it runs out. Stay on foot. "
            .. "No mounts, no vehicles, no flying, and no clever nonsense that makes my paperwork "
            .. "harder.\n\n"
            .. "If you die, the run is failed. Simple, clean, and only mildly dangerous.\n\n"
            .. "So, you want in?",
    options = {
        -- Start the race.
        { text = "Start the race. I'm ready.", icon = "SmallQuestBang",
          onSelect = function()
              -- Hard gate: must still be within ~10yd of the analyst AND inside the race start
              -- radius, so a panel that somehow persisted can't start the race from across the zone.
              local inRange = FG:OpenNpcInRange()
                  and WB.Race and WB.Race.AtStart and WB.Race.AtStart()
              if not inRange then
                  print(WB.TAG .. " You're too far from the Demolitions Analyst to start the race.")
                  FG:Close()
                  return
              end
              if WB.Race and WB.Race.Begin then WB.Race:Begin() end
              FG:Close()
          end },
        -- Decline — just closes the window.
        { text = "Maybe later.", icon = "Map-MarkedDefeated",
          onSelect = function() FG:Close() end },
    },
}

FG.Register(50512, {
    title = "Demolitions Analyst II",
    gate  = function() return WB.Race and WB.Race.AtStart and WB.Race.AtStart() end,
    greeting = "Hey, hey! You look like someone with legs, lungs, and a very flexible definition of "
            .. "personal safety.\n\n"
            .. "We're running a little field test through the Badlands. Simple job: start here, reach "
            .. "the finish line before the timer runs out, and try not to become a cautionary "
            .. "footnote in my report.\n\n"
            .. "No mounts, no rides, no shortcuts. This is a pure ground-speed trial.\n\n"
            .. "Interested?",
    options = function()
        -- Mid-race (or counting down): no dialogue options.
        if WB.Race and (WB.Race.state == "active" or WB.Race.state == "countdown") then return {} end
        return {
        -- Advance to the rules briefing (page 2).
        { text = "What are the rules?", icon = "crosshair_speak_32",
          onSelect = function() FG:RenderPage(RACE_RULES) end },
        }
    end,
})

-- ── Helpers ─────────────────────────────────────────────────────────────────────
local function GetNPCID(unit)
    -- None of our NPCs exist inside ANY instance (raid / dungeon / arena / BG / scenario), and
    -- instanced content is exactly where the taint traps live:
    --   1) the game hands addons SECRET unit GUIDs — a string op on one (strsplit below) throws
    --      "string conversion on a secret string value" and taints us;
    --   2) worse, from a tooltip post-call (which runs inside securecallfunction), UnitGUID(unit)
    --      ITSELF throws "bad argument #1 ... secret values only allowed during untainted execution"
    --      — before we ever see the result, so a result-check can't help.
    -- So skip the whole lookup in instances, and pcall the UnitGUID call as a hard backstop for the
    -- secure-execution throw. Belt-and-suspenders: also drop a secret guid if one slips through.
    if WB.InInstance and WB.InInstance() then return nil end
    local ok, guid = pcall(UnitGUID, unit)
    if not ok or not guid then return nil end
    if WB.IsSecret and WB.IsSecret(guid) then return nil end
    local kind, _, _, _, _, id = strsplit("-", guid)
    if kind == "Creature" or kind == "Vehicle" then return tonumber(id) end
    return nil
end

local function FactionOK(cfg)
    if not cfg or not cfg.faction then return true end
    return UnitFactionGroup("player") == cfg.faction
end

-- Config for `unit` if it's a registered NPC this player qualifies for, else nil. An optional
-- cfg.gate() lets an NPC be conditionally live — used for the Demolitions Analyst II, whose id is
-- shared by copies elsewhere in the Badlands, so it only responds within the race start radius. A
-- failed gate reads as "not one of ours": no dialog, no cursor, no <Speak> hint.
local function ConfigForUnit(unit)
    local id = GetNPCID(unit)
    local cfg = id and NPCS[id]
    if cfg and FactionOK(cfg) and (not cfg.gate or cfg.gate()) then return cfg, id end
    return nil
end

-- ════════════════════════════════════════════════════════════════════════════
--  FRAME (self-spawned dialogue panel — pure UI, no server authority)
-- ════════════════════════════════════════════════════════════════════════════

local function SetPortrait(f, unit)
    if unit and UnitExists(unit) and f.SetPortraitToUnit then
        pcall(f.SetPortraitToUnit, f, unit)
        return
    end
    -- Fallback: paint the portrait texture directly, or use a generic icon.
    local tex = (f.PortraitContainer and f.PortraitContainer.portrait) or f.portrait
    if tex and SetPortraitTexture then
        if unit and UnitExists(unit) then SetPortraitTexture(tex, unit)
        else tex:SetTexture("Interface\\QuestFrame\\UI-QuestLog-BookIcon") end
    end
end

local function SetTitle(f, text)
    if f.SetTitle then pcall(f.SetTitle, f, text); return end
    local t = f.TitleText or (f.TitleContainer and f.TitleContainer.TitleText)
    if t then t:SetText(text or "") end
end

local OPTION_ICON_SIZE = 15
local function AcquireOption(f, i)
    local b = f.optionButtons[i]
    if b then return b end
    b = CreateFrame("Button", nil, f.scrollChild)
    b:SetHeight(18)

    -- Bullet icon at the row's left edge (set per-option in Open). Vertically centered on the row.
    local icon = b:CreateTexture(nil, "ARTWORK")
    icon:SetSize(OPTION_ICON_SIZE, OPTION_ICON_SIZE)
    icon:SetPoint("LEFT", b, "LEFT", 0, 0)
    b.icon = icon

    -- Option text — QuestFontLeft. Its left edge lands at the greeting margin (button x + icon).
    local fs = b:CreateFontString(nil, "OVERLAY", "QuestFontLeft")
    fs:SetPoint("LEFT", icon, "RIGHT", 2, 0)
    fs:SetJustifyH("LEFT")
    fs:SetWordWrap(true)
    b:SetFontString(fs)
    b.text = fs

    -- Real gossip hover highlight (HIGHLIGHT layer auto-shows on mouseover), fileID from the dump.
    local hl = b:CreateTexture(nil, "HIGHLIGHT")
    hl:SetTexture(136810)
    hl:SetBlendMode("ADD")
    hl:SetPoint("TOPLEFT", fs, "TOPLEFT", -2, 3)
    hl:SetPoint("BOTTOMRIGHT", fs, "BOTTOMRIGHT", 2, -3)

    f.optionButtons[i] = b
    return b
end

local function BuildFrame()
    if FG.frame then return FG.frame end

    local f = CreateFrame("Frame", "WarBoardFakeGossip", UIParent, "ButtonFrameTemplate")
    f:SetSize(338, 496)   -- match the real GossipFrame
    f:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 16, -116)   -- roughly where gossip/quest frames sit
    f:SetFrameStrata("HIGH")
    f:SetToplevel(true)
    f:Hide()

    -- Drag to reposition.
    f:SetMovable(true)
    f:EnableMouse(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop", f.StopMovingOrSizing)

    -- Content area (inside the template's inset).
    local content = f.Inset or f
    f.content = content

    -- Parchment page background — the real gossip frame's exact atlas (from /wb gossipdump).
    if f.Inset and f.Inset.Bg then f.Inset.Bg:Hide() end
    local page = content:CreateTexture(nil, "BACKGROUND", nil, 1)
    page:SetAtlas("QuestBG-Parchment")
    page:SetPoint("TOPLEFT", content, "TOPLEFT", 3, -3)
    page:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", -3, 3)
    f.page = page

    -- Scrollable content — the parchment stays fixed behind; greeting + options scroll over it.
    local scroll = CreateFrame("ScrollFrame", "WarBoardFakeGossipScroll", content, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", content, "TOPLEFT", 8, -14)   -- slim left inset → icons sit further left, more text room
    scroll:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", -30, 12)
    f.scroll = scroll

    local child = CreateFrame("Frame", nil, scroll)
    child:SetSize(1, 1)
    scroll:SetScrollChild(child)
    scroll:SetScript("OnSizeChanged", function(_, w) child:SetWidth(w) end)
    f.scrollChild = child

    -- Greeting paragraph — QuestFont, the parchment font real gossip greetings use. Tight line
    -- spacing to match the real frame; width is set in Open so it wraps + measures correctly.
    local greeting = child:CreateFontString(nil, "OVERLAY", "QuestFont")
    greeting:SetPoint("TOPLEFT", child, "TOPLEFT", 2, 0)   -- flush-left with the option bullets below
    greeting:SetJustifyH("LEFT")
    greeting:SetJustifyV("TOP")
    greeting:SetSpacing(1)
    f.greeting = greeting

    f.optionButtons = {}

    -- "Goodbye" bottom-right, matching the real gossip frame (78x22 UIPanelButton). X also closes.
    local bye = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    bye:SetSize(80, 22)
    bye:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -8, 3)
    bye:SetText("Goodbye")
    bye:SetScript("OnClick", function() FG:Close() end)
    f.bye = bye

    -- Escape closes it.
    if UISpecialFrames then tinsert(UISpecialFrames, "WarBoardFakeGossip") end

    -- Whenever the panel hides (X button, Escape, Goodbye, decline, or the range watch), forget the
    -- NPC and stop polling its distance.
    f:HookScript("OnHide", function()
        FG.openGuid = nil
        if StopCloseWatch then StopCloseWatch() end
        PlaySound(170872, "Master")   -- iQuestLogClose, matching the open cue
    end)

    FG.frame = f
    return f
end

-- Render a "page" (greeting + option rows) into the already-built frame, keeping the title,
-- portrait, and range-watch. A page is { greeting = "...", options = { {text, icon, onSelect}, ... } };
-- greeting/options may also be functions, used by stateful pages like Supply Run outcomes.
-- the NPC config's top level IS its first page, and an option's onSelect can call FG:RenderPage(next)
-- to advance to a sub-dialogue (like a real gossip "next page").
function FG:RenderPage(page)
    local f = FG.frame
    if not f or type(page) ~= "table" then return end
    local greeting = page.greeting
    if type(greeting) == "function" then greeting = greeting() end
    local options = page.options
    if type(options) == "function" then options = options() end

    -- Wrap width from the scroll frame (fall back to a sensible default before its first layout).
    local sw = (f.scroll and f.scroll:GetWidth()) or 0
    if sw < 10 then sw = 284 end
    f.scrollChild:SetWidth(sw)
    f.greeting:SetWidth(sw - 6)
    f.greeting:SetText(greeting or "")

    -- Lay out option rows under the greeting (inside the scroll child). The bullet icon sits flush
    -- under the greeting's left edge; the option text is indented past it.
    for _, b in ipairs(f.optionButtons) do b:Hide() end
    local anchor, anchorPoint, gap = f.greeting, "BOTTOMLEFT", -14
    local shown = 0
    for i, info in ipairs(options or {}) do
        local b = AcquireOption(f, i)
        b:ClearAllPoints()
        b:SetPoint("TOPLEFT", anchor, anchorPoint, 0, gap)   -- flush-left under the greeting
        b:SetWidth(sw - 4)
        b.text:SetWidth(sw - (OPTION_ICON_SIZE + 8))   -- leaves room for the bullet icon + gap
        -- Per-option bullet: an ObjectIconsAtlas region name, OR a function returning one (so an
        -- option can vary its icon by state). Falls back to the gossip bubble.
        local atlas = info.icon
        if type(atlas) == "function" then atlas = atlas() end
        if atlas then
            local ok = pcall(b.icon.SetAtlas, b.icon, atlas)
            if not ok or not b.icon:GetAtlas() then b.icon:SetTexture(136788) end
        else
            b.icon:SetTexture(136788)
        end
        b.text:SetText(info.text or "")
        b:SetHeight(math.max(20, (b.text:GetStringHeight() or 14) + 4))
        b.onSelect = info.onSelect
        b:SetScript("OnClick", function(self)
            if self.onSelect then pcall(self.onSelect) end
        end)
        b:Show()
        anchor, anchorPoint, gap = b, "BOTTOMLEFT", -8
        shown = i
    end

    -- Size the scroll child to fit its contents so the scrollbar range is correct.
    local h = (f.greeting:GetStringHeight() or 0) + 6
    for i = 1, shown do
        h = h + (i == 1 and 14 or 8) + (f.optionButtons[i]:GetHeight() or 16)
    end
    f.scrollChild:SetHeight(math.max(h, 10))
    if f.scroll then f.scroll:SetVerticalScroll(0) end
end

function FG:Open(cfg, unit)
    if type(cfg) ~= "table" then return end
    local f = BuildFrame()

    SetTitle(f, cfg.title)
    SetPortrait(f, unit)
    FG.openGuid = UnitGUID(unit)   -- remember who we opened for, so we can poll their range
    FG:RenderPage(cfg)             -- the config's top level is page 1

    f:Show()
    PlaySound(SOUNDKIT and SOUNDKIT.IG_QUEST_LIST_OPEN or 826, "Master")
    if StartCloseWatch then StartCloseWatch() end
end

function FG:Close()
    if self.frame then self.frame:Hide() end   -- OnHide clears openGuid + stops the range watch
end

-- Walk-out-of-range close monitor: while the panel is open, poll the NPC's distance and close it
-- when the player leaves interact range (like real gossip). We resolve the NPC by the GUID we
-- opened from, via whatever unit token currently points at it (target or mouseover).
local closeWatch = CreateFrame("Frame")
closeWatch:Hide()
closeWatch.t = 0
local function UnitForGuid(guid)
    -- Never compare against a secret GUID (arena/instanced units) — comparing one taints us the
    -- same way a string op does. openGuid only comes from our own world NPCs, so it's not secret;
    -- guard the live token reads too so a secret mouseover/target can't taint the compare.
    if not guid or (WB.IsSecret and WB.IsSecret(guid)) then return nil end
    local tg = UnitGUID("target")
    if tg and not (WB.IsSecret and WB.IsSecret(tg)) and tg == guid then return "target" end
    local mo = UnitGUID("mouseover")
    if mo and not (WB.IsSecret and WB.IsSecret(mo)) and mo == guid then return "mouseover" end
    return nil
end

-- Is the NPC this panel was opened for STILL within interact (~10yd) range? Gates a state-changing
-- option (starting the race) so a panel that somehow lingered can't fire it from across the zone.
-- Returns false if the NPC can't even be resolved to a unit — i.e. we can't confirm we're next to it.
function FG:OpenNpcInRange()
    local guid = FG.openGuid
    if not guid then return false end
    local unit = UnitForGuid(guid)
    return (unit ~= nil and InInteractRange(unit)) and true or false
end
closeWatch:SetScript("OnUpdate", function(self, dt)
    self.t = self.t + (dt or 0)
    if self.t < 0.2 then return end
    self.t = 0
    local guid = FG.openGuid
    if not guid then self:Hide(); return end
    local unit = UnitForGuid(guid)
    -- Close only on a CONFIRMED out-of-range reading. If the NPC can't be resolved to a unit
    -- (untargeted + not moused-over) we leave the panel open — X / Goodbye / Escape still close it.
    if unit and not InInteractRange(unit) then
        FG:Close()
    end
end)
StartCloseWatch = function() closeWatch.t = 0; closeWatch:Show() end
StopCloseWatch  = function() closeWatch:Hide() end

-- ════════════════════════════════════════════════════════════════════════════
--  CURSOR (a speak icon that follows the mouse while hovering a registered NPC)
-- ════════════════════════════════════════════════════════════════════════════

local hoverID   -- npcID the mouseover currently resolves to (one of ours), or nil

-- The follower icon: a small "speak" texture pinned to the cursor. Built lazily.
local cursorIcon
local function EnsureCursorIcon()
    if cursorIcon then return cursorIcon end
    local f = CreateFrame("Frame", nil, UIParent)
    f:SetSize(24, 24)
    f:SetFrameStrata("TOOLTIP")   -- draw above the world and most UI
    f:EnableMouse(false)          -- never intercept clicks
    f:Hide()
    local tex = f:CreateTexture(nil, "OVERLAY")
    tex:SetAllPoints(f)
    -- Item cursor art. The "uiitemcursor2x" sheet holds several cursors; pick the exact region by
    -- its atlas name (cursor_item_64), not the sheet file. Fall back to a generic icon if absent.
    local ok = pcall(tex.SetAtlas, tex, "cursor_item_64")
    if not ok or not tex:GetAtlas() then tex:SetTexture(136788) end
    f.tex = tex
    cursorIcon = f
    return f
end

local function HideCursorIcon()
    if cursorIcon then cursorIcon:Hide() end
end

local cursorDriver = CreateFrame("Frame")
cursorDriver:Hide()
cursorDriver:SetScript("OnUpdate", function(self)
    -- Stop once the mouseover unit is no longer our NPC.
    if hoverID == nil or GetNPCID("mouseover") ~= hoverID then
        hoverID = nil
        HideCursorIcon()
        self:Hide()
        return
    end
    -- Pin the speak icon just off the pointer each frame — flicker-free (it's a repositioned
    -- texture). We can't hide the arrow itself: over the 3D world the engine owns the cursor, so
    -- SetCursor(transparent) strobes and SetCursorByMode(NoCursor) is ignored.
    local f = EnsureCursorIcon()
    local x, y = GetCursorPosition()
    local s = UIParent:GetEffectiveScale()
    if x and s and s ~= 0 then
        f:ClearAllPoints()
        f:SetPoint("CENTER", UIParent, "BOTTOMLEFT", x / s, y / s)
        f:Show()
    end
end)

-- ════════════════════════════════════════════════════════════════════════════
--  EVENTS
-- ════════════════════════════════════════════════════════════════════════════

local ev = CreateFrame("Frame")
ev:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
ev:SetScript("OnEvent", function()
    -- Start the follower icon whenever the mouseover unit is one of our NPCs.
    local cfg, id = ConfigForUnit("mouseover")
    if cfg then
        hoverID = id
        cursorDriver:Show()
    else
        hoverID = nil
        HideCursorIcon()
        cursorDriver:Hide()
    end
end)

-- Open the panel on a mouse-press on the NPC's world model while in interact range. Clicking a
-- no-dialogue NPC does nothing on its own (it just targets it and plays the usual click sound), so
-- a post-hook on WorldFrame's mouse-down lets us react without intercepting anything. Gated to our
-- registered NPCs + interact range.
if WorldFrame and WorldFrame.HookScript then
    WorldFrame:HookScript("OnMouseDown", function()
        local cfg = ConfigForUnit("mouseover")
        if cfg and InInteractRange("mouseover") then   -- only in true interact range
            FG:Open(cfg, "mouseover")
        end
    end)
    -- A click can briefly clear the mouseover unit (targeting), which hides the follower icon.
    -- Re-arm it on release if we're still hovering one of our NPCs.
    WorldFrame:HookScript("OnMouseUp", function()
        local cfg, id = ConfigForUnit("mouseover")
        if cfg then
            hoverID = id
            cursorDriver:Show()
        end
    end)
end

-- ── Tooltip hint ────────────────────────────────────────────────────────────────
-- The world cursor is engine-locked over the NPC's 3D model (SetCursor / SetCursorByMode have no
-- effect over WorldFrame), so we can't swap it there. Instead, add a gold "<Speak>" line to the
-- NPC's mouseover tooltip as the "you can talk to me" affordance. Always works, any nameplate
-- settings. Uses the modern TooltipDataProcessor; silently does nothing on clients without it.
if TooltipDataProcessor and TooltipDataProcessor.AddTooltipPostCall and Enum and Enum.TooltipDataType then
    TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, function(tooltip)
        if tooltip ~= GameTooltip then return end
        -- This post-call runs inside securecallfunction. In instanced content, resolving a unit's
        -- GUID here throws (secret value during tainted execution) — and we have no NPCs in instances
        -- anyway — so bail before touching any unit data. (GetNPCID guards this too; stopping here
        -- keeps the secure tooltip path from calling into our code at all.)
        if WB.InInstance and WB.InInstance() then return end
        local _, unit = tooltip:GetUnit()
        if unit and ConfigForUnit(unit) then
            tooltip:AddLine("<Speak>", GOLD.r, GOLD.g, GOLD.b)
            tooltip:Show()
        end
    end)
end

-- ── DEV-ONLY: dump a REAL gossip window (data + visuals) ─────────────────────────
-- Open a normal NPC's dialogue, then run /wb gossipdump. Prints the gossip DATA (greeting,
-- options, quests) AND the frame's textures / atlases / fonts, so we can rebuild the panel 1:1.
local function DumpRegions(frame, pad)
    local ok, regions = pcall(function() return { frame:GetRegions() } end)
    if not ok then return end
    for _, r in ipairs(regions) do
        local t = r:GetObjectType()
        if t == "Texture" then
            local atlas = r.GetAtlas and r:GetAtlas()
            local tex   = (not atlas) and r.GetTexture and r:GetTexture()
            print(pad.."TEX ["..tostring(r:GetDrawLayer()).."] "..(atlas and ("atlas="..atlas) or ("file="..tostring(tex))))
        elseif t == "FontString" then
            local fo  = r.GetFontObject and r:GetFontObject()
            local txt = (r:GetText() or ""):gsub("%s+", " "):sub(1, 24)
            print(pad.."FS  font="..tostring(fo and fo:GetName() or fo).."  text=\""..txt.."\"")
        end
    end
end

local function DumpFrameTree(frame, label, depth)
    if not frame then return end
    local pad = ("  "):rep(depth)
    local w, h = 0, 0
    pcall(function() w, h = frame:GetSize() end)
    print(("%s%s  [%s]  %.0fx%.0f"):format(pad, label, frame:GetObjectType(), w or 0, h or 0))
    DumpRegions(frame, pad.."  ")
    if depth < 2 then
        local ok, kids = pcall(function() return { frame:GetChildren() } end)
        if ok then
            for _, c in ipairs(kids) do
                DumpFrameTree(c, c:GetName() or "(anon)", depth + 1)
            end
        end
    end
end

function FG.DumpReal()
    if not (C_GossipInfo and GossipFrame and GossipFrame:IsShown()) then
        print(WB.TAG.." Open a real NPC's gossip window first, then run /wb gossipdump.")
        return
    end
    print(WB.TAG.." ==== GOSSIP DATA ====")
    print("  NPC: "..(UnitName("npc") or "?"))
    print("  greeting: \""..((C_GossipInfo.GetText() or ""):gsub("%s+", " ")).."\"")
    local opts = C_GossipInfo.GetOptions and C_GossipInfo.GetOptions()
    for i, o in ipairs(opts or {}) do
        print(("  option %d: id=%s icon=%s name=%q"):format(i, tostring(o.gossipOptionID), tostring(o.icon), tostring(o.name)))
    end
    local aq  = (C_GossipInfo.GetAvailableQuests and C_GossipInfo.GetAvailableQuests()) or {}
    local acq = (C_GossipInfo.GetActiveQuests and C_GossipInfo.GetActiveQuests()) or {}
    print(("  quests: %d available, %d active"):format(#aq, #acq))
    print(WB.TAG.." ==== GOSSIP FRAME VISUALS (atlas/file/font) ====")
    DumpFrameTree(GossipFrame, "GossipFrame", 0)
end

-- ── DEV-ONLY preview (pure UI, no live-score impact) ─────────────────────────────
-- Opens Gorthak's panel using your own portrait, so you can see/style it without traveling.
function FG.Preview()
    local cfg = NPCS[17304]
    if cfg then FG:Open(cfg, "player") end
end
