--[[
AutoQuestLearner v2.0
Enhanced standalone quest addon with auto-learning, quest tracker, map integration, and database.
Combines data from pfQuest and pfQuest-ascension with custom UI.

Features:
- Auto-learn quest locations from gossip, quest events, and proximity scans
- Quest tracker with objectives and progress tracking
- World map and minimap node display
- Quest database integration with pfQuest data (optional)
- Configuration panel for customization
- Standalone operation (no dependencies required)

Original Auto-Learning Features:
  QUEST_GREETING  - Learn from multi-quest greeting windows
  GOSSIP_SHOW     - Learn from gossip dialogs
  QUEST_DETAIL    - Learn from quest detail dialogs
  QUEST_PROGRESS  - Learn from quest progress dialogs
  QUEST_COMPLETE  - Learn from quest completion dialogs
  MERCHANT_SHOW   - Learn from vendor NPCs

New Features v2.0:
- Quest tracker with expandable objectives
- World map integration with quest nodes
- Minimap node display
- Database integration with pfQuest/pfQuest-ascension (optional)
- Configuration UI
- Enhanced slash commands
- Completely standalone (no external dependencies)

Slash commands:
  /aql         - show addon status and stats
  /aql scan    - force proximity scan now
  /aql gossip  - reprocess current gossip window
  /aql config  - open configuration panel
  /aql tracker - toggle quest tracker
]]

local addonName, ns = ...

-- Create global table for addon functions
AutoQuestLearner = AutoQuestLearner or CreateFrame("Frame")

------------------------------------------------------------
-- SavedVariables bootstrap
------------------------------------------------------------
local function EnsureDatabaseState()
    AutoQuestLearnerDB = AutoQuestLearnerDB or {}

    -- Quest/unit/object/item/zone data lives in AutoQuestLearnerData (a
    -- plain, non-SavedVariable global) — see AutoQuestLearner_Database.lua
    -- for why this must never be persisted to disk.
    AutoQuestLearnerData = AutoQuestLearnerData or {}
    AutoQuestLearnerData.quests = AutoQuestLearnerData.quests or {}
    AutoQuestLearnerData.quests["data"] = AutoQuestLearnerData.quests["data"] or {}
    AutoQuestLearnerData.quests["loc"] = AutoQuestLearnerData.quests["loc"] or {}
    AutoQuestLearnerData.units = AutoQuestLearnerData.units or {}
    AutoQuestLearnerData.units["data"] = AutoQuestLearnerData.units["data"] or {}
    AutoQuestLearnerData.objects = AutoQuestLearnerData.objects or {}
    AutoQuestLearnerData.objects["data"] = AutoQuestLearnerData.objects["data"] or {}
    AutoQuestLearnerData.items = AutoQuestLearnerData.items or {}
    AutoQuestLearnerData.items["data"] = AutoQuestLearnerData.items["data"] or {}
    AutoQuestLearnerData.zones = AutoQuestLearnerData.zones or {}
    AutoQuestLearnerData.zones["loc"] = AutoQuestLearnerData.zones["loc"] or {}

    -- Strip any leftover copy of the database from a previous version so
    -- it doesn't keep bloating the SavedVariables file.
    AutoQuestLearnerDB.quests = nil
    AutoQuestLearnerDB.units = nil
    AutoQuestLearnerDB.objects = nil
    AutoQuestLearnerDB.items = nil
    AutoQuestLearnerDB.zones = nil

    AutoQuestLearnerDB.config = AutoQuestLearnerDB.config or {}
    AutoQuestLearnerDB.history = AutoQuestLearnerDB.history or {}
    AutoQuestLearnerDB.colors = AutoQuestLearnerDB.colors or {}

    -- TomTom integration was on by default in an earlier version and
    -- has since been changed to opt-in only. A code-level default
    -- change doesn't retroactively affect a value that's already been
    -- saved to disk, so anyone who ran that earlier version has
    -- "1" persisted here regardless of the current default -- force it
    -- off once, explicitly, rather than relying on the default.
    if AutoQuestLearnerDB.config.tomtomForcedOff ~= "1" then
        AutoQuestLearnerDB.config.tomtomEnabled = "0"
        AutoQuestLearnerDB.config.tomtomAuto = "0"
        AutoQuestLearnerDB.config.tomtomForcedOff = "1"
    end

    -- Same issue as above: the node style default was originally
    -- "Simple Markers" (single markers only, no dot clusters), and
    -- anyone who opened the config panel before this changed already
    -- has "simple" persisted regardless of the current default order.
    -- Force it to "spawn" (dot clusters) once, matching the
    -- consistently-requested preference.
    if AutoQuestLearnerDB.config.nodeStyleForcedSpawn ~= "1" then
        AutoQuestLearnerDB.config.nodestyle = "spawn"
        AutoQuestLearnerDB.config.nodeStyleForcedSpawn = "1"
    end
end

EnsureDatabaseState()

AutoQuestLearnerDB.settings = AutoQuestLearnerDB.settings or {
    autoImportPfQuest = false, -- auto-trigger RexxarDB's pfQuest import on first login
    enableProximityScan = true, -- poll for nearby quest givers every few seconds
    proximityInterval = 10, -- seconds between proximity polls (cheap; not per-frame)
    gossipLearning = true, -- record locations from gossip / quest greeting windows
    eventLearning = true, -- record locations from quest detail / progress / complete events
}
AutoQuestLearnerDB.stats = AutoQuestLearnerDB.stats or {
    gossipLearned = 0,
    eventLearned = 0,
    proximityLearned = 0,
    lastImportCount = 0,
    lastImportTime = 0,
}

