local ADDON_NAME, ns = ...

local MapView = {}
ns.MapView = MapView

local NAME_TO_SLUG = {
    ["arathi basin"] = "arathi-basin",
    ["the battle for gilneas"] = "battle-for-gilneas",
    ["battle for gilneas"] = "battle-for-gilneas",
    ["deepwind gorge"] = "deepwind-gorge",
    ["deephaul ravine"] = "deephaul-ravine",
    ["eye of the storm"] = "eye-of-the-storm",
    ["silvershard mines"] = "silvershard-mines",
    ["temple of kotmogu"] = "temple-of-kotmogu",
    ["twin peaks"] = "twin-peaks",
    ["warsong gulch"] = "warsong-gulch",
}

local SLUG_TO_UIMAP = {
    ["warsong-gulch"] = 92,
    ["arathi-basin"] = 93,
    ["eye-of-the-storm"] = 112,
    ["twin-peaks"] = 206,
    ["battle-for-gilneas"] = 275,
    ["temple-of-kotmogu"] = 417,
    ["silvershard-mines"] = 423,
    ["deepwind-gorge"] = 519,
    ["deephaul-ravine"] = 2345,
}

function MapView:SlugForCurrentInstance()
    local name = GetInstanceInfo and GetInstanceInfo()
    if type(name) ~= "string" then return nil end
    return NAME_TO_SLUG[name:lower()]
end

local BUFF_CATS = {
    berserking = true, restoration = true, ["rune-of-frequency"] = true,
    ["cooldown-reset"] = true, ["into-the-shadows"] = true, ["absorption-shield"] = true,
    ["shadow-sight"] = true, ["runners-boots"] = true, ["random-empowerment"] = true,
}

local function GroupFor(cat)
    if not cat then return "other" end
    if BUFF_CATS[cat] then return "buffs" end
    if cat:find("graveyard") then return "graveyards" end
    if cat:find("lobby") or cat:find("^base") or cat:find("^cart") or cat:find("^crystal")
        or cat == "objective-flag" or cat == "kotmogu-orb" or cat == "mine-depot"
        or cat == "track-switch" then
        return "objectives"
    end
    return "other"
end

function MapView:HideCanvas()
    if self.canvas then self.canvas:Hide() end
    if self.imap and self.imap.viewport then self.imap.viewport:Hide() end
end

local function EnsureCanvas(self, parent)
    if self.canvas then return self.canvas end
    local c = CreateFrame("Frame", nil, parent)
    c.tiles = {}
    c.pins = {}
    self.canvas = c
    return c
end

local function AcquireTile(canvas, i)
    local t = canvas.tiles[i]
    if not t then
        t = canvas:CreateTexture(nil, "ARTWORK")
        canvas.tiles[i] = t
    end
    t:Show()
    return t
end

local function AcquirePin(canvas, i)
    local p = canvas.pins[i]
    if not p then
        p = CreateFrame("Button", nil, canvas)
        p:SetSize(20, 20)
        p:SetHitRectInsets(-4, -4, -4, -4)
        p.icon = p:CreateTexture(nil, "OVERLAY")
        p.icon:SetAllPoints(p)
        p:SetScript("OnEnter", function(s)
            GameTooltip:SetOwner(s, "ANCHOR_RIGHT")
            GameTooltip:AddLine(s._label or "", 1, 0.82, 0)
            if s._tip and s._tip ~= "" then
                GameTooltip:AddLine(s._tip, 0.9, 0.9, 0.9, true)
            end
            GameTooltip:Show()
        end)
        p:SetScript("OnLeave", function() GameTooltip:Hide() end)
        canvas.pins[i] = p
    end
    p:Show()
    return p
end

-- Website marker x/y are percent of the playable mapArt region, not the full
-- image. Convert to full-image percent exactly like blitz-map-viewer.js
-- markerImagePct / mapArtBounds so pins land on the same huts / GYs / flags.
-- Two shapes live in MapData: an explicit bbox (deephaul: right 97, bottom 96)
-- and inset margins (everything else). Small margins like temple's 0.2 / 0.75
-- also satisfy right > left, so a bbox has to span most of the image to count.
local function MapArtBounds(art)
    if not art then return 0, 0, 100, 100 end
    if art.right and art.left and art.bottom and art.top
        and art.right > art.left and art.bottom > art.top
        and art.right > 50 and art.bottom > 50 then
        return art.left, art.top, art.right - art.left, art.bottom - art.top
    end
    local left = art.left or 0
    local top = art.top or 0
    local right = art.right or 0
    local bottom = art.bottom or 0
    return left, top, 100 - left - right, 100 - top - bottom
