EbonholdHub.EchoMapView = {}

local SW = EbonholdHub.SiteWidgets
local ST = EbonholdHub.SiteTheme
local SC = ST.C
local SL = ST.Layout
local FLAT = ST.FLAT

local UI = EbonholdHub.UI
local C  = UI.Colors
local DATA = EbonholdHub.EchoMapData

local DETAIL_WIDTH   = 260
local MARKER_SIZE    = 24
local TOOLBAR_HEIGHT = 48
local FOOTER_HEIGHT  = 40
local MIN_ZOOM       = 1.0
local MAX_ZOOM       = 8.0
local ZOOM_STEP      = 1.1
local DRAG_THRESHOLD_SQ = 36
local SPIDER_MIN_ZOOM = 1.35
local CLUSTER_THRESHOLD_PCT = 0.45
local LINE_TEXTURE = "Interface\\Buttons\\WHITE8X8"
local LINE_SEGMENT_SIZE = 2
local LINE_SEGMENT_STEP = 5

local QUALITY_COLORS = {
    rare  = { 0.0, 112 / 255, 221 / 255 },
    epic  = { 163 / 255, 53 / 255, 238 / 255 },
}

local WOE_QUALITY = {
    common    = 0,
    uncommon  = 1,
    rare      = 2,
    epic      = 3,
    legendary = 4,
}

local QUALITY_SUFFIXES = {
    " %- Common$", " %- Uncommon$", " %- Rare$", " %- Epic$", " %- Legendary$",
}

local iconLookup

local viewFrame
local zoneDropdown
local searchBox
local mapHolder, mapScroll, mapCanvas, mapOverlay
local mapTiles = {}
local detailPanel, detailTitle, detailBody, detailScrollHolder, detailMeasure
local markerPool = {}
local segmentPool = {}
local anchorPool = {}
local zoomLabel

local state = {
    zoneIndex = 1,
    search    = "",
    selected  = nil,
    baseW     = 680,
    baseH     = 680,
    displayW  = 680,
    displayH  = 680,
    zoom      = 1.0,
}

local panState = {
    active     = false,
    dragging   = false,
    startX     = 0,
    startY     = 0,
    startScrollX = 0,
    startScrollY = 0,
}

local linkDialog

local UpdateMapLayout
local RefreshMap

local function GetCanvasSize()
    return state.baseW * state.zoom, state.baseH * state.zoom
end

local function GetViewportSize()
    if not mapScroll then return 680, 400 end
    return mapScroll:GetWidth(), mapScroll:GetHeight()
end

local function ClampScroll(x, y)
    if not mapScroll then return end
    local vpW, vpH = GetViewportSize()
    local canvasW, canvasH = GetCanvasSize()
    local maxX = math.max(0, canvasW - vpW)
    local maxY = math.max(0, canvasH - vpH)
    mapScroll:SetHorizontalScroll(math.max(0, math.min(maxX, x)))
    mapScroll:SetVerticalScroll(math.max(0, math.min(maxY, y)))
end

local function CursorInFrame(frame)
    local x, y = GetCursorPosition()
    local scale = frame:GetEffectiveScale()
    x, y = x / scale, y / scale
    local left, top = frame:GetLeft(), frame:GetTop()
    if not left or not top then return nil, nil end
    return x - left, top - y
end

local function UpdateZoomLabel()
    if zoomLabel then
        zoomLabel:SetText(string.format("%.0f%%", state.zoom * 100))
    end
end

local function BeginPan()
    panState.active = true
    panState.dragging = false
    panState.startX, panState.startY = GetCursorPosition()
    panState.startScrollX = mapScroll:GetHorizontalScroll()
    panState.startScrollY = mapScroll:GetVerticalScroll()
end

local function UpdatePan()
    if not panState.active or not IsMouseButtonDown("LeftButton") then
        return
    end

    local cx, cy = GetCursorPosition()
    local dx = cx - panState.startX
    local dy = cy - panState.startY
    if not panState.dragging then
        if dx * dx + dy * dy < DRAG_THRESHOLD_SQ then
            return
        end
        panState.dragging = true
    end

    local scale = mapScroll:GetEffectiveScale()
    ClampScroll(
        panState.startScrollX - dx / scale,
        panState.startScrollY + dy / scale
    )
end

local function EndPan()
    panState.active = false
    panState.dragging = false
end