local function EnsureSettings()
    AutoQuestLearnerDB = AutoQuestLearnerDB or {}
    if not AutoQuestLearnerDB.settings then
        AutoQuestLearnerDB.settings = {
            autoImportPfQuest = false,
            enableProximityScan = true,
            proximityInterval = 10,
            gossipLearning = true,
            eventLearning = true,
        }
    end
    if not AutoQuestLearnerDB.stats then
        AutoQuestLearnerDB.stats = {
            gossipLearned = 0,
            eventLearned = 0,
            proximityLearned = 0,
            lastImportCount = 0,
            lastImportTime = 0,
        }
    end
    -- Initialize config table for new modules
    if not AutoQuestLearnerDB.config then
        AutoQuestLearnerDB.config = {
            showtracker = "1",
            trackeralpha = "0",
            trackerfontsize = "12",
            trackerexpand = "0",
            allquestgivers = "1",
            currentquestgivers = "1",
            showlowlevel = "0",
            showhighlevel = "0",
            minimapnodes = "1",
            focusmode = "1",
            spawncolors = "0",
            worldmaptransp = "1.0",
            minimaptransp = "1.0",
            mouseover = "1",
            showtooltips = "1",
            trackerlevel = "1",
            questloglevel = "0",
            questlogbuttons = "1",
            worldmapmenu = "1",
            trackingmethod = 1,
        }
        print("|cff00ff00AutoQuestLearner:|r Thanks for installing! Type |cffffcc00/aql guide|r for a quick how-to-use guide, or |cffffcc00/aql config|r for settings.")

        -- Also pop the guide window itself open automatically, a few
        -- seconds after this first-ever install is detected (not
        -- instantly -- gives the player a moment to actually load into
        -- the world first, rather than a window appearing before
        -- they've even seen their character). Guarded since this file
        -- can technically run before AutoQuestLearner_Guide.lua has
        -- finished loading, however unlikely in practice.
        local welcomeTimer = CreateFrame("Frame")
        local welcomeElapsed = 0
        welcomeTimer:SetScript("OnUpdate", function(self, e)
            welcomeElapsed = welcomeElapsed + (e or 0)
            if welcomeElapsed < 3 then return end
            self:SetScript("OnUpdate", nil)
            if AutoQuestLearner_ShowGuide then
                AutoQuestLearner_ShowGuide()
            end
        end)
    end
    -- Forward-compat: fill in any new settings added after a user's existing DB
    local s = AutoQuestLearnerDB.settings
    s.autoImportPfQuest = (s.autoImportPfQuest == nil) and false or s.autoImportPfQuest
    s.enableProximityScan = (s.enableProximityScan == nil) and true or s.enableProximityScan
    s.proximityInterval = s.proximityInterval or 10
    s.gossipLearning = (s.gossipLearning == nil) and true or s.gossipLearning
    s.eventLearning = (s.eventLearning == nil) and true or s.eventLearning
end

------------------------------------------------------------
-- Standalone data storage system
------------------------------------------------------------
local function GetPlayerKey()
    return UnitName("player") .. "-" .. GetRealmName()
end

local function GetChar()
    local key = GetPlayerKey()
    AutoQuestLearnerDB.chars = AutoQuestLearnerDB.chars or {}
    AutoQuestLearnerDB.chars[key] = AutoQuestLearnerDB.chars[key] or {
        questLocations = {},
    }
    return AutoQuestLearnerDB.chars[key]
end

local function GetCurrentMap()
    -- IMPORTANT: AutoQuestLearnerMap.nodes are keyed by pfDB's own
    -- internal numeric zone-ID scheme (the same one coord[3] uses in
    -- units/objects coordinate data), which AutoQuestLearnerMap:GetMapID()
    -- resolves via a translation table -- NOT the numeric ID from
    -- GetCurrentMapAreaID() (Blizzard's own, unrelated AreaID scheme),
    -- and NOT the raw zone-name string either. Those are three
    -- different value spaces -- using the wrong one means nothing ever
    -- matches what UpdateNodes() / UpdateMinimap() look up, so nothing
    -- renders.
    --
    -- BUGFIX: GetCurrentMapContinent()/GetCurrentMapZone() report
    -- whatever zone the World Map UI is currently NAVIGATED TO, not
    -- necessarily the player's actual physical location -- if the map
    -- was ever manually browsed to a different zone (or simply left in
    -- that state), this kept returning the wrong zone for everything
    -- downstream, including the arrow and GetPlayerPos's coordinates.
    -- SetMapToCurrentZone() forces it back to reality, but only while
    -- the World Map is closed -- calling it while the map is open would
    -- yank it away from the player mid-browse, which is worse.
    if WorldMapFrame and not WorldMapFrame:IsVisible() then
        SetMapToCurrentZone()
    end

    if AutoQuestLearnerMap then
        return AutoQuestLearnerMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
    end
    return GetCurrentMapAreaID()
end
AutoQuestLearner_GetCurrentMap = GetCurrentMap

-- Cached separately from GetCurrentMap() itself: that function
-- deliberately does NOT force-sync to the player's real position while
-- the World Map is open (see its own comment above -- doing so would
-- yank the view away mid-browse). That's correct for its own use by
-- the arrow, but means it can't be used to answer "what zone is the
-- player ACTUALLY in" while the map is open and browsed elsewhere --
-- it would just report whatever's being browsed to. This cache updates
-- only when the map is closed (when GetCurrentMap() is safe to trust),
-- so it stays accurate regardless of what's currently being browsed.
AutoQuestLearner_LastKnownPlayerZone = nil
local playerZoneCacheFrame = CreateFrame("Frame")
playerZoneCacheFrame:RegisterEvent("ZONE_CHANGED")
playerZoneCacheFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
playerZoneCacheFrame:RegisterEvent("ZONE_CHANGED_INDOORS")
playerZoneCacheFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
playerZoneCacheFrame:SetScript("OnEvent", function()
    if not WorldMapFrame or not WorldMapFrame:IsVisible() then
        AutoQuestLearner_LastKnownPlayerZone = GetCurrentMap()
    end
end)

local function GetPlayerPos()
    local x, y = GetPlayerMapPosition("player")
    if not x or (x == 0 and y == 0) then return nil end
    -- GetPlayerMapPosition returns 0-1 fractions; the rest of the addon
    -- (AddNode, pfDB coords, minimap/world map placement math) all use
    -- 0-100 percentages, so scale here once at the source.
    return x * 100, y * 100
end

------------------------------------------------------------
-- AutoQuestLearner's own shared database
--
-- This is separate from GetChar()'s per-character questLocations
-- (which only tracks "have I already recorded this on THIS character",
-- for idempotency). AutoQuestLearnerDB.learned is account-wide and
-- shared across every character -- knowledge one character picks up
-- benefits all your alts immediately, and this is what actually gets
-- drawn as map/minimap nodes. Structure:
--   learned.quests[questID] = { title=, giver={x,y,mapID}, turnin={x,y,mapID} }
--   learned.objectives[questID][objIndex] = { text=, spawns={ {x,y,mapID}, ... } }
------------------------------------------------------------
-- Bump this whenever the shape or meaning of location data changes in
-- a way that makes previously-saved entries invalid or misleading.
-- The mapID field has gone through three different, mutually
-- incompatible formats over the course of development (Blizzard's raw
-- AreaID, then a zone-name string, now pfDB's own internal numeric
-- zone-ID scheme) -- rather than try to detect and salvage entries
-- from earlier formats, we just wipe and let it rebuild cleanly, since
-- none of those entries ever actually rendered correctly anyway.
local LEARNED_SCHEMA_VERSION = 4

local function GetLearnedDB()
    if AutoQuestLearnerDB.learnedSchemaVersion ~= LEARNED_SCHEMA_VERSION then
        AutoQuestLearnerDB.learned = { quests = {}, objectives = {} }
        if AutoQuestLearnerDB.chars then
            for _, char in pairs(AutoQuestLearnerDB.chars) do
                char.questLocations = {}
            end
        end
        AutoQuestLearnerDB.learnedSchemaVersion = LEARNED_SCHEMA_VERSION
    end

    AutoQuestLearnerDB.learned = AutoQuestLearnerDB.learned or {}
    AutoQuestLearnerDB.learned.quests = AutoQuestLearnerDB.learned.quests or {}
    AutoQuestLearnerDB.learned.objectives = AutoQuestLearnerDB.learned.objectives or {}
    return AutoQuestLearnerDB.learned
end

-- questID resolution (see GetQuestIDs() in AutoQuestLearner_Database.lua)
-- prefers GetQuestLink()'s numeric ID, but falls back to the raw quest
-- title string when that's unavailable -- which can happen
-- inconsistently between characters, or even between polls in the same
-- session, since GetQuestLink can be timing-dependent right after a
-- quest updates. That means the SAME quest can end up learned under two
-- different keys: a numeric ID on one occasion, its title string on
-- another. Without this, data learned under one key form is completely
-- invisible to a lookup using the other -- observed as "this quest was
-- already learned on another character but the arrow still doesn't
-- know about it." These helpers check both forms.
local function GetLearnedQuestEntry(learned, questID, questTitle)
    local entry = learned.quests[questID]
    if entry then return entry end
    if questTitle and questTitle ~= questID then
        return learned.quests[questTitle]
    end
    return nil
end
-- Exposed globally so AutoQuestLearner_Database.lua's SearchQuestID
-- (a different file/chunk) can also look up learned locations, to keep
-- static map-node placement consistent with the arrow/TomTom priority
-- fix above (learned beats static, not "closer wins").
AutoQuestLearner_GetLearnedQuestEntry = GetLearnedQuestEntry

local function GetLearnedObjectives(learned, questID, questTitle)
    local objectives = learned.objectives[questID]
    if objectives then return objectives end
    if questTitle and questTitle ~= questID then
        return learned.objectives[questTitle]
    end
    return nil
end
-- Exposed globally for the same reason as AutoQuestLearner_GetLearnedQuestEntry
-- above -- SearchQuestID needs this to keep the objective map/minimap
-- clusters consistent with the arrow's learned-beats-static priority.
AutoQuestLearner_GetLearnedObjectives = GetLearnedObjectives


-- Which "role" a learning source represents -- where you pick the
-- quest up ("giver") vs where you turn it in ("turnin").
local sourceRole = {
    gossip = "giver",
    ["gossip-turnin"] = "turnin",
    proximity = "giver",
    ["proximity-turnin"] = "turnin",
    detail = "giver",
    progress = "turnin",
    complete = "turnin",
}

-- Map Node Style, matching pfQuest's own three-way mode selector:
--   "simple"   - one marker per quest giver/turn-in, no spawn clusters
--   "combined" - single markers AND spawn-point dot clusters together
--   "spawn"    - spawn-point dot clusters only, no single markers
local function GetNodeStyle()
    return AutoQuestLearnerDB.config.nodestyle or "combined"
end
local function ShouldShowClusters()
    return GetNodeStyle() ~= "simple"
end
local function ShouldShowSingleMarkers()
    return GetNodeStyle() ~= "spawn"
end

local function ResolveQuestTitle(questID)
    if AutoQuestLearnerData and AutoQuestLearnerData.quests and AutoQuestLearnerData.quests["loc"]
        and AutoQuestLearnerData.quests["loc"][questID] then
        return AutoQuestLearnerData.quests["loc"][questID].T
    end
    if AutoQuestLearner.questlog and AutoQuestLearner.questlog[questID] then
        return AutoQuestLearner.questlog[questID].title
    end
    return nil
end

-- Picks pfQuest's own "complete" (bright "?", still working on it) vs
-- "complete_c" (ready to hand in) icon for a personally-learned turn-in
-- node, based on the quest's live status if it's currently in the log.
-- Same robust completion check used for the arrow and for the
-- bundled-database-driven nodes in AutoQuestLearner_Database.lua's
-- SearchQuestID -- doesn't trust Blizzard's isComplete flag alone,
-- since it can lag behind the leaderboard actually reporting done.
local function GetTurninNodeTexture(questID)
    local qlogid = AutoQuestLearner.questlog and AutoQuestLearner.questlog[questID]
        and AutoQuestLearner.questlog[questID].qlogid
    local isComplete = false
    if qlogid then
        local _, _, _, _, _, complete = AutoQuestLearner_GetQuestLogTitle(qlogid)
        isComplete = complete and true or false
        if not isComplete then
            local numObjectives = GetNumQuestLeaderBoards(qlogid)
            if numObjectives and numObjectives > 0 then
                isComplete = true
                for i = 1, numObjectives do
                    local _, _, objDone = GetQuestLogLeaderBoard(i, qlogid)
                    if not objDone then
                        isComplete = false
                        break
                    end
                end
            end
        end
    end
    return isComplete
        and "Interface\\AddOns\\AutoQuestLearner\\img\\complete_c"
        or "Interface\\AddOns\\AutoQuestLearner\\img\\complete"
end

-- Draws a single learned pickup/turn-in location as a map/minimap node.
-- Shared by RecordLocation() (fresh learns) and the login replay of
-- previously-learned locations. Uses pfQuest's own icon set for each
-- role/state (available_c for the giver, complete/complete_c for the
-- turn-in) rather than a plain colored dot, matching how pfQuest itself
-- distinguishes these on the map.
local function DrawLearnedNode(questID, role, loc, title)
    if not AutoQuestLearnerMap or not loc or not loc.mapID then return end
    if not ShouldShowSingleMarkers() then return end

    local texture = (role == "turnin")
        and GetTurninNodeTexture(questID)
        or "Interface\\AddOns\\AutoQuestLearner\\img\\available_c"
    local questTitle = title or ResolveQuestTitle(questID) or ("Quest " .. questID)

    AutoQuestLearnerMap:AddNode({
        addon = "AUTOQUESTLEARNER",
        title = questTitle,
        zone = loc.mapID,
        x = loc.x,
        y = loc.y,
        spawn = questTitle .. " (" .. role .. ")",
        texture = texture,
        vertex = { 1, 1, 1 },
        questid = questID,
    })
end

-- Deterministic, well-spread color per quest ID, used to tint the plain
-- circular dot texture below. A naive `questID % N` clusters adjacent
-- quest IDs (which are extremely common to see together on the same
-- map, since they're often from the same zone/quest chain) into
-- near-identical hues; multiplying by 37 (coprime to 360) before taking
-- the modulus spreads consecutive IDs by a full 37-degree hue jump each
-- time instead, giving the map the same "different quest = different
-- color" readability pfQuest's own multicolor dot clouds have.
-- Same curated palette as AutoQuestLearner_Map.lua's str2rgb, kept as
-- a separate copy here to avoid cross-file load-order dependencies for
-- such a small constant. Deliberately identical values so a given
-- color reads the same whether it came from this function or that one.
local QUEST_COLOR_PALETTE = {
    { 0.65, 0.13, 0.13 }, -- dark red
    { 0.13, 0.52, 0.13 }, -- dark green
    { 0.20, 0.33, 0.65 }, -- dark blue
    { 0.49, 0.36, 0.00 }, -- dark gold
    { 0.65, 0.36, 0.07 }, -- dark orange
    { 0.00, 0.36, 0.36 }, -- dark teal
    { 0.59, 0.16, 0.39 }, -- dark pink
}

local function QuestColor(id)
    local numericID
    if type(id) == "number" then
        numericID = id
    elseif type(id) == "string" then
        -- Simple string hash for non-numeric IDs (e.g. an NPC name used
        -- as the target key) -- doesn't need to be cryptographically
        -- sound, just consistent for the same string every time.
        numericID = 0
        for i = 1, string.len(id) do
            numericID = (numericID * 31 + string.byte(id, i)) % 2147483647
        end
    else
        numericID = 0
    end
    local paletteSize = table.getn(QUEST_COLOR_PALETTE)
    local index = (numericID - math.floor(numericID / paletteSize) * paletteSize) + 1
    local c = QUEST_COLOR_PALETTE[index]
    return c[1], c[2], c[3]
end
AutoQuestLearner_IDColor = QuestColor

-- Draws a single learned objective spawn point (kill/collect/interact
-- target) as a cluster-style node, matching pfDB's obj.U/obj.O style
-- "cloud of dots" for a quest objective.
local function DrawObjectiveNode(questID, spawn, questTitle, objText, targetID)
    if not AutoQuestLearnerMap or not spawn or not spawn.mapID then return end

    -- "Spawn Clusters" style (matching pfQuest's "Spawn Points" mode)
    -- plots every known spawn as its own dot, building up a visible
    -- cloud over time. The alternative "single marker" style shows
    -- just one representative icon per objective instead, for a less
    -- cluttered map.
    --
    -- IMPORTANT: uses img\node (a plain flat-gray circular blip built
    -- for vertex-color tinting), NOT img\cluster_mob/cluster_misc --
    -- those two are detailed baked-in icon shapes (a sword and a sack,
    -- respectively), not circles, and don't respond to color tinting
    -- the way a "multicolor dot cloud" needs. Using them here is why
    -- objective clusters were rendering as tiny swords instead of the
    -- colored circles pfQuest itself shows.
    --
    -- Colored by targetID (the specific NPC/object/item-source) when
    -- known, rather than by questID -- a quest with multiple distinct
    -- objectives (e.g. "collect 4 different items from 4 different
    -- creatures") would otherwise show every single one of those
    -- creatures' spawn points in one indistinguishable color, making it
    -- impossible to tell which dots belong to which target. Falls back
    -- to questID when no specific target is known (personally-learned
    -- objectives don't capture which exact NPC was killed, only where).
    local useClusters = ShouldShowClusters()
    -- Ring instead of a solid filled dot -- lets the character arrow
    -- underneath a dense cluster stay visible through the hollow
    -- center, instead of being hidden under a solid circle.
    local texture = "Interface\\AddOns\\AutoQuestLearner\\img\\node_ring"
    local r, g, b = 1, 1, 1
    if useClusters then
        r, g, b = QuestColor(targetID or questID)
    end

    AutoQuestLearnerMap:AddNode({
        addon = "AUTOQUESTLEARNER",
        title = questTitle or ("Quest " .. questID),
        zone = spawn.mapID,
        x = spawn.x,
        y = spawn.y,
        spawn = objText or ("Quest " .. questID .. " objective"),
        texture = texture,
        vertex = { r, g, b },
        questid = questID,
        isObjective = true,
    })
end

-- Draws every known STATIC spawn point for a quest's kill/interact
-- objectives immediately, using the bundled units/objects coordinate
-- databases -- not the player's own play history. This is what actually
-- reproduces pfQuest's familiar "cloud of dots" the moment a quest is
-- picked up: previously, AutoQuestLearner could only ever plot spawn
-- points the player had personally already seen (via
-- AutoQuestLearner_RecordObjectiveProgress), which meant every quest
-- started with an empty map and only slowly built up a handful of dots
-- over many play sessions, never approaching pfQuest's immediate,
-- comprehensive cluster. staticQuest["obj"]["U"]/["O"] tells us which
-- NPC/object IDs satisfy the objective; cross-referencing those against
-- the bundled units/objects "data" tables (which independently already
-- carry full multi-spawn coordinate lists per NPC/object, per the
-- MergeSpawnEntry union logic above) gives the same full picture pfQuest
-- itself uses. Objective type "I" (item-collection, e.g. "Collect 8
-- Witchwing Talons") is resolved via the bundled items+refloot
-- databases (AutoQuestLearner_ResolveItemSources) to whichever NPCs/
-- objects actually drop it. "A" (areatrigger) is still not drawn: no
-- areatrigger coordinate table is bundled at all, so guessing would be
-- worse than showing nothing.
local staticObjectiveDrawn = {}
local function DrawStaticObjectiveClusters(questID, questTitle)
    if staticObjectiveDrawn[questID] then return end
    if not ShouldShowClusters() then return end
    if not AutoQuestLearnerData or not AutoQuestLearnerData.quests then return end

    local staticQuest = AutoQuestLearnerData.quests["data"] and AutoQuestLearnerData.quests["data"][questID]
    if not staticQuest or not staticQuest["obj"] then return end

    questTitle = questTitle or ResolveQuestTitle(questID) or ("Quest " .. questID)
    staticObjectiveDrawn[questID] = true

    -- Skip any sub-objective that's already individually done, by name
    -- -- same reasoning and same heuristic as FindClosestQuestDestination's
    -- equivalent filter: the bundled data has no direct link from a
    -- specific NPC/object/item-source to which leaderboard line it
    -- belongs to, so this cross-references by matching names against
    -- the live leaderboard's already-done entries.
    local doneObjectiveTexts = {}
    local qlogid = AutoQuestLearner.questlog and AutoQuestLearner.questlog[questID] and AutoQuestLearner.questlog[questID].qlogid
    if qlogid then
        local numObjectives = GetNumQuestLeaderBoards(qlogid)
        if numObjectives and numObjectives > 0 then
            for oi = 1, numObjectives do
                local otext, _, odone = GetQuestLogLeaderBoard(oi, qlogid)
                if odone and otext then
                    local label = string.match(otext, "^(.-)%s*:?%s*%d") or otext
                    table.insert(doneObjectiveTexts, string.lower(label))
                end
            end
        end
    end
    local function isAlreadyDone(name)
        if not name or table.getn(doneObjectiveTexts) == 0 then return false end
        local nameLower = string.lower(name)
        for _, doneText in pairs(doneObjectiveTexts) do
            if string.find(doneText, nameLower, 1, true) or string.find(nameLower, doneText, 1, true) then
                return true
            end
        end
        return false
    end

    -- Cap per objective type so a genuinely common mob (hundreds of
    -- world spawns) doesn't flood the map with an unreadable wall of
    -- dots -- mirrors the existing 40-spawn cap on the live-learned
    -- side (AutoQuestLearner_RecordObjectiveProgress).
    local MAX_NODES_PER_TYPE = 60

    for _, npcID in pairs(staticQuest["obj"]["U"] or {}) do
        local unitData = AutoQuestLearnerData.units and AutoQuestLearnerData.units["data"]
            and AutoQuestLearnerData.units["data"][npcID]
        local label = (AutoQuestLearner_GetUnitName and AutoQuestLearner_GetUnitName(npcID))
            or (questTitle .. " objective")
        if unitData and unitData.coords and not isAlreadyDone(label) then
            local count = 0
            for _, c in pairs(unitData.coords) do
                if count >= MAX_NODES_PER_TYPE then break end
                DrawObjectiveNode(questID, { x = c[1], y = c[2], mapID = c[3] }, questTitle, label, npcID)
                count = count + 1
            end
        end
    end

    for _, objID in pairs(staticQuest["obj"]["O"] or {}) do
        local objData = AutoQuestLearnerData.objects and AutoQuestLearnerData.objects["data"]
            and AutoQuestLearnerData.objects["data"][objID]
        local label = (AutoQuestLearner_GetObjectName and AutoQuestLearner_GetObjectName(objID))
            or (questTitle .. " objective")
        if objData and objData.coords and not isAlreadyDone(label) then
            local count = 0
            for _, c in pairs(objData.coords) do
                if count >= MAX_NODES_PER_TYPE then break end
                DrawObjectiveNode(questID, { x = c[1], y = c[2], mapID = c[3] }, questTitle, label, objID)
                count = count + 1
            end
        end
    end

    if staticQuest["obj"]["I"] and AutoQuestLearner_ResolveItemSources then
        for _, itemID in pairs(staticQuest["obj"]["I"]) do
            local itemName = AutoQuestLearner_GetItemName and AutoQuestLearner_GetItemName(itemID)
            if not isAlreadyDone(itemName) then
                local sources = AutoQuestLearner_ResolveItemSources(itemID)
                for _, src in pairs(sources) do
                    local data = src.type == "U"
                        and AutoQuestLearnerData.units and AutoQuestLearnerData.units["data"] and AutoQuestLearnerData.units["data"][src.id]
                        or AutoQuestLearnerData.objects and AutoQuestLearnerData.objects["data"] and AutoQuestLearnerData.objects["data"][src.id]
                    local label = itemName
                        or (src.type == "U" and AutoQuestLearner_GetUnitName and AutoQuestLearner_GetUnitName(src.id))
                        or (src.type == "O" and AutoQuestLearner_GetObjectName and AutoQuestLearner_GetObjectName(src.id))
                        or (questTitle .. " objective")
                    if data and data.coords and not isAlreadyDone(label) then
                        local count = 0
                        for _, c in pairs(data.coords) do
                            if count >= MAX_NODES_PER_TYPE then break end
                            DrawObjectiveNode(questID, { x = c[1], y = c[2], mapID = c[3] }, questTitle, label, src.id)
                            count = count + 1
                        end
                    end
                end
            end
        end
    end

    -- Areatrigger objectives ("Find X nearby" -- e.g. discover a cave
    -- entrance or search a radius, rather than kill/loot/collect
    -- anything specific).
    if staticQuest["obj"]["A"] then
        for _, triggerID in pairs(staticQuest["obj"]["A"]) do
            local data = AutoQuestLearnerData.areatrigger and AutoQuestLearnerData.areatrigger["data"]
                and AutoQuestLearnerData.areatrigger["data"][triggerID]
            if data and data.coords and not isAlreadyDone("search area") then
                local count = 0
                for _, c in pairs(data.coords) do
                    if count >= MAX_NODES_PER_TYPE then break end
                    DrawObjectiveNode(questID, { x = c[1], y = c[2], mapID = c[3] }, questTitle,
                        questTitle .. " objective (search area)", triggerID)
                    count = count + 1
                end
            end
        end
    end
end

-- Global wrapper so AutoQuestLearner_Quest.lua (a separate file) can
-- trigger this when it detects a quest newly added to the log.
function AutoQuestLearner_DrawStaticObjectiveClusters(questID, questTitle)
    DrawStaticObjectiveClusters(questID, questTitle)
end
-- Idempotent per-character AND per-role: skips only if this character
-- has already recorded a location for this SPECIFIC role (giver vs.
-- turnin) of this questID, so re-visiting the same NPC doesn't spam
-- duplicate work -- but the underlying shared entry benefits every
-- character regardless of who wrote it.
--
-- BUGFIX: this used to gate on char.questLocations[questID] alone (a
-- single flat entry per quest, not per role). Since the giver location
-- is almost always learned first (you accept the quest before you can
-- ever turn it in), that single entry got permanently claimed by
-- role="giver" the moment the quest was accepted -- which silently
-- blocked the turn-in location from EVER being recorded afterward for
-- that quest, on that character, forever (this is saved data, so it
-- didn't even clear on relog). For any quest whose turn-in location
-- isn't in the bundled static database, this meant
-- FindClosestQuestDestination()/the arrow never got a turn-in
-- candidate at all -- reproducing exactly the "arrow points back at
-- the quest giver" symptom. Keying per-role fixes this: giver and
-- turnin are now independent slots, both learnable.
-- Returns true if a new entry was actually written. `force` (used only
-- by the explicit "/aql learn" manual command) overwrites an existing
-- learned entry for this role instead of being blocked by the gate --
-- appropriate there because a human deliberately standing at a spot and
-- typing a command is a much stronger signal than the passive
-- gossip/event/proximity auto-learners, which should keep leaving a
-- first-write-wins entry alone so one bad automatic hit can't silently
-- clobber a good one.
local function RecordLocation(questID, questTitle, label, source, force)
    if not questID or questID == 0 then return false end
    local x, y = GetPlayerPos()
    if not x then return false end
    local mapID = GetCurrentMap()

    local role = sourceRole[source] or "giver"

    local char = GetChar()
    char.questLocations[questID] = char.questLocations[questID] or {}
    if char.questLocations[questID][role] and not force then
        return false
    end

    -- Every call site here fires while a quest-related NPC frame is
    -- open (gossip, quest detail/progress/complete), which reliably
    -- sets that NPC as your target on this client -- so grab its name
    -- while we have the chance. Without this, a learned (as opposed to
    -- static-database) location had no name attached at all, so the
    -- arrow fell back to a generic "Ready to turn in here." instead of
    -- naming the NPC the way it does for static candidates.
    local npcName = UnitExists("target") and not UnitIsPlayer("target") and UnitName("target") or nil

    local loc = { x = x, y = y, mapID = mapID, name = npcName }

    local learned = GetLearnedDB()
    learned.quests[questID] = learned.quests[questID] or {}
    learned.quests[questID][role] = loc
    -- IMPORTANT: store the bare quest title here, NOT the decorated
    -- label (which has a "(source)" suffix baked in for the print
    -- confirmation message). Storing the decorated version meant every
    -- future place that displays this quest's title would append yet
    -- another suffix on top of an already-suffixed string, stacking up
    -- over time (e.g. "Blueleaf Tubers (turn-in) (quest area)").
    learned.quests[questID].title = questTitle or learned.quests[questID].title

    char.questLocations[questID][role] = {
        x = x, y = y, mapID = mapID, source = source, role = role,
        label = label or (questTitle or ("Quest " .. questID)) .. " (" .. source .. ")",
    }

    -- This is the step that was missing entirely before: without this,
    -- everything the addon learned was saved to disk but never actually
    -- drawn as a node anywhere.
    DrawLearnedNode(questID, role, loc, learned.quests[questID].title)

    return true
end

------------------------------------------------------------
-- Objective (kill/collect/interact) spawn-point learning
--
-- Called from AutoQuestLearner_Quest.lua whenever a tracked quest's
-- objective progress is observed to increase. Records the player's
-- current position as a known spawn point for that objective, deduping
-- against nearby already-known points so repeated kills of the same
-- mob don't spam the map with near-identical dots, and capping the
-- total per objective to keep the cluster readable.
------------------------------------------------------------
function AutoQuestLearner_RecordObjectiveProgress(questID, objIndex, objText)
    if not questID or not objIndex then return end
    local x, y = GetPlayerPos()
    if not x then return end
    local mapID = GetCurrentMap()

    local learned = GetLearnedDB()
    learned.objectives[questID] = learned.objectives[questID] or {}
    local obj = learned.objectives[questID][objIndex]
    if not obj then
        obj = { text = objText, spawns = {} }
        learned.objectives[questID][objIndex] = obj
    end
    obj.text = objText or obj.text

    -- Skip if close enough to a spawn point we already know about
    -- (within roughly 1% of the map in each axis).
    for _, spawn in pairs(obj.spawns) do
        if spawn.mapID == mapID then
            local dx, dy = spawn.x - x, spawn.y - y
            if (dx * dx + dy * dy) < 1 then
                return
            end
        end
    end

    if table.getn(obj.spawns) >= 40 then return end

    local spawn = { x = x, y = y, mapID = mapID }
    table.insert(obj.spawns, spawn)

    -- In simple (non-cluster) mode, only the first known spawn point
    -- actually needs to be drawn -- additional ones are still recorded
    -- (useful if you switch back to cluster mode later) but skipped
    -- here to avoid stacking multiple single-markers on top of each
    -- other.
    local useClusters = ShouldShowClusters()
    if useClusters or table.getn(obj.spawns) == 1 then
        DrawObjectiveNode(questID, spawn, ResolveQuestTitle(questID), obj.text)
    end
end

------------------------------------------------------------
-- Manual objective-location save.
--
-- AutoQuestLearner_RecordObjectiveProgress() (above) is normally
-- triggered automatically by AutoQuestLearner_Quest.lua watching for
-- the leaderboard count to increase between polls. That covers the
-- vast majority of cases, but isn't foolproof -- e.g. a kill that
-- grants credit for multiple objectives at once, an objective whose
-- text format doesn't match the "n/n" pattern cleanly, or just bad
-- timing against the poll. This gives a manual fallback: stand where
-- you want the location remembered and run it, no dependency on the
-- automatic delta-detection having caught anything.
--
-- Deliberately scoped to ONE specific quest per call (never "save my
-- current spot for everything in my log at once") -- with several
-- quests tracked simultaneously, blindly recording all of them at
-- wherever you happen to be standing would attribute a location to
-- quests you have nothing to do with right now, which is worse than
-- not recording anything.
------------------------------------------------------------
function AutoQuestLearner_ManualLearnObjective(filterText)
    if not AutoQuestLearner.questlog then
        print("|cff00ff00AutoQuestLearner:|r Quest log not loaded yet.")
        return
    end

    local targetQuestID, targetTitle

    if filterText and filterText ~= "" then
        local lowered = filterText:lower()
        local matches = {}
        for qid, entry in pairs(AutoQuestLearner.questlog) do
            if entry.title and string.find(entry.title:lower(), lowered, 1, true) then
                table.insert(matches, qid)
            end
        end
        if table.getn(matches) == 0 then
            print("|cff00ff00AutoQuestLearner:|r No quest in your log matches \"" .. filterText .. "\".")
            return
        elseif table.getn(matches) > 1 then
            print("|cff00ff00AutoQuestLearner:|r Multiple quests match \"" .. filterText .. "\" -- be more specific:")
            for _, qid in pairs(matches) do
                print("  - " .. AutoQuestLearner.questlog[qid].title)
            end
            return
        end
        targetQuestID = matches[1]
    elseif AutoQuestLearner.pinnedQuestID and AutoQuestLearner.questlog[AutoQuestLearner.pinnedQuestID] then
        targetQuestID = AutoQuestLearner.pinnedQuestID
    else
        -- No quest specified and none pinned -- fall back to "exactly
        -- one quest is currently being watched/tracked" as a
        -- reasonable guess, but refuse to guess among several.
        local watchedIDs = {}
        for qid, entry in pairs(AutoQuestLearner.questlog) do
            if entry.state and string.find(entry.state, "^track") then
                table.insert(watchedIDs, qid)
            end
        end
        if table.getn(watchedIDs) == 1 then
            targetQuestID = watchedIDs[1]
        else
            print("|cff00ff00AutoQuestLearner:|r Not sure which quest you mean.")
            print("  Either pin one first (Alt+Right-Click it in the tracker) and run |cffffcc00/aql learn|r again,")
            print("  or specify it directly: |cffffcc00/aql learn <part of quest name>|r")
            return
        end
    end

    targetTitle = AutoQuestLearner.questlog[targetQuestID].title
    local qlogid = AutoQuestLearner.questlog[targetQuestID].qlogid
    if not qlogid then
        print("|cff00ff00AutoQuestLearner:|r Couldn't find \"" .. tostring(targetTitle) .. "\" in the live quest log right now.")
        return
    end

    local objectives = GetNumQuestLeaderBoards(qlogid)

    -- Check completion the same robust way used everywhere else in this
    -- addon (Blizzard's own isComplete flag alone isn't reliable on
    -- this client -- see the notes elsewhere on this). If the quest is
    -- complete, standing here and running this command means "this is
    -- where I turn it in" -- record a TURN-IN location (the same data
    -- slot the arrow's turn-in logic actually reads from), not an
    -- objective location, which would silently go to the wrong place
    -- and never actually fix the arrow for this quest.
    local _, _, _, _, _, isComplete = AutoQuestLearner_GetQuestLogTitle(qlogid)
    if not isComplete and objectives and objectives > 0 then
        isComplete = true
        for i = 1, objectives do
            local _, _, objDone = GetQuestLogLeaderBoard(i, qlogid)
            if not objDone then
                isComplete = false
                break
            end
        end
    end

    if isComplete then
        local learnedEntry = GetLearnedQuestEntry(GetLearnedDB(), targetQuestID, targetTitle)
        local existed = learnedEntry and learnedEntry.turnin and true or false
        if RecordLocation(targetQuestID, targetTitle, targetTitle .. " (manual turn-in)", "complete", true) then
            if existed then
                print("|cff00ff00AutoQuestLearner:|r Updated the turn-in location for \"" .. targetTitle .. "\" to your current position (previous learned spot overwritten).")
            else
                print("|cff00ff00AutoQuestLearner:|r Saved your current position as the turn-in location for \"" .. targetTitle .. "\".")
            end
        end
        return
    end

    if not objectives or objectives == 0 then
        print("|cff00ff00AutoQuestLearner:|r \"" .. targetTitle .. "\" has no trackable objectives -- nothing to save here.")
        return
    end

    local saved = 0
    for i = 1, objectives do
        local text = GetQuestLogLeaderBoard(i, qlogid)
        if text then
            local objLabel = string.match(text, "^(.-)%s*:?%s*%d") or text
            AutoQuestLearner_RecordObjectiveProgress(targetQuestID, i, objLabel)
            saved = saved + 1
        end
    end

    print(string.format("|cff00ff00AutoQuestLearner:|r Saved your current position for %d objective(s) of \"%s\".", saved, targetTitle))
end

------------------------------------------------------------
-- TomTom waypoint integration
--
-- Verified directly against TomTom's own source (TomTom.lua,
-- TomTom:AddWaypoint / AddZWaypoint): the 3-argument form
-- TomTom:AddWaypoint(x, y, desc) always targets the player's CURRENT
-- zone (it reads GetCurrentMapContinent()/GetCurrentMapAreaID()
-- internally) and divides x/y by 100 itself (AddZWaypoint does
-- `self:GetCoord(x / 100, y / 100)`), so it wants the same 0-100
-- percentage format this addon already uses everywhere. This reliably
-- works for anything in the player's current zone; for a target in a
-- different zone we say so rather than silently place the pin wrong.
------------------------------------------------------------
local lastTomTomUID = nil

-- Checks whether ANY location data (learned or static) is known for a
-- quest, in any zone -- not just the current one. Used to give honest
-- feedback when pinning a quest that has nothing to show yet, rather
-- than silently making the arrow disappear with no explanation.
function AutoQuestLearner_HasAnyKnownLocation(questID, questTitle)
    if not questID then return false end

    local learned = GetLearnedDB()
    local questEntry = GetLearnedQuestEntry(learned, questID, questTitle)
    if questEntry and (questEntry.giver or questEntry.turnin) then return true end
    local objectives = GetLearnedObjectives(learned, questID, questTitle)
    if objectives and next(objectives) then return true end

    local staticQuest = AutoQuestLearnerData.quests["data"][questID]
    if staticQuest and (staticQuest["end"] or staticQuest["obj"] or staticQuest["start"]) then
        return true
    end

    return false
end

function AutoQuestLearner_SetTomTomWaypoint(questID, title)
    if not AutoQuestLearnerDB.config.tomtomEnabled or AutoQuestLearnerDB.config.tomtomEnabled == "0" then
        print("|cff00ff00AutoQuestLearner:|r TomTom waypoints are disabled -- enable them in the config panel.")
        return
    end
    if not TomTom or not TomTom.AddWaypoint then
        print("|cff00ff00AutoQuestLearner:|r TomTom does not appear to be installed or loaded.")
        return
    end
    if not questID then
        print("|cff00ff00AutoQuestLearner:|r No quest ID available for this entry.")
        return
    end

    local learned = GetLearnedDB()
    local questEntry = GetLearnedQuestEntry(learned, questID, title)
    -- Prefer the turn-in location for a quest you're actively tracking
    -- (more likely what you want to navigate to), falling back to the
    -- pickup location if that's all that's known.
    local entry = questEntry and (questEntry.turnin or questEntry.giver)
    if not entry or not entry.mapID then
        print(string.format(
            "|cff00ff00AutoQuestLearner:|r No learned location for '%s' yet.",
            title or ("quest " .. tostring(questID))))
        return
    end

    local currentZone = GetCurrentMap()
    if entry.mapID ~= currentZone then
        print(string.format(
            "|cff00ff00AutoQuestLearner:|r '%s' was learned in %s, not your current zone (%s) -- travel there first.",
            title or "this quest", tostring(entry.mapID), tostring(currentZone)))
        return
    end

    -- Clear any waypoint we previously set so repeated Alt+Clicks don't
    -- stack multiple pins.
    if lastTomTomUID and TomTom.RemoveWaypoint then
        TomTom:RemoveWaypoint(lastTomTomUID)
    end

    lastTomTomUID = TomTom:AddWaypoint(entry.x, entry.y, title or ("Quest " .. questID))
    print(string.format("|cff00ff00AutoQuestLearner:|r TomTom waypoint set for '%s'.", title or "quest"))
end

-- Clears any waypoint AutoQuestLearner has set, without touching any
-- other waypoints TomTom may be tracking for other reasons. Called
-- when the TomTom integration checkbox is turned off.
function AutoQuestLearner_ClearTomTomWaypoint()
    if lastTomTomUID and TomTom and TomTom.RemoveWaypoint then
        TomTom:RemoveWaypoint(lastTomTomUID)
        lastTomTomUID = nil
    end
end

------------------------------------------------------------
-- Available quest giver cache (for the arrow)
--
-- "Display Available Quest Givers" (allquestgivers config) already
-- draws map/minimap pins for nearby NPCs offering quests you haven't
-- picked up yet, via AutoQuestLearnerDatabase:SearchQuests(). That's
-- purely a map-drawing pass though -- the arrow never considered these
-- as a destination, only quests already in your log. This mirrors the
-- same filtering (faction, level range, completion history) to build a
-- small per-zone candidate list the arrow can read cheaply, refreshed
-- on the same ~3s cadence as the map scan rather than rescanning the
-- whole quest database on every 0.5s arrow update.
------------------------------------------------------------
------------------------------------------------------------
-- Authoritative quest completion check
--
-- AutoQuestLearnerDB.history only knows about quests turned in SINCE
-- this addon was tracking them -- a quest completed earlier (before
-- installing/updating the addon, or in a much older playthrough) would
-- have no record there at all, making it look "available" forever even
-- though it's genuinely done. IsQuestFlaggedCompleted (the modern,
-- reliable way to ask this) doesn't exist on this client generation --
-- it wasn't added until Mists of Pandaria, years after 3.3.5. The real
-- period-correct answer is QueryQuestsCompleted() + the
-- QUEST_QUERY_COMPLETE event + GetQuestsCompleted(): this asks the
-- SERVER for your actual, complete completion history (every quest
-- you've ever turned in on this character, regardless of when or
-- whether this addon existed yet), which is what several real WotLK
-- addons solving this exact "have I already done this" problem
-- (e.g. Quest Completist, EveryQuest) rely on.
------------------------------------------------------------
local serverCompletedQuests = {}
if QueryQuestsCompleted and GetQuestsCompleted then
    local questQueryFrame = CreateFrame("Frame")
    questQueryFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
    questQueryFrame:RegisterEvent("QUEST_QUERY_COMPLETE")
    questQueryFrame:SetScript("OnEvent", function(self, event)
        if event == "PLAYER_ENTERING_WORLD" then
            QueryQuestsCompleted()
        elseif event == "QUEST_QUERY_COMPLETE" then
            GetQuestsCompleted(serverCompletedQuests)
        end
    end)
end

-- questTitle is optional (falls back to AutoQuestLearnerDB.history's
-- title-keyed alias, same reasoning as everywhere else in this addon:
-- completion can get recorded under whichever key form GetQuestIDs()
-- resolved to at turn-in time).
local function AutoQuestLearner_IsQuestCompleted(questID, questTitle)
    -- Repeatable/daily quests can be done over and over -- "completed
    -- before" doesn't mean "unavailable now" the way it does for a
    -- normal quest. Once we've personally seen a quest flagged daily
    -- in the log (see UpdateQuestlog in AutoQuestLearner_Quest.lua),
    -- permanently treat it as never "completed" for availability
    -- purposes -- we have no reliable way to know whether today's
    -- reset has happened, so the honest answer is "don't claim to
    -- know," not "assume it's still done."
    if AutoQuestLearnerDB.repeatable then
        if questID and AutoQuestLearnerDB.repeatable[questID] then return false end
        if questTitle and AutoQuestLearnerDB.repeatable[questTitle] then return false end
    end
    if questID and serverCompletedQuests[questID] then return true end
    if AutoQuestLearnerDB.history then
        if questID and AutoQuestLearnerDB.history[questID] then return true end
        if questTitle and AutoQuestLearnerDB.history[questTitle] then return true end
    end
    return false
end
_G.AutoQuestLearner_IsQuestCompleted = AutoQuestLearner_IsQuestCompleted

local availableGiverCache = {}
-- Tracks how far along each destination zone's checkpoint chain the
-- player has advanced (keyed by destination mapID, since two different
-- quests pointing at the same real-world zone should share progress
-- along the same physical path). Forward-only/self-correcting, same
-- reasoning as the objective-spawn priority system above -- never
-- regresses if you backtrack, matching how the rest of this addon
-- treats "have I been here" state.
local travelRouteProgress = {}
-- This bundled database (converted from real game data) carries roughly
-- 200 internal/development-only quest entries that were never meant to
-- be seen by players -- deprecated duplicates, cut content, or
-- placeholder flags, each marked with a recognizable title prefix
-- (confirmed by direct count against the bundled data: ~80 "OLD ",
-- ~115 "BETA ", plus a handful of "UNUSED"/"FLAG "/"[Not Used]").
-- Without filtering these out, a stale duplicate like "OLD Conquering
-- Arathi Basin" could get suggested as a real, obtainable quest.
-- Reverse lookup built from AutoQuestLearnerQuestChain's forward links
-- (this quest -> the one that follows it) -- for checking the OTHER
-- direction: does THIS quest require a prerequisite, and if so, is it
-- done? Built once, lazily, since the bundled chain data doesn't
-- change mid-session. Covers only the ~1436 chain links this addon's
-- bundled data actually knows about (a subset, not every real
-- prerequisite in the game) -- this can only correctly HIDE a quest we
-- know is gated, never incorrectly hide one we don't have data for, so
-- it's a strict improvement with no downside, just not total coverage.
local questChainPrereq = nil
-- BUGFIX: the old check compared questData["race"] against a small
-- fixed list of known "whole faction" bitmask values (77/1101 = every
-- Alliance race combined, 178/690 = every Horde race combined). That
-- only caught quests restricted to an ENTIRE faction's race set -- a
-- quest restricted to a single race or a subset (e.g. Draenei-only,
-- Night Elf-only) uses a completely different number that matches
-- none of those four, so it silently passed through unfiltered. The
-- correct check is a real bitwise test: does the quest's race
-- restriction share ANY bit with the player's own faction's full race
-- set? If not, every race that could take this quest is on the other
-- faction, regardless of which specific one(s).
local ALLIANCE_RACES = 1101 -- Human|Dwarf|NightElf|Gnome|Draenei
local HORDE_RACES = 690     -- Orc|Undead|Tauren|Troll|BloodElf
local function AutoQuestLearner_IsWrongFactionQuest(raceField, playerFaction)
    if not raceField or raceField == 0 then return false end
    local factionMask
    if playerFaction == "Alliance" then
        factionMask = ALLIANCE_RACES
    elseif playerFaction == "Horde" then
        factionMask = HORDE_RACES
    else
        return false
    end
    if bit and bit.band then
        return bit.band(raceField, factionMask) == 0
    end
    -- Fallback if the bit library isn't available on this client: at
    -- least catch the whole-faction case, same as before.
    return raceField == (playerFaction == "Alliance" and HORDE_RACES or ALLIANCE_RACES)
        or raceField == (playerFaction == "Alliance" and 178 or 77)
end
_G.AutoQuestLearner_IsWrongFactionQuest = AutoQuestLearner_IsWrongFactionQuest

local function GetQuestPrerequisites(questID)
    -- Prefer the real, bundled prerequisite data -- found this session:
    -- 2,415 quests have this field, including entries with MULTIPLE
    -- prerequisites (101 of them) that the old single-value chain
    -- table couldn't represent at all. Confirmed against two quests
    -- already manually researched and verified this session (Marg
    -- Speaks -> prereq 1240, Sigil of Thoradin -> prereq 640) -- both
    -- matched exactly. Falls back to the manually-researched
    -- questchain.lua entries (built up one real, verified quest at a
    -- time earlier this session) for anything the bundled data doesn't
    -- have.
    local questData = AutoQuestLearnerData.quests and AutoQuestLearnerData.quests["data"] and AutoQuestLearnerData.quests["data"][questID]
    if questData and questData["pre"] and table.getn(questData["pre"]) > 0 then
        return questData["pre"]
    end

    if not questChainPrereq then
        questChainPrereq = {}
        if AutoQuestLearnerQuestChain then
            for fromID, toID in pairs(AutoQuestLearnerQuestChain) do
                questChainPrereq[toID] = fromID
            end
        end
    end
    local fallback = questChainPrereq[questID]
    if fallback then
        return { fallback }
    end
    return nil
end
_G.AutoQuestLearner_GetQuestPrerequisites = GetQuestPrerequisites

-- BUGFIX: real bundled data can leave a quest's own "race" field
-- untagged even when the quest is clearly faction-restricted -- e.g.
-- "Hints of a New Plague?" (quest 657, Alliance-only) has race=nil,
-- while the FIRST quest in that same chain (659) correctly has
-- race=77. The bitwise check (AutoQuestLearner_IsWrongFactionQuest)
-- can only act on data that exists; it has nothing to check when the
-- field itself is missing. Since a quest chain essentially never
-- switches faction partway through, walking to the nearest tagged
-- chain relative (prerequisite first, then follow-up) recovers the
-- real restriction for untagged mid-chain quests generally, instead of
-- needing each one patched individually as it's noticed. Capped at 10
-- steps each direction as a safety net against any cyclical data.
local function AutoQuestLearner_GetEffectiveRaceField(questID, questData)
    if questData and questData["race"] and questData["race"] ~= 0 then
        return questData["race"]
    end
    if not questID or not AutoQuestLearnerData.quests or not AutoQuestLearnerData.quests["data"] then
        return questData and questData["race"]
    end

    local seen = {}
    local queue = { questID }
    local visited = 0
    while table.getn(queue) > 0 and visited < 30 do
        local id = table.remove(queue, 1)
        local prereqs = GetQuestPrerequisites(id)
        if prereqs then
            for _, prereq in ipairs(prereqs) do
                if not seen[prereq] then
                    seen[prereq] = true
                    visited = visited + 1
                    local prereqData = AutoQuestLearnerData.quests["data"][prereq]
                    if prereqData and prereqData["race"] and prereqData["race"] ~= 0 then
                        return prereqData["race"]
                    end
                    table.insert(queue, prereq)
                end
            end
        end
    end

    seen = {}
    id = questID
    for _ = 1, 10 do
        local nextID = AutoQuestLearnerQuestChain and AutoQuestLearnerQuestChain[id]
        if not nextID or seen[nextID] then break end
        seen[nextID] = true
        local nextData = AutoQuestLearnerData.quests["data"][nextID]
        if nextData and nextData["race"] and nextData["race"] ~= 0 then
            return nextData["race"]
        end
        id = nextID
    end

    return questData and questData["race"]
end
_G.AutoQuestLearner_GetEffectiveRaceField = AutoQuestLearner_GetEffectiveRaceField

-- Lazily-built reverse lookup: is a given string a known ITEM name?
-- Built once on first use (not scanned every 0.5s tick) since the
-- bundled item database doesn't change mid-session. Used to tell a
-- learned objective's name apart from a real creature/object name --
-- e.g. a single-required-item objective's leaderboard text is often
-- JUST the item's name ("Gnoll Paw", no visible count), which isn't
-- something a /targetexact macro can target, and shouldn't be allowed
-- to block the static resolution's real creature names either.
local knownItemNames = nil
-- Skill-line IDs the bundled quest data's "skill" field uses to mark
-- profession-gated quests -- cross-verified against multiple
-- independent sources (WoWWiki's GetProfessionInfo page, a detailed
-- sequential skill-ID list distinguishing it from the unrelated
-- Retribution talent tree at 184, and Wowhead's own skill=165 page
-- confirming Leatherworking specifically, the profession that
-- surfaced this whole gap in the first place). WotLK-era professions
-- only; Archaeology (added later, in Cataclysm) is irrelevant here.
local SKILL_LINE_NAMES = {
    [164] = "Blacksmithing",
    [165] = "Leatherworking",
    [171] = "Alchemy",
    [182] = "Herbalism",
    [185] = "Cooking",
    [186] = "Mining",
    [197] = "Tailoring",
    [202] = "Engineering",
    [129] = "First Aid",
    [333] = "Enchanting",
    [356] = "Fishing",
    [393] = "Skinning",
    [755] = "Jewelcrafting",
    [773] = "Inscription",
}

-- Cached per-session (professions don't change mid-session outside of
-- deliberately unlearning one, which is rare enough not to poll for)
-- since GetProfessions()/GetProfessionInfo() calls add up if run on
-- every single quest in the database.
local playerSkillNames = nil
local function AutoQuestLearner_HasRequiredSkill(skillLineID)
    if not skillLineID then return true end
    local requiredName = SKILL_LINE_NAMES[skillLineID]
    if not requiredName then return true end -- unknown skill ID, don't block on something we can't verify

    if not playerSkillNames then
        playerSkillNames = {}
        if GetProfessions then
            local profs = { GetProfessions() }
            for _, index in ipairs(profs) do
                if index and GetProfessionInfo then
                    local name = GetProfessionInfo(index)
                    if name then playerSkillNames[name] = true end
                end
            end
        end
    end
    return playerSkillNames[requiredName] == true
end
_G.AutoQuestLearner_HasRequiredSkill = AutoQuestLearner_HasRequiredSkill

local skillCacheInvalidateFrame = CreateFrame("Frame")
skillCacheInvalidateFrame:RegisterEvent("SKILL_LINES_CHANGED")
skillCacheInvalidateFrame:SetScript("OnEvent", function()
    playerSkillNames = nil
end)

local function AutoQuestLearner_IsKnownItemName(name)
    if not name then return false end
    if not knownItemNames then
        knownItemNames = {}
        if AutoQuestLearnerData.items and AutoQuestLearnerData.items["loc"] then
            for _, v in pairs(AutoQuestLearnerData.items["loc"]) do
                local itemName = type(v) == "string" and v or (type(v) == "table" and v["T"])
                if itemName then
                    knownItemNames[itemName] = true
                end
            end
        end
    end
    return knownItemNames[name] == true
end

local function AutoQuestLearner_IsDeprecatedQuestTitle(title)
    if not title then return false end
    if string.find(title, "^OLD ") then return true end
    if string.find(title, "^BETA ") then return true end
    if string.find(title, "^UNUSED") then return true end
    if string.find(title, "^FLAG ") then return true end
    if string.find(title, "%[Not Used%]") then return true end
    return false
end
_G.AutoQuestLearner_IsDeprecatedQuestTitle = AutoQuestLearner_IsDeprecatedQuestTitle

local function RefreshAvailableGiverCache()
    if AutoQuestLearnerDB.config.allquestgivers ~= "1" then
        availableGiverCache = {}
        return
    end
    local mapID = GetCurrentMap()
    if not mapID then return end

    local playerLevel = UnitLevel("player")
    local playerFaction = UnitFactionGroup and UnitFactionGroup("player")

    local list = {}
    for questID, questData in pairs(AutoQuestLearnerData.quests["data"]) do
        local questLoc = AutoQuestLearnerData.quests["loc"][questID]
        if questLoc then
            local shouldSkip = AutoQuestLearner_IsDeprecatedQuestTitle(questLoc.T)
            local raceField = AutoQuestLearner_GetEffectiveRaceField(questID, questData)
            if not shouldSkip and AutoQuestLearner_IsWrongFactionQuest(raceField, playerFaction) then
                shouldSkip = true
            end
            -- Profession-gated quests (recipe/pattern teaching quests
            -- especially) shouldn't be suggested to a player who
            -- doesn't even have the required profession -- confirmed
            -- real with "Wild Leather Armor" (skill=165/Leatherworking
            -- in the bundled data), which was showing as available
            -- with no NPC "!" actually present.
            if not shouldSkip and not AutoQuestLearner_HasRequiredSkill(questData["skill"]) then
                shouldSkip = true
            end

            local minLevel = questData["min"] or 0
            local questLevel = questData["lvl"] or minLevel
            if not shouldSkip and AutoQuestLearnerDB.config.showhighlevel == "0" and questLevel > playerLevel + 3 then
                shouldSkip = true
            end
            if not shouldSkip and AutoQuestLearnerDB.config.showlowlevel == "0" and questLevel < playerLevel - 5 then
                shouldSkip = true
            end
            if not shouldSkip and AutoQuestLearner_IsQuestCompleted(questID, questLoc.T) then
                shouldSkip = true
            end
            -- Skip quests we know are gated behind an incomplete
            -- prerequisite -- the NPC would actually offer a different
            -- (or no) quest until that prior one's done, so surfacing
            -- this one as "available" would be misleading. A quest can
            -- have multiple prerequisites (the real bundled data has
            -- 101 such cases) -- treated as AND (all must be done),
            -- the more conservative reading, matching this whole
            -- system's "don't show unless confident" approach.
            if not shouldSkip then
                local prereqIDs = GetQuestPrerequisites(questID)
                if prereqIDs then
                    for _, prereqID in ipairs(prereqIDs) do
                        local prereqLoc = AutoQuestLearnerData.quests["loc"][prereqID]
                        if not AutoQuestLearner_IsQuestCompleted(prereqID, prereqLoc and prereqLoc.T) then
                            shouldSkip = true
                            break
                        end
                    end
                end
            end
            -- Unlike the map-node pass, always skip quests already in
            -- your log here regardless of the "Display Current Quest
            -- Givers" setting -- those are already fully handled (with
            -- learned-location priority, objective tracking, etc.) by
            -- the main candidate loop above; an "available" duplicate
            -- would just be redundant.
            if not shouldSkip and AutoQuestLearner.questlog and
                (AutoQuestLearner.questlog[questID] or (questLoc.T and AutoQuestLearner.questlog[questLoc.T])) then
                shouldSkip = true
            end

            if not shouldSkip and questData["start"] then
                local function addCandidates(idSet, dataTable, nameResolver)
                    if not idSet then return end
                    for _, id in pairs(idSet) do
                        local entryData = dataTable[id]
                        if entryData and entryData["coords"] then
                            local resolvedName = (nameResolver and nameResolver(id)) or entryData["name"]
                            for _, coord in pairs(entryData["coords"]) do
                                if coord[3] == mapID then
                                    table.insert(list, {
                                        x = coord[1], y = coord[2], mapID = mapID,
                                        title = (questLoc.T or ("Quest " .. questID)) .. " (available)",
                                        name = resolvedName,
                                        questid = questID,
                                    })
                                end
                            end
                        end
                    end
                end
                addCandidates(questData["start"]["U"], AutoQuestLearnerData.units["data"], AutoQuestLearner_GetUnitName)
                addCandidates(questData["start"]["O"], AutoQuestLearnerData.objects["data"], AutoQuestLearner_GetObjectName)
            end
        end
    end
    availableGiverCache = list
end
AutoQuestLearner_RefreshAvailableGiverCache = RefreshAvailableGiverCache


-- Instead of requiring an Alt+Click, this continuously picks the
-- CLOSEST known destination among: pickup/turn-in locations for
-- quests currently in your log, and objective spawn points for their
-- incomplete objectives -- then keeps a single TomTom waypoint on it,
-- automatically switching to the next-closest thing as you move,
-- complete objectives, or turn quests in.
--
-- Limited to your current zone, same as manual waypoints -- TomTom's
-- 3-argument AddWaypoint() (the only form verified to work correctly
-- against this addon's own coordinate format) always targets the
-- player's current zone.
------------------------------------------------------------
local function FindClosestQuestDestination(questFilter)
    local px, py = GetPlayerPos()
    if not px then return nil end
    local currentZone = GetCurrentMap()
    if not currentZone or not AutoQuestLearner.questlog then return nil end

    -- A pinned quest that's no longer in your log (turned in,
    -- abandoned, etc.) can't be un-pinned via Alt+Right-Click anymore,
    -- since it's no longer in the tracker to click -- auto-release it
    -- instead of leaving the arrow permanently stuck on nothing.
    if AutoQuestLearner.pinnedQuestID and not AutoQuestLearner.questlog[AutoQuestLearner.pinnedQuestID] then
        print("|cff00ff00AutoQuestLearner:|r Pinned quest is no longer active -- auto-unpinned, arrow back to auto-select.")
        AutoQuestLearner.pinnedQuestID = nil
    end

    local learned = GetLearnedDB()
    local bestLoc, bestDist, bestTitle, bestName, bestQuestID = nil, nil, nil, nil, nil
    -- Tracks the best candidate found in a DIFFERENT zone than the
    -- player's current one, for when nothing is known in-zone at all.
    -- Previously these were silently discarded entirely (consider()
    -- just returned early) -- so a quest whose only known location was,
    -- say, Ashenvale, while standing in Stonetalon Mountains, showed
    -- "no location known" even though the addon actually knew exactly
    -- where to go, just not in the zone currently open. Distance can't
    -- be meaningfully compared across zones, so this just keeps
    -- whichever cross-zone candidate is found first rather than trying
    -- to rank them.
    local crossZoneMapID, crossZoneTitle, crossZoneName = nil, nil, nil
    -- Set at the top of each outer per-quest loop iteration below; consider()
    -- reads this implicitly so every existing call site (there are many)
    -- doesn't need to be touched to also pass a questID through.
    local currentConsiderQuestID = nil

    local bestFallbackLoc, bestFallbackDist, bestFallbackTitle, bestFallbackName = nil, nil, nil, nil

    local function consider(loc, title, name, isFallback)
        if not loc then return end
        if loc.mapID ~= currentZone then
            -- Only real (non-fallback) candidates count for cross-zone
            -- reporting -- a fallback guess in some other zone isn't
            -- meaningfully better information than not knowing at all.
            if not isFallback and not crossZoneMapID then
                crossZoneMapID = loc.mapID
                crossZoneTitle = title
                crossZoneName = name
            end
            return
        end
        local dx, dy = loc.x - px, loc.y - py
        local dist = dx * dx + dy * dy
        if isFallback then
            if not bestFallbackDist or dist < bestFallbackDist then
                bestFallbackDist = dist
                bestFallbackLoc = loc
                bestFallbackTitle = title
                bestFallbackName = name
            end
            return
        end
        if not bestDist or dist < bestDist then
            bestDist = dist
            bestLoc = loc
            bestTitle = title
            bestName = name
            bestQuestID = currentConsiderQuestID
        end
    end

    -- Considers every known spawn coordinate for a set of NPC/object IDs
    -- from the static bundled database (pfQuest + Questie-X), the same
    -- data SearchQuestID() draws from for map nodes. This is what makes
    -- the arrow/TomTom useful immediately, not just after something has
    -- been personally observed -- the learned database above still
    -- takes priority via distance (closer wins), so anything you've
    -- actually found in-game naturally corrects a bad static guess.
    local function considerStatic(idSet, idType, title, isFallback)
        if not idSet then return end
        for _, id in pairs(idSet) do
            local data = idType == "U" and AutoQuestLearnerData.units["data"][id]
                or idType == "O" and AutoQuestLearnerData.objects["data"][id]
            local name = idType == "U" and AutoQuestLearner_GetUnitName(id)
                or idType == "O" and AutoQuestLearner_GetObjectName(id)
            if data and data["coords"] then
                for _, coord in pairs(data["coords"]) do
                    if coord[3] then
                        consider({ x = coord[1], y = coord[2], mapID = coord[3] }, title, name, isFallback)
                    end
                end
            end
        end
    end

    -- Old learned turn-in entries (recorded before RecordLocation()
    -- started capturing the target's name) have coordinates but no
    -- name. The *name* can still safely come from the static database
    -- even when its *coordinates* can't be trusted -- who the NPC is
    -- doesn't change just because Ascension placed them somewhere
    -- different, only where they are does.
    local function StaticTurninName(staticQuest)
        if not staticQuest or not staticQuest["end"] then return nil end
        local endU = staticQuest["end"]["U"]
        if endU then
            for _, id in pairs(endU) do
                local nm = AutoQuestLearner_GetUnitName(id)
                if nm then return nm end
            end
        end
        local endO = staticQuest["end"]["O"]
        if endO then
            for _, id in pairs(endO) do
                local nm = AutoQuestLearner_GetObjectName(id)
                if nm then return nm end
            end
        end
        return nil
    end

    local questsToCheck = AutoQuestLearner.questlog
    if questFilter then
        -- Route planner: restrict to exactly one quest for this call,
        -- reusing all the same location-resolution logic below rather
        -- than a separate, duplicated implementation.
        questsToCheck = {}
        if AutoQuestLearner.questlog[questFilter] then
            questsToCheck[questFilter] = AutoQuestLearner.questlog[questFilter]
        end
    elseif AutoQuestLearner.pinnedQuestID then
        -- A quest is pinned -- only ever consider that one, ignoring
        -- everything else, until it's unpinned (Alt+Right-Click it
        -- again in the tracker).
        questsToCheck = {}
        if AutoQuestLearner.questlog[AutoQuestLearner.pinnedQuestID] then
            questsToCheck[AutoQuestLearner.pinnedQuestID] = AutoQuestLearner.questlog[AutoQuestLearner.pinnedQuestID]
        end
    end

    for questID, logData in pairs(questsToCheck) do
        currentConsiderQuestID = questID
        local questTitle = logData.title or ("Quest " .. tostring(questID))
        local questEntry = GetLearnedQuestEntry(learned, questID, questTitle)
        questTitle = (questEntry and questEntry.title) or questTitle

        -- "Path to Ascension" quests are Ascension's own built-in
        -- tutorial/leveling guide, completed through a special client
        -- UI panel (open it, click through objectives, "Start Quest"
        -- buttons, etc.) -- not a real quest giver/turn-in NPC anywhere
        -- in the world. Any location data we might have "learned" or
        -- matched for one of these is either wrong or irrelevant, so
        -- skip them entirely rather than send the arrow somewhere
        -- meaningless.
        if string.find(questTitle, "^Path to Ascension") then
            -- skip
        else

        local qlogid = logData.qlogid
        local staticQuest = AutoQuestLearnerData.quests["data"][questID]

        -- Once a quest is in your log, its pickup ("giver") location is
        -- no longer relevant to route toward -- only ever consider the
        -- turn-in location or remaining incomplete objectives.
        local _, _, _, header, _, questComplete = qlogid and AutoQuestLearner_GetQuestLogTitle(qlogid)

        -- Note: deliberately NOT also checking
        -- GetNumQuestLeaderBoards(qlogid) == 0 here. On this client,
        -- that can return 0 for a quest whose objective/leaderboard
        -- detail simply hasn't been loaded yet (it's often only
        -- populated once you've actually selected/viewed that quest in
        -- your log), not just for quests that genuinely have zero
        -- objectives -- which caused real, uncompleted quests to be
        -- misreported as ready to turn in.
        --
        -- BUGFIX: Blizzard's own isComplete flag (questComplete above)
        -- is NOT reliable enough on its own -- observed in practice on
        -- this client (Ascension): a quest with two kill objectives
        -- both individually confirmed done (leaderboard reads 1/1 and
        -- 1/1, tracker shows 100%) still had isComplete come back
        -- false, leaving the arrow stuck pointing at "objective nearby"
        -- forever instead of switching to the turn-in. Cross-check with
        -- the leaderboard as a second, independent signal: if it has at
        -- least one objective AND every single one of them reports
        -- done, treat the quest as complete regardless of what
        -- isComplete says. This is a much stronger positive signal than
        -- the "0 objectives" false-positive case above (which required
        -- guarding against an EMPTY leaderboard, not a fully-done one),
        -- so it doesn't reopen that old bug.
        if not questComplete and qlogid then
            local numObjectives = GetNumQuestLeaderBoards(qlogid)
            if numObjectives and numObjectives > 0 then
                local allDone = true
                for i = 1, numObjectives do
                    local _, _, objDone = GetQuestLogLeaderBoard(i, qlogid)
                    if not objDone then
                        allDone = false
                        break
                    end
                end
                if allDone then
                    questComplete = true
                end
            end
        end

        if questComplete then
            -- Every objective is done (or there were none to begin
            -- with) -- all that's left is turning it in.
            --
            -- BUGFIX: a personally-confirmed ("learned") turn-in
            -- location now WINS OUTRIGHT over the static/RXP database,
            -- instead of both being fed into consider() and letting
            -- raw distance decide. Ascension is a private server with
            -- its own custom NPC placements -- the bundled static data
            -- (converted from vanilla/WotLK sources) is frequently
            -- just wrong here, sometimes by a whole building or more.
            -- "Closer wins" meant that if the (wrong) static coordinate
            -- happened to be nearer than the real, learned one given
            -- your approach direction, the arrow/TomTom/map pin would
            -- keep sending you to the wrong spot even AFTER you'd
            -- personally stood at the correct one and it got recorded
            -- -- exactly backwards from what "learning" a location
            -- should do.
            local hasLearnedTurnin = questEntry and questEntry.turnin and questEntry.turnin.mapID
            if hasLearnedTurnin then
                local turninName = questEntry.turnin.name or StaticTurninName(staticQuest)
                consider(questEntry.turnin, questTitle .. " (turn-in)", turninName)
            else
                if staticQuest and staticQuest["end"] then
                    considerStatic(staticQuest["end"]["U"], "U", questTitle .. " (turn-in)")
                    considerStatic(staticQuest["end"]["O"], "O", questTitle .. " (turn-in)")
                end
                -- Precise turn-in coordinates from RXPGuides (see
                -- bundled-db/rxp-coords.lua), cross-validated against
                -- this addon's own data before being bundled -- still
                -- just static/bundled data, so it only applies when we
                -- have no personally-learned location of our own yet.
                local numericQuestID2 = tonumber(questID)
                if numericQuestID2 and AutoQuestLearnerRXPTurnin and AutoQuestLearnerRXPTurnin[numericQuestID2] then
                    local rxp = AutoQuestLearnerRXPTurnin[numericQuestID2]
                    consider({ x = rxp[1], y = rxp[2], mapID = rxp[3] }, questTitle .. " (turn-in)", nil)
                end
            end
        else
            -- Objective candidates (real, known locations) always take
            -- priority over the giver/turn-in fallback below -- even if
            -- the fallback happens to be closer to the player right now
            -- (e.g. you're still standing near the quest giver). Without
            -- this, the "closest wins" logic in consider() would let the
            -- fallback win purely on proximity, so the arrow pointed
            -- back at the giver until you'd already walked most of the
            -- way to the real objective -- defeating the point of
            -- knowing the objective location at all. objectiveCandidateCount
            -- tracks whether we found ANY real objective candidate in
            -- this zone; the fallback only runs if that's zero.
            local objectiveCandidateCount = 0
            local hasLearnedObjective = false

            local objectives = GetLearnedObjectives(learned, questID, questTitle)
            if objectives and qlogid then
                for objIndex, obj in pairs(objectives) do
                    local _, _, objDone = GetQuestLogLeaderBoard(tonumber(objIndex), qlogid)
                    if not objDone then
                        -- Learned objectives don't carry a specific NPC ID
                        -- (RecordObjectiveProgress only ever captured
                        -- position + the leaderboard text, not what was
                        -- actually killed there) -- the leaderboard text
                        -- itself is usually already NPC-name-shaped
                        -- ("Bandit slain: 0/5"), so strip the counter and
                        -- use that as the best available name.
                        --
                        -- BUGFIX: some objectives (item-collection ones
                        -- especially, e.g. a single required item like
                        -- "Gnoll Paw") show NO count suffix at all in
                        -- their leaderboard text -- no digit anywhere for
                        -- the pattern above to anchor on, so it silently
                        -- produced nil. That name=nil then fed into the
                        -- learned-beats-static priority system, which
                        -- suppresses static objective data (the real
                        -- creature name, e.g. "Riverpaw Bandit") once ANY
                        -- learned entry exists for the quest -- so this
                        -- one broken name was blocking a perfectly good
                        -- one instead of just being a cosmetic miss.
                        -- Falling back to the raw text itself when the
                        -- pattern doesn't match ensures this is never nil.
                        local learnedName = obj.text and (string.match(obj.text, "^(.-)%s*:?%s*%d") or obj.text)

                        -- BUGFIX: this client reports DIFFERENT "current
                        -- zone" mapIDs for the same physical spot
                        -- depending on exactly where you're standing --
                        -- e.g. Durotar (14) broadly vs. the named
                        -- "Valley of Trials" sub-area (363) within it.
                        -- consider() requires an exact mapID match, so a
                        -- learned spawn recorded under one of those IDs
                        -- was invisible while standing somewhere that
                        -- currently reads as a different (but related)
                        -- one -- even though it's the same real place.
                        -- If the CURRENT zone itself is already one of
                        -- the mapIDs this exact objective has spawns
                        -- under, treat every mapID in that same set as
                        -- interchangeable for this objective specifically
                        -- -- the addon having recorded them together
                        -- under one objective IS the proof they're the
                        -- same place, which is more reliable evidence
                        -- than a single zone-ID reading.
                        local zoneFamily = nil
                        for _, spawn in pairs(obj.spawns or {}) do
                            if spawn.mapID == currentZone then
                                zoneFamily = true
                                break
                            end
                        end

                        -- Only a learned name that's actually a
                        -- creature/object (not an item) should be
                        -- allowed to suppress static resolution -- an
                        -- item-only name like "Gnoll Paw" isn't
                        -- targetable anyway, so blocking the static
                        -- data's real creature names (e.g. "Riverpaw
                        -- Bandit") over it would trade a good result
                        -- for a useless one. The learned spawn's
                        -- LOCATION is still used as a candidate either
                        -- way -- this only affects whether it gates out
                        -- static candidates too.
                        local learnedNameIsCreature = learnedName and not AutoQuestLearner_IsKnownItemName(learnedName)

                        for _, spawn in pairs(obj.spawns or {}) do
                            if learnedNameIsCreature then
                                hasLearnedObjective = true
                            end
                            if spawn.mapID == currentZone then
                                objectiveCandidateCount = objectiveCandidateCount + 1
                                consider(spawn, obj.text or (questTitle .. " objective"), learnedName)
                            elseif zoneFamily then
                                -- Same objective, sibling zone ID -- relabel
                                -- to the current zone so consider()'s exact
                                -- match (and its distance math) treats it as
                                -- local instead of silently discarding it.
                                objectiveCandidateCount = objectiveCandidateCount + 1
                                consider({ x = spawn.x, y = spawn.y, mapID = currentZone },
                                    obj.text or (questTitle .. " objective"), learnedName)
                            else
                                consider(spawn, obj.text or (questTitle .. " objective"), learnedName)
                            end
                        end
                    end
                end
            end
            -- Precise "stand here" coordinates for zone-wide "kill/
            -- interact somewhere in this area" objectives, from a
            -- community leveling guide (see bundled-db/tourguide-
            -- coords.lua) -- exactly the class of quest our own NPC/
            -- object-ID-based data is least precise for, since there's
            -- no single spawn point to anchor to. Just another
            -- candidate in the same pool; consider() already picks
            -- whichever candidate is actually closest.
            local numericQuestID = tonumber(questID)
            if numericQuestID and AutoQuestLearnerTourGuideCoords and AutoQuestLearnerTourGuideCoords[numericQuestID] then
                local tg = AutoQuestLearnerTourGuideCoords[numericQuestID]
                consider({ x = tg[1], y = tg[2], mapID = tg[3] }, questTitle .. " objective", nil)
            end

            if staticQuest and staticQuest["obj"] and not hasLearnedObjective then
                -- BUGFIX: previously this static objective data was
                -- always considered alongside any learned objective
                -- location, and consider()'s "closest wins" logic
                -- decided between them by raw distance -- the same bug
                -- already fixed above for giver/turn-in locations, just
                -- not yet applied here. On Ascension, where the bundled
                -- static coordinates (converted from vanilla/WotLK
                -- sources) frequently don't match where the mob/object
                -- actually spawns, that meant a wrong static cluster
                -- could keep outcompeting a correctly learned one
                -- depending on which was nearer at any given moment.
                -- Now: once we've personally recorded ANY real
                -- objective location for this quest, the static "obj"
                -- pool is skipped entirely rather than left to compete.
                --
                -- The bundled "obj" data is a flat pool of every
                -- NPC/object/areatrigger ID relevant to the quest as a
                -- whole -- it doesn't record which specific leaderboard
                -- line each one belongs to. Without this, a quest with
                -- one sub-objective finished (e.g. "Razormane Geomancer
                -- slain: 8/8") and another still in progress ("Kreenig
                -- Snarlsnout's Tusk: 0/1") would keep offering the
                -- FINISHED one as a candidate right alongside the
                -- unfinished one -- and since you're often already
                -- standing at/near the finished one when it completes,
                -- it would keep winning "closest" and the arrow would
                -- never advance to what's actually still needed. Build
                -- the set of already-done leaderboard texts once here,
                -- then skip any static candidate whose resolved name
                -- matches one (same substring heuristic used for map
                -- node culling, for consistency between the two).
                local doneObjectiveTexts = {}
                if qlogid then
                    local numObjectives = GetNumQuestLeaderBoards(qlogid)
                    if numObjectives and numObjectives > 0 then
                        for oi = 1, numObjectives do
                            local otext, _, odone = GetQuestLogLeaderBoard(oi, qlogid)
                            if odone and otext then
                                local label = string.match(otext, "^(.-)%s*:?%s*%d") or otext
                                table.insert(doneObjectiveTexts, string.lower(label))
                            end
                        end
                    end
                end

                local function isAlreadyDone(name)
                    if not name or table.getn(doneObjectiveTexts) == 0 then return false end
                    local nameLower = string.lower(name)
                    for _, doneText in pairs(doneObjectiveTexts) do
                        if string.find(doneText, nameLower, 1, true) or string.find(nameLower, doneText, 1, true) then
                            return true
                        end
                    end
                    return false
                end

                local function considerStaticCounting(idSet, idType, title)
                    if not idSet then return end
                    for _, id in pairs(idSet) do
                        local data = idType == "U" and AutoQuestLearnerData.units["data"][id]
                            or idType == "O" and AutoQuestLearnerData.objects["data"][id]
                            or idType == "A" and AutoQuestLearnerData.areatrigger["data"][id]
                        local name = idType == "U" and AutoQuestLearner_GetUnitName(id)
                            or idType == "O" and AutoQuestLearner_GetObjectName(id)
                            or idType == "A" and "the search area"
                        if data and data["coords"] and not isAlreadyDone(name) then
                            for _, coord in pairs(data["coords"]) do
                                if coord[3] then
                                    if coord[3] == currentZone then
                                        objectiveCandidateCount = objectiveCandidateCount + 1
                                    end
                                    consider({ x = coord[1], y = coord[2], mapID = coord[3] }, title, name)
                                end
                            end
                        end
                    end
                end
                considerStaticCounting(staticQuest["obj"]["U"], "U", questTitle .. " objective")
                considerStaticCounting(staticQuest["obj"]["O"], "O", questTitle .. " objective")
                considerStaticCounting(staticQuest["obj"]["A"], "A", questTitle .. " objective")

                -- Item-collection objectives ("Collect 8 Witchwing
                -- Talons"): the quest itself has no NPC/object ID
                -- attached, only an item requirement, so it has to be
                -- resolved through the item's drop sources first. Uses
                -- the exact same resolution AutoQuestLearner_Database.lua's
                -- SearchQuestID uses for map nodes -- without this, the
                -- arrow and the map nodes could disagree with each
                -- other (nodes showing a cluster while the arrow still
                -- says "no location known").
                if staticQuest["obj"]["I"] and AutoQuestLearner_ResolveItemSources then
                    for _, itemID in pairs(staticQuest["obj"]["I"]) do
                        local itemName = AutoQuestLearner_GetItemName and AutoQuestLearner_GetItemName(itemID)
                        if not isAlreadyDone(itemName) then
                            local sources = AutoQuestLearner_ResolveItemSources(itemID)
                            for _, src in pairs(sources) do
                                considerStaticCounting({ src.id }, src.type, questTitle .. " objective")
                            end
                        end
                    end
                end
            end

            -- Fallback: our bundled data usually only has start/end
            -- linkage, not objective-specific kill-target NPCs (that
            -- field was skipped during conversion due to schema
            -- uncertainty -- see bundled-db conversion notes). Without
            -- this fallback, an incomplete quest with no objective data
            -- would never get a candidate at all, even though we
            -- perfectly well know where to turn it in eventually.
            -- IMPORTANT: labeled as "(quest area)", NOT "(turn-in)" --
            -- this is the turn-in NPC's location being used as a rough
            -- proxy for "where this quest happens", not a claim that
            -- the quest is actually ready to hand in. Only used when we
            -- have zero real objective candidates (see above).
            if objectiveCandidateCount == 0 then
                -- Same learned-beats-static priority as the completed-quest
                -- branch above, applied to this rougher "quest area" guess too.
                if questEntry and questEntry.turnin and questEntry.turnin.mapID then
                    local turninName = questEntry.turnin.name or StaticTurninName(staticQuest)
                    consider(questEntry.turnin, questTitle .. " (quest area)", turninName, true)
                else
                    if staticQuest and staticQuest["end"] then
                        considerStatic(staticQuest["end"]["U"], "U", questTitle .. " (quest area)", true)
                        considerStatic(staticQuest["end"]["O"], "O", questTitle .. " (quest area)", true)
                    end
                end
            end
        end
        end
    end

    -- Available (not-yet-picked-up) quest givers -- only in auto-select
    -- mode, since pinning a specific quest means "only care about this
    -- one", not "also suggest new quests to grab along the way".
    if not AutoQuestLearner.pinnedQuestID then
        for _, cand in pairs(availableGiverCache) do
            currentConsiderQuestID = cand.questid
            consider(cand, cand.title, cand.name)
        end
    end

    -- If the quest's real destination is in a different zone, but this
    -- travel route has a verified CHECKPOINT CHAIN (waypoints, ordered)
    -- instead of just a single point, walk it self-correctingly: once
    -- within a checkpoint's own arrival radius, advance to the next one
    -- in the chain -- same self-correction pattern already used for
    -- learned objectives earlier in this file, just applied to a
    -- pre-authored route instead of personally-recorded spawns. This is
    -- what lets the route follow an actual road around a mountain
    -- instead of a single straight line through it, once a chain with
    -- real intermediate points exists for a given zone.
    if not bestLoc and crossZoneMapID and AutoQuestLearnerTravelRoutes then
        local route = AutoQuestLearnerTravelRoutes[crossZoneMapID]
        local chain = route and route.waypoints
        if chain and table.getn(chain) > 0 then
            local progressKey = tostring(crossZoneMapID)
            local startIndex = travelRouteProgress[progressKey] or 1
            for i = startIndex, table.getn(chain) do
                local cp = chain[i]
                if cp.mapID == currentZone then
                    bestLoc = { x = cp.x, y = cp.y, mapID = cp.mapID }
                    bestTitle = (crossZoneTitle or "") .. " (travel route)"
                    bestName = cp.label
                    if px and py then
                        local d = math.sqrt((cp.x - px) ^ 2 + (cp.y - py) ^ 2)
                        if d <= (cp.radius or 2.5) then
                            travelRouteProgress[progressKey] = i + 1
                        end
                    end
                    break
                end
            end
        end
    end

    local crossZoneInfo = nil
    if not bestLoc and crossZoneMapID then
        local zoneName = AutoQuestLearnerData.zones and AutoQuestLearnerData.zones["loc"]
            and AutoQuestLearnerData.zones["loc"][crossZoneMapID]
        crossZoneInfo = { mapID = crossZoneMapID, title = crossZoneTitle, name = crossZoneName, zoneName = zoneName }
    end

    -- Only fall back to the low-confidence in-zone "(quest area)" guess
    -- once both better options (a real in-zone location, or knowing the
    -- real location is just in a different zone) have been ruled out.
    if not bestLoc and not crossZoneInfo and bestFallbackLoc then
        bestLoc, bestTitle, bestName = bestFallbackLoc, bestFallbackTitle, bestFallbackName
    end

    return bestLoc, bestTitle, bestName, bestQuestID, crossZoneInfo
end

-- Route planner ("least backtracking" quest order suggestion).
--
-- Gathers every active quest's known destination in the player's
-- CURRENT zone only (cross-zone entries are skipped -- a "least
-- backtracking" route only makes sense within one local area), reusing
-- FindClosestQuestDestination per-quest via the questFilter parameter
-- above so this can never resolve a location differently than the
-- arrow itself would for the same quest.
--
-- Ordering uses a greedy nearest-neighbor heuristic: starting from the
-- player's position, repeatedly pick whichever remaining stop is
-- closest to wherever the route currently ends, and add it next. This
-- is NOT guaranteed to be the mathematically optimal route (that's the
-- traveling salesman problem, expensive to solve exactly as stop count
-- grows) -- but nearest-neighbor is a standard, well-understood
-- heuristic that's fast, simple enough to get right without live
-- testing, and produces a genuinely reasonable route in practice at
-- the scale this ever needs to handle (a player's active quest count,
-- typically well under 20).
-- Looks up a quest's turn-in NPC location in the CURRENT zone,
-- regardless of whether the quest is actually complete yet -- unlike
-- FindClosestQuestDestination above, which only reports the turn-in
-- once a quest is done (correct for the arrow, since you can't turn in
-- an incomplete quest yet). The route planner needs this to see BOTH a
-- quest's objective AND its eventual turn-in point as two separate
-- stops, so the routing can actually discover "do these three nearby
-- objectives, then loop back for turn-ins" instead of only ever seeing
-- one location per quest and bouncing back and forth.
local function GetQuestTurnInLocation(questID)
    local currentZone = GetCurrentMap()
    if not currentZone or not AutoQuestLearner.questlog then return nil end
    local logData = AutoQuestLearner.questlog[questID]
    if not logData then return nil end
    local questTitle = logData.title or ("Quest " .. tostring(questID))
    if string.find(questTitle, "^Path to Ascension") then return nil end

    local learned = GetLearnedDB()
    local questEntry = GetLearnedQuestEntry(learned, questID, questTitle)
    local staticQuest = AutoQuestLearnerData.quests["data"][questID]

    if questEntry and questEntry.turnin and questEntry.turnin.mapID == currentZone then
        return { x = questEntry.turnin.x, y = questEntry.turnin.y }, (questEntry.title or questTitle)
    end
    if staticQuest and staticQuest["end"] then
        for _, idSet in pairs({ { staticQuest["end"]["U"], "U" }, { staticQuest["end"]["O"], "O" } }) do
            local ids, idType = idSet[1], idSet[2]
            if ids then
                for _, id in pairs(ids) do
                    local data = idType == "U" and AutoQuestLearnerData.units["data"][id]
                        or idType == "O" and AutoQuestLearnerData.objects["data"][id]
                    if data and data["coords"] then
                        for _, coord in pairs(data["coords"]) do
                            if coord[3] == currentZone then
                                return { x = coord[1], y = coord[2] }, questTitle
                            end
                        end
                    end
                end
            end
        end
    end
    return nil
end

function AutoQuestLearner_PlanRoute()
    local px, py = GetPlayerPos()
    if not px then return nil end
    if not AutoQuestLearner.questlog then return nil end

    local stops = {}
    for questID, logData in pairs(AutoQuestLearner.questlog) do
        local loc, title, name = FindClosestQuestDestination(questID)
        if loc then
            table.insert(stops, { x = loc.x, y = loc.y, title = title, name = name, questID = questID })
            -- If this stop was the objective (not already a turn-in),
            -- the quest isn't complete yet -- also add its eventual
            -- turn-in point as a SEPARATE stop, if known. This is what
            -- lets the route actually cluster nearby objectives before
            -- looping back for turn-ins, instead of only ever seeing
            -- one location per quest and bouncing between them one at
            -- a time.
            if title and not string.find(title, "%(turn%-in%)") then
                local turnLoc, turnTitle = GetQuestTurnInLocation(questID)
                if turnLoc and (turnLoc.x ~= loc.x or turnLoc.y ~= loc.y) then
                    table.insert(stops, { x = turnLoc.x, y = turnLoc.y, title = (turnTitle or logData.title) .. " (turn-in)", name = nil, questID = questID })
                end
            end
        end
    end

    if table.getn(stops) == 0 then
        return {}
    end

    -- Geographic clustering: group stops by physical proximity to each
    -- other first, then order within each cluster (objectives before
    -- that cluster's turn-ins), then visit clusters nearest-first. Pure
    -- nearest-neighbor across all stops (the previous approach) could
    -- still hop between different areas of the map mid-cluster if a
    -- single point happened to be marginally closer -- this instead
    -- commits to finishing one whole area before moving to the next,
    -- which is what was actually asked for: group quests by which side
    -- of the map their turn-in NPCs are on, finish that whole area
    -- before heading to a different one.
    --
    -- CLUSTER_THRESHOLD is a judgment call, not a precisely derived
    -- value -- same 0-100 zone-percentage coordinate space used
    -- throughout this addon (see the route command's own distance
    -- output). Tuned to catch "same general area of a zone" without
    -- being so wide it merges genuinely separate areas together.
    local CLUSTER_THRESHOLD = 15
    local CLUSTER_THRESHOLD_SQ = CLUSTER_THRESHOLD * CLUSTER_THRESHOLD

    local function distSq(a, b)
        local dx, dy = a.x - b.x, a.y - b.y
        return dx * dx + dy * dy
    end

    local function nearestIndex(fromX, fromY, list)
        local bestIdx, bestDist = nil, nil
        for idx, stop in pairs(list) do
            local dx, dy = stop.x - fromX, stop.y - fromY
            local dist = dx * dx + dy * dy
            if not bestDist or dist < bestDist then
                bestDist, bestIdx = dist, idx
            end
        end
        return bestIdx, bestDist
    end

    local function removeAt(list, idx)
        local newList = {}
        for i, v in pairs(list) do
            if i ~= idx then table.insert(newList, v) end
        end
        return newList
    end

    local route = {}
    local curX, curY = px, py
    local unassigned = stops

    while table.getn(unassigned) > 0 do
        -- Seed the next cluster with whichever remaining stop is
        -- closest to wherever the route currently ends -- this is what
        -- makes clusters themselves get visited nearest-first.
        local seedIdx = nearestIndex(curX, curY, unassigned)
        local cluster = { unassigned[seedIdx] }
        unassigned = removeAt(unassigned, seedIdx)

        -- Expand the cluster: keep pulling in any remaining stop that's
        -- within the threshold of ANYTHING already in the cluster,
        -- repeating until nothing more qualifies. This lets a cluster
        -- grow organically along a line of nearby quest hubs, not just
        -- stops immediately next to the seed.
        local grew = true
        while grew do
            grew = false
            local stillUnassigned = {}
            for _, stop in pairs(unassigned) do
                local joinsCluster = false
                for _, member in pairs(cluster) do
                    if distSq(stop, member) <= CLUSTER_THRESHOLD_SQ then
                        joinsCluster = true
                        break
                    end
                end
                if joinsCluster then
                    table.insert(cluster, stop)
                    grew = true
                else
                    table.insert(stillUnassigned, stop)
                end
            end
            unassigned = stillUnassigned
        end

        -- Within this cluster: objectives first, then turn-ins for
        -- this same cluster -- both nearest-neighbor ordered from
        -- wherever the route currently is.
        local objectives, turnins = {}, {}
        for _, stop in pairs(cluster) do
            if stop.title and string.find(stop.title, "%(turn%-in%)") then
                table.insert(turnins, stop)
            else
                table.insert(objectives, stop)
            end
        end

        for _, group in pairs({ objectives, turnins }) do
            local remaining = group
            while table.getn(remaining) > 0 do
                local idx = nearestIndex(curX, curY, remaining)
                local chosen = remaining[idx]
                chosen.distFromPrev = math.sqrt((chosen.x - curX) ^ 2 + (chosen.y - curY) ^ 2)
                table.insert(route, chosen)
                curX, curY = chosen.x, chosen.y
                remaining = removeAt(remaining, idx)
            end
        end
    end

    return route
end


------------------------------------------------------------
-- Native navigation arrow
--
-- This is AutoQuestLearner's own in-world directional arrow, requiring
-- neither pfQuest nor TomTom. The rotation math is adapted directly
-- from pfQuest's route.lua (itself adapted from TomTomVanilla, per its
-- own comment) -- this exact formula is intricate and precision-
-- sensitive (it mixes radians and a degree-scale value by design, in a
-- way that's only internally consistent as a whole pipeline), so it's
-- reproduced verbatim rather than re-derived, using our own bundled
-- arrow.tga (confirmed byte-identical to pfQuest's own copy, so the
-- same texture-atlas coordinate math applies correctly).
--
-- pfQuest's own "Distance" readout is not real yards either -- it's
-- the same 0-100 percentage-space Euclidean distance used here, just
-- without a unit suffix to avoid implying a precision it doesn't have.
------------------------------------------------------------
-- math.mod does not exist on this client -- matches pfQuest's own
-- modulo() helper exactly.
local function mod(val, by)
    return val - math.floor(val / by) * by
end

AutoQuestLearner.arrow = CreateFrame("Frame", "AutoQuestLearnerArrow", UIParent)
AutoQuestLearner.arrow:SetFrameStrata("HIGH")
AutoQuestLearner.arrow:SetPoint("CENTER", 0, -100)
AutoQuestLearner.arrow:SetWidth(48)
AutoQuestLearner.arrow:SetHeight(36)
AutoQuestLearner.arrow:SetClampedToScreen(true)
AutoQuestLearner.arrow:SetMovable(true)
AutoQuestLearner.arrow:EnableMouse(true)
AutoQuestLearner.arrow:RegisterForDrag("LeftButton")
AutoQuestLearner.arrow:SetScript("OnDragStart", function()
    if IsShiftKeyDown() then
        this:StartMoving()
    end
end)
AutoQuestLearner.arrow:SetScript("OnDragStop", function()
    this:StopMovingOrSizing()
end)
AutoQuestLearner.arrow:SetAlpha(0)

AutoQuestLearner.arrow.model = AutoQuestLearner.arrow:CreateTexture("AutoQuestLearnerArrowModel", "MEDIUM")
AutoQuestLearner.arrow.model:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\arrow")
AutoQuestLearner.arrow.model:SetTexCoord(0, 0, 0.109375, 0.08203125)
AutoQuestLearner.arrow.model:SetAllPoints()
AutoQuestLearner.arrow.model:SetVertexColor(0, 1, 0)

-- Quest-marker icon (the "?" mark), matching pfQuest's layout of a
-- small icon sitting just under the arrow model itself.
AutoQuestLearner.arrow.icon = AutoQuestLearner.arrow:CreateTexture("AutoQuestLearnerArrowIcon", "OVERLAY")
AutoQuestLearner.arrow.icon:SetWidth(20)
AutoQuestLearner.arrow.icon:SetHeight(20)
AutoQuestLearner.arrow.icon:SetPoint("BOTTOM", AutoQuestLearner.arrow.model, "BOTTOM", 0, -2)

AutoQuestLearner.arrow.title = AutoQuestLearner.arrow:CreateFontString("AutoQuestLearnerArrowText", "HIGH", "GameFontNormal")
AutoQuestLearner.arrow.title:SetPoint("TOP", AutoQuestLearner.arrow.model, "BOTTOM", 0, -10)
AutoQuestLearner.arrow.title:SetTextColor(1, .8, 0)
AutoQuestLearner.arrow.title:SetJustifyH("CENTER")

AutoQuestLearner.arrow.description = AutoQuestLearner.arrow:CreateFontString("AutoQuestLearnerArrowDescription", "HIGH", "GameFontHighlightSmall")
AutoQuestLearner.arrow.description:SetPoint("TOP", AutoQuestLearner.arrow.title, "BOTTOM", 0, -2)
AutoQuestLearner.arrow.description:SetTextColor(1, 1, 1)
AutoQuestLearner.arrow.description:SetJustifyH("CENTER")
AutoQuestLearner.arrow.description:SetWidth(220)

AutoQuestLearner.arrow.distance = AutoQuestLearner.arrow:CreateFontString("AutoQuestLearnerArrowDistance", "HIGH", "GameFontHighlightSmall")
AutoQuestLearner.arrow.distance:SetPoint("TOP", AutoQuestLearner.arrow.description, "BOTTOM", 0, -2)
AutoQuestLearner.arrow.distance:SetTextColor(.7, .7, .7)
AutoQuestLearner.arrow.distance:SetJustifyH("CENTER")

local arrowInvalidSince = nil
local arrowLastTitle = nil
local standingOnItTargetKey = nil
local standingOnItSince = nil

local arrowSearchElapsed = 0
local arrowCachedLoc, arrowCachedTitle, arrowCachedName, arrowCachedQuestID, arrowCachedCrossZone = nil, nil, nil, nil, nil
AutoQuestLearner_GetTrackedQuestID = function() return arrowCachedQuestID end

-- This runs independently of whether the arrow itself is shown, so the
-- route line (which reuses this same cache) keeps working even if only
-- the arrow specifically gets disabled.
------------------------------------------------------------
-- Self-calibrating minimap zone dimensions
--
-- Some starting-zone sub-areas (Shadowglen, Coldridge Valley, Camp
-- Narache, Red Cloud Mesa, Deathknell, Valley of Trials) are treated
-- as their own separately-zoomable maps on this client -- a real
-- Ascension-specific client customization (verified: standard WotLK
-- 3.3.5 never had this; it was added to the base game much later, in
-- patch 5.0.4). Because of that, no external community database
-- (pfQuest, Astrolabe, Cartographer, Gatherer, etc.) has ever needed
-- this exact dimension, since it doesn't exist anywhere outside
-- Ascension's own client files -- confirmed via direct research before
-- building this. Rather than use the much larger PARENT zone's
-- dimension as a stand-in (which was producing minimap dots that
-- drifted further wrong the more you moved, since the scale itself was
-- wrong), this measures the real value directly from actual play,
-- matching the same "learn from personal observation" approach the
-- rest of this addon already uses everywhere else.
--
-- How it works: GetUnitSpeed("player") gives real movement speed in
-- yards/second -- a genuine Blizzard API value, not an estimate. By
-- comparing how far you actually moved (speed * time) against how much
-- your percent-position changed over that same short window, one axis
-- at a time (only counting samples where movement was clearly mostly
-- horizontal or mostly vertical, to avoid diagonal-movement math
-- getting the two axes tangled), it collects real samples over time as
-- you naturally walk around, and once there's enough of them, saves a
-- permanent, real value -- not a synthetic estimate.
------------------------------------------------------------
local STARTING_ZONE_TARGETS = {
    [188] = true, -- Shadowglen
    [132] = true, -- Coldridge Valley
    [221] = true, -- Camp Narache
    [220] = true, -- Red Cloud Mesa
    [154] = true, -- Deathknell
    [363] = true, -- Valley of Trials
}
local MIN_CALIBRATION_SAMPLES = 15

local calibSamples = {} -- [rawZoneID] = { widths = {}, heights = {} }
local calibLastX, calibLastY, calibLastTime, calibLastZone = nil, nil, nil, nil

local function Median(list)
    if not list or table.getn(list) == 0 then return nil end
    local sorted = {}
    for i, v in ipairs(list) do sorted[i] = v end
    table.sort(sorted)
    local n = table.getn(sorted)
    local mid = math.ceil(n / 2)
    return sorted[mid]
end

function AutoQuestLearner_UpdateMinimapCalibration()
    if not AutoQuestLearnerMap or not GetUnitSpeed then return end
    AutoQuestLearnerDB.calibratedMinimap = AutoQuestLearnerDB.calibratedMinimap or {}

    local rawZone = AutoQuestLearnerMap:GetMapID(nil, nil, true)
    if not STARTING_ZONE_TARGETS[rawZone] or AutoQuestLearnerDB.calibratedMinimap[rawZone] then
        -- Not a zone we need to calibrate, or already done -- reset
        -- tracking state so a stale reading doesn't leak into a later
        -- zone's samples.
        calibLastX, calibLastY, calibLastTime, calibLastZone = nil, nil, nil, nil
        return
    end

    local x, y = GetPlayerMapPosition("player")
    if not x or (x == 0 and y == 0) then return end
    x, y = x * 100, y * 100
    local now = GetTime()

    if calibLastZone == rawZone and calibLastX and calibLastTime then
        local dt = now - calibLastTime
        local speed = GetUnitSpeed("player")
        -- Guard against noise: too short an interval is imprecise,
        -- too long risks non-straight-line movement (turning corners)
        -- corrupting the sample, and zero speed means not moving at
        -- all (nothing to measure).
        if speed and speed > 0 and dt >= 0.3 and dt <= 2.0 then
            local dx = x - calibLastX
            local dy = y - calibLastY
            local totalYards = speed * dt
            local absDx, absDy = math.abs(dx), math.abs(dy)

            calibSamples[rawZone] = calibSamples[rawZone] or { widths = {}, heights = {} }
            local s = calibSamples[rawZone]

            -- Only use samples that are clearly mostly-horizontal or
            -- mostly-vertical -- a diagonal movement can't cleanly
            -- isolate which axis it's telling you about.
            if absDx > 0.1 and absDx > 3 * absDy then
                table.insert(s.widths, totalYards * 100 / absDx)
            elseif absDy > 0.1 and absDy > 3 * absDx then
                table.insert(s.heights, totalYards * 100 / absDy)
            end

            if table.getn(s.widths) >= MIN_CALIBRATION_SAMPLES and table.getn(s.heights) >= MIN_CALIBRATION_SAMPLES then
                local width = Median(s.widths)
                local height = Median(s.heights)
                AutoQuestLearnerDB.calibratedMinimap[rawZone] = { width, height }
                calibSamples[rawZone] = nil
                local zoneName = AutoQuestLearnerData.zones and AutoQuestLearnerData.zones["loc"] and AutoQuestLearnerData.zones["loc"][rawZone]
                print(string.format("|cff00ff00AutoQuestLearner:|r Finished calibrating minimap scale for %s (%.0f x %.0f yards) -- minimap positioning should now be accurate here.",
                    tostring(zoneName or rawZone), width, height))
            end
        end
    end

    calibLastX, calibLastY, calibLastTime, calibLastZone = x, y, now, rawZone
end

local calibrationFrame = CreateFrame("Frame")
local calibrationElapsed = 0
local calibrationErrorShown = false
calibrationFrame:SetScript("OnUpdate", function(self, elapsed)
    calibrationElapsed = calibrationElapsed + (elapsed or 0)
    if calibrationElapsed < 0.3 then return end
    calibrationElapsed = 0
    local ok, err = pcall(AutoQuestLearner_UpdateMinimapCalibration)
    if not ok and not calibrationErrorShown then
        calibrationErrorShown = true
        print("|cffff0000AutoQuestLearner:|r Minimap calibration error: " .. tostring(err))
    end
end)


local destinationSearchErrorShown = false
local destinationSearchFrame = CreateFrame("Frame")
destinationSearchFrame:SetScript("OnUpdate", function(self, elapsed)
    arrowSearchElapsed = arrowSearchElapsed + (elapsed or 0)
    if arrowSearchElapsed < 0.5 then return end
    arrowSearchElapsed = 0

    local perfStart
    if AutoQuestLearner_PerfDebug and AutoQuestLearner_PerfDebug.active then
        local ok2, t = pcall(debugprofilestop)
        if ok2 then perfStart = t end
    end
    local ok, a, b, c, d, e = pcall(FindClosestQuestDestination)
    if perfStart then
        local elapsed = debugprofilestop() - perfStart
        AutoQuestLearner_PerfDebug.totals["FindClosestQuestDestination"] = (AutoQuestLearner_PerfDebug.totals["FindClosestQuestDestination"] or 0) + elapsed
        AutoQuestLearner_PerfDebug.calls["FindClosestQuestDestination"] = (AutoQuestLearner_PerfDebug.calls["FindClosestQuestDestination"] or 0) + 1
    end
    if ok then
        local questChanged = (d ~= arrowCachedQuestID)
        arrowCachedLoc, arrowCachedTitle, arrowCachedName, arrowCachedQuestID, arrowCachedCrossZone = a, b, c, d, e
        if questChanged and AutoQuestLearnerDB.config.focusmode == "1" and AutoQuestLearnerMap then
            AutoQuestLearnerMap:UpdateNodes()
        end
    elseif not destinationSearchErrorShown then
        destinationSearchErrorShown = true
        print("|cffff0000AutoQuestLearner:|r Navigation arrow error (this is what's breaking it): " .. tostring(a))
    end
end)

-- Approximate "have we arrived" radius, in the same 0-100
-- percent-of-zone units GetPlayerPos()/FindClosestQuestDestination use
-- everywhere else (NOT yards -- see the GetPlayerPos comment above).
-- Inspired by how Joana's Guides (a paid, closed-source addon --
-- license reserves all rights, so none of its code is reused here,
-- just the general idea) treats each stop on a route as its own
-- checkpoint with its own arrival radius rather than one fixed value
-- for everything: a turn-in/giver NPC is a fixed point you walk right
-- up to, so it gets a tight radius; an objective can be a roaming mob
-- or a wide area, so it gets a looser one; the "(quest area)" fallback
-- is only ever a coarse guess (the turn-in NPC's spot standing in for
-- "somewhere around here"), so it's loosest of all. Kind is inferred
-- from the role suffix already baked into every candidate's title
-- string (see the consider() call sites above and the matching
-- string.find() calls in UpdateArrowFrame) rather than threading a new
-- parameter through every call site.
local function GetArrivalRadius(title)
    if not title then return 2.0 end
    if string.find(title, "%(turn%-in%)") then
        return 1.2
    elseif string.find(title, "%(available%)") then
        return 1.2
    elseif string.find(title, "%(travel route%)") then
        return 2.5
    elseif string.find(title, "%(quest area%)") then
        return 4.5
    elseif string.find(title, "search area") then
        return 3.5
    elseif string.find(title, "objective") then
        return 2.5
    end
    return 2.0
end

local autoWaypointElapsed = 0
local autoWaypointSafetyElapsed = 0
-- What the currently-placed TomTom waypoint is actually pointing at,
-- so a tick can tell "same destination, just got closer" apart from
-- "destination changed, must refresh" without re-running the full
-- quest-log scan itself.
local autoWaypointTargetKey = nil
local autoWaypointArrived = false
local autoWaypointFrame = CreateFrame("Frame")
autoWaypointFrame:SetScript("OnUpdate", function(self, elapsed)
    if AutoQuestLearnerDB.config.tomtomEnabled == "0" or AutoQuestLearnerDB.config.tomtomAuto ~= "1" then
        return
    end
    if not TomTom or not TomTom.AddWaypoint then return end

    -- Checked frequently (every 0.3s) but cheaply: this reads the
    -- destination cache the navigation arrow already refreshes every
    -- 0.5s (destinationSearchFrame, above) instead of running its own
    -- separate full quest-log scan -- previously this ran
    -- FindClosestQuestDestination() a SECOND time on its own 5s timer,
    -- duplicating that work and leaving the TomTom waypoint up to 5
    -- seconds stale relative to what the arrow was already showing.
    autoWaypointElapsed = autoWaypointElapsed + (elapsed or 0)
    if autoWaypointElapsed < 0.3 then return end
    autoWaypointElapsed = 0

    local loc, title = arrowCachedLoc, arrowCachedTitle
    if not loc then return end

    local targetKey = tostring(title) .. "|" .. tostring(loc.x) .. "|" .. tostring(loc.y) .. "|" .. tostring(loc.mapID)
    local targetChanged = targetKey ~= autoWaypointTargetKey

    -- Self-correcting "arrival" check: once within this destination's
    -- own arrival radius, force one refresh right at that moment even
    -- though the target itself hasn't changed yet -- this is what
    -- catches TomTom having auto-cleared its own waypoint on arrival
    -- (a normal TomTom feature) before the next quest-state update
    -- swaps the underlying target, so the arrow doesn't sit dark
    -- waiting on the next periodic tick. autoWaypointArrived latches
    -- so it only fires once per approach, not every 0.3s while
    -- standing still inside the radius.
    local px, py = GetPlayerPos()
    local justArrived = false
    if px and loc.mapID == GetCurrentMap() then
        local dist = math.sqrt((loc.x - px) ^ 2 + (loc.y - py) ^ 2)
        local withinRadius = dist <= GetArrivalRadius(title)
        if withinRadius and not autoWaypointArrived then
            justArrived = true
        end
        autoWaypointArrived = withinRadius
    else
        autoWaypointArrived = false
    end

    -- Safety-net periodic refresh (every 3s) in case something changed
    -- that neither of the above caught -- e.g. TomTom itself was
    -- reloaded/reset externally.
    autoWaypointSafetyElapsed = autoWaypointSafetyElapsed + 0.3
    local safetyDue = autoWaypointSafetyElapsed >= 3
    if safetyDue then autoWaypointSafetyElapsed = 0 end

    if not (targetChanged or justArrived or safetyDue) then return end

    if lastTomTomUID and TomTom.RemoveWaypoint then
        TomTom:RemoveWaypoint(lastTomTomUID)
    end
    lastTomTomUID = TomTom:AddWaypoint(loc.x, loc.y, title)
    autoWaypointTargetKey = targetKey
end)

-- Checks for a known flight connection between two zones, using the
-- bundled InFlight route graph and the (deliberately partial) flight
-- point -> zone mapping. Only covers the ~18 flight points that could
-- be safely cross-referenced -- see bundled-db/flightzones.lua's own
-- notes for why the rest were excluded rather than guessed at. Returns
-- nil whenever it can't find a *verified* connection, rather than a
-- best-effort guess.
local function FindFlightSuggestion(fromZoneID, toZoneID)
    if not AutoQuestLearnerFlightZones or not AutoQuestLearnerFlightGraph then return nil end
    if not fromZoneID or not toZoneID then return nil end
    local faction = UnitFactionGroup and UnitFactionGroup("player")
    if not faction then return nil end

    local fromPoints, toPoints = {}, {}
    for name, zid in pairs(AutoQuestLearnerFlightZones) do
        if zid == fromZoneID then table.insert(fromPoints, name) end
        if zid == toZoneID then table.insert(toPoints, name) end
    end
    if table.getn(fromPoints) == 0 or table.getn(toPoints) == 0 then return nil end

    local bestOrigin, bestDest, bestTime = nil, nil, nil
    for _, origin in pairs(fromPoints) do
        local edges = AutoQuestLearnerFlightGraph[faction .. "|" .. origin]
        if edges then
            for _, dest in pairs(toPoints) do
                local flightTime = edges[dest]
                if flightTime and (not bestTime or flightTime < bestTime) then
                    bestTime = flightTime
                    bestOrigin = origin
                    bestDest = dest
                end
            end
        end
    end
    return bestOrigin, bestDest
end

local function UpdateArrowFrame()
    if AutoQuestLearnerDB.config.navArrow == "0" then
        AutoQuestLearner.arrow:SetAlpha(0)
        return
    end

    local loc, title, npcName, questID, crossZone = arrowCachedLoc, arrowCachedTitle, arrowCachedName, arrowCachedQuestID, arrowCachedCrossZone

    if not loc then
        if crossZone then
            -- Nothing known in this zone, but something IS known in a
            -- different one -- show that instead of a flat "no location
            -- known". Can't compute a meaningful rotation across zones
            -- (there's no shared coordinate space), so the directional
            -- arrow graphic itself is hidden while the text panel stays
            -- up with the actual useful information: which zone to go
            -- to. This is what tells you "the quest isn't here, it's in
            -- Ashenvale" instead of silently going blank.
            arrowInvalidSince = nil
            AutoQuestLearner.arrow:SetAlpha(1)
            if AutoQuestLearner.arrow.model then
                AutoQuestLearner.arrow.model:Hide()
            end
            local zoneName = crossZone.zoneName or "another zone"
            local displayKey = "crosszone|" .. tostring(crossZone.mapID) .. "|" .. tostring(crossZone.title)
            if displayKey ~= arrowLastTitle then
                arrowLastTitle = displayKey
                local prefix = AutoQuestLearner.pinnedQuestID and "|cff00ccff[Pinned]|r " or ""
                AutoQuestLearner.arrow.title:SetText(prefix .. (crossZone.title or ""))
                AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
                AutoQuestLearner.arrow.icon:SetVertexColor(.6, .8, 1)

                local flightFrom, flightTo = FindFlightSuggestion(currentZone, crossZone.mapID)
                local travelRoute = AutoQuestLearnerTravelRoutes and AutoQuestLearnerTravelRoutes[crossZone.mapID]
                local travelDirections = travelRoute and travelRoute.text
                local locationPhrase = crossZone.name and (crossZone.name .. " is in " .. zoneName)
                    or ("known to be in " .. zoneName)
                if flightFrom and flightTo then
                    AutoQuestLearner.arrow.description:SetText(
                        "Not in this zone -- " .. locationPhrase ..
                        ". Take the flight from " .. flightFrom .. " to " .. flightTo .. ".")
                elseif travelDirections then
                    AutoQuestLearner.arrow.description:SetText(
                        "Not in this zone -- " .. locationPhrase .. ". " .. travelDirections)
                else
                    AutoQuestLearner.arrow.description:SetText(
                        "Not in this zone -- " .. locationPhrase .. ". Fly or travel there.")
                end
                AutoQuestLearner.arrow.distance:SetText("")
            end
            return
        end

        if AutoQuestLearner.arrow.model then
            AutoQuestLearner.arrow.model:Show()
        end
        if arrowInvalidSince and arrowInvalidSince < GetTime() then
            AutoQuestLearner.arrow:SetAlpha(0)
        elseif not arrowInvalidSince then
            arrowInvalidSince = GetTime() + 1
        end
        return
    end
    arrowInvalidSince = nil
    if AutoQuestLearner.arrow.model then
        AutoQuestLearner.arrow.model:Show()
    end
    AutoQuestLearner.arrow:SetAlpha(1)

    local xplayer, yplayer = GetPlayerMapPosition("player")
    if not xplayer or (xplayer == 0 and yplayer == 0) then
        AutoQuestLearner.arrow:SetAlpha(0)
        return
    end

    -- Rotation math verbatim from pfQuest's route.lua (itself adapted
    -- from TomTomVanilla). xDelta gets a 1.5x multiplier to correct for
    -- the world map's aspect ratio; the mixed radian/degree-scale
    -- handling below is intentional and self-consistent as a whole,
    -- even though individual steps look mismatched in isolation.
    local xDelta = (loc.x - xplayer * 100) * 1.5
    local yDelta = (loc.y - yplayer * 100)
    local dir = atan2(xDelta, -(yDelta))
    dir = dir > 0 and (math.pi * 2) - dir or -dir
    if dir < 0 then dir = dir + 360 end
    -- Rotation math verbatim from pfQuest's route.lua (itself adapted
    -- from TomTomVanilla). xDelta gets a 1.5x multiplier to correct for
    -- the world map's aspect ratio; the mixed radian/degree-scale
    -- handling below is intentional and self-consistent as a whole,
    -- even though individual steps look mismatched in isolation.
    local angle = math.rad(dir)

    local player = GetPlayerFacing() or 0
    angle = angle - player

    local cell = mod(floor(angle / (math.pi * 2) * 108 + 0.5), 108)
    local column = mod(cell, 9)
    local row = floor(cell / 9)
    local xstart = (column * 56) / 512
    local ystart = (row * 42) / 512
    local xend = ((column + 1) * 56) / 512
    local yend = ((row + 1) * 42) / 512

    AutoQuestLearner.arrow.model:SetTexCoord(xstart, xend, ystart, yend)

    local distance = floor(math.sqrt((loc.x - xplayer * 100) ^ 2 + (loc.y - yplayer * 100) ^ 2) * 10) / 10
    local standingOnIt = distance < 0.3

    -- This client's position API is 2D-only (GetPlayerMapPosition gives
    -- X/Y as a percent of the zone, no altitude) -- true of every addon
    -- on this client era, not just this one. That means a quest giver
    -- on the 2nd floor of a building sits at essentially the SAME
    -- coordinate as an NPC directly below them on the ground floor;
    -- there's no API to tell the addon "go up" vs "go down". Detecting
    -- the floor itself isn't possible, but sitting on top of the pin
    -- for a while without it resolving is a reasonable proxy for "this
    -- is probably a multi-floor building" -- worth a hint rather than
    -- leaving the player wondering why the arrow says they're there.
    if standingOnIt then
        local targetKey = tostring(loc.x) .. "," .. tostring(loc.y) .. "," .. tostring(loc.mapID)
        if standingOnItTargetKey ~= targetKey then
            standingOnItTargetKey = targetKey
            standingOnItSince = GetTime()
        end
    else
        standingOnItTargetKey = nil
        standingOnItSince = nil
    end
    local standingTooLong = standingOnIt and standingOnItSince and (GetTime() - standingOnItSince) > 8

    local displayKey = tostring(title) .. "|" .. tostring(npcName) .. "|" .. tostring(AutoQuestLearner.pinnedQuestID) .. "|" .. tostring(standingOnIt) .. "|" .. tostring(standingTooLong)
    if displayKey ~= arrowLastTitle then
        arrowLastTitle = displayKey
        local prefix = AutoQuestLearner.pinnedQuestID and "|cff00ccff[Pinned]|r " or ""
        AutoQuestLearner.arrow.title:SetText(prefix .. (title or ""))

        -- Icon + description, inferred from the role suffix already
        -- baked into the title text ("(turn-in)", "(quest area)",
        -- "objective"). Matches pfQuest's layout: a quest-marker icon
        -- with a short instructional line underneath the title. Names
        -- the specific NPC/object when known (npcName), rather than
        -- just a generic instructional phrase.
        if title and string.find(title, "%(travel route%)") then
            AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
            AutoQuestLearner.arrow.icon:SetVertexColor(.6, .8, 1)
            AutoQuestLearner.arrow.description:SetText(
                (npcName or "Head this way to reach the next zone") .. ".")
        elseif title and string.find(title, "%(available%)") then
            AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon")
            AutoQuestLearner.arrow.icon:SetVertexColor(1, 1, 1)
            local baseText = npcName and ("Speak to " .. npcName .. " to accept this quest.") or "Quest available here."
            AutoQuestLearner.arrow.description:SetText(baseText ..
                (standingTooLong and " |cffff8800(Not seeing them? Coordinates can't tell floors apart -- check upstairs/downstairs.)|r" or ""))
        elseif title and string.find(title, "%(turn%-in%)") then
            AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
            AutoQuestLearner.arrow.icon:SetVertexColor(1, 1, .3)
            local baseText = npcName and ("Turn in to " .. npcName .. ".") or "Ready to turn in here."

            -- Quest chain preview: if this quest is known to lead
            -- directly into another one, name it -- so turning in
            -- doesn't come as a surprise follow-up, and it's easier to
            -- notice if the next quest didn't auto-offer for some
            -- reason. Only covers quests this addon's bundled data
            -- actually has chain data for (a subset, not every chain
            -- that exists) -- silently says nothing extra rather than
            -- guessing when it doesn't know.
            local nextText = ""
            if questID and AutoQuestLearnerQuestChain then
                local nextQuestID = AutoQuestLearnerQuestChain[questID]
                if nextQuestID then
                    local nextLoc = AutoQuestLearnerData.quests["loc"] and AutoQuestLearnerData.quests["loc"][nextQuestID]
                    local nextTitle = nextLoc and nextLoc.T
                    if nextTitle then
                        nextText = " |cff00ccffNext:|r " .. nextTitle
                    end
                end
            end

            AutoQuestLearner.arrow.description:SetText(baseText .. nextText ..
                (standingTooLong and " |cffff8800(Not seeing them? Coordinates can't tell floors apart -- check upstairs/downstairs.)|r" or ""))
        elseif title and string.find(title, "objective") then
            AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
            AutoQuestLearner.arrow.icon:SetVertexColor(.7, .7, .7)
            local objText = npcName and ("Find " .. npcName .. " nearby.") or "Complete this objective nearby."
            AutoQuestLearner.arrow.description:SetText(objText ..
                (standingTooLong and " |cffff8800(Coordinates can't tell floors apart -- check upstairs/downstairs.)|r" or ""))
        else
            AutoQuestLearner.arrow.icon:SetTexture("Interface\\GossipFrame\\ActiveQuestIcon")
            AutoQuestLearner.arrow.icon:SetVertexColor(.7, .7, .7)
            -- If this fallback candidate is right where the player is
            -- already standing, it's not really pointing anywhere new
            -- -- it means no separate objective location is known yet
            -- (no bundled data, nothing personally observed). Say so
            -- plainly instead of implying there's a fresh destination.
            if standingOnIt then
                AutoQuestLearner.arrow.description:SetText(
                    "No separate objective location known yet.")
            elseif npcName then
                AutoQuestLearner.arrow.description:SetText(
                    "Quest area (near " .. npcName .. ") -- not yet complete.")
            else
                AutoQuestLearner.arrow.description:SetText("Quest area -- not yet complete.")
            end
        end
    end

    AutoQuestLearner.arrow.distance:SetText("Distance: " .. string.format("%.1f", distance))
end

local arrowUpdateErrorShown = false
AutoQuestLearner.arrow:SetScript("OnUpdate", function(self, elapsed)
    local ok, err = pcall(UpdateArrowFrame)
    if not ok and not arrowUpdateErrorShown then
        arrowUpdateErrorShown = true
        print("|cffff0000AutoQuestLearner:|r Navigation arrow error (this is what's breaking it): " .. tostring(err))
    end
end)

------------------------------------------------------------
-- Route line (dotted path)
--
-- Draws a trail of small dots from the player to the current arrow
-- target, matching pfQuest's route.tga dotted-path feature, on both
-- the minimap and the world map (when open). The minimap math reuses
-- the exact same zoom/calibration approach already proven in
-- AutoQuestLearnerMap:UpdateMinimap() (Minimap:GetViewRadius(), the
-- bundled minimap_sizes calibration table); the world map math is
-- pfQuest's own (simpler, since the world map doesn't zoom/pan the
-- same way).
------------------------------------------------------------
local routeDotsMinimap = {}
local routeDotsWorldmap = {}

local function ClearRouteDots(dots)
    for _, tex in pairs(dots) do
        tex.enable = false
        tex:Hide()
    end
end

local function DrawRouteLine(dots, parent, x, y, nx, ny, isMinimap, xDraw, yDraw, xPlayerPix, yPlayerPix, startOffset)
    local dx, dy = x - nx, y - ny
    local totalDots = ceil(math.sqrt(dx * 1.5 * dx * 1.5 + dy * dy))
    if totalDots < 2 then return startOffset or 0 end

    local shown = startOffset or 0
    for i = (isMinimap and 1 or 2), totalDots - (isMinimap and 1 or 2) do
        local xpos = nx + dx / totalDots * i
        local ypos = ny + dy / totalDots * i
        local display = true
        local drawX, drawY

        if isMinimap then
            drawX = (xpos - xPlayerPix) * xDraw
            drawY = -(ypos - yPlayerPix) * yDraw
            local dist = math.sqrt(drawX * drawX + drawY * drawY)
            display = (dist + 1 < Minimap:GetWidth() / 2)
        else
            drawX = xpos / 100 * WorldMapButton:GetWidth()
            drawY = ypos / 100 * WorldMapButton:GetHeight()
        end

        if display then
            shown = shown + 1
            local tex = dots[shown]
            if not tex then
                tex = parent:CreateTexture(nil, "OVERLAY")
                tex:SetWidth(4)
                tex:SetHeight(4)
                tex:SetTexture("Interface\\AddOns\\AutoQuestLearner\\img\\route")
                tex:SetVertexColor(.75, .55, .2, 1)
                dots[shown] = tex
            end
            tex:ClearAllPoints()
            if isMinimap then
                tex:SetPoint("CENTER", parent, "CENTER", drawX, drawY)
            else
                tex:SetPoint("CENTER", parent, "TOPLEFT", drawX, -drawY)
            end
            tex:Show()
            tex.enable = true
        end
    end

    return shown
end

local function HideRouteDotsFrom(dots, fromIndex)
    for i = fromIndex + 1, table.getn(dots) do
        if dots[i] then
            dots[i]:Hide()
            dots[i].enable = false
        end
    end
end

local routeFrame = CreateFrame("Frame")
routeFrame.tick = 0
routeFrame.planTick = 0
routeFrame.cachedPlan = nil
routeFrame:SetScript("OnUpdate", function(self, elapsed)
    if AutoQuestLearnerDB.config.navArrow == "0" or AutoQuestLearnerDB.config.routeLine == "0" then
        ClearRouteDots(routeDotsMinimap)
        ClearRouteDots(routeDotsWorldmap)
        return
    end

    routeFrame.tick = routeFrame.tick + (elapsed or 0)
    if routeFrame.tick < 0.2 then return end
    routeFrame.tick = 0

    local xplayer, yplayer = GetPlayerMapPosition("player")
    if not xplayer or (xplayer == 0 and yplayer == 0) then
        ClearRouteDots(routeDotsMinimap)
        ClearRouteDots(routeDotsWorldmap)
        return
    end
    xplayer, yplayer = xplayer * 100, yplayer * 100

    -- Minimap route line: single segment to the arrow's current
    -- target. A full multi-stop route wouldn't fit in the minimap's
    -- limited view radius anyway, so this stays as-is.
    local loc = arrowCachedLoc
    if loc and AutoQuestLearnerMap and AutoQuestLearnerMap.GetMinimapSizes then
        local mapID = (AutoQuestLearner_GetCurrentMap and AutoQuestLearner_GetCurrentMap()) or AutoQuestLearnerMap:GetMapID()
        local calib = AutoQuestLearnerMap:GetMinimapSizes()[mapID]
        if calib and calib[1] and calib[2] then
            local mapZoom = Minimap:GetViewRadius() * 2
            local xScale = mapZoom / calib[1]
            local yScale = mapZoom / calib[2]
            local xDraw = Minimap:GetWidth() / xScale / 100
            local yDraw = Minimap:GetHeight() / yScale / 100
            local used = DrawRouteLine(routeDotsMinimap, Minimap, loc.x, loc.y, xplayer, yplayer, true, xDraw, yDraw, xplayer, yplayer, 0)
            HideRouteDotsFrom(routeDotsMinimap, used)
        else
            ClearRouteDots(routeDotsMinimap)
        end
    else
        ClearRouteDots(routeDotsMinimap)
    end

    -- World map route line, only while the world map is actually open:
    -- the FULL multi-stop clustered route (same logic as /aql route),
    -- not just a single segment to the nearest target -- matching how
    -- pfQuest itself draws a connected path across the whole zone
    -- rather than one line to the closest quest.
    if WorldMapFrame and WorldMapFrame:IsShown() and WorldMapButton then
        -- The full route is more expensive to recompute than just
        -- redrawing dot positions (it scans the quest log and runs the
        -- clustering pass), so it's cached and only refreshed once a
        -- second -- the dots themselves still redraw every 0.2s so
        -- they track the player's actual movement smoothly in between.
        routeFrame.planTick = routeFrame.planTick + (elapsed or 0)
        if routeFrame.planTick >= 1 or not routeFrame.cachedPlan then
            routeFrame.planTick = 0
            routeFrame.cachedPlan = AutoQuestLearner_PlanRoute()
        end

        local plan = routeFrame.cachedPlan
        if plan and table.getn(plan) > 0 then
            local used = 0
            local curX, curY = xplayer, yplayer
            for _, stop in pairs(plan) do
                used = DrawRouteLine(routeDotsWorldmap, WorldMapButton, stop.x, stop.y, curX, curY, false, nil, nil, nil, nil, used)
                curX, curY = stop.x, stop.y
            end
            HideRouteDotsFrom(routeDotsWorldmap, used)
        else
            ClearRouteDots(routeDotsWorldmap)
        end
    else
        ClearRouteDots(routeDotsWorldmap)
        routeFrame.cachedPlan = nil
    end
end)

------------------------------------------------------------
-- Quest ID resolution by title
--
-- pfDB's GetQuestLink()-based lookup is the most reliable, DB-independent
-- way to resolve a quest already in your log to its real numeric ID
-- (see AutoQuestLearnerDatabase:GetQuestIDs for the same technique).
-- Falls back to matching against the local pfQuest database by title
-- when the quest isn't in the log yet.
------------------------------------------------------------
local function ResolveQuestID(questTitle)
    if not questTitle or questTitle == "" then return nil end

    if GetQuestLink then
        for qlogid = 1, GetNumQuestLogEntries() do
            local title, _, _, header = AutoQuestLearner_GetQuestLogTitle(qlogid)
            if title == questTitle and not header then
                local link = GetQuestLink(qlogid)
                if link then
                    local _, _, id = strfind(link, "|c.*|Hquest:([%d]+):([%-]?[%d]+)|h%[(.*)%]|h|r")
                    if id then return tonumber(id) end
                end
                break
            end
        end
    end

    if AutoQuestLearner.db and AutoQuestLearner.db.GetIDByName then
        local ids = AutoQuestLearner.db:GetIDByName(questTitle, "quests")
        if ids and ids[1] then return ids[1] end
    end

    -- No numeric ID resolvable -- this happens for gossip-window
    -- "available" quests you haven't accepted yet (not in your quest
    -- log, so GetQuestLink() can't help) when there's also no pfQuest
    -- database to match the title against. Rather than lose the
    -- learning opportunity entirely, fall back to using the title
    -- itself as the key -- the same pattern already used elsewhere
    -- (e.g. AutoQuestLearnerDatabase:GetQuestIDs) when no numeric ID
    -- is available. If you later accept the quest, event-based
    -- learning will resolve and store it under the real numeric ID.
    return questTitle
end

------------------------------------------------------------
-- Gossip / quest greeting learning
--
-- When a quest NPC opens its multi-quest greeting or gossip window, the
-- WoW client populates data about every quest that NPC offers / tracks.
-- This is a goldmine for learning -- you talk to one NPC and you can
-- record positions for 5-10 quests at once, even if you never accept
-- any of them.
--
-- IMPORTANT: this client does NOT have the modern indexed
-- GetGossipAvailableQuestInfo(i) / GetGossipActiveQuestInfo(i)
-- functions -- those are retail-only additions that don't exist here at
-- all. The only functions available on this client are the flat,
-- no-index forms: GetGossipAvailableQuests() / GetGossipActiveQuests(),
-- which return every quest's fields concatenated into ONE long list in
-- a single call. Worse, this client's era predates the "questID" field
-- being added to these functions entirely (that only happened in a much
-- later retail patch), so there is no numeric ID available from gossip
-- at all -- only titles. We resolve each title to a real ID via
-- ResolveQuestID() (which falls back to a title match against the
-- local pfQuest database).
--
-- Rather than hardcode how many fields belong to each quest (which has
-- changed across patches and may differ further on this custom server),
-- we compute it dynamically: total values returned, divided by the
-- quest count from GetNumGossipAvailableQuests()/GetNumGossipActiveQuests().
-- The title is always the first field in each quest's group either way.
------------------------------------------------------------
local function LearnFromGossip()
    if not AutoQuestLearnerDB.settings.gossipLearning then return end
    local char = GetChar()
    local learned = 0

    -- Available quests (the ones this NPC can give you).
    local numAvailable = GetNumGossipAvailableQuests and GetNumGossipAvailableQuests() or 0
    if numAvailable and numAvailable > 0 then
        local values = { GetGossipAvailableQuests() }
        local stride = math.floor(table.getn(values) / numAvailable)
        if stride >= 1 then
            for i = 1, numAvailable do
                local title = values[(i - 1) * stride + 1]
                if title and title ~= "" then
                    local questID = ResolveQuestID(title)
                    -- Note: no outer "already known" pre-check here --
                    -- RecordLocation() itself gates per-role now, which
                    -- is the fix for the turn-in-never-learned bug. An
                    -- outer check keyed only on questID would silently
                    -- reintroduce that same bug.
                    if questID then
                        if RecordLocation(questID, title,
                            string.format("%s (gossip)", title), "gossip") then
                            learned = learned + 1
                        end
                    end
                end
            end
        end
    end

    -- Active quests (the ones this NPC is tracking for you -- turn-in
    -- candidates).
    local numActive = GetNumGossipActiveQuests and GetNumGossipActiveQuests() or 0
    if numActive and numActive > 0 then
        local values = { GetGossipActiveQuests() }
        local stride = math.floor(table.getn(values) / numActive)
        if stride >= 1 then
            for i = 1, numActive do
                local title = values[(i - 1) * stride + 1]
                if title and title ~= "" then
                    local questID = ResolveQuestID(title)
                    if questID then
                        if RecordLocation(questID, title,
                            string.format("%s (turn-in)", title), "gossip-turnin") then
                            learned = learned + 1
                        end
                    end
                end
            end
        end
    end

    if learned > 0 then
        AutoQuestLearnerDB.stats.gossipLearned =
            (AutoQuestLearnerDB.stats.gossipLearned or 0) + learned
        print(string.format(
            "|cff00ff00AutoQuestLearner:|r Learned %d quest location%s from gossip window.",
            learned, learned == 1 and "" or "s"))
    end
end

------------------------------------------------------------
-- Quest detail / progress / complete learning
--
-- Fires when the client is ABOUT to show a specific quest's detail
-- dialog, the progress dialog, or the turn-in dialog. In all three
-- cases, we know the player is standing next to the relevant NPC.
--
-- Note: this client has NEITHER the modern per-context functions
-- (GetAvailableQuestID() / GetActiveQuestID() / GetQuestIDToComplete())
-- NOR the older unified GetQuestID() — all of them are nil globals
-- here. GetTitleText() does work though, and gives us the title of
-- whatever quest frame is currently open.
--
-- For QUEST_PROGRESS / QUEST_COMPLETE the quest is already in the
-- player's quest log, so we can resolve its real ID for free by
-- finding the matching quest log entry and pulling the ID out of
-- GetQuestLink()'s hyperlink text — no database required. This is the
-- exact technique pfQuest itself uses (see pfDatabase:GetQuestIDs).
-- For QUEST_DETAIL the quest isn't in the log yet, so that trick isn't
-- available; we fall back to matching the title against our local
-- pfQuest database, and simply skip if that database isn't loaded.
------------------------------------------------------------
local function LearnFromQuestEvent(source)
    if not AutoQuestLearnerDB.settings.eventLearning then return end
    if type(GetTitleText) ~= "function" then return end

    local questTitle = GetTitleText()
    if not questTitle or questTitle == "" then return end

    local questID = ResolveQuestID(questTitle)
    if not questID then return end

    -- No outer "already known" pre-check here -- RecordLocation() gates
    -- per-role now (giver vs. turnin independently). This function is
    -- the main path for QUEST_DETAIL (accept, role=giver) AND
    -- QUEST_PROGRESS/QUEST_COMPLETE (turn-in, role=turnin), so an outer
    -- check keyed only on questID would block the turn-in dialog from
    -- ever recording its location once the accept dialog already had --
    -- which was the actual bug behind the arrow pointing at the quest
    -- giver instead of the turn-in/objective.
    local label = questTitle .. " (" .. source .. ")"
    if RecordLocation(questID, questTitle, label, source) then
        AutoQuestLearnerDB.stats.eventLearned =
            (AutoQuestLearnerDB.stats.eventLearned or 0) + 1
        print(string.format(
            "|cff00ff00AutoQuestLearner:|r Learned location for '%s' (quest %s) from %s.",
            questTitle, tostring(questID), source))
    end
end

------------------------------------------------------------
-- Proximity scan
--
-- Even without an open gossip window, any NPC the player is standing
-- near that has a quest relationship to the player is a learning
-- opportunity. This iterates the local unit list (everything within
-- nameplate range, which is generous on this client), and for any
-- quest-related entry, records a position snapshot under the right
-- quest IDs.
--
-- The big question: how do you tell from a nameplate alone whether
-- an NPC is a quest giver? The 3.3.5 client has IsActiveQuestTrivial
-- / IsActiveQuestTrivial and a few Gossip-related APIs that require
-- the unit to be the player's target. So this scan is target-only:
-- it checks the current target, not every nearby nameplate. Cheap
-- (one API call per scan) and avoids the "scan the whole world"
-- cost of iterating every nameplate in range.
--
-- The target check is intentionally narrow: if the target has
-- any active or available quest for us, record under that quest ID.
-- Most of the time the target is empty, so the scan is a no-op.
------------------------------------------------------------
local function ProximityScan()
    if not AutoQuestLearnerDB.settings.enableProximityScan then return end

    -- Guard: nothing to do if the player has no target
    if not UnitExists("target") then return end
    if not UnitCanCooperate("player", "target") then
        -- Enemy / hostile — quest NPCs are friendly, so skip
        return
    end
    if UnitIsDead("target") or UnitIsDeadOrGhost("player") then return end

    local learned = 0

    -- Available quests (this NPC has for the player). This client's
    -- actual signature (added 3.3.3) is 5 values -- isTrivial, frequency,
    -- isRepeatable, isLegendary, questID -- with no title and no extra
    -- "isDaily" field. (A previous version of this code inserted an
    -- extra field before questID, which pushed the real questID into a
    -- 6th return value that doesn't exist on this client, so it was
    -- always nil.)
    for i = 1, GetNumAvailableQuests() do
        local isTrivial, frequency, isRepeatable, isLegendary,
            questID = GetAvailableQuestInfo(i)
        if questID and questID > 0 then
            do
                -- No API on this client resolves a title from a numeric
                -- quest ID directly, so we just use a generic label here.
                local title = "Quest " .. questID
                if RecordLocation(questID, title, title .. " (proximity)", "proximity") then
                    learned = learned + 1
                end
            end
        end
    end

    -- Active quests (this NPC is tracking for the player — turn-in).
    -- GetActiveQuestInfo() is not a real function on this client (or
    -- any client version, as far as can be determined) -- the correct
    -- older-style functions are GetActiveTitle(index)/GetActiveLevel(index),
    -- and neither returns a quest ID at all, so we resolve it by title
    -- via ResolveQuestID() the same way gossip learning does.
    for i = 1, GetNumActiveQuests() do
        local questName = GetActiveTitle(i)
        if questName and questName ~= "" then
            local questID = ResolveQuestID(questName)
            if questID then
                if RecordLocation(questID, questName, questName .. " (turn-in proximity)", "proximity-turnin") then
                    learned = learned + 1
                end
            end
        end
    end

    if learned > 0 then
        AutoQuestLearnerDB.stats.proximityLearned =
            (AutoQuestLearnerDB.stats.proximityLearned or 0) + learned
    end
end

------------------------------------------------------------
-- pfQuest data import (optional)
------------------------------------------------------------
local function MaybeAutoImportPfQuest()
    if not AutoQuestLearnerDB.settings.autoImportPfQuest then return end
    local char = GetChar()
    local existingCount = 0
    for _ in pairs(char.questLocations) do existingCount = existingCount + 1 end
    if existingCount > 0 then return end -- already learned something; don't import

    -- If pfQuest is available, we could import data from it
    -- This is now optional since we work standalone
    if pfDB and AutoQuestLearner.db then
        print("|cff00ff00AutoQuestLearner:|r pfQuest detected. Database integration available.")
    end
end

------------------------------------------------------------
-- Diagnostics
------------------------------------------------------------
local function PrintDebug()
    print("|cff00ff00AutoQuestLearner Diagnostics|r")

    print("  AutoQuestLearnerDB.localized: " ..
        tostring(AutoQuestLearnerDB and AutoQuestLearnerDB.localized))

    print("  pfDB global present: " .. tostring(pfDB ~= nil))

    local dbQuestCount = 0
    if AutoQuestLearnerData and AutoQuestLearnerData.quests and AutoQuestLearnerData.quests["data"] then
        for _ in pairs(AutoQuestLearnerData.quests["data"]) do dbQuestCount = dbQuestCount + 1 end
    end
    print("  AutoQuestLearnerData.quests.data entries: " .. dbQuestCount)

    local qlogCacheCount = 0
    if AutoQuestLearner.questlog then
        for _ in pairs(AutoQuestLearner.questlog) do qlogCacheCount = qlogCacheCount + 1 end
    end
    print("  AutoQuestLearner.questlog (internal cache) entries: " .. qlogCacheCount)

    local _, numQuests = GetNumQuestLogEntries()
    print("  Real quest log entries (GetNumQuestLogEntries): " .. tostring(numQuests))

    print("  this.lock active (still in startup delay): " ..
        tostring(AutoQuestLearner.lock and AutoQuestLearner.lock > GetTime()))

    local trackerButtonCount = 0
    local trackerVisibleCount = 0
    if AutoQuestLearnerTracker and AutoQuestLearnerTracker.buttons then
        for _, button in pairs(AutoQuestLearnerTracker.buttons) do
            trackerButtonCount = trackerButtonCount + 1
            if not button.empty then trackerVisibleCount = trackerVisibleCount + 1 end
        end
    end
    print("  Tracker buttons created: " .. trackerButtonCount ..
        " (non-empty: " .. trackerVisibleCount .. ")")

    print("  Tracker frame shown: " ..
        tostring(AutoQuestLearnerTracker and AutoQuestLearnerTracker:IsShown()))
    print("  Tracker mode: " .. tostring(AutoQuestLearnerTracker and AutoQuestLearnerTracker.mode))

    print("  AutoQuestLearnerMap loaded: " .. tostring(AutoQuestLearnerMap ~= nil))

    -- How much data actually exists to plot as nodes, vs. whether it's
    -- being rendered. This distinguishes "nothing learned/known yet"
    -- from "data exists but isn't showing up".
    local learnedQuestCount, learnedGiverCount, learnedTurninCount = 0, 0, 0
    if AutoQuestLearnerDB.learned and AutoQuestLearnerDB.learned.quests then
        for _, entry in pairs(AutoQuestLearnerDB.learned.quests) do
            learnedQuestCount = learnedQuestCount + 1
            if entry.giver then learnedGiverCount = learnedGiverCount + 1 end
            if entry.turnin then learnedTurninCount = learnedTurninCount + 1 end
        end
    end
    print("  Learned quests (shared DB): " .. learnedQuestCount ..
        " (giver locations: " .. learnedGiverCount ..
        ", turn-in locations: " .. learnedTurninCount .. ")")

    local learnedObjectiveSpawns = 0
    if AutoQuestLearnerDB.learned and AutoQuestLearnerDB.learned.objectives then
        for _, objectives in pairs(AutoQuestLearnerDB.learned.objectives) do
            for _, obj in pairs(objectives) do
                learnedObjectiveSpawns = learnedObjectiveSpawns + table.getn(obj.spawns or {})
            end
        end
    end
    print("  Learned objective spawn points (shared DB): " .. learnedObjectiveSpawns)

    local totalNodes, currentZoneNodes = 0, 0

    -- Detailed step-by-step trace of GetMapID(), since by its own logic
    -- it should never be able to return nil (it always falls back to a
    -- "C<cid>Z<mid>" string as a last resort) -- if it does return nil
    -- in practice, this pinpoints exactly which step produced it.
    local rawCid = GetCurrentMapContinent()
    local rawMid = GetCurrentMapZone()
    print("  Raw GetCurrentMapContinent(): " .. tostring(rawCid))
    print("  Raw GetCurrentMapZone(): " .. tostring(rawMid))
    if AutoQuestLearnerMap then
        local zoneList = { GetMapZones(rawCid) }
        print("  GetMapZones(continent) entry count: " .. table.getn(zoneList))
        print("  Zone name at that index: " .. tostring(zoneList[rawMid]))
    end

    local currentZone = AutoQuestLearnerMap and
        AutoQuestLearnerMap:GetMapID(GetCurrentMapContinent(), GetCurrentMapZone())
    local currentZoneNoArgs = AutoQuestLearnerMap and AutoQuestLearnerMap:GetMapID()
    print("  GetMapID(explicit args): " .. tostring(currentZone))
    print("  GetMapID(no args, internal defaults): " .. tostring(currentZoneNoArgs))

    if AutoQuestLearnerMap and AutoQuestLearnerMap.nodes then
        for addon, byMap in pairs(AutoQuestLearnerMap.nodes) do
            for mapKey, byCoord in pairs(byMap) do
                for _, byTitle in pairs(byCoord) do
                    for _ in pairs(byTitle) do
                        totalNodes = totalNodes + 1
                        if mapKey == currentZone then
                            currentZoneNodes = currentZoneNodes + 1
                        end
                    end
                end
            end
        end
    end
    print("  Total plotted nodes (all zones): " .. totalNodes)
    print("  Current zone: " .. tostring(currentZone) ..
        " (nodes here: " .. currentZoneNodes .. ")")

    -- List first few real quest log titles for cross-reference
    print("  First quest log entries seen:")
    local shown = 0
    for qlogid=1,40 do
        local title, level, tag, header = AutoQuestLearner_GetQuestLogTitle(qlogid)
        if title and not header then
            print("    [" .. qlogid .. "] " .. title)
            shown = shown + 1
            if shown >= 5 then break end
        end
    end
    if shown == 0 then
        print("    (none found — your quest log appears empty)")
    end
end

------------------------------------------------------------
-- Status print
------------------------------------------------------------
local function PrintStatus()
    EnsureSettings()
    local char = GetChar()
    local count = 0
    for _ in pairs(char.questLocations) do count = count + 1 end
    local stats = AutoQuestLearnerDB.stats or {}

    local learnedQuestCount, learnedObjCount = 0, 0
    if AutoQuestLearnerDB.learned then
        if AutoQuestLearnerDB.learned.quests then
            for _ in pairs(AutoQuestLearnerDB.learned.quests) do learnedQuestCount = learnedQuestCount + 1 end
        end
        if AutoQuestLearnerDB.learned.objectives then
            for _, objectives in pairs(AutoQuestLearnerDB.learned.objectives) do
                for _, obj in pairs(objectives) do
                    learnedObjCount = learnedObjCount + table.getn(obj.spawns or {})
                end
            end
        end
    end

    print("|cff00ff00AutoQuestLearner v2.0|r")
    print("  Quest locations for this character: " .. count)
    print("  Learned via gossip:    " .. (stats.gossipLearned or 0))
    print("  Learned via events:    " .. (stats.eventLearned or 0))
    print("  Learned via proximity: " .. (stats.proximityLearned or 0))
    print("  Shared database: " .. learnedQuestCount .. " quests, " ..
        learnedObjCount .. " objective spawn points learned")
    print("  pfQuest data loaded:   " ..
        (pfDB ~= nil and "|cff00ff00yes|r (bonus data available)" or
        "|cffffcc00no|r (fully optional -- own learning system still works)"))
    print("  Proximity scan:        " ..
        (AutoQuestLearnerDB.settings.enableProximityScan and
            "|cff00ff00on|r (" .. (AutoQuestLearnerDB.settings.proximityInterval or 10) .. "s)" or "|cffff0000off|r"))
    print("  Quest Tracker:         " ..
        (AutoQuestLearnerDB.config.showtracker == "1" and "|cff00ff00enabled|r" or "|cffff0000disabled|r"))
    print("  Map Nodes:             " ..
        (AutoQuestLearnerDB.config.minimapnodes == "1" and "|cff00ff00enabled|r" or "|cffff0000disabled|r"))
    print("  Database Loaded:       " ..
        (AutoQuestLearnerDB.localized and "|cff00ff00yes|r" or "|cffff0000no|r"))
    print("")
    print("  Commands:")
    print("  /aql scan    - force proximity scan now")
    print("  /aql proximity - toggle automatic proximity-based learning on/off")
    print("  /aql learn [quest name] - manually save your current spot as an objective location")
    print("  /aql db      - detailed breakdown of the shared learned database")
    print("  /aql resync  - force-reload all learned locations & refresh map nodes")
    print("  /aql focus   - toggle showing only what the arrow is currently tracking")
    print("  /aql gossip  - reprocess current gossip window")
    print("  /aql config  - open configuration panel")
    print("  /aql guide   - open the in-game how-to-use guide")
    print("  /aql flickerdebug - diagnose tracker flickering (5s recording)")
    print("  /aql chain [quest name] - show the known follow-up quest, if any")
    print("  /aql zone    - show known-quest completion progress for this zone")
    print("  /aql route   - suggest an order to tackle your active quests in this zone (least backtracking)")
    print("  /aql perf    - check this addon's own memory/CPU footprint")
    print("  /aql perfdebug - real timing measurements for likely-expensive functions (10s)")
    print("  /aql tracker - toggle quest tracker")
    print("  /aql blizztracker - toggle Blizzard's default tracker")
    print("  /aql tomtomauto - toggle auto-select closest TomTom waypoint")
    print("  /aql arrow - diagnose why the navigation arrow isn't showing")
    print("  /aql questitems - diagnose why quest item buttons aren't showing")
    print("  /aql calibrate [reset] - check/reset minimap scale calibration for starting zones")
    print("  /aql opendb [questID] - open the Ascension Database page for the selected (or given) quest")
    print("  /aql unpin - clear a pinned quest (arrow back to auto-select)")
    print("  Alt+Right-Click a tracked quest - pin/unpin the arrow to that quest")
    print("  /aql map     - update map nodes")
    print("  /aql debug   - print diagnostic info")
    print("  Alt+Click a tracked quest - set a TomTom waypoint (if enabled)")
end

------------------------------------------------------------
-- Replace Blizzard's default quest tracker
--
-- Blizzard's built-in objective tracker on this client generation is
-- the WatchFrame (introduced in patch 3.3.0, replacing the older
-- vanilla/TBC QuestWatchFrame). It auto-shows itself again on various
-- events (accepting a quest, zoning, combat ending), so a one-time
-- Hide() isn't enough -- we hook its own Show() to keep it collapsed
-- for as long as the option is enabled, and restore normal behavior
-- immediately if the option gets turned back off.
------------------------------------------------------------
local watchFrameHooked = false

function AutoQuestLearner_UpdateBlizzardTrackerVisibility()
    if not WatchFrame then return end

    if AutoQuestLearnerDB.config.hideblizzardtracker == "0" then
        WatchFrame:Show()
        return
    end

    WatchFrame:Hide()

    if not watchFrameHooked then
        watchFrameHooked = true
        WatchFrame:HookScript("OnShow", function(self)
            if AutoQuestLearnerDB.config.hideblizzardtracker ~= "0" then
                self:Hide()
            end
        end)
    end
end

-- One-time cleanup: the auto-targeting macro feature (AQLTalk/AQLKill)
-- was removed from the addon -- delete any macros it already created
-- so they aren't left behind as orphaned, no-longer-updating clutter.
local macroCleanupFrame = CreateFrame("Frame")
macroCleanupFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
macroCleanupFrame:SetScript("OnEvent", function(self)
    if InCombatLockdown and InCombatLockdown() then return end -- DeleteMacro only works out of combat
    if not DeleteMacro or not GetMacroIndexByName then return end
    for _, name in ipairs({ "AQLTalk", "AQLKill" }) do
        if GetMacroIndexByName(name) ~= 0 then
            DeleteMacro(name)
        end
    end
    self:UnregisterEvent("PLAYER_ENTERING_WORLD")
end)

local watchFrameEventFrame = CreateFrame("Frame")
watchFrameEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
watchFrameEventFrame:SetScript("OnEvent", AutoQuestLearner_UpdateBlizzardTrackerVisibility)

------------------------------------------------------------
-- Quest item panel ("use this on the objective")
--
-- Stock WotLK 3.3.5 has this feature built into Blizzard's own
-- WatchFrame (WatchFrameItemN buttons), and Ascension's client still
-- computes the same underlying WatchFrame/WATCHFRAME_QUESTLINES data
-- in the background (confirmed: GetQuestLogSpecialItemInfo returns
-- real data, and WatchFrame's own top-level position tracks the
-- visible tracker correctly). But individual line positions inside it
-- proved unreliable to anchor to directly on this client -- three
-- different anchoring strategies (direct anchor, protected-frame-safe
-- absolute offset, continuous polling) all landed in the same wrong
-- spot, which points to the per-line layout data itself being stale
-- or not cascading the way a normal WoW frame hierarchy would.
--
-- Rather than keep fighting that, this is a small standalone panel:
-- not anchored into the mystery tracker at all, so it can't inherit
-- whatever's unreliable about it. Each row is labeled with the quest
-- name directly, so which icon belongs to which quest is never
-- ambiguous even without being physically next to that quest's line.
-- Shift-drag to move it anywhere; position is remembered.
------------------------------------------------------------
AutoQuestLearnerDB.config.questitempanelpos = AutoQuestLearnerDB.config.questitempanelpos or nil

local questItemPanel = CreateFrame("Frame", "AutoQuestLearnerQuestItemPanel", UIParent)
questItemPanel:SetWidth(200)
questItemPanel:SetHeight(20)
questItemPanel:SetMovable(true)
questItemPanel:EnableMouse(true)
questItemPanel:RegisterForDrag("LeftButton")
questItemPanel:SetScript("OnDragStart", function(self)
    if IsShiftKeyDown() then self:StartMoving() end
end)
questItemPanel:SetScript("OnDragStop", function(self)
    self:StopMovingOrSizing()
    local point, _, _, x, y = self:GetPoint()
    AutoQuestLearnerDB.config.questitempanelpos = { point, x, y }
end)

if AutoQuestLearnerDB.config.questitempanelpos then
    local point, x, y = unpack(AutoQuestLearnerDB.config.questitempanelpos)
    questItemPanel:SetPoint(point, UIParent, point, x, y)
else
    questItemPanel:SetPoint("TOPRIGHT", WatchFrame or UIParent, "TOPLEFT", -10, -4)
end

local bg = questItemPanel:CreateTexture(nil, "BACKGROUND")
bg:SetAllPoints()
bg:SetTexture(0, 0, 0, 0.35)

local questItemRows = {}

local function GetOrCreateQuestItemRow(i)
    if questItemRows[i] then return questItemRows[i] end
    local row = CreateFrame("Button", "AutoQuestLearnerQuestItemRow" .. i, questItemPanel, "SecureActionButtonTemplate")
    row:SetWidth(196)
    row:SetHeight(20)
    row:SetPoint("TOP", questItemPanel, "TOP", 0, -(i - 1) * 22 - 2)
    row:RegisterForClicks("AnyUp", "AnyDown")

    row.icon = row:CreateTexture(nil, "ARTWORK")
    row.icon:SetWidth(18)
    row.icon:SetHeight(18)
    row.icon:SetPoint("LEFT", 2, 0)
    row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)

    local border = row:CreateTexture(nil, "OVERLAY")
    border:SetTexture("Interface\\Buttons\\UI-Quickslot2")
    border:SetPoint("CENTER", row.icon, "CENTER", 0, 0)
    border:SetWidth(18)
    border:SetHeight(18)

    row.label = row:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
    row.label:SetPoint("LEFT", row.icon, "RIGHT", 4, 0)
    row.label:SetJustifyH("LEFT")
    row.label:SetWidth(170)

    row:SetScript("OnEnter", function(self)
        if not self.questIndex then return end
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetQuestLogSpecialItem(self.questIndex)
        GameTooltip:Show()
    end)
    row:SetScript("OnLeave", function() GameTooltip:Hide() end)

    -- Rows cover almost the entire panel, so the panel's own
    -- OnDragStart never fires -- the mouse event is consumed by
    -- whichever row is under the cursor first. Same fix already used
    -- by AutoQuestLearnerTracker's own buttons: put the shift+drag
    -- handling on each row instead of the parent.
    row:SetScript("OnMouseDown", function(self)
        if IsShiftKeyDown() then
            questItemPanel:StartMoving()
        end
    end)
    row:SetScript("OnMouseUp", function(self)
        questItemPanel:StopMovingOrSizing()
        local point, _, _, x, y = questItemPanel:GetPoint()
        AutoQuestLearnerDB.config.questitempanelpos = { point, x, y }
    end)

    questItemRows[i] = row
    return row
end

local function AutoQuestLearner_UpdateQuestItemButtons_Impl()
    if AutoQuestLearnerDB.config.questitembuttons == "0" then
        questItemPanel:Hide()
        return
    end
    if not GetNumQuestWatches or not GetQuestIndexForWatch or not GetQuestLogSpecialItemInfo then return end
    if InCombatLockdown and InCombatLockdown() then return end -- secure attributes can't change in combat

    local shown = 0
    local numWatches = GetNumQuestWatches()
    for i = 1, numWatches do
        local questIndex = GetQuestIndexForWatch(i)
        if questIndex then
            local link, item, charges = GetQuestLogSpecialItemInfo(questIndex)
            if item then
                shown = shown + 1
                local title = GetQuestLogTitle(questIndex) or "?"
                local row = GetOrCreateQuestItemRow(shown)
                row.icon:SetTexture(item)
                row.label:SetText(title)
                row.questIndex = questIndex
                row:SetAttribute("type", "item")
                row:SetAttribute("item", link)
                row:Show()
            end
        end
    end

    for i = shown + 1, table.getn(questItemRows) do
        questItemRows[i]:Hide()
    end

    if shown > 0 then
        questItemPanel:SetHeight(shown * 22 + 4)
        questItemPanel:Show()
    else
        questItemPanel:Hide()
    end
end

local questItemButtonsLastError = nil
function AutoQuestLearner_UpdateQuestItemButtons()
    local ok, err = pcall(AutoQuestLearner_UpdateQuestItemButtons_Impl)
    if not ok then
        questItemButtonsLastError = err
    else
        questItemButtonsLastError = nil
    end
end

local questItemEventFrame = CreateFrame("Frame")
questItemEventFrame:RegisterEvent("QUEST_LOG_UPDATE")
questItemEventFrame:RegisterEvent("QUEST_WATCH_UPDATE")
questItemEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
questItemEventFrame:SetScript("OnEvent", AutoQuestLearner_UpdateQuestItemButtons)

------------------------------------------------------------
-- Replay previously-learned locations on login
--
-- RecordLocation() / AutoQuestLearner_RecordObjectiveProgress() draw a
-- node immediately when something is freshly learned, but that only
-- covers the current session. Everything learned in past sessions
-- lives in the shared AutoQuestLearnerDB.learned database and needs to
-- be redrawn each time the addon loads. GetLearnedDB() already wipes
-- stale entries from earlier, incompatible mapID formats via schema
-- versioning, so anything here is safe to draw directly.
------------------------------------------------------------
local function ReplayLearnedLocations()
    local learned = GetLearnedDB()

    for questID, questEntry in pairs(learned.quests) do
        for _, role in ipairs({ "giver", "turnin" }) do
            local loc = questEntry[role]
            if loc then
                DrawLearnedNode(questID, role, loc, questEntry.title)
            end
        end
    end

    -- Also plot static objective clusters for every quest already
    -- sitting in the log from before this session/update -- not just
    -- ones freshly picked up going forward (that's handled separately
    -- in AutoQuestLearner_Quest.lua's "NEW" quest detection). Without
    -- this, existing quests would only get their cluster the next time
    -- they're re-accepted, which for most quests never happens. pcall
    -- per-quest so one bad entry can't skip the live-learned replay
    -- loop below.
    --
    -- BUGFIX: this used to redraw unconditionally, with no check for
    -- whether the quest is already complete. Since this replay runs on
    -- every login AND every zone change, it would immediately redraw a
    -- just-culled quest's objective clusters right back the moment you
    -- crossed a zone line -- looking exactly like the cleanup never
    -- worked, when it actually had, right up until the next replay
    -- silently undid it. Uses the same leaderboard cross-check as the
    -- arrow/cull logic elsewhere (isComplete alone isn't reliable on
    -- this client).
    if AutoQuestLearner.questlog then
        for questID, logData in pairs(AutoQuestLearner.questlog) do
            local skipDraw = false
            if logData.qlogid then
                local _, _, _, _, _, isComplete = AutoQuestLearner_GetQuestLogTitle(logData.qlogid)
                if not isComplete then
                    local numObjectives = GetNumQuestLeaderBoards(logData.qlogid)
                    if numObjectives and numObjectives > 0 then
                        isComplete = true
                        for oi = 1, numObjectives do
                            local _, _, objDone = GetQuestLogLeaderBoard(oi, logData.qlogid)
                            if not objDone then
                                isComplete = false
                                break
                            end
                        end
                    end
                end
                skipDraw = isComplete and true or false
            end
            if not skipDraw then
                local ok, err = pcall(DrawStaticObjectiveClusters, questID, logData.title)
                if not ok and AutoQuestLearner.Debug then
                    AutoQuestLearner:Debug("DrawStaticObjectiveClusters (replay) error: " .. tostring(err))
                end
            end
        end
    end

    local useClusters = ShouldShowClusters()
    for questID, objectives in pairs(learned.objectives) do
        local questTitle = ResolveQuestTitle(questID)
        for _, obj in pairs(objectives) do
            if useClusters then
                for _, spawn in pairs(obj.spawns or {}) do
                    DrawObjectiveNode(questID, spawn, questTitle, obj.text)
                end
            elseif obj.spawns and obj.spawns[1] then
                DrawObjectiveNode(questID, obj.spawns[1], questTitle, obj.text)
            end
        end
    end
end

local replayFrame = CreateFrame("Frame")
replayFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
-- ZONE_CHANGED_NEW_AREA covers moving between zones on foot/mount/
-- flight path within the same continent -- a case that does NOT
-- reliably fire PLAYER_ENTERING_WORLD (that one's really only
-- guaranteed on login, /reload, and continent-crossing loading
-- screens). Without this, a fresh zone's learned locations could
-- fail to appear until the next full reload. AddNode()'s own
-- dedup cache (keyed by addon/map/coords/title/etc.) makes calling
-- ReplayLearnedLocations() again here cheap -- it's a no-op for
-- anything already drawn.
replayFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA")
replayFrame:SetScript("OnEvent", function()
    if event == "ZONE_CHANGED_NEW_AREA" then
        if AutoQuestLearnerDB and AutoQuestLearnerDB.localized then
            ReplayLearnedLocations()
        end
        return
    end

    -- Small delay so AutoQuestLearnerData (pfQuest DB copy, used for
    -- resolving quest titles) has finished initializing first.
    local elapsedTotal = 0
    replayFrame:SetScript("OnUpdate", function(self, elapsed)
        elapsedTotal = elapsedTotal + elapsed
        if elapsedTotal >= 2 then
            self:SetScript("OnUpdate", nil)
            ReplayLearnedLocations()
        end
    end)
end)

------------------------------------------------------------
-- Slash command (single /aql command)
------------------------------------------------------------
SLASH_AUTOQUESTLEARNER1 = "/aql"
SlashCmdList["AUTOQUESTLEARNER"] = function(msg)
    msg = (msg or ""):lower():match("^%s*(.-)%s*$")
    
    -- Default: open config panel if no argument
    if msg == "" then
        if AutoQuestLearnerConfig then
            AutoQuestLearnerConfig:Show()
        else
            print("|cff00ff00AutoQuestLearner:|r Configuration module not loaded.")
            PrintStatus()
        end
        return
    end
    
    if msg == "status" or msg == "help" then
        PrintStatus()
        return
    end
    
    if string.find(msg, "^learn") then
        local filterText = string.match(msg, "^learn%s+(.*)$")
        AutoQuestLearner_ManualLearnObjective(filterText)
        return
    end

    if msg == "db" then
        -- Direct proof that the shared, account-wide learned database
        -- is actually accumulating data (not just "trust me") -- these
        -- numbers should only ever grow as you play, on ANY character,
        -- and never reset between sessions (aside from the one-time
        -- schema-version wipe noted below, which only fires on an
        -- addon update that changes the data format).
        local learned = GetLearnedDB()
        local giverCount, turninCount, bothCount = 0, 0, 0
        for _, entry in pairs(learned.quests) do
            local hasGiver, hasTurnin = entry.giver and true or false, entry.turnin and true or false
            if hasGiver then giverCount = giverCount + 1 end
            if hasTurnin then turninCount = turninCount + 1 end
            if hasGiver and hasTurnin then bothCount = bothCount + 1 end
        end
        local objQuestCount, spawnPointCount = 0, 0
        for _, objectives in pairs(learned.objectives) do
            objQuestCount = objQuestCount + 1
            for _, obj in pairs(objectives) do
                spawnPointCount = spawnPointCount + table.getn(obj.spawns or {})
            end
        end
        print("|cff00ff00AutoQuestLearner:|r Shared learned database (account-wide, SavedVariables: AutoQuestLearnerDB.learned, schema v" .. LEARNED_SCHEMA_VERSION .. "):")
        print(string.format("  Quests with a learned giver location: %d", giverCount))
        print(string.format("  Quests with a learned turn-in location: %d", turninCount))
        print(string.format("  Quests with BOTH learned: %d", bothCount))
        print(string.format("  Quests with at least one learned objective spawn: %d (%d total spawn points recorded)", objQuestCount, spawnPointCount))
        print("  This is shared across every character on this account -- log into an alt and run this again to confirm the same numbers show up (or higher, if that alt has learned more).")
    elseif msg == "scan" then
        ProximityScan()
        print("|cff00ff00AutoQuestLearner:|r Proximity scan complete.")
    elseif msg == "resync" then
        -- Manual safety net: force-redraw every learned quest location
        -- from the shared account-wide database, then refresh the
        -- currently visible map/minimap nodes. Normally this happens
        -- automatically on login/reload and on zone changes -- this is
        -- here for the rare case something didn't catch (e.g. right
        -- after installing this update, or if a node got stuck).
        --
        -- Also resets and re-runs the objective-node cleanup tracking:
        -- a quest whose cluster failed to clear (e.g. from a bug that's
        -- since been fixed) would have its "already tried" flag stuck
        -- true for the rest of the session, silently preventing even a
        -- corrected cleanup from running again until a full /reload.
        -- This clears that memory and immediately re-checks every
        -- quest currently in the log.
        AutoQuestLearner.objectiveNodesCulled = {}
        AutoQuestLearner.objCulled = {}
        ReplayLearnedLocations()
        if AutoQuestLearner.UpdateQuestlog then
            AutoQuestLearner:UpdateQuestlog()
        end
        if AutoQuestLearnerMap then
            AutoQuestLearnerMap:UpdateNodes()
        end
        print("|cff00ff00AutoQuestLearner:|r Resynced all learned quest locations and re-checked completed quests for stuck objective clusters.")
    elseif msg == "focus" then
        -- Toggles focus mode: only show map/minimap nodes for whatever
        -- quest the arrow is currently tracking, hiding everything
        -- else. Switches automatically as the arrow's target changes.
        local newVal = (AutoQuestLearnerDB.config.focusmode == "1") and "0" or "1"
        AutoQuestLearnerDB.config.focusmode = newVal
        if AutoQuestLearnerMap then
            AutoQuestLearnerMap:UpdateNodes()
        end
        print("|cff00ff00AutoQuestLearner:|r Focus mode " .. (newVal == "1" and "ON -- only showing what the arrow is tracking." or "OFF -- showing all known nodes again."))
    elseif msg == "gossip" then
        LearnFromGossip()
    elseif msg == "proximity" then
        EnsureSettings()
        AutoQuestLearnerDB.settings.enableProximityScan =
            not AutoQuestLearnerDB.settings.enableProximityScan
        print("|cff00ff00AutoQuestLearner:|r Proximity scan " ..
            (AutoQuestLearnerDB.settings.enableProximityScan and "enabled" or "disabled") .. ".")
    elseif msg == "config" then
        if AutoQuestLearnerConfig then
            AutoQuestLearnerConfig:Show()
        else
            print("|cff00ff00AutoQuestLearner:|r Configuration module not loaded.")
        end
    elseif msg == "guide" then
        if AutoQuestLearner_ShowGuide then
            AutoQuestLearner_ShowGuide()
        else
            print("|cff00ff00AutoQuestLearner:|r Guide module not loaded.")
        end
    elseif msg == "flickerdebug" then
        if not AutoQuestLearner_FlickerDebug then
            print("|cff00ff00AutoQuestLearner:|r Tracker module not loaded.")
        elseif AutoQuestLearner_FlickerDebug.active then
            print("|cff00ff00AutoQuestLearner:|r Already running -- wait for the current window to finish.")
        else
            AutoQuestLearner_FlickerDebug.counts = {}
            AutoQuestLearner_FlickerDebug.active = true
            print("|cff00ff00AutoQuestLearner:|r Recording for 5 seconds -- hold your mouse still over a quest with visible objectives now.")
            local dbgFrame = CreateFrame("Frame")
            local dbgElapsed = 0
            dbgFrame:SetScript("OnUpdate", function(self, e)
                dbgElapsed = dbgElapsed + (e or 0)
                if dbgElapsed < 5 then return end
                self:SetScript("OnUpdate", nil)
                AutoQuestLearner_FlickerDebug.active = false
                local counts = AutoQuestLearner_FlickerDebug.counts
                local names = {}
                for k in pairs(counts) do table.insert(names, k) end
                table.sort(names)
                print("|cff00ff00AutoQuestLearner:|r Results (5 second window):")
                if table.getn(names) == 0 then
                    print("  Nothing fired at all -- if the flicker happened during this window, it isn't coming from any of the tracked functions.")
                else
                    for _, name in pairs(names) do
                        print(string.format("  %s: %d", name, counts[name]))
                    end
                end
            end)
        end
    elseif msg == "perf" then
        local memKB = GetAddOnMemoryUsage and GetAddOnMemoryUsage("AutoQuestLearner")
        if UpdateAddOnMemoryUsage then UpdateAddOnMemoryUsage() end
        memKB = GetAddOnMemoryUsage and GetAddOnMemoryUsage("AutoQuestLearner")

        print("|cff00ff00AutoQuestLearner:|r Performance snapshot:")
        if memKB then
            print(string.format("  Memory in use: %.1f MB", memKB / 1024))
        else
            print("  Memory in use: unavailable on this client.")
        end

        -- NOTE: this deliberately does NOT suggest enabling
        -- scriptProfile for CPU timing -- confirmed to crash this
        -- specific client. Memory usage above is always safely
        -- available with no setup; for CPU/FPS impact specifically,
        -- the reliable method on this client is a direct A/B test:
        -- disable the addon (ESC -> AddOns, uncheck it, /reload),
        -- compare FPS for a few minutes, then re-enable and /reload
        -- again. No crash risk, no special console commands needed.
        print("  For FPS impact specifically: disable this addon (ESC menu -> AddOns, uncheck, /reload), compare your FPS for a few minutes, then re-enable and /reload again. (Not suggesting scriptProfile CPU tracking here -- confirmed to crash on this client.)")
    elseif msg == "chain" or string.find(msg, "^chain ") then
        local filterText = string.find(msg, "^chain ") and string.sub(msg, 7) or ""
        if not AutoQuestLearner.questlog then
            print("|cff00ff00AutoQuestLearner:|r Quest log not loaded yet.")
        elseif not AutoQuestLearnerQuestChain then
            print("|cff00ff00AutoQuestLearner:|r Quest chain data not loaded.")
        else
            local targetQuestID, targetTitle

            if filterText and filterText ~= "" then
                local lowered = filterText:lower()
                local matches = {}
                for qid, entry in pairs(AutoQuestLearner.questlog) do
                    if entry.title and string.find(entry.title:lower(), lowered, 1, true) then
                        table.insert(matches, qid)
                    end
                end
                if table.getn(matches) == 0 then
                    print("|cff00ff00AutoQuestLearner:|r No quest in your log matches \"" .. filterText .. "\".")
                    targetQuestID = nil
                elseif table.getn(matches) > 1 then
                    print("|cff00ff00AutoQuestLearner:|r Multiple quests match \"" .. filterText .. "\" -- be more specific:")
                    for _, qid in pairs(matches) do
                        print("  - " .. AutoQuestLearner.questlog[qid].title)
                    end
                    targetQuestID = nil
                else
                    targetQuestID = matches[1]
                end
            elseif AutoQuestLearner.pinnedQuestID and AutoQuestLearner.questlog[AutoQuestLearner.pinnedQuestID] then
                targetQuestID = AutoQuestLearner.pinnedQuestID
            else
                local watchedIDs = {}
                for qid, entry in pairs(AutoQuestLearner.questlog) do
                    if entry.state and string.find(entry.state, "^track") then
                        table.insert(watchedIDs, qid)
                    end
                end
                if table.getn(watchedIDs) == 1 then
                    targetQuestID = watchedIDs[1]
                else
                    print("|cff00ff00AutoQuestLearner:|r Not sure which quest you mean.")
                    print("  Either pin one first (Alt+Right-Click it in the tracker) and run |cffffcc00/aql chain|r again,")
                    print("  or specify it directly: |cffffcc00/aql chain <part of quest name>|r")
                end
            end

            if targetQuestID then
                targetTitle = AutoQuestLearner.questlog[targetQuestID].title
                -- The chain data is keyed by this addon's bundled
                -- numeric quest IDs specifically -- questlog keys can
                -- be a title string instead (same alias reasoning used
                -- throughout this addon), so resolve a real numeric ID
                -- via GetQuestIDs() when the questlog key itself isn't
                -- already one.
                local numericID = tonumber(targetQuestID)
                if not numericID then
                    local qlogid = AutoQuestLearner.questlog[targetQuestID].qlogid
                    local resolved = qlogid and AutoQuestLearner.db and AutoQuestLearner.db:GetQuestIDs(qlogid)
                    numericID = resolved and resolved[1] and tonumber(resolved[1])
                end

                local nextQuestID = numericID and AutoQuestLearnerQuestChain[numericID]
                if not nextQuestID then
                    print("|cff00ff00AutoQuestLearner:|r \"" .. tostring(targetTitle) .. "\" -- no known follow-up quest in this addon's data. (Might genuinely be a one-off, or just not covered by this dataset.)")
                else
                    local nextLoc = AutoQuestLearnerData.quests["loc"] and AutoQuestLearnerData.quests["loc"][nextQuestID]
                    local nextTitle = nextLoc and nextLoc.T
                    if nextTitle then
                        print("|cff00ff00AutoQuestLearner:|r \"" .. tostring(targetTitle) .. "\" |cff00ccff->|r \"" .. nextTitle .. "\"")
                    else
                        print("|cff00ff00AutoQuestLearner:|r \"" .. tostring(targetTitle) .. "\" leads to a known quest ID, but its name isn't in this addon's data.")
                    end
                end
            end
        end
    elseif msg == "zone" then
        if not (AutoQuestLearner.db and AutoQuestLearner.db.GetZoneCompletion) then
            print("|cff00ff00AutoQuestLearner:|r Database module not loaded.")
        else
            local total, done, incompleteTitles, mapID = AutoQuestLearner.db:GetZoneCompletion()
            local zoneName = (AutoQuestLearnerData.zones and AutoQuestLearnerData.zones["loc"]
                and AutoQuestLearnerData.zones["loc"][mapID]) or "this zone"
            if total == 0 then
                print(string.format("|cff00ff00AutoQuestLearner:|r No known quests for %s in the bundled database.", zoneName))
            else
                local pct = math.floor(done / total * 100)
                print(string.format("|cff00ff00AutoQuestLearner:|r %s: %d/%d known quests done (%d%%)",
                    zoneName, done, total, pct))
                if table.getn(incompleteTitles) > 0 then
                    print("  Remaining (up to 15 shown):")
                    for _, title in pairs(incompleteTitles) do
                        print("    - " .. title)
                    end
                end
                print("  |cff888888Counts quests whose giver is known to be in this zone -- not every quest ever available here, just what's in this addon's bundled+learned database.|r")
            end
        end
    elseif msg == "route" then
        if not AutoQuestLearner_PlanRoute then
            print("|cff00ff00AutoQuestLearner:|r Route planner not loaded.")
        else
            local route = AutoQuestLearner_PlanRoute()
            if not route then
                print("|cff00ff00AutoQuestLearner:|r Couldn't determine your position or quest log right now.")
            elseif table.getn(route) == 0 then
                print("|cff00ff00AutoQuestLearner:|r No quests with a known location in this zone right now -- nothing to route.")
            else
                print(string.format("|cff00ff00AutoQuestLearner:|r Suggested order (%d stop%s, this zone only):",
                    table.getn(route), table.getn(route) == 1 and "" or "s"))
                for i, stop in pairs(route) do
                    local label = stop.name and (stop.title .. " -- " .. stop.name) or stop.title
                    -- Same distance unit the arrow itself already
                    -- displays (raw zone-percentage points, not yards --
                    -- there's no fixed yards-per-point ratio across
                    -- zones of different physical sizes, so showing a
                    -- converted number would mean fabricating a
                    -- conversion factor with no way to verify it).
                    print(string.format("  %d. %s (Distance: %.1f)", i, label, stop.distFromPrev))
                end
                print("  |cff888888Straight-line order, greedy nearest-stop routing -- doesn't account for terrain, walls, or actual walking paths. Distance is the same unit the arrow shows.|r")
            end
        end
    elseif msg == "perfdebug" then
        if not AutoQuestLearner_PerfDebug then
            print("|cff00ff00AutoQuestLearner:|r Not loaded yet.")
        elseif AutoQuestLearner_PerfDebug.active then
            print("|cff00ff00AutoQuestLearner:|r Already running -- wait for the current window to finish.")
        else
            local ok = pcall(debugprofilestop)
            if not ok then
                print("|cff00ff00AutoQuestLearner:|r debugprofilestop() isn't available on this client -- can't measure timing this way. The disable-and-compare FPS test from /aql perf is the fallback.")
            else
                AutoQuestLearner_PerfDebug.totals = {}
                AutoQuestLearner_PerfDebug.calls = {}
                AutoQuestLearner_PerfDebug.active = true
                print("|cff00ff00AutoQuestLearner:|r Timing for 10 seconds -- move around and quest normally, don't just stand still (this measures real usage, not idle state).")
                local dbgFrame = CreateFrame("Frame")
                local dbgElapsed = 0
                dbgFrame:SetScript("OnUpdate", function(self, e)
                    dbgElapsed = dbgElapsed + (e or 0)
                    if dbgElapsed < 10 then return end
                    self:SetScript("OnUpdate", nil)
                    AutoQuestLearner_PerfDebug.active = false
                    local totals = AutoQuestLearner_PerfDebug.totals
                    local calls = AutoQuestLearner_PerfDebug.calls
                    local names = {}
                    for k in pairs(totals) do table.insert(names, k) end
                    table.sort(names, function(a, b) return totals[a] > totals[b] end)
                    print("|cff00ff00AutoQuestLearner:|r Results (10 second window, real time spent per function):")
                    if table.getn(names) == 0 then
                        print("  Nothing measured -- none of the instrumented functions ran during this window.")
                    else
                        for _, name in pairs(names) do
                            print(string.format("  %s: %.1f ms total over %d call(s) (%.2f ms avg)",
                                name, totals[name], calls[name] or 0, totals[name] / (calls[name] or 1)))
                        end
                        print("  For reference: 16.7ms budget per frame at 60fps, 33ms at 30fps -- a function's TOTAL here versus that budget shows roughly how much it could be costing.")
                    end
                end)
            end
        end
    elseif msg == "tracker" then
        if AutoQuestLearnerTracker then
            if AutoQuestLearnerTracker:IsShown() then
                AutoQuestLearnerTracker:Hide()
                print("|cff00ff00AutoQuestLearner:|r Tracker hidden.")
            else
                AutoQuestLearnerTracker:Show()
                print("|cff00ff00AutoQuestLearner:|r Tracker shown.")
            end
        else
            print("|cff00ff00AutoQuestLearner:|r Tracker module not loaded.")
        end
    elseif msg == "blizztracker" then
        if AutoQuestLearnerDB.config.hideblizzardtracker == "0" then
            AutoQuestLearnerDB.config.hideblizzardtracker = "1"
            print("|cff00ff00AutoQuestLearner:|r Blizzard's default quest tracker hidden.")
        else
            AutoQuestLearnerDB.config.hideblizzardtracker = "0"
            print("|cff00ff00AutoQuestLearner:|r Blizzard's default quest tracker restored.")
        end
        AutoQuestLearner_UpdateBlizzardTrackerVisibility()
    elseif msg == "unpin" then
        if AutoQuestLearner.pinnedQuestID then
            print("|cff00ff00AutoQuestLearner:|r Unpinned. Arrow back to auto-select.")
            AutoQuestLearner.pinnedQuestID = nil
        else
            print("|cff00ff00AutoQuestLearner:|r No quest is currently pinned.")
        end
    elseif msg == "clusterdebug" then
        AutoQuestLearner_ClusterDebug = not AutoQuestLearner_ClusterDebug
        print("|cff00ff00AutoQuestLearner:|r Cluster merge debug " .. (AutoQuestLearner_ClusterDebug and "enabled -- open the world map to see it" or "disabled") .. ".")
    elseif msg == "calibrate" or string.find(msg, "^calibrate ") then
        print("|cff00ff00AutoQuestLearner:|r Minimap calibration status")
        AutoQuestLearnerDB.calibratedMinimap = AutoQuestLearnerDB.calibratedMinimap or {}
        if string.find(msg, "^calibrate reset") then
            local rawZone = AutoQuestLearnerMap and AutoQuestLearnerMap:GetMapID(nil, nil, true)
            if rawZone and STARTING_ZONE_TARGETS[rawZone] then
                AutoQuestLearnerDB.calibratedMinimap[rawZone] = nil
                calibSamples[rawZone] = nil
                print("  Reset calibration for your current zone -- it'll start measuring again as you move.")
            else
                print("  You're not currently in one of the zones this calibrates (Shadowglen, Coldridge Valley, Camp Narache, Red Cloud Mesa, Deathknell, Valley of Trials).")
            end
        else
            for rawID in pairs(STARTING_ZONE_TARGETS) do
                local zoneName = AutoQuestLearnerData.zones and AutoQuestLearnerData.zones["loc"] and AutoQuestLearnerData.zones["loc"][rawID]
                local done = AutoQuestLearnerDB.calibratedMinimap[rawID]
                if done then
                    print(string.format("  %s (id %s): calibrated -- %.0f x %.0f yards", tostring(zoneName or rawID), tostring(rawID), done[1], done[2]))
                else
                    local s = calibSamples[rawID]
                    local w = s and table.getn(s.widths) or 0
                    local h = s and table.getn(s.heights) or 0
                    print(string.format("  %s (id %s): not yet calibrated -- %d/%d width samples, %d/%d height samples",
                        tostring(zoneName or rawID), tostring(rawID), w, MIN_CALIBRATION_SAMPLES, h, MIN_CALIBRATION_SAMPLES))
                end
            end
            print("  (Use '/aql calibrate reset' while standing in one of these zones to re-measure it.)")
        end
    elseif msg == "questitems" then
        print("|cff00ff00AutoQuestLearner:|r Quest item panel diagnostics")
        print("  config enabled: " .. tostring(AutoQuestLearnerDB.config.questitembuttons))
        print("  InCombatLockdown(): " .. tostring(InCombatLockdown and InCombatLockdown()))
        if not GetNumQuestWatches then
            print("  GetNumQuestWatches API not available on this client")
        else
            local numWatches = GetNumQuestWatches()
            print("  GetNumQuestWatches(): " .. tostring(numWatches))
            for i = 1, numWatches do
                local questIndex = GetQuestIndexForWatch(i)
                if questIndex then
                    local qtitle = GetQuestLogTitle(questIndex)
                    local link, item, charges = GetQuestLogSpecialItemInfo(questIndex)
                    print(string.format("    watch %d: [%s] questIndex=%s -- item=%s",
                        i, tostring(qtitle), tostring(questIndex), tostring(item)))
                else
                    print("    watch " .. i .. ": GetQuestIndexForWatch returned nil")
                end
            end
        end
        print("  Forcing a fresh update...")
        AutoQuestLearner_UpdateQuestItemButtons()
        print("  Last error: " .. tostring(questItemButtonsLastError))
        print("  Panel shown: " .. tostring(AutoQuestLearnerQuestItemPanel and AutoQuestLearnerQuestItemPanel:IsShown()))
        if AutoQuestLearnerQuestItemPanel then
            local point, _, _, x, y = AutoQuestLearnerQuestItemPanel:GetPoint()
            print("  Panel position: point=" .. tostring(point) .. " x=" .. tostring(x) .. " y=" .. tostring(y))
        end
        print("  (Shift-drag the panel to move it anywhere on screen)")
    elseif msg == "opendb" or string.find(msg, "^opendb ") then
        -- Opens the Ascension Database page for a quest, using the
        -- same client-provided function QuestLink uses
        -- (OpenAscensionDBURL) -- this isn't the addon fetching data,
        -- it's telling the OS to open a browser tab, same as clicking
        -- a hyperlink. Nothing comes back into the addon; your browser
        -- renders the actual page (which sidesteps that site's heavy
        -- JavaScript rendering entirely).
        -- NOTE: named "opendb" rather than "db" because "db" was
        -- already taken by the learned-database-stats command above.
        if not OpenAscensionDBURL then
            print("|cffff0000AutoQuestLearner:|r This client doesn't provide OpenAscensionDBURL -- can't open the database from here.")
        else
            local explicitID = string.match(msg, "^opendb%s+(%d+)$")
            local questID = explicitID and tonumber(explicitID)
            if not questID then
                local selection = GetQuestLogSelection and GetQuestLogSelection()
                if selection and selection > 0 then
                    questID = select(9, GetQuestLogTitle(selection))
                end
            end
            if questID then
                OpenAscensionDBURL("?quest=" .. questID)
            else
                print("|cffff0000AutoQuestLearner:|r No quest selected in your quest log, and no quest ID given.")
                print("  Usage: /aql opendb (with a quest selected/open in your quest log), or /aql opendb 641 (a specific quest ID)")
            end
        end
    elseif msg == "arrow" then
        print("|cff00ff00AutoQuestLearner:|r Arrow diagnostics")
        print("  Pinned quest ID: " .. tostring(AutoQuestLearner.pinnedQuestID) .. " (nil = auto-select mode)")
        print("  navArrow setting: " .. tostring(AutoQuestLearnerDB.config.navArrow))
        print("  Arrow frame alpha: " .. tostring(AutoQuestLearner.arrow and AutoQuestLearner.arrow:GetAlpha()) ..
            " (0 = invisible, 1 = visible)")
        if AutoQuestLearner.arrow then
            local cx, cy = AutoQuestLearner.arrow:GetCenter()
            print("  Arrow screen position (center): x=" .. tostring(cx) .. " y=" .. tostring(cy) ..
                " (nil means the frame has never been laid out at all)")
            print("  Arrow size: " .. tostring(AutoQuestLearner.arrow:GetWidth()) .. "x" .. tostring(AutoQuestLearner.arrow:GetHeight()))
            print("  Arrow strata: " .. tostring(AutoQuestLearner.arrow:GetFrameStrata()))
            print("  Arrow model texture set: " .. tostring(AutoQuestLearner.arrow.model and AutoQuestLearner.arrow.model:GetTexture()))
        end
        local ok, loc, title, npcName = pcall(FindClosestQuestDestination)
        local px, py = GetPlayerPos()
        print("  Your position: x=" .. tostring(px) .. " y=" .. tostring(py) .. " mapID=" .. tostring(GetCurrentMap()))
        if not ok then
            print("  |cffff0000FindClosestQuestDestination() errored:|r " .. tostring(loc))
        elseif not loc then
            print("  FindClosestQuestDestination() found no candidate (nothing known in current zone for your active quests).")
        else
            print("  Closest destination: " .. tostring(title) .. (npcName and (" -- " .. npcName) or ""))
            print("  Location: x=" .. tostring(loc.x) .. " y=" .. tostring(loc.y) .. " mapID=" .. tostring(loc.mapID))
            print("  Current zone mapID: " .. tostring(GetCurrentMap()))
        end

        -- Per-quest breakdown: exactly why each active quest does or
        -- doesn't have a usable destination right now.
        print("  |cffffcc00Per-quest breakdown:|r")
        local currentZone = GetCurrentMap()
        local learned = GetLearnedDB()
        if AutoQuestLearner.questlog then
            for questID, logData in pairs(AutoQuestLearner.questlog) do
                local questTitle = logData.title or ("Quest " .. tostring(questID))
                local staticQuest = AutoQuestLearnerData.quests["data"][questID]
                local questEntry = GetLearnedQuestEntry(learned, questID, questTitle)

                local hasStatic = staticQuest and (staticQuest["end"] or staticQuest["obj"]) and true or false
                local staticCoordCount, staticCoordInZone = 0, 0
                if staticQuest then
                    local function countIDs(idSet, idType)
                        if not idSet then return end
                        for _, id in pairs(idSet) do
                            local data = idType == "U" and AutoQuestLearnerData.units["data"][id]
                                or idType == "O" and AutoQuestLearnerData.objects["data"][id]
                                or idType == "A" and AutoQuestLearnerData.areatrigger["data"][id]
                            if data and data["coords"] then
                                for _, c in pairs(data["coords"]) do
                                    staticCoordCount = staticCoordCount + 1
                                    if c[3] == currentZone then
                                        staticCoordInZone = staticCoordInZone + 1
                                    end
                                end
                            end
                        end
                    end
                    if staticQuest["end"] then
                        countIDs(staticQuest["end"]["U"], "U")
                        countIDs(staticQuest["end"]["O"], "O")
                    end
                    if staticQuest["obj"] then
                        countIDs(staticQuest["obj"]["U"], "U")
                        countIDs(staticQuest["obj"]["O"], "O")
                        countIDs(staticQuest["obj"]["A"], "A")
                        if staticQuest["obj"]["I"] and AutoQuestLearner_ResolveItemSources then
                            for _, itemID in pairs(staticQuest["obj"]["I"]) do
                                local sources = AutoQuestLearner_ResolveItemSources(itemID)
                                for _, src in pairs(sources) do
                                    countIDs({ src.id }, src.type)
                                end
                            end
                        end
                    end
                end

                local hasLearned = (questEntry and (questEntry.giver or questEntry.turnin)) and true or false
                local diagObjectives = GetLearnedObjectives(learned, questID, questTitle)
                local hasLearnedObj = diagObjectives and next(diagObjectives) and true or false

                print(string.format("    [%s] questID=%s -- static: %s (coords: %d total, %d in this zone) | learned giver/turnin: %s | learned objectives: %s",
                    questTitle, tostring(questID), tostring(hasStatic), staticCoordCount, staticCoordInZone, tostring(hasLearned), tostring(hasLearnedObj)))

                -- Print the actual learned spawn coordinates (not just
                -- whether they exist) so a wrong learned location can be
                -- told apart from the priority logic failing to use it.
                if diagObjectives then
                    for objIndex, obj in pairs(diagObjectives) do
                        local spawns = obj.spawns or {}
                        local shown = 0
                        for _, spawn in pairs(spawns) do
                            shown = shown + 1
                            if shown <= 5 then
                                print(string.format("        objIndex=%s spawn: x=%.1f y=%.1f mapID=%s%s",
                                    tostring(objIndex), spawn.x, spawn.y, tostring(spawn.mapID),
                                    spawn.mapID == currentZone and "" or "  |cffff8800(different zone!)|r"))
                            end
                        end
                        if shown > 5 then
                            print(string.format("        ... and %d more spawn(s) for objIndex=%s", shown - 5, tostring(objIndex)))
                        end
                    end
                end
            end
        end
    elseif msg == "tomtomauto" then
        if AutoQuestLearnerDB.config.tomtomAuto == "1" then
            AutoQuestLearnerDB.config.tomtomAuto = "0"
            AutoQuestLearner_ClearTomTomWaypoint()
            print("|cff00ff00AutoQuestLearner:|r TomTom auto-select disabled.")
        else
            AutoQuestLearnerDB.config.tomtomAuto = "1"
            print("|cff00ff00AutoQuestLearner:|r TomTom auto-select enabled -- will pick the closest known destination in your current zone.")
        end
    elseif msg == "map" then
        if AutoQuestLearnerMap then
            AutoQuestLearnerMap:UpdateNodes()
            print("|cff00ff00AutoQuestLearner:|r Map nodes updated.")
        else
            print("|cff00ff00AutoQuestLearner:|r Map module not loaded.")
        end
    elseif msg == "debug" then
        PrintDebug()
    else
        print("|cff00ff00AutoQuestLearner:|r Unknown command. Use /aql for config or /aql status for help.")
    end
end

------------------------------------------------------------
-- Events
------------------------------------------------------------
local frame = CreateFrame("Frame")
frame:RegisterEvent("ADDON_LOADED")
frame:RegisterEvent("PLAYER_LOGIN")
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
frame:RegisterEvent("QUEST_GREETING")
frame:RegisterEvent("GOSSIP_SHOW")
frame:RegisterEvent("QUEST_DETAIL")
frame:RegisterEvent("QUEST_PROGRESS")
frame:RegisterEvent("QUEST_COMPLETE")
frame:RegisterEvent("QUEST_FINISHED")
frame:RegisterEvent("GOSSIP_CLOSED")

-- The proximity-scan ticker is a separate, dedicated frame so its
-- OnUpdate / OnInterval logic doesn't run on every game event (and so
-- it can be paused cleanly by setting its script to nil without
-- touching the main event frame).
local proximityFrame = CreateFrame("Frame")
local lastProximityScan = 0
local function ProximityTicker(self, elapsed)
    EnsureSettings()
    if not AutoQuestLearnerDB.settings.enableProximityScan then return end
    local interval = AutoQuestLearnerDB.settings.proximityInterval or 10
    local now = GetTime()
    if now - lastProximityScan < interval then return end
    lastProximityScan = now
    ProximityScan()
end
proximityFrame:SetScript("OnUpdate", ProximityTicker)

-- GOSSIP_CLOSED is interesting: when the player closes the gossip
-- window, the gossip data is wiped. We use it as a hint to re-process
-- what was on screen the next time a quest event fires (which is the
-- normal sequence: open gossip -> click a quest -> see QUEST_DETAIL).
-- So this event mostly just acts as a "you closed it" bookend; the
-- real work happens in QUEST_GREETING / GOSSIP_SHOW.
local lastGossipHadQuests = false

frame:SetScript("OnEvent", function(self, event, ...)
    if event == "ADDON_LOADED" then
        -- Nothing to do; the .toc dependency ensures RexxarDB Lite is
        -- already bootstrapped by the time we get here.

    elseif event == "PLAYER_LOGIN" then
        EnsureSettings()
        GetChar() -- ensure per-character table exists
        MaybeAutoImportPfQuest()

    elseif event == "QUEST_GREETING" then
        -- Multi-quest greeting window is now showing. Wait one frame for
        -- the client to populate GetGossipAvailableQuests() etc., then
        -- learn from it. (In practice the data is populated before this
        -- event returns, but a defer is cheap insurance against
        -- frame-order quirks on slower clients.)
        local f = CreateFrame("Frame")
        f:SetScript("OnUpdate", function(self)
            self:SetScript("OnUpdate", nil)
            LearnFromGossip()
            lastGossipHadQuests = true
        end)

    elseif event == "GOSSIP_SHOW" then
        local f = CreateFrame("Frame")
        f:SetScript("OnUpdate", function(self)
            self:SetScript("OnUpdate", nil)
            LearnFromGossip()
            lastGossipHadQuests = true
        end)

    elseif event == "QUEST_DETAIL" then
        -- "About to accept" dialog.
        LearnFromQuestEvent("detail")

    elseif event == "QUEST_PROGRESS" then
        -- Active quest, progress window.
        LearnFromQuestEvent("progress")

    elseif event == "QUEST_COMPLETE" then
        -- About to turn in.
        LearnFromQuestEvent("complete")

    elseif event == "QUEST_FINISHED" then
        -- Fired after a quest is accepted or turned in. Useful for
        -- "accept" — combined with our event listeners above, by the
        -- time QUEST_FINISHED fires we've already learned the location
        -- via QUEST_DETAIL (accept) or QUEST_COMPLETE (turn-in), so
        -- this is just a no-op confirmation hook for future
        -- enhancements (e.g. learning turn-in NPC's name).

    elseif event == "GOSSIP_CLOSED" then
        lastGossipHadQuests = false

    elseif event == "PLAYER_TARGET_CHANGED" then
        -- New target acquired: opportunistically try the proximity scan
        -- (which is a no-op unless the target has quest relationships).
        -- Don't go through the timer — when the user clicks a new target,
        -- checking immediately is the right behavior.
        ProximityScan()
    end
end)

-- Expose functions for UI buttons
AutoQuestLearner.ProximityScan = ProximityScan
AutoQuestLearner.LearnFromGossip = LearnFromGossip
AutoQuestLearner.PrintStatus = PrintStatus