end

function MapView:MarkerNormalizer(map)
    local minX, maxX, minY, maxY = math.huge, -math.huge, math.huge, -math.huge
    local needsWorld = false
    for _, m in ipairs(map.markers) do
        if type(m.x) == "number" and type(m.y) == "number" then
            if m.x < 0 or m.x > 100 or m.y < 0 or m.y > 100 then needsWorld = true end
            if m.x < minX then minX = m.x end
            if m.x > maxX then maxX = m.x end
            if m.y < minY then minY = m.y end
            if m.y > maxY then maxY = m.y end
        end
    end

    -- Legacy world-coord markers (raw engine units). Stretch into the playable
    -- region; prefer fixing data to 0-100 mapArt percents when possible.
    if needsWorld then
        local spanX = (maxX - minX)
        local spanY = (maxY - minY)
        local left, top, width, height = MapArtBounds(map.mapArt)
        local function nx(v)
            if type(v) ~= "number" or spanX <= 0 then return nil end
            return left + ((v - minX) / spanX) * width
        end
        local function ny(v)
            if type(v) ~= "number" or spanY <= 0 then return nil end
            return top + ((v - minY) / spanY) * height
        end
        return nx, ny
    end

    local left, top, width, height = MapArtBounds(map.mapArt)
    local function nx(v)
        if type(v) ~= "number" then return nil end
        return left + (v / 100) * width
    end
    local function ny(v)
        if type(v) ~= "number" then return nil end
        return top + (v / 100) * height
    end
    return nx, ny
end

-- Overlay RatedTracker markers onto a rendered map. dispW/dispH are the
-- displayed map dimensions; xOff is the horizontal centering offset.
function MapView:RenderPins(canvas, slug, xOff, dispW, dispH)
    local map = ns.MapData and ns.MapData[slug]
    if not (map and map.markers) then return end
    local nx, ny = self:MarkerNormalizer(map)
    local pinIdx = 0
    for _, m in ipairs(map.markers) do
        local px, py = nx(m.x), ny(m.y)
        local cat = m.category
        local mt = ns.MarkerTypes and ns.MarkerTypes[cat]
        if px and not (mt and mt.labelOnly) then
            pinIdx = pinIdx + 1
            local pin = AcquirePin(canvas, pinIdx)
            pin._label = m.label or (mt and mt.label) or cat or "Point"
            pin._tip = m.tooltip or (mt and mt.tooltip)
            local size = ns.UI.ApplyPinArt(pin.icon, cat)
            pin:SetSize(size, size)
            pin:ClearAllPoints()
            pin:SetPoint("CENTER", canvas, "TOPLEFT", xOff + (px / 100) * dispW, -((py / 100) * dispH))
        end
    end
end

-- Render the bundled RatedTracker battle-map image + pins. Returns height.
function MapView:RenderImageCanvas(parent, slug, width, yOffset)
    local img = ns.GetMapImage and ns.GetMapImage(slug)
    if not img then return 0 end

    -- Source parchment maps are 3:2; the stored TGA aspect is irrelevant since
    -- we map the full texture into a correctly-proportioned frame.
    local aspect = 1002 / 668
    local maxH = 520
    local dispW = width
    local dispH = dispW / aspect
    if dispH > maxH then
        dispH = maxH
        dispW = dispH * aspect
    end
    local xOff = math.floor((width - dispW) / 2 + 0.5)

    local canvas = EnsureCanvas(self, parent)
    canvas:ClearAllPoints()
    canvas:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    canvas:SetSize(width, dispH)
    canvas:Show()
    canvas:SetFrameLevel(parent:GetFrameLevel() + 2)

    for _, t in ipairs(canvas.tiles) do t:Hide() end
    for _, p in ipairs(canvas.pins) do p:Hide() end

    local t = AcquireTile(canvas, 1)
    t:SetTexture(img)
    t:SetTexCoord(0, 1, 0, 1)
    t:ClearAllPoints()
    t:SetPoint("TOPLEFT", canvas, "TOPLEFT", xOff, 0)
    t:SetSize(dispW, dispH)

    self:RenderPins(canvas, slug, xOff, dispW, dispH)
    return dispH
