local pluginName, pluginShared = ...
local Arrow = {}
pluginShared.GuideArrow = Arrow

local FULL_TURN = math.pi * 2
local PIN_NAMESPACE = "AppearanceMaticGuideArrow"
local ARRIVE_YARDS = 12
-- Waiting for a bearing is measured in seconds, never in frames: a frame count makes the arrow three
-- times slower to appear at 30 fps than at 90.
local ATTACH_RETRY = 1.0      -- seconds between re-attaching the pin while there is no bearing
local NO_BEARING_AFTER = 0.35 -- how long a fresh hop may stay blank before the dimmed arrow shows
local STALL_GIVE_UP = 5.0     -- a bearing lost this long stops showing the last known distance
local TEXTURE_ROOT = "Interface\\AddOns\\AppearanceMatic\\Media\\"

local HEADING_SHEET = { path = TEXTURE_ROOT .. "Textures\\guide_arrow_sheet", cells = 108, columns = 9, cellW = 56, cellH = 42, sheetW = 512, sheetH = 512 }
local ARRIVAL_SHEET = { path = TEXTURE_ROOT .. "Textures\\guide_arrow_arrival", cells = 55, columns = 9, cellW = 56, cellH = 56, sheetW = 512, sheetH = 512 }
local ARRIVAL_SOUND = TEXTURE_ROOT .. "Sounds\\guide_arrival.ogg"

local function settings()
    local profile = pluginShared.db and pluginShared.db.profile
    return profile and profile.guideArrow
end

local function pinsLib()
    return LibStub and LibStub("HereBeDragons-Pins-2.0", true)
end

-- Locale strings resolve once; OnUpdate asks for them every frame.
local stringCache = {}
local function localeString(key, fallback)
    local cached = stringCache[key]
    if cached ~= nil then
        return cached
    end
    local locale = LibStub and LibStub("AceLocale-3.0", true)
    local L = locale and locale:GetLocale("AppearanceMatic", true)
    local text = (L and L[key]) or fallback
    if L then
        stringCache[key] = text
    end
    return text
end

-- The yards text only touches the font string when it changes.
local function setYards(frame, state, text)
    if state.yardsShown ~= text then
        state.yardsShown = text
        frame.yards:SetText(text)
    end
end

function Arrow.CellForBearing(bearing, cells)
    if cells <= 0 then return 1 end
    return (math.floor(bearing / FULL_TURN * cells + 0.5) % cells) + 1
end

-- A hop that means "use this item" (a Hearthstone) shows the item itself instead of a bearing.
function Arrow.ItemIconForHop(hop)
    if type(hop) ~= "table" then
        return nil
    end
    if hop.hearthSpellID and type(C_Spell) == "table" and type(C_Spell.GetSpellTexture) == "function" then
        local ok, icon = pcall(C_Spell.GetSpellTexture, hop.hearthSpellID)
        if ok and icon then
            return icon
        end
    end
    if hop.hearthItemID and type(C_Item) == "table" and type(C_Item.GetItemIconByID) == "function" then
        local ok, icon = pcall(C_Item.GetItemIconByID, hop.hearthItemID)
        if ok and icon then
            return icon
        end
    end
    return nil
end

-- The icon draws a touch inside the frame so it reads as an item, not a fill.
function Arrow.IconInset(size)
    return math.floor(size * 0.85 + 0.5)
end

local function setSpriteMode(frame, mode)
    if frame.spriteMode == mode then
        return
    end
    frame.spriteMode = mode
    frame.sprite:ClearAllPoints()
    if mode == "icon" then
        frame.sprite:SetPoint("CENTER")
        local w = frame:GetWidth() or 64
        frame.sprite:SetSize(Arrow.IconInset(w), Arrow.IconInset(w))
    else
        frame.sprite:SetAllPoints()
        frame.sprite:SetTexture(HEADING_SHEET.path)
    end
end
Arrow.SetSpriteMode = setSpriteMode

function Arrow.ApplyCell(texture, sheet, index)
    local cell = (index - 1) % sheet.cells
    local column = cell % sheet.columns
    local row = math.floor(cell / sheet.columns)
    texture:SetTexCoord(
        column * sheet.cellW / sheet.sheetW, (column + 1) * sheet.cellW / sheet.sheetW,
        row * sheet.cellH / sheet.sheetH, (row + 1) * sheet.cellH / sheet.sheetH)