local function EndPanKeepDragFlag()
    local wasDragging = panState.dragging
    panState.active = false
    panState.dragging = false
    return wasDragging
end

local function ZoomAt(delta)
    local localX, localY = CursorInFrame(mapScroll)
    if not localX then return end

    local oldZoom = state.zoom
    local factor = delta > 0 and ZOOM_STEP or (1 / ZOOM_STEP)
    local newZoom = math.max(MIN_ZOOM, math.min(MAX_ZOOM, oldZoom * factor))
    if math.abs(newZoom - oldZoom) < 0.001 then return end

    local scrollX = mapScroll:GetHorizontalScroll()
    local scrollY = mapScroll:GetVerticalScroll()
    local ratio = newZoom / oldZoom
    local mapX = scrollX + localX
    local mapY = scrollY + localY

    state.zoom = newZoom
    UpdateMapLayout()
    ClampScroll(mapX * ratio - localX, mapY * ratio - localY)
    UpdateZoomLabel()
end

local function ResetMapView()
    state.zoom = MIN_ZOOM
    UpdateMapLayout()
    ClampScroll(0, 0)
    UpdateZoomLabel()
end

local function OnMapMouseWheel(_, delta)
    ZoomAt(delta)
end

local function OnMapMouseDown(_, button)
    if button == "LeftButton" then
        BeginPan()
    end
end

local function BindMapInteraction(frame)
    frame:EnableMouse(true)
    frame:EnableMouseWheel(true)
    frame:SetScript("OnMouseDown", OnMapMouseDown)
    frame:SetScript("OnMouseUp", EndPan)
    frame:SetScript("OnMouseWheel", OnMapMouseWheel)
end

local function GetZone()
    return DATA.ZONES[state.zoneIndex]
end

local function NormalizeSearch(text)
    return (text or ""):lower():gsub("^%s+", ""):gsub("%s+$", "")
end

local function LocationMatchesSearch(loc, query)
    if query == "" then return true end
    if loc.name and loc.name:lower():find(query, 1, true) then return true end
    if loc.placeName and loc.placeName:lower():find(query, 1, true) then return true end
    if loc.notes and loc.notes:lower():find(query, 1, true) then return true end
    for _, mob in ipairs(loc.mobs or {}) do
        if mob:lower():find(query, 1, true) then return true end
    end
    return false
end