end

-- Render map art + pins. Returns height consumed. Prefers the bundled
-- RatedTracker battle map, falling back to in-game Blizzard textures.
function MapView:RenderCanvas(parent, slug, width, yOffset)
    yOffset = yOffset or 0

    local imgH = self:RenderImageCanvas(parent, slug, width, yOffset)
    if imgH > 0 then return imgH end

    local uiMapID = SLUG_TO_UIMAP[slug]
    if not (uiMapID and C_Map and C_Map.GetMapArtLayers and C_Map.GetMapArtLayerTextures) then
        self:HideCanvas()
        return 0
    end

    local layers = C_Map.GetMapArtLayers(uiMapID)
    if not layers or not layers[1] then
        self:HideCanvas()
        return 0
    end
    local layer = layers[1]
    local textures = C_Map.GetMapArtLayerTextures(uiMapID, 1)
    if not textures or #textures == 0 then
        self:HideCanvas()
        return 0
    end

    local layerW = layer.layerWidth or 1
    local layerH = layer.layerHeight or 1
    local tileW = layer.tileWidth or layerW
    local tileH = layer.tileHeight or layerH
    if layerW <= 0 or layerH <= 0 then self:HideCanvas(); return 0 end

    local maxH = 520
    local scale = math.min(width / layerW, maxH / layerH)
    local dispW = layerW * scale
    local dispH = layerH * scale
    local xOff = math.floor((width - dispW) / 2 + 0.5)

    local canvas = EnsureCanvas(self, parent)
    canvas:ClearAllPoints()
    canvas:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -yOffset)
    canvas:SetSize(width, dispH)
    canvas:Show()
    canvas:SetFrameLevel(parent:GetFrameLevel() + 2)

    for _, t in ipairs(canvas.tiles) do t:Hide() end
    for _, p in ipairs(canvas.pins) do p:Hide() end

    local cols = math.ceil(layerW / tileW)
    local rows = math.ceil(layerH / tileH)
    local dtW = tileW * scale
    local dtH = tileH * scale
    local idx = 0
    for row = 0, rows - 1 do
        for col = 0, cols - 1 do
            idx = idx + 1
            local fileID = textures[idx]
            if fileID then
                local t = AcquireTile(canvas, idx)
                t:SetTexture(fileID)
                t:ClearAllPoints()
                t:SetPoint("TOPLEFT", canvas, "TOPLEFT", xOff + col * dtW, -(row * dtH))
                t:SetSize(dtW, dtH)
            end
        end
    end

    local map = ns.MapData and ns.MapData[slug]
    if map and map.markers then
        local nx, ny = self:MarkerNormalizer(map)
        local pinIdx = 0
        for _, m in ipairs(map.markers) do
            local px, py = nx(m.x), ny(m.y)
            local cat = m.category
            local mt = ns.MarkerTypes and ns.MarkerTypes[cat]
            if px and not (mt and mt.labelOnly) then
                pinIdx = pinIdx + 1
                local pin = AcquirePin(canvas, pinIdx)
                pin._label = m.label or (mt and mt.label) or cat or "Point"
                pin._tip = m.tooltip or (mt and mt.tooltip)
                local size = ns.UI.ApplyPinArt(pin.icon, cat, { scale = math.max(0.72, math.min(1.0, dispW / 640)) })
                pin:SetSize(size, size)
                pin:ClearAllPoints()
                pin:SetPoint("CENTER", canvas, "TOPLEFT", xOff + (px / 100) * dispW, -((py / 100) * dispH))
            end
        end
    end

    return dispH
end

-- Interactive (zoom + pan) map --------------------------------------------------

local ZOOM_MIN, ZOOM_MAX = 1, 4
local MAP_ASPECT = 1002 / 668