end

local function blend(t, a, b)
    return a + (b - a) * t
end

function Arrow.HeadingColor(bearing)
    local straightness = math.abs((math.pi - math.abs(bearing)) / math.pi)
    local r, g, b
    if straightness <= 0.5 then
        local t = straightness / 0.5
        r, g, b = blend(t, 0.85, 1.00), blend(t, 0.10, 0.80), blend(t, 0.10, 0.20)
    else
        local t = (straightness - 0.5) / 0.5
        r, g, b = blend(t, 1.00, 0.20), blend(t, 0.80, 0.95), blend(t, 0.20, 0.30)
    end
    if straightness > 0.985 then
        r, g, b = 0.30, 1.00, 0.30
    end
    return r, g, b
end

local state = {
    hop = nil,
    proxy = nil,
    frame = nil,
    seenVector = false,
    lastYards = nil,
    lastColor = nil,
    arriving = false,
    arrivalCell = 0,
    pingDone = false,
    etaClock = 0,
    etaSpeed = 0,
    etaSamples = 0,
    etaPrevYards = 0,
    nilSeconds = 0,
    nextAttach = 0,
    tomtomHidden = nil,
}

local function ensureProxy()
    if state.proxy then return state.proxy end
    state.proxy = CreateFrame("Frame", nil, UIParent)
    state.proxy:SetSize(1, 1)
    state.proxy:Hide()
    return state.proxy
end

local function detachProxy()
    local pins = pinsLib()
    if pins then
        pins:RemoveAllMinimapIcons(PIN_NAMESPACE)
    end
end

local function attachProxy(hop)
    local pins = pinsLib()
    if not (pins and hop and hop.mapID and hop.x and hop.y) then
        return false
    end
    detachProxy()
    pins:AddMinimapIconMap(PIN_NAMESPACE, ensureProxy(), hop.mapID, hop.x, hop.y, true, true)
    return true
end

local function applyChrome(frame)
    local cfg = settings()
    if not cfg then return end
    frame:SetScale(cfg.scale or 1)
    frame:SetAlpha(cfg.alpha or 1)
end

local function savePoint(frame)
    local cfg = settings()
    if not cfg then return end
    -- a drag re-anchors the frame by whatever corner the client chooses; the whole anchor is
    -- remembered, since re-applying its offsets to CENTER lands the frame somewhere else
    local point, _, relPoint, x, y = frame:GetPoint(1)
    cfg.point = { point = point or "TOP", relPoint = relPoint or "TOP", x = x or 30, y = y or -4 }
end

local function restorePoint(frame)
    local cfg = settings()
    local point = cfg and cfg.point
    frame:ClearAllPoints()
    if type(point) == "table" then
        frame:SetPoint(point.point or "TOP", UIParent, point.relPoint or "TOP", point.x or 30, point.y or -4)
    else
        frame:SetPoint("TOP", UIParent, "TOP", 30, -4)
    end
end
Arrow.SavePoint, Arrow.RestorePoint = savePoint, restorePoint

local function playArrivalPing()
    if state.pingDone then return end
    if type(UnitOnTaxi) == "function" and UnitOnTaxi("player") then
        -- flying over the hop's node is not arriving; the ping waits for touchdown
        return
    end
    state.pingDone = true
    local cfg = settings()
    if not (cfg and cfg.arrivalPing) then return end
    local profile = pluginShared.db and pluginShared.db.profile
    local channel = profile and profile.soundSettings and profile.soundSettings.channel or "Master"
    if profile and profile.soundSettings and profile.soundSettings.muteAll then return end
    local throttle = LibStub and LibStub("LibMatic-SoundThrottle-1.0", true)
    if throttle then
        throttle:Play("AppearanceMaticGuide", ARRIVAL_SOUND, channel)
    elseif type(PlaySoundFile) == "function" then
        pcall(PlaySoundFile, ARRIVAL_SOUND, channel)
    end
end