local function GetFilteredLocations()
    local zone = GetZone()
    if not zone then return {} end
    local all = DATA.Locations[zone.id] or {}
    local query = NormalizeSearch(state.search)
    if query == "" then return all end
    local out = {}
    for _, loc in ipairs(all) do
        if LocationMatchesSearch(loc, query) then
            out[#out + 1] = loc
        end
    end
    return out
end

local function QualityColor(quality)
    return QUALITY_COLORS[quality] or C.accent
end

local function NormalizeEchoName(name)
    name = name or ""
    name = name:gsub("’", "'"):gsub("‘", "'"):gsub("`", "'")
    return name
end

local function StripQualitySuffix(name)
    name = NormalizeEchoName(name)
    for _, pattern in ipairs(QUALITY_SUFFIXES) do
        local stripped = name:match("^(.+)" .. pattern)
        if stripped then return stripped end
    end
    return name
end

local function EnsureIconLookup()
    if iconLookup then return end
    iconLookup = {}
    if not ProjectEbonhold or not ProjectEbonhold.PerkDatabase then return end
    for spellId, data in pairs(ProjectEbonhold.PerkDatabase) do
        local raw = data.comment
        if raw and raw ~= "" then
            local name = StripQualitySuffix(raw)
            iconLookup[name] = iconLookup[name] or {}
            iconLookup[name][data.quality] = spellId
        end
    end
end

local function GetLocationSpellId(loc)
    EnsureIconLookup()
    local entry = iconLookup and iconLookup[NormalizeEchoName(loc.name)]
    if not entry then return nil end

    local quality = WOE_QUALITY[loc.quality or ""] or 2
    local spellId = entry[quality]
    if spellId then return spellId end

    for q = 4, 0, -1 do
        if entry[q] then return entry[q] end
    end
    return nil
end

local function GetLocationIcon(loc)
    local spellId = GetLocationSpellId(loc)
    if spellId then
        local tex = select(3, GetSpellInfo(spellId))
        if tex then return tex end
    end
    return "Interface\\Icons\\INV_Misc_Book_09"
end

local function GetLocationQuality(loc)
    return WOE_QUALITY[loc.quality or ""] or 2
end

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

local function GetSpreadRadius(zoom, count)
    if zoom <= SPIDER_MIN_ZOOM or count <= 1 then
        return 0
    end
    local t = (zoom - SPIDER_MIN_ZOOM) / math.max(0.001, MAX_ZOOM - SPIDER_MIN_ZOOM)
    if t < 0 then t = 0 elseif t > 1 then t = 1 end
    local ring = MARKER_SIZE * (0.65 + count * 0.28)
    return ring * (0.35 + t * 1.65)
end

local function BuildPlacementData(locations, canvasW, canvasH)
    local thresholdSq = CLUSTER_THRESHOLD_PCT * CLUSTER_THRESHOLD_PCT
    local points = {}
    for index, loc in ipairs(locations) do
        points[index] = {
            index = index,
            loc = loc,
            x = canvasW * loc.x / 100,
            y = canvasH * loc.y / 100,
            displayX = canvasW * loc.x / 100,
            displayY = canvasH * loc.y / 100,
        }
    end

    local clusters = {}
    local assigned = {}

    for i, point in ipairs(points) do
        if not assigned[i] then
            local cluster = { members = {} }
            cluster.members[#cluster.members + 1] = point
            assigned[i] = true

            local changed = true
            while changed do
                changed = false
                for j, other in ipairs(points) do
                    if not assigned[j] then
                        for _, member in ipairs(cluster.members) do
                            if LocationMapDistanceSq(member.loc, other.loc) <= thresholdSq then
                                cluster.members[#cluster.members + 1] = other
                                assigned[j] = true
                                changed = true
                                break
                            end
                        end
                    end
                end
            end

            clusters[#clusters + 1] = cluster
        end
    end

    for _, cluster in ipairs(clusters) do
        local count = #cluster.members
        local cx, cy = 0, 0
        for _, member in ipairs(cluster.members) do
            cx = cx + member.x
            cy = cy + member.y
        end
        cx = cx / count
        cy = cy / count

        local radius = GetSpreadRadius(state.zoom, count)
        if radius > 0 and count > 1 then
            table.sort(cluster.members, function(a, b)
                local nameA = a.loc.name or ""
                local nameB = b.loc.name or ""
                if nameA ~= nameB then return nameA < nameB end
                return (a.loc.tomeId or "") < (b.loc.tomeId or "")
            end)

            local angleStep = (2 * math.pi) / count
            local startAngle = -math.pi / 2
            for memberIndex, member in ipairs(cluster.members) do
                local angle = startAngle + (memberIndex - 1) * angleStep
                member.displayX = cx + math.cos(angle) * radius
                member.displayY = cy + math.sin(angle) * radius
            end
        else
            for _, member in ipairs(cluster.members) do
                member.displayX = member.x
                member.displayY = member.y
            end
        end
    end

    return points
end

local segmentUsed = 0

local function AcquireSegment(index)
    if segmentPool[index] then return segmentPool[index] end
    local seg = mapOverlay:CreateTexture(nil, "OVERLAY")
    seg:SetTexture(LINE_TEXTURE)
    if seg.SetDrawLayer then
        seg:SetDrawLayer("OVERLAY", 1)
    end
    segmentPool[index] = seg
    return seg
end

local function DrawConnectorSegments(x1, y1, x2, y2)
    local dx, dy = x2 - x1, y2 - y1
    local length = math.sqrt(dx * dx + dy * dy)
    if length < 2 then return end

    local steps = math.max(2, math.floor(length / LINE_SEGMENT_STEP))
    for step = 0, steps do
        segmentUsed = segmentUsed + 1
        local t = step / steps
        local seg = AcquireSegment(segmentUsed)
        seg:SetWidth(LINE_SEGMENT_SIZE)
        seg:SetHeight(LINE_SEGMENT_SIZE)
        seg:SetVertexColor(C.accent[1], C.accent[2], C.accent[3], 0.7)
        seg:ClearAllPoints()
        seg:SetPoint("CENTER", mapOverlay, "TOPLEFT", x1 + dx * t, -(y1 + dy * t))
        seg:Show()
    end
end

local function HideExtraSegments(fromIndex)
    for index = fromIndex, #segmentPool do
        segmentPool[index]:Hide()
    end
end

local function AcquireAnchor(index)
    if anchorPool[index] then return anchorPool[index] end
    local dot = mapOverlay:CreateTexture(nil, "OVERLAY")
    dot:SetTexture(LINE_TEXTURE)
    dot:SetVertexColor(C.accent[1], C.accent[2], C.accent[3], 1)
    dot:SetWidth(6)
    dot:SetHeight(6)
    anchorPool[index] = dot
    return dot
end

local function ApplyMarkerSelection(btn, selected)
    if not btn._selectGlow then return end
    if selected then
        btn._selectGlow:Show()
    else
        btn._selectGlow:Hide()
    end
end

local function CenterLinkDialog(dialog)
    local main = EbonholdHub.MainWindow and EbonholdHub.MainWindow._frame
    dialog:ClearAllPoints()
    if main and main:IsShown() then
        dialog:SetFrameStrata("FULLSCREEN_DIALOG")
        dialog:SetFrameLevel(main:GetFrameLevel() + 20)
        dialog:SetPoint("CENTER", main, "CENTER", 0, 0)
    else
        dialog:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
    end
end

local function EnsureLinkDialog()
    if linkDialog then return end
    local f = UI.CreateWindow("EbonholdHubEchoMapLinkDialog", 520, 130, "World of Echoes", {
        strata = "FULLSCREEN_DIALOG",
        toplevel = true,
    })
    local content = f.content

    local hint = UI.CreateLabel(content,
        "Full map viewer on the web. Copy the link:",
        UI.FONT_SIZE_SMALL, C.textDim)
    hint:SetPoint("TOPLEFT", content, "TOPLEFT", 0, 0)
    f._hint = hint

    local editHolder = UI.CreateEditBox(content, 480, 28, false)
    editHolder:SetPoint("TOPLEFT", hint, "BOTTOMLEFT", 0, -8)
    local box = editHolder.editBox
    box:SetMaxLetters(256)
    box:SetScript("OnEscapePressed", function() f:Hide() end)

    local close = UI.CreateButton(content, 80, 26, "Close")
    close:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0)
    close:SetScript("OnClick", function() f:Hide() end)

    f._editBox = box
    linkDialog = f
end

local function ShowWebsiteLink()
    EnsureLinkDialog()
    local url = DATA.SOURCE_URL or "https://worldofechoes.pages.dev"
    local opened = EbonholdHub.OpenExternalURL and EbonholdHub.OpenExternalURL(url)
    if linkDialog._hint then
        if opened then
            linkDialog._hint:SetText("Opened in your browser. Copy the link below if needed:")
        else
            linkDialog._hint:SetText("Full map viewer on the web. Copy the link:")
        end
    end
    linkDialog._editBox:SetText(url)
    linkDialog._editBox:HighlightText()
    CenterLinkDialog(linkDialog)
    linkDialog:Show()
    linkDialog._editBox:SetFocus()
end

local function UpdateDetailLayout()
    if not detailScrollHolder or not detailBody then return end
    local scroll = detailScrollHolder.scrollFrame
    local child = detailScrollHolder.scrollChild
    local width = scroll and scroll:GetWidth()
    if not width or width <= 0 then return end

    local textW = math.max(1, width - 8)
    detailBody:SetWidth(textW)
    if detailMeasure then
        detailMeasure:SetWidth(textW)
        local text = detailBody:GetText()
        if text and text ~= "" then
            detailMeasure:SetText(text)
        end
        local textHeight = detailMeasure:GetStringHeight() or 0
        child:SetWidth(textW)
        child:SetHeight(math.max(textHeight + 8, scroll:GetHeight()))
    end
    scroll:SetVerticalScroll(0)
    if detailScrollHolder.UpdateThumb then
        detailScrollHolder.UpdateThumb()
    end
end

local function SetDetailLocation(loc)
    state.selected = loc
    if not loc then
        detailTitle:SetText("Select a marker")
        detailTitle:SetTextColor(unpack(SC.text))
        detailBody:SetText("Click a dot on the map to see the Tome of Echo, zone, and mobs.\n\nData from World of Echoes.")
        UpdateDetailLayout()
        return
    end

    detailTitle:SetText(loc.name or "Unknown")
    local qc = QualityColor(loc.quality)
    detailTitle:SetTextColor(qc[1], qc[2], qc[3])

    local lines = {}
    lines[#lines + 1] = (loc.quality or "rare"):upper()
    if loc.placeName and loc.placeName ~= "" then
        lines[#lines + 1] = loc.placeName
    end
    if loc.mobs and #loc.mobs > 0 then
        lines[#lines + 1] = "Mobs: " .. table.concat(loc.mobs, ", ")
    end
    if loc.notes and loc.notes ~= "" then
        lines[#lines + 1] = loc.notes
    end
    if loc.description and loc.description ~= "" then
        lines[#lines + 1] = ""
        lines[#lines + 1] = loc.description
    end
    detailBody:SetText(table.concat(lines, "\n"))
    UpdateDetailLayout()
end

local function BuildZoneOptions()
    local options = {}
    for index, zone in ipairs(DATA.ZONES) do
        options[#options + 1] = { text = zone.name, value = index }
    end
    return options
end

local function AcquireMarker(index)
    local btn = markerPool[index]
    if btn and btn.icon then return btn end
    if btn then btn:Hide() end

    btn = UI.CreateIconButton(mapCanvas, MARKER_SIZE)
    btn:SetFrameLevel(mapCanvas:GetFrameLevel() + 20)

    local selectGlow = btn:CreateTexture(nil, "OVERLAY")
    selectGlow:SetTexture("Interface\\Buttons\\UI-ActionButton-Border")
    selectGlow:SetBlendMode("ADD")
    selectGlow:SetPoint("TOPLEFT", -5, 5)
    selectGlow:SetPoint("BOTTOMRIGHT", 5, -5)
    selectGlow:SetVertexColor(unpack(C.accent))
    selectGlow:Hide()
    btn._selectGlow = selectGlow

    btn:EnableMouseWheel(true)
    btn:SetScript("OnMouseDown", function()
        BeginPan()
    end)
    btn:SetScript("OnMouseUp", function(self)
        if EndPanKeepDragFlag() then return end
        SetDetailLocation(self._location)
        for _, marker in ipairs(markerPool) do
            ApplyMarkerSelection(marker, false)
        end
        ApplyMarkerSelection(self, true)
    end)
    btn:SetScript("OnMouseWheel", OnMapMouseWheel)
    btn:SetScript("OnEnter", function(self)
        local loc = self._location
        if not loc then return end
        if self._bg then self._bg:SetVertexColor(unpack(C.elementHov)) end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:ClearLines()
        local spellId = GetLocationSpellId(loc)
        if spellId then
            GameTooltip:SetHyperlink("spell:" .. spellId)
        else
            local qc = QualityColor(loc.quality)
            GameTooltip:AddLine(loc.name or "Unknown", qc[1], qc[2], qc[3])
        end
        if loc.placeName and loc.placeName ~= "" then
            GameTooltip:AddLine(loc.placeName, 1, 1, 1)
        end
        if loc.mobs and #loc.mobs > 0 then
            GameTooltip:AddLine(table.concat(loc.mobs, ", "), 0.7, 0.7, 0.7, true)
        end
        GameTooltip:Show()
    end)
    btn:SetScript("OnLeave", function(self)
        if self._bg then self._bg:SetVertexColor(unpack(C.elementBg)) end
        GameTooltip:Hide()
    end)

    markerPool[index] = btn
    return btn
end

local function ApplyTileTexture(tex, path)
    if not path or path == "" then
        tex:SetTexture(nil)
        return false
    end
    if not path:match("%.tga$") then
        path = path .. ".tga"
    end
    tex:SetTexture(path)
    return tex:GetTexture() ~= nil
end

local function ClearMapTiles()
    for _, entry in ipairs(mapTiles) do
        entry:Hide()
    end
end

local function LayoutMapTiles(zone, displayW, displayH)
    ClearMapTiles()
    local scaleX = displayW / zone.width
    local scaleY = displayH / zone.height

    for index, tile in ipairs(zone.tiles or {}) do
        local tex = mapTiles[index]
        if not tex then
            tex = mapCanvas:CreateTexture(nil, "ARTWORK")
            mapTiles[index] = tex
        end

        local w = math.max(1, math.floor(tile.width * scaleX))
        local h = math.max(1, math.floor(tile.height * scaleY))
        local x = math.floor(tile.offsetX * scaleX)
        local y = -math.floor(tile.offsetY * scaleY)

        ApplyTileTexture(tex, tile.file)
        tex:ClearAllPoints()
        tex:SetPoint("TOPLEFT", mapCanvas, "TOPLEFT", x, y)
        tex:SetSize(w, h)
        tex:Show()
    end

    for index = #(zone.tiles or {}) + 1, #mapTiles do
        mapTiles[index]:Hide()
    end
end

local function RecalcBaseSize()
    local zone = GetZone()
    if not zone then return end

    local viewportW = mapScroll and mapScroll:GetWidth()
    if not viewportW or viewportW < 200 then
        viewportW = math.max(200, (viewFrame:GetWidth() or 720) - DETAIL_WIDTH - 24)
    end
    state.baseW = math.floor(viewportW)
    state.baseH = math.floor(zone.height * (state.baseW / zone.width))
    if state.baseH < 1 then state.baseH = 1 end
end

UpdateMapLayout = function()
    local zone = GetZone()
    if not zone then return end

    local canvasW = math.floor(state.baseW * state.zoom)
    local canvasH = math.floor(state.baseH * state.zoom)
    state.displayW = canvasW
    state.displayH = canvasH

    mapCanvas:SetSize(canvasW, canvasH)
    LayoutMapTiles(zone, canvasW, canvasH)

    local locations = GetFilteredLocations()
    local placements = BuildPlacementData(locations, canvasW, canvasH)
    segmentUsed = 0
    local anchorUsed = 0
    local anchorShown = {}
    local used = 0
    for index, loc in ipairs(locations) do
        used = index
        local placement = placements[index]
        local trueX = placement.x
        local trueY = placement.y
        local displayX = placement.displayX
        local displayY = placement.displayY
        local spread = (displayX - trueX) * (displayX - trueX) + (displayY - trueY) * (displayY - trueY)

        local btn = AcquireMarker(index)
        btn._location = loc
        btn:ClearAllPoints()
        btn:SetPoint("CENTER", mapCanvas, "TOPLEFT", displayX, -displayY)
        btn.icon:SetTexture(GetLocationIcon(loc))
        UI.SetIconButtonQuality(btn, GetLocationQuality(loc))
        ApplyMarkerSelection(btn, state.selected == loc)
        btn:Show()

        if spread > 1 then
            DrawConnectorSegments(displayX, displayY, trueX, trueY)
            local key = string.format("%.3f|%.3f", loc.x, loc.y)
            if not anchorShown[key] then
                anchorShown[key] = true
                anchorUsed = anchorUsed + 1
                local anchor = AcquireAnchor(anchorUsed)
                anchor:ClearAllPoints()
                anchor:SetPoint("CENTER", mapOverlay, "TOPLEFT", trueX, -trueY)
                anchor:Show()
            end
        end
    end

    for index = used + 1, #markerPool do
        markerPool[index]:Hide()
    end
    HideExtraSegments(segmentUsed + 1)
    for index = anchorUsed + 1, #anchorPool do
        if anchorPool[index] then anchorPool[index]:Hide() end
    end
end

RefreshMap = function(resetView)
    RecalcBaseSize()
    if resetView then
        state.zoom = MIN_ZOOM
    end
    UpdateMapLayout()
    if resetView then
        ClampScroll(0, 0)
    else
        ClampScroll(mapScroll:GetHorizontalScroll(), mapScroll:GetVerticalScroll())
    end
    UpdateZoomLabel()
end

local function SelectZone(index)
    if index < 1 or index > #DATA.ZONES then return end
    state.zoneIndex = index
    state.selected = nil
    SetDetailLocation(nil)
    RefreshMap(true)
end

local function BuildViewFrame()
    local f = CreateFrame("Frame", nil, UIParent)

    local body = CreateFrame("Frame", nil, f)
    body:SetPoint("TOPLEFT", SL.PAD, -SL.PAD)
    body:SetPoint("BOTTOMRIGHT", -SL.PAD, SL.PAD)

    local mapPanel = CreateFrame("Frame", nil, body)
    mapPanel:SetAllPoints(body)
    SW.Fill(mapPanel, SC.bg)
    mapPanel._border = SW.ThinBorder(mapPanel, SC.border, 1)

    local toolbar = CreateFrame("Frame", nil, mapPanel)
    toolbar:SetPoint("TOPLEFT", mapPanel, "TOPLEFT", 0, 0)
    toolbar:SetPoint("TOPRIGHT", mapPanel, "TOPRIGHT", 0, 0)
    toolbar:SetHeight(TOOLBAR_HEIGHT)
    SW.Fill(toolbar, SC.bgElevated)
    local toolbarSep = toolbar:CreateTexture(nil, "ARTWORK")
    toolbarSep:SetTexture(FLAT)
    toolbarSep:SetVertexColor(unpack(SC.border))
    toolbarSep:SetHeight(1)
    toolbarSep:SetPoint("BOTTOMLEFT", toolbar, "BOTTOMLEFT", 0, 0)
    toolbarSep:SetPoint("BOTTOMRIGHT", toolbar, "BOTTOMRIGHT", 0, 0)

    local zoneLabel = SW.Label(toolbar, "Zone", 10, SC.textMuted)
    zoneLabel:SetPoint("LEFT", 14, 0)

    zoneDropdown = UI.CreateDropdown(toolbar, 170, BuildZoneOptions(), 1)
    zoneDropdown:SetPoint("LEFT", zoneLabel, "RIGHT", 8, 0)
    zoneDropdown.OnValueChanged = function(value)
        SelectZone(value or 1)
    end

    local resetBtn = SW.CreateOutlineButton(toolbar, "Reset view", 88)
    resetBtn:SetPoint("LEFT", zoneDropdown, "RIGHT", 10, 0)
    resetBtn:SetScript("OnClick", ResetMapView)

    local searchHolder = UI.CreateEditBox(toolbar, 180, 26, false)
    searchHolder:SetPoint("RIGHT", toolbar, "RIGHT", -14, 0)
    searchBox = searchHolder.editBox
    searchBox:SetMaxLetters(64)
    searchBox:SetScript("OnTextChanged", function(self)
        state.search = self:GetText() or ""
        UpdateMapLayout()
    end)
    searchBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    local searchHint = SW.Label(toolbar, "Search", 10, SC.textMuted)
    searchHint:SetPoint("RIGHT", searchHolder, "LEFT", -8, 0)

    detailPanel = CreateFrame("Frame", nil, mapPanel)
    detailPanel:SetPoint("TOPRIGHT", toolbar, "BOTTOMRIGHT", 0, 0)
    detailPanel:SetPoint("BOTTOMRIGHT", mapPanel, "BOTTOMRIGHT", 0, FOOTER_HEIGHT)
    detailPanel:SetWidth(DETAIL_WIDTH)
    SW.Fill(detailPanel, SC.bgElevated)
    local detailDivider = detailPanel:CreateTexture(nil, "ARTWORK")
    detailDivider:SetTexture(FLAT)
    detailDivider:SetVertexColor(unpack(SC.border))
    detailDivider:SetWidth(1)
    detailDivider:SetPoint("TOPLEFT", detailPanel, "TOPLEFT", 0, 0)
    detailDivider:SetPoint("BOTTOMLEFT", detailPanel, "BOTTOMLEFT", 0, 0)

    detailTitle = SW.Label(detailPanel, "Select a marker", 13, SC.text, false, "semibold")
    detailTitle:SetPoint("TOPLEFT", detailPanel, "TOPLEFT", 14, -12)
    detailTitle:SetPoint("TOPRIGHT", detailPanel, "TOPRIGHT", -14, -12)
    detailTitle:SetJustifyH("LEFT")
    detailTitle:SetWordWrap(true)

    detailScrollHolder = UI.CreateScrollFrame(detailPanel)
    detailScrollHolder:SetPoint("TOPLEFT", detailTitle, "BOTTOMLEFT", -4, -8)
    detailScrollHolder:SetPoint("BOTTOMRIGHT", detailPanel, "BOTTOMRIGHT", -8, 8)

    local detailChild = detailScrollHolder.scrollChild
    detailBody = detailChild:CreateFontString(nil, "OVERLAY")
    SW.SetFont(detailBody, 11, "regular")
    detailBody:SetTextColor(unpack(SC.textMuted))
    detailBody:SetPoint("TOPLEFT", detailChild, "TOPLEFT", 4, -4)
    detailBody:SetJustifyH("LEFT")
    detailBody:SetJustifyV("TOP")
    detailBody:SetWordWrap(true)
    detailBody:SetText("Click a dot on the map to see Tome details.")

    detailMeasure = detailChild:CreateFontString(nil, "OVERLAY")
    SW.SetFont(detailMeasure, 11, "regular")
    detailMeasure:Hide()

    detailScrollHolder.scrollFrame:SetScript("OnSizeChanged", UpdateDetailLayout)

    mapHolder = CreateFrame("Frame", nil, mapPanel)
    mapHolder:SetPoint("TOPLEFT", toolbar, "BOTTOMLEFT", 0, 0)
    mapHolder:SetPoint("BOTTOMLEFT", mapPanel, "BOTTOMLEFT", 0, FOOTER_HEIGHT)
    mapHolder:SetPoint("RIGHT", detailPanel, "LEFT", 0, 0)
    SW.Fill(mapHolder, SC.bg)
    mapHolder._border = SW.ThinBorder(mapHolder, SC.border, 1)

    mapScroll = CreateFrame("ScrollFrame", nil, mapHolder)
    mapScroll:SetPoint("TOPLEFT", 4, -4)
    mapScroll:SetPoint("BOTTOMRIGHT", -4, 4)

    mapCanvas = CreateFrame("Frame", nil, mapScroll)
    mapCanvas:SetSize(680, 680)
    mapScroll:SetScrollChild(mapCanvas)
    if not mapCanvas._bg then
        mapCanvas._bg = SW.Fill(mapCanvas, SC.bg)
    end

    mapOverlay = CreateFrame("Frame", nil, mapCanvas)
    mapOverlay:SetAllPoints(mapCanvas)
    mapOverlay:EnableMouse(false)
    mapOverlay:SetFrameLevel(mapCanvas:GetFrameLevel() + 8)

    BindMapInteraction(mapScroll)
    BindMapInteraction(mapCanvas)

    f:SetScript("OnSizeChanged", function()
        if f:IsShown() then
            RefreshMap(false)
            UpdateDetailLayout()
        end
    end)
    f:SetScript("OnUpdate", function(self, elapsed)
        if panState.active then
            UpdatePan()
        end
        if self._layoutWait then
            self._layoutWait = self._layoutWait - elapsed
            if self._layoutWait <= 0 then
                self._layoutWait = nil
                RefreshMap(false)
                UpdateDetailLayout()
            end
        end
    end)

    local footer = CreateFrame("Frame", nil, mapPanel)
    footer:SetPoint("BOTTOMLEFT", mapPanel, "BOTTOMLEFT", 0, 0)
    footer:SetPoint("BOTTOMRIGHT", mapPanel, "BOTTOMRIGHT", 0, 0)
    footer:SetHeight(FOOTER_HEIGHT)
    SW.Fill(footer, SC.elementBg)
    local footerSep = footer:CreateTexture(nil, "ARTWORK")
    footerSep:SetTexture(FLAT)
    footerSep:SetVertexColor(unpack(SC.border))
    footerSep:SetHeight(1)
    footerSep:SetPoint("TOPLEFT", footer, "TOPLEFT", 0, 0)
    footerSep:SetPoint("TOPRIGHT", footer, "TOPRIGHT", 0, 0)

    local credit = SW.Label(footer,
        "Wheel: zoom  |  Drag: pan  |  Map data from World of Echoes",
        10, SC.textMuted)
    credit:SetPoint("LEFT", 14, 0)

    local webBtn = SW.CreateAccentButton(footer, "Open website", ShowWebsiteLink)
    webBtn:SetSize(110, 28)
    webBtn:SetPoint("RIGHT", footer, "RIGHT", -14, 0)

    zoomLabel = SW.Label(footer, "100%", 11, SC.text, false, "semibold")
    zoomLabel:SetPoint("RIGHT", webBtn, "LEFT", -12, 0)

    f._echoMapLayoutV1 = true
    return f
end

function EbonholdHub.EchoMapView.Mount(container)
    if viewFrame and not viewFrame._echoMapLayoutV1 then
        viewFrame = nil
    end
    if not viewFrame then
        viewFrame = BuildViewFrame()
    end
    viewFrame:SetParent(container)
    viewFrame:ClearAllPoints()
    viewFrame:SetAllPoints(container)

    state.zoneIndex = 1
    state.search = ""
    state.selected = nil
    state.zoom = MIN_ZOOM
    iconLookup = nil
    if searchBox then searchBox:SetText("") end
    if zoneDropdown then zoneDropdown:SetSelectedIndex(1) end
    SetDetailLocation(nil)
    viewFrame:Show()
    viewFrame._layoutWait = 0.05
    RefreshMap(true)
end

function EbonholdHub.EchoMapView.Unmount()
    GameTooltip:Hide()
    EndPan()
    if UI.CloseAllDropdowns then UI.CloseAllDropdowns() end
    if linkDialog then linkDialog:Hide() end
    if viewFrame then viewFrame:Hide() end
end

function EbonholdHub.EchoMapView.Init()
end