local function IMap_Apply(im)
    im.dispW = im.baseW * im.zoom
    im.dispH = im.baseH * im.zoom

    -- Clamp so the map never drifts off the viewport (center when smaller).
    if im.dispW <= im.viewW then
        im.ox = (im.viewW - im.dispW) / 2
    else
        im.ox = math.max(im.viewW - im.dispW, math.min(0, im.ox))
    end
    if im.dispH <= im.viewH then
        im.oy = -(im.viewH - im.dispH) / 2
    else
        im.oy = math.max(0, math.min(im.dispH - im.viewH, im.oy))
    end

    im.holder:ClearAllPoints()
    im.holder:SetPoint("TOPLEFT", im.viewport, "TOPLEFT", im.ox, im.oy)
    im.holder:SetSize(im.dispW, im.dispH)
    im.tex:SetSize(im.dispW, im.dispH)

    for _, p in ipairs(im.activePins or {}) do
        p:ClearAllPoints()
        p:SetPoint("CENTER", im.holder, "TOPLEFT", (p._px / 100) * im.dispW, -((p._py / 100) * im.dispH))
    end
    for _, l in ipairs(im.activeLabels or {}) do
        l:ClearAllPoints()
        l:SetPoint("CENTER", im.holder, "TOPLEFT", (l._px / 100) * im.dispW, -((l._py / 100) * im.dispH))
    end
    for _, seg in ipairs(im.activeLines or {}) do
        seg.line:SetStartPoint("TOPLEFT", im.holder, (seg.x1 / 100) * im.dispW, -((seg.y1 / 100) * im.dispH))
        seg.line:SetEndPoint("TOPLEFT", im.holder, (seg.x2 / 100) * im.dispW, -((seg.y2 / 100) * im.dispH))
    end

    if im.zoomIn then im.zoomIn:SetEnabled(im.zoom < ZOOM_MAX) end
    if im.zoomOut then im.zoomOut:SetEnabled(im.zoom > ZOOM_MIN) end
end

local function IMap_ZoomTo(im, z)
    z = math.max(ZOOM_MIN, math.min(ZOOM_MAX, z))
    if z == im.zoom then return end
    local fx = (im.viewW / 2 - im.ox) / im.dispW
    local fy = (im.viewH / 2 + im.oy) / im.dispH
    im.zoom = z
    im.dispW = im.baseW * z
    im.dispH = im.baseH * z
    im.ox = im.viewW / 2 - fx * im.dispW
    im.oy = fy * im.dispH - im.viewH / 2
    IMap_Apply(im)
end

local function IMap_UpdateOverlay(im)
    local on = im.overlayOn and true or false
    for _, seg in ipairs(im.activeLines or {}) do
        if on then seg.line:Show() else seg.line:Hide() end
    end
end

local function EnsureInteractive(self, parent)
    if self.imap then
        self.imap.viewport:SetParent(parent)
        return self.imap
    end
    local im = { pins = {}, activePins = {}, zoom = 1, ox = 0, oy = 0 }

    local vp = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    vp:SetClipsChildren(true)
    vp:EnableMouse(true)
    vp:EnableMouseWheel(true)
    vp:SetBackdrop({ edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1 })
    vp:SetBackdropBorderColor(0, 0, 0, 0.8)

    local holder = CreateFrame("Frame", nil, vp)
    holder:SetPoint("TOPLEFT")
    local tex = holder:CreateTexture(nil, "ARTWORK")
    tex:SetPoint("TOPLEFT")

    im.viewport, im.holder, im.tex = vp, holder, tex

    vp:SetScript("OnMouseWheel", function(_, delta)
        IMap_ZoomTo(im, im.zoom + (delta > 0 and 0.5 or -0.5))
    end)
    vp:SetScript("OnMouseDown", function(f, btn)
        if btn ~= "LeftButton" then return end
        local cx, cy = GetCursorPosition()
        im.dragging = true
        im.dragCX, im.dragCY = cx, cy
        im.dragOX, im.dragOY = im.ox, im.oy
    end)
    vp:SetScript("OnMouseUp", function() im.dragging = false end)
    vp:SetScript("OnHide", function() im.dragging = false end)
    vp:SetScript("OnUpdate", function(f)
        if not im.dragging then return end
        if not IsMouseButtonDown("LeftButton") then im.dragging = false; return end
        local cx, cy = GetCursorPosition()
        local s = f:GetEffectiveScale()
        if s <= 0 then return end
        im.ox = im.dragOX + (cx - im.dragCX) / s
        im.oy = im.dragOY + (cy - im.dragCY) / s
        IMap_Apply(im)
    end)

    local function MakeZoomBtn(text, onClick)
        local b = CreateFrame("Button", nil, vp, "UIPanelButtonTemplate")
        b:SetSize(26, 24)
        b:SetText(text)
        b:SetScript("OnClick", onClick)
        b:SetFrameLevel(vp:GetFrameLevel() + 10)
        return b
    end
    im.reset = CreateFrame("Button", nil, vp, "UIPanelButtonTemplate")
    im.reset:SetSize(58, 22)
    im.reset:SetText("Reset")
    im.reset:SetPoint("BOTTOMRIGHT", vp, "BOTTOMRIGHT", -8, 8)
    im.zoomOut = MakeZoomBtn("-", function() IMap_ZoomTo(im, im.zoom - 0.5) end)
    im.zoomOut:SetPoint("BOTTOMRIGHT", im.reset, "TOPRIGHT", 0, 6)
    im.zoomIn = MakeZoomBtn("+", function() IMap_ZoomTo(im, im.zoom + 0.5) end)
    im.zoomIn:SetPoint("BOTTOM", im.zoomOut, "TOP", 0, 4)
    im.reset:SetFrameLevel(vp:GetFrameLevel() + 10)
    im.reset:SetScript("OnClick", function()
        im.ox, im.oy = 0, 0
        IMap_ZoomTo(im, 1)
        im.zoom = 1
        IMap_Apply(im)
    end)

    local toggle = CreateFrame("CheckButton", nil, vp, "UICheckButtonTemplate")
    toggle:SetSize(20, 20)
    toggle:SetPoint("BOTTOMLEFT", vp, "BOTTOMLEFT", 6, 6)
    toggle:SetFrameLevel(vp:GetFrameLevel() + 10)
    toggle.text = toggle:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    toggle.text:SetPoint("LEFT", toggle, "RIGHT", 0, 0)
    toggle.text:SetShadowColor(0, 0, 0, 1)
    toggle.text:SetShadowOffset(1, -1)
    toggle:SetScript("OnClick", function(b)
        im.overlayOn = b:GetChecked() and true or false
        IMap_UpdateOverlay(im)
    end)
    toggle:Hide()
    im.overlayToggle = toggle

    self.imap = im
    return im