-- Ring log of why the arrow went invisible, read back by /am diag.
local function noteHide(reason)
    local t = type(GetTime) == "function" and GetTime() or 0
    state.hideLog = state.hideLog or {}
    local last = state.hideLog[#state.hideLog]
    if last and last.reason == reason and (t - last.at) < 1 then
        last.until_ = t
        return
    end
    state.hideLog[#state.hideLog + 1] = { reason = reason, at = t, until_ = t }
    if #state.hideLog > 12 then table.remove(state.hideLog, 1) end
end

function Arrow:HideLog()
    local out = {}
    local t = type(GetTime) == "function" and GetTime() or 0
    for _, e in ipairs(state.hideLog or {}) do
        out[#out + 1] = string.format("%s @-%ds for %.1fs", e.reason, math.floor(t - e.at), e.until_ - e.at)
    end
    return table.concat(out, "; ")
end

local function buildFrame()
    if state.frame then return state.frame end
    -- The secure shell is the only frame the state driver touches, so the
    -- visible arrow below stays an ordinary frame we may Show/Hide in combat.
    local shell = CreateFrame("Frame", "AppearanceMaticGuideArrowShell", UIParent, "SecureHandlerStateTemplate")
    shell:SetAllPoints(UIParent)
    shell:SetAttribute("_onstate-combathide", [[
        if newstate == "combat" then self:Hide() else self:Show() end
    ]])
    if type(RegisterStateDriver) == "function" then
        RegisterStateDriver(shell, "combathide", "[combat] combat; nocombat")
    end
    state.shell = shell
    local frame = CreateFrame("Button", "AppearanceMaticGuideArrow", shell)
    frame:SetSize(64, 64) -- 15% up from the original 56: the arrow reads better at a glance
    restorePoint(frame)
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", function(self)
        local cfg = settings()
        if cfg and cfg.locked then return end
        self:StartMoving()
    end)
    frame:SetScript("OnDragStop", function(self)
        self:StopMovingOrSizing()
        savePoint(self)
    end)
    frame:EnableMouseWheel(true)
    frame:SetScript("OnMouseWheel", function(self, delta)
        local cfg = settings()
        if not (cfg and type(IsControlKeyDown) == "function" and IsControlKeyDown()) then return end
        local scale = (cfg.scale or 1) + (delta > 0 and 0.05 or -0.05)
        if scale < 0.5 then scale = 0.5 end
        if scale > 2.5 then scale = 2.5 end
        cfg.scale = scale
        self:SetScale(scale)
    end)
    frame:RegisterForClicks("RightButtonUp")
    frame:SetScript("OnClick", function(_, button)
        if button ~= "RightButton" then return end
        if pluginShared.Route then pluginShared.Route:Stop() end
        if pluginShared.Lockouts and pluginShared.Lockouts.StopGuide then pluginShared.Lockouts:StopGuide() end
        Arrow:Clear()
    end)

    local sprite = frame:CreateTexture(nil, "ARTWORK")
    sprite:SetAllPoints()
    sprite:SetTexture(HEADING_SHEET.path)
    Arrow.ApplyCell(sprite, HEADING_SHEET, 1)
    frame.sprite = sprite

    local hopTitle = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hopTitle:SetPoint("TOP", frame, "BOTTOM", 0, -2)
    frame.hopTitle = hopTitle

    local yards = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    yards:SetPoint("TOP", hopTitle, "BOTTOM", 0, -1)
    frame.yards = yards

    local eta = frame:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    eta:SetPoint("TOP", yards, "BOTTOM", 0, -1)
    eta:SetTextColor(1.0, 0.82, 0.0, 1.0)
    frame.eta = eta

    frame:Hide()
    frame:SetScript("OnUpdate", function(self, elapsed) Arrow:OnUpdate(self, elapsed) end)
    state.frame = frame
    return frame
end

local function renderArrived(frame, title)
    if frame:GetAlpha() < 1 then applyChrome(frame) end
    if not state.arriving then
        frame.sprite:SetTexture(ARRIVAL_SHEET.path)
        state.arriving = true
    end
    state.arrivalCell = (state.arrivalCell + 1) % ARRIVAL_SHEET.cells
    Arrow.ApplyCell(frame.sprite, ARRIVAL_SHEET, state.arrivalCell + 1)
    frame.sprite:SetVertexColor(0.30, 1.00, 0.30, 1)
    if title then frame.hopTitle:SetText(title) end
    state.yardsShown = nil
    frame.yards:SetText(localeString("guide_arrow_arrived", "Arrived"))
    frame.eta:SetText("")
    playArrivalPing()
end

-- Show the arrival state without a bearing, e.g. standing inside the target instance.
function Arrow:ShowArrived(title)
    local frame = buildFrame()
    local cfg = settings()
    if state.insideTitle == title and state.hop and state.hop.inside then
        return
    end
    state.hop = { inside = true, title = title or "" }
    state.insideTitle = title
    detachProxy()
    self:MuteTomTom()
    if cfg and cfg.shown == false then
        frame:Hide()
        return
    end
    frame:SetAlpha(0)
    frame:Show()
end

function Arrow:OnUpdate(frame, elapsed)
    if not state.hop then
        noteHide("no hop")
        frame:Hide()
        return
    end
    if state.hop.inside then
        renderArrived(frame, state.hop.title)
        return
    end
    local itemIcon = Arrow.ItemIconForHop(state.hop)
    if itemIcon then
        -- "use this item" needs no bearing: the stone itself stands where the arrow would point
        if frame:GetAlpha() < 1 then applyChrome(frame) end
        setSpriteMode(frame, "icon")
        frame.sprite:SetTexture(itemIcon)
        frame.sprite:SetTexCoord(0, 1, 0, 1)
        frame.sprite:SetVertexColor(1, 1, 1, 1)
        setYards(frame, state, "")
        frame.eta:SetText("")
        return
    end
    setSpriteMode(frame, "sheet")
    local pins = pinsLib()
    if not (pins and state.proxy) then
        noteHide(pins and "no proxy" or "no HBD pins")
        setYards(frame, state, "")
        if pins then attachProxy(state.hop) end
        return
    end
    local bearing, yards = pins:GetVectorToIcon(state.proxy)
    if not yards then
        state.nilSeconds = state.nilSeconds + (elapsed or 0)
        -- the pin can miss on the very frame it is added: re-attach at once, then once a second
        if state.nilSeconds >= state.nextAttach then
            attachProxy(state.hop)
            state.nextAttach = state.nilSeconds + ATTACH_RETRY
        end
        if not state.seenVector then
            -- No bearing yet. After a moment (another continent, or a map the pins cannot place) show the
            -- arrow dimmed with the hop title so the guide is never simply invisible.
            if state.nilSeconds > NO_BEARING_AFTER then
                if frame:GetAlpha() < 1 then applyChrome(frame) end
                frame.sprite:SetTexture(HEADING_SHEET.path)
                Arrow.ApplyCell(frame.sprite, HEADING_SHEET, 1)
                frame.sprite:SetVertexColor(0.6, 0.6, 0.6, 1)
                setYards(frame, state, localeString("guide_no_bearing", "no bearing from here"))
                frame.eta:SetText("")
                return
            end
            noteHide("vector nil before first fix (hop map " .. tostring(state.hop.mapID) .. ")")
            frame:SetAlpha(0)
            setYards(frame, state, "")
            return
        end
        if state.nilSeconds >= STALL_GIVE_UP then noteHide("vector nil " .. string.format("%.1f", state.nilSeconds) .. "s (hop map " .. tostring(state.hop.mapID) .. ")") end
        if state.nilSeconds < STALL_GIVE_UP and state.lastColor then
            -- the last known distance, formatted once per stall
            if not state.stallText then
                state.stallText = state.lastYards and string.format(localeString("guide_yards_abbrev_fmt", "%d yd"), math.floor(state.lastYards + 0.5)) or ""
            end
            setYards(frame, state, state.stallText)
            frame.sprite:SetVertexColor(state.lastColor[1], state.lastColor[2], state.lastColor[3], 1)
            return
        end
        frame:SetAlpha(0)
        setYards(frame, state, "")
        return
    end
    state.nilSeconds, state.nextAttach = 0, 0
    state.stallText = nil
    if not state.seenVector or frame:GetAlpha() < 1 then
        applyChrome(frame)
    end

    if yards <= (state.hop.arriveYards or ARRIVE_YARDS) then
        if not state.arriving then
            frame.sprite:SetTexture(ARRIVAL_SHEET.path)
            state.arriving = true
        end
        state.arrivalCell = (state.arrivalCell + 1) % ARRIVAL_SHEET.cells
        Arrow.ApplyCell(frame.sprite, ARRIVAL_SHEET, state.arrivalCell + 1)
        frame.sprite:SetVertexColor(0.30, 1.00, 0.30, 1)
        setYards(frame, state, localeString("guide_arrow_arrived", "Arrived"))
        frame.eta:SetText("")
        playArrivalPing()
        return
    end

    if state.arriving then
        frame.sprite:SetTexture(HEADING_SHEET.path)
        state.arriving = false
    end
    state.pingDone = false

    local facing = (type(GetPlayerFacing) == "function" and GetPlayerFacing()) or 0
    local relative = bearing - facing
    Arrow.ApplyCell(frame.sprite, HEADING_SHEET, Arrow.CellForBearing(relative, HEADING_SHEET.cells))
    local r, g, b = Arrow.HeadingColor(relative)
    frame.sprite:SetVertexColor(r, g, b)
    local color = state.lastColor
    if not color then
        color = {}
        state.lastColor = color
    end
    color[1], color[2], color[3] = r, g, b
    state.seenVector = true
    local shown = math.floor(yards + 0.5)
    if shown ~= state.shownYards then
        state.shownYards = shown
        state.yardsText = string.format(localeString("guide_yards_fmt", "%d yards"), shown)
    end
    setYards(frame, state, state.yardsText)
    state.lastYards = yards

    state.etaClock = state.etaClock + (elapsed or 0)
    if state.etaClock >= 1.0 then
        local sample = (state.etaPrevYards - yards) / state.etaClock
        if state.etaPrevYards == 0 then sample = 0 end
        if state.etaSamples < 2 then
            state.etaSpeed = (state.etaSpeed + sample) / 2
            state.etaSamples = state.etaSamples + 1
        else
            state.etaSpeed = sample
            state.etaSamples = 0
        end
        if state.etaSpeed > 0 then
            local secs = math.floor(yards / state.etaSpeed + 0.5)
            local mins = math.floor(secs / 60)
            frame.eta:SetText(string.format("%d:%02d", mins, secs - mins * 60))
        else
            frame.eta:SetText("")
        end
        state.etaPrevYards = yards
        state.etaClock = 0
    end
end

local function hideTomTomPins(hidden, tomtom)
    if not (type(tomtom.waypoints) == "table" and type(tomtom.HideWaypoint) == "function") then
        return
    end
    for _, byKey in pairs(tomtom.waypoints) do
        if type(byKey) == "table" then
            for _, uid in pairs(byKey) do
                if type(uid) == "table" and not hidden.seen[uid] then
                    hidden.seen[uid] = true
                    pcall(tomtom.HideWaypoint, tomtom, uid, true, true)
                    hidden.pins[#hidden.pins + 1] = uid
                end
            end
        end
    end
end

local function installTomTomArrowHook()
    local crazy = _G.TomTomCrazyArrow
    if not (crazy and crazy.HookScript) or state.tomtomHooked then
        return
    end
    state.tomtomHooked = true
    crazy:HookScript("OnShow", function(f)
        if state.tomtomHidden then
            f:Hide()
        end
    end)
end

local function highlightTaxiNodes()
    local dest = state.hop and state.hop.taxiDest
    local map = _G.FlightMapFrame
    if not (map and type(map.EnumeratePinsByTemplate) == "function") then
        return
    end
    local wanted = dest and dest:lower()
    state.tintedPins = state.tintedPins or {}
    for pin in map:EnumeratePinsByTemplate("FlightMap_FlightPointPinTemplate") do
        local name = pin.taxiNodeData and pin.taxiNodeData.name
        local hit = wanted and type(name) == "string" and name:lower():find(wanted, 1, true) ~= nil
        if pin.Icon then
            if hit then
                pin.Icon:SetVertexColor(0.35, 0.65, 1.0)
                if pin.IconHighlight then pin.IconHighlight:SetVertexColor(0.35, 0.65, 1.0) end
                state.tintedPins[pin] = true
            elseif state.tintedPins[pin] then
                pin.Icon:SetVertexColor(1, 1, 1)
                if pin.IconHighlight then pin.IconHighlight:SetVertexColor(1, 1, 1) end
                state.tintedPins[pin] = nil
            end
        end
    end
end

function Arrow:HighlightTaxiDestination()
    highlightTaxiNodes()
    if type(C_Timer) == "table" and type(C_Timer.After) == "function" then
        C_Timer.After(0.3, highlightTaxiNodes)
        C_Timer.After(1.0, highlightTaxiNodes)
    end
end

function Arrow:MuteTomTom()
    local cfg = settings()
    local tomtom = _G.TomTom
    if not (cfg and cfg.hideTomTom and type(tomtom) == "table") then
        return
    end
    local hidden = state.tomtomHidden
    if not hidden then
        hidden = { pins = {}, seen = {} }
        local crazy = _G.TomTomCrazyArrow
        hidden.crazyWasShown = crazy and crazy.IsShown and crazy:IsShown() or false
        state.tomtomHidden = hidden
    end
    hideTomTomPins(hidden, tomtom)
    installTomTomArrowHook()
    local crazy = _G.TomTomCrazyArrow
    if crazy and crazy:IsShown() then
        crazy:Hide()
    end
    if not state.muteTicker and type(C_Timer) == "table" and type(C_Timer.NewTicker) == "function" then
        state.muteTicker = C_Timer.NewTicker(1, function()
            if state.hop and state.tomtomHidden then
                Arrow:MuteTomTom()
            else
                if state.muteTicker then state.muteTicker:Cancel() end
                state.muteTicker = nil
            end
        end)
    end
end

function Arrow:RestoreTomTom()
    local hidden = state.tomtomHidden
    state.tomtomHidden = nil
    if state.muteTicker then
        state.muteTicker:Cancel()
        state.muteTicker = nil
    end
    local tomtom = _G.TomTom
    if not (hidden and type(tomtom) == "table") then
        return
    end
    if type(tomtom.ShowWaypoint) == "function" then
        for _, uid in ipairs(hidden.pins) do
            pcall(tomtom.ShowWaypoint, tomtom, uid)
        end
    end
    if hidden.crazyWasShown and type(tomtom.ShowHideCrazyArrow) == "function" then
        pcall(tomtom.ShowHideCrazyArrow, tomtom)
    end
end

function Arrow:SetHop(hop)
    if type(hop) ~= "table" then
        self:Clear()
        return
    end
    local cfg = settings()
    state.hop = { mapID = hop.mapID, x = hop.x, y = hop.y, title = hop.text or hop.title or "", arriveYards = hop.arriveYards, taxiDest = hop.taxiDest, hearthItemID = hop.hearthItemID, hearthSpellID = hop.hearthSpellID }
    noteHide("new hop: " .. tostring(hop.text or hop.title))
    state.seenVector = false
    state.nilSeconds, state.nextAttach = 0, 0
    state.pingDone = false
    state.arriving = false
    state.etaClock, state.etaSpeed, state.etaSamples, state.etaPrevYards = 0, 0, 0, 0
    local frame = buildFrame()
    frame.hopTitle:SetText(state.hop.title)
    frame.sprite:SetTexture(HEADING_SHEET.path)
    attachProxy(state.hop)
    self:MuteTomTom()
    if cfg and cfg.shown == false then
        frame:Hide()
        return
    end
    frame:SetAlpha(0)
    frame:Show()
end

function Arrow:Clear()
    noteHide("Clear called")
    state.hop = nil
    state.insideTitle = nil
    detachProxy()
    if state.frame then state.frame:Hide() end
    self:RestoreTomTom()
end

function Arrow:DebugVector()
    local pins = pinsLib()
    if not (pins and state.proxy) then return nil, nil end
    return pins:GetVectorToIcon(state.proxy)
end

function Arrow:CurrentTaxiDestination()
    return state.hop and state.hop.taxiDest or nil
end

function Arrow:IsActive()
    return state.hop ~= nil
end

function Arrow:RefreshChrome()
    if state.frame then
        applyChrome(state.frame)
        local cfg = settings()
        if cfg and cfg.shown == false then
            state.frame:Hide()
        elseif state.hop then
            state.frame:Show()
        end
    end
end

function Arrow:ResetPosition()
    local cfg = settings()
    if cfg then cfg.point = { point = "TOP", relPoint = "TOP", x = 30, y = -4 } end
    if state.frame then restorePoint(state.frame) end
end

local taxiWatcher = CreateFrame and CreateFrame("Frame")
if taxiWatcher then
    taxiWatcher:RegisterEvent("TAXIMAP_OPENED")
    taxiWatcher:SetScript("OnEvent", function()
        if pluginShared.Route and pluginShared.Route.OnTaxiMapOpened then
            pluginShared.Route:OnTaxiMapOpened()
        end
        Arrow:HighlightTaxiDestination()
    end)
end