end

local function IMap_AcquireLine(im, i, thickness, r, g, b, a)
    im.lines = im.lines or {}
    local l = im.lines[i]
    if not l then
        l = im.holder:CreateLine(nil, "OVERLAY")
        im.lines[i] = l
    end
    l:SetThickness(thickness)
    l:SetColorTexture(r, g, b, a)
    return l
end

local function IMap_AcquirePin(im, i)
    local p = im.pins[i]
    if not p then
        p = CreateFrame("Button", nil, im.holder)
        p:SetSize(20, 20)
        -- Expand the clickable/hover area a few pixels beyond the icon so the
        -- whole marker is interactive, not just its centre.
        p:SetHitRectInsets(-4, -4, -4, -4)
        p.icon = p:CreateTexture(nil, "OVERLAY")
        p.icon:SetAllPoints(p)
        p:SetScript("OnEnter", function(s)
            s._baseLevel = s._baseLevel or s:GetFrameLevel()
            s:SetFrameLevel(s._baseLevel + 50)     -- pop above neighbours on hover
            GameTooltip:SetOwner(s, "ANCHOR_RIGHT")
            GameTooltip:AddLine(s._label or "", 1, 0.82, 0)
            if s._tip and s._tip ~= "" then
                GameTooltip:AddLine(s._tip, 0.9, 0.9, 0.9, true)
            end
            GameTooltip:Show()
        end)
        p:SetScript("OnLeave", function(s)
            if s._baseLevel then s:SetFrameLevel(s._baseLevel) end
            GameTooltip:Hide()
        end)
        im.pins[i] = p
    end
    p:Show()
    return p
end

local function IMap_AcquireLabel(im, i)
    im.labels = im.labels or {}
    local l = im.labels[i]
    if not l then
        l = im.holder:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        l:SetShadowColor(0, 0, 0, 1)
        l:SetShadowOffset(1, -1)
        im.labels[i] = l
    end
    l:Show()
    return l
end

-- Interactive canvas embedded at the builder's current y. Returns height.
function MapView:RenderInteractive(parent, slug, width, yOffset)
    local img = ns.GetMapImage and ns.GetMapImage(slug)
    if not img then return 0 end

    -- When the chapter list is collapsed the content page is much wider; keep
    -- the map at 80% of that width so it doesn't dominate the parchment.
    local fullW = width
    local xOff = 0
    local mf = ns.MainFrame
    if mf and mf.IsChapterNavCollapsed and mf:IsChapterNavCollapsed() then
        width = math.floor(fullW * 0.80 + 0.5)
        xOff = math.floor((fullW - width) / 2 + 0.5)
    end

    local viewW = width
    local viewH = math.min(math.floor(width / MAP_ASPECT) + 0, 460)

    local im = EnsureInteractive(self, parent)
    im.viewW, im.viewH = viewW, viewH
    if im.slug ~= slug then
        im.slug = slug
        im.overlayOn = false
    end

    -- Base size fits the map fully inside the viewport (contain).
    local baseW = viewW
    local baseH = viewW / MAP_ASPECT
    if baseH > viewH then
        baseH = viewH
        baseW = viewH * MAP_ASPECT
    end
    im.baseW, im.baseH = baseW, baseH
    im.zoom, im.ox, im.oy = 1, 0, 0
    -- Scale every pin to how large the map is actually drawn (reference 640px),
    -- so clustered maps like Arathi Basin don't get dwarfed by fixed-size icons.
    im.pinScale = math.max(0.72, math.min(1.0, baseW / 640))

    im.viewport:ClearAllPoints()
    im.viewport:SetPoint("TOPLEFT", parent, "TOPLEFT", xOff, -yOffset)
    im.viewport:SetSize(viewW, viewH)
    im.viewport:SetFrameLevel(parent:GetFrameLevel() + 2)
    im.viewport:Show()

    im.tex:SetTexture(img)
    im.tex:SetTexCoord(0, 1, 0, 1)

    -- Pins.
    for _, p in ipairs(im.pins) do p:Hide() end
    for _, l in ipairs(im.labels or {}) do l:Hide() end
    wipe(im.activePins)
    im.activeLabels = im.activeLabels or {}
    wipe(im.activeLabels)
    local baseLevel = im.holder:GetFrameLevel() + 5
    local map = ns.MapData and ns.MapData[slug]
    if map and map.markers then
        local nx, ny = self:MarkerNormalizer(map)
        local idx, lidx = 0, 0
        for _, m in ipairs(map.markers) do
            local px, py = nx(m.x), ny(m.y)
            if px then
                local cat = m.category
                local mt = ns.MarkerTypes and ns.MarkerTypes[cat]
                if mt and mt.labelOnly then
                    -- Node name labels (Stables, Blacksmith, ...) are text, not
                    -- clickable pins, so they never cover real markers.
                    lidx = lidx + 1
                    local l = IMap_AcquireLabel(im, lidx)
                    l:SetText(m.label or mt.label or "")
                    -- Location names: muted teal (meaning), not gold.
                    l:SetTextColor(0.49, 0.72, 0.69)
                    l._px, l._py = px, py
                    im.activeLabels[#im.activeLabels + 1] = l
                else
                    idx = idx + 1
                    local pin = IMap_AcquirePin(im, idx)
                    pin._label = m.label or (mt and mt.label) or cat or "Point"
                    pin._tip = m.tooltip or (mt and mt.tooltip)
                    pin._px, pin._py = px, py
                    local size = ns.UI.ApplyPinArt(pin.icon, cat, { scale = im.pinScale })
                    pin:SetSize(size, size)
                    -- Smaller markers sit on a higher level so they stay clickable
                    -- when overlapped by larger ones.
                    pin._baseLevel = baseLevel + (40 - math.min(size, 40))
                    pin:SetFrameLevel(pin._baseLevel)
                    im.activePins[#im.activePins + 1] = pin
                end
            end
        end
    end

    -- Optional route overlay, off by default like the website toggle. Drawn as
    -- a dark underlay plus a coloured core so it reads on the parchment art.
    for _, l in ipairs(im.lines or {}) do l:Hide() end
    im.activeLines = im.activeLines or {}
    wipe(im.activeLines)
    local overlay = map and map.overlays and map.overlays[1]
    if overlay and overlay.points and #overlay.points > 1 then
        local nx, ny = self:MarkerNormalizer(map)
        local pts = overlay.points
        local lidx = 0
        for pass = 1, 2 do
            for i = 1, #pts - 1 do
                local x1, y1 = nx(pts[i].x), ny(pts[i].y)
                local x2, y2 = nx(pts[i + 1].x), ny(pts[i + 1].y)
                if x1 and y1 and x2 and y2 then
                    lidx = lidx + 1
                    local line
                    if pass == 1 then
                        line = IMap_AcquireLine(im, lidx, 7, 0.04, 0.07, 0.13, 0.6)
                    else
                        line = IMap_AcquireLine(im, lidx, 3, 0.24, 0.53, 0.95, 1)
                    end
                    im.activeLines[#im.activeLines + 1] =
                        { line = line, x1 = x1, y1 = y1, x2 = x2, y2 = y2 }
                end
            end
        end
    end

    if im.overlayToggle then
        if overlay then
            im.overlayToggle.text:SetText(overlay.label or "Show route")
            im.overlayToggle:SetChecked(im.overlayOn and true or false)
            im.overlayToggle:Show()
        else
            im.overlayToggle:Hide()
        end
    end
    IMap_UpdateOverlay(im)

    IMap_Apply(im)
    return viewH
end

-- Embed inside a continuous guide document at the builder's current y.
function MapView:RenderEmbedded(builder, slug)
    local mf = ns.MainFrame
    local width = builder.width
    local yOffset = builder.y

    local canvasH = self:RenderInteractive(mf.content, slug, width, yOffset)
    if canvasH > 0 then
        if self.canvas then self.canvas:Hide() end
        builder.y = yOffset + canvasH + 10
        builder:Hint("Scroll to zoom, drag to pan. Hover an icon for details.")
        return
    end

    -- Fallback: static Blizzard map textures when no bundled image exists.
    if self.imap and self.imap.viewport then self.imap.viewport:Hide() end
    canvasH = self:RenderCanvas(mf.content, slug, width, yOffset)
    if canvasH > 0 then
        builder.y = yOffset + canvasH + 12
        builder:Hint("Hover an icon for details.")
    else
        builder:Meta("Map art is unavailable for this battleground.")
        self:RenderLegend(builder, slug)
    end
end

-- Fallback tips/labels so every Map Legend row has a body (AB standard).
-- Kept here (not only in generated MarkerTypes) so regen cannot blank them.
local LEGEND_FALLBACKS = {
    ["objective-flag"] = {
        label = "Objective Flag",
        tip = "Capture point. Interact to take the base or tower.",
    },
    ["graveyard-node"] = {
        label = "Node Graveyard",
        tip = "Only the faction that controls the linked base can resurrect here.",
    },
    crystal = {
        tip = "Carry to a turn-in for points plus team Bloodlust or Heroism.",
    },
    ["crystal-turnin"] = {
        tip = "Turn in the Deephaul Crystal here for points and Bloodlust or Heroism.",
    },
    ["mine-depot"] = {
        tip = "Central depot where mine carts spawn.",
    },
    ["track-switch"] = {
        tip = "Redirect carts onto a different route.",
    },
}

-- Canonical row order inside each legend group so Map & Buffs reads the same
-- way on every map (marker insertion order must not reshuffle the list).
local LEGEND_SORT = {
    ["objective-flag"] = 10,
    orb = 20,
    ["mine-depot"] = 30,
    cart = 40,
    ["track-switch"] = 50,
    crystal = 60,
    ["crystal-turnin"] = 70,
    ["faction-flag"] = 80,
    ["faction-spawn"] = 90,
    ["random-empowerment"] = 10,
    berserking = 20,
    restoration = 30,
    ["runners-boots"] = 40,
    ["rune-of-frequency"] = 50,
    ["cooldown-reset"] = 60,
    ["into-the-shadows"] = 70,
    ["absorption-shield"] = 80,
    ["shadow-sight"] = 90,
    ["graveyard-node"] = 10,
    ["faction-gy"] = 20,
    ["kotmogu-gy"] = 30,
}

local FLAG_ROOM_SLUGS = {
    ["warsong-gulch"] = true,
    ["twin-peaks"] = true,
}

-- Collapse faction-split / duplicate categories into one legend row.
-- slug matters for lobby markers (flag room on CTF maps vs spawn elsewhere).
local function LegendMerge(cat, mt, slug)
    -- Returns: key, label, tip, categories (array of marker cats for legend icons)
    if cat == "graveyard-horde" or cat == "graveyard-alliance" then
        return "faction-gy", "Faction Graveyard",
            "Horde and Alliance each have their own graveyard.",
            { "graveyard" }  -- untinted tombstone; map pins keep faction color
    end
    if cat == "lobby-horde" or cat == "lobby-alliance" then
        if FLAG_ROOM_SLUGS[slug] then
            return "faction-flag", "Faction Flag",
                "Each faction's flag room / flag pickup.",
                { "lobby-alliance", "lobby-horde" }
        end
        return "faction-spawn", "Faction Spawn",
            "Each faction's starting spawn.",
            { "lobby-alliance", "lobby-horde" }
    end
    if cat == "kotmogu-orb" then
        return "orb", "Orb of Power",
            (mt and mt.tooltip) or "Carry an orb to earn Victory Points over time.",
            { "kotmogu-orb" }
    end
    if cat == "kotmogu-graveyard" then
        return "kotmogu-gy", "Graveyard",
            (mt and mt.tooltip) or "Respawn point. You resurrect on the opposite side from where you died.",
            { "kotmogu-graveyard" }
    end
    if cat and cat:find("^cart") then
        return "cart", "Mine Cart",
            "Cart spawn and capture points.",
            { cat }
    end
    local fb = LEGEND_FALLBACKS[cat]
    local label = (fb and fb.label) or (mt and mt.label) or cat or "Point"
    local tip = (mt and mt.tooltip) or (fb and fb.tip)
    -- Some markers (e.g. Random Empowerment) describe several possible outcomes
    -- via tooltipHead + tooltipBlocks instead of a single tooltip. Build a full
    -- body so the legend reads like the website instead of showing nothing.
    if (not tip or tip == "") and mt and (mt.tooltipHead or mt.tooltipBlocks) then
        local parts = {}
        if mt.tooltipHead and mt.tooltipHead ~= "" then parts[#parts + 1] = mt.tooltipHead end
        for _, blk in ipairs(mt.tooltipBlocks or {}) do
            local bt = ns.MarkerTypes and ns.MarkerTypes[blk.category]
            local name = (bt and bt.label) or blk.category or ""
            parts[#parts + 1] = name .. ": " .. (blk.text or "")
        end
        tip = table.concat(parts, "\n")
    end
    if (not tip or tip == "") and fb and fb.tip then tip = fb.tip end
    return cat or label, label, tip, cat and { cat } or nil
end

function MapView:RenderLegend(builder, slug)
    local map = ns.MapData and ns.MapData[slug]
    if not map or not map.markers then return end

    local buckets = { objectives = {}, buffs = {}, graveyards = {}, other = {} }
    local labels = {
        objectives = "Objectives",
        buffs = "Buffs & Runes",
        graveyards = "Graveyards & Spawns",
        other = "Other Points",
    }
    local seen = {}
    for _, m in ipairs(map.markers) do
        local cat = m.category
        local mt = ns.MarkerTypes and ns.MarkerTypes[cat]
        local key, label, tip, cats = LegendMerge(cat, mt, slug)
        -- Prefer a marker's own tip only when not merged into a shared row.
        if key == cat and m.tooltip and m.tooltip ~= "" then tip = m.tooltip end
        if not seen[key] then
            seen[key] = true
            local spellID = ns.UI.SPELL_IDS and ns.UI.SPELL_IDS[cat]
            if key == "orb" then spellID = ns.UI.SPELL_IDS and ns.UI.SPELL_IDS["kotmogu-orb"] end
            -- Markers that list several possible outcomes (Random Empowerment)
            -- render as an icon-bulleted list rather than a text blob.
            local blocks = mt and mt.tooltipBlocks
            table.insert(buckets[GroupFor(cat)], {
                key = key,
                label = label,
                tip = blocks and (mt.tooltipHead or "Can spawn:") or tip,
                spellID = spellID,
                categories = cats,
                icon = mt and mt.icon,
                blocks = blocks,
            })
        end
    end

    for _, id in ipairs({ "objectives", "buffs", "graveyards", "other" }) do
        local list = buckets[id]
        if #list > 0 then
            table.sort(list, function(a, b)
                local sa = LEGEND_SORT[a.key] or 500
                local sb = LEGEND_SORT[b.key] or 500
                if sa ~= sb then return sa < sb end
                return tostring(a.label) < tostring(b.label)
            end)
            builder:Subheading(labels[id])
            for _, item in ipairs(list) do
                local title = item.label
                if item.spellID then
                    title = "{spell:" .. item.spellID .. ":" .. item.label .. "}"
                end
                builder:LegendRow({
                    title = title,
                    body = item.tip,
                    categories = item.categories,
                    icon = item.icon,
                    spellID = item.spellID,
                    tipTitle = item.label,
                })
                if item.blocks then
                    builder:EmpowermentList(item.blocks)
                end
            end
            builder:Spacer(4)
        end
    end
end
