-- Map tab (FEASIBILITY SPIKE). Renders the in-game map via an <InGameMap>
-- element and drops a marker on every registered barn/pasture. The two unknowns
-- (the mission-map accessor and the MapHotspot constructor) are resolved
-- empirically here, with heavy logging, before the full left-panel layout is
-- built on top.

PHMenuMapFrame = {}
local PHMenuMapFrame_mt = Class(PHMenuMapFrame, TabbedMenuFrameElement)

local modDirectory = g_currentModDirectory

local function placeableName(p)
    if p == nil then return "?" end
    -- Prefer a player-assigned name (Rename option) over the store name.
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    if reg ~= nil and type(reg.getCustomName) == "function" then
        local cn = reg:getCustomName(p)
        if type(cn) == "string" and cn ~= "" then return cn end
    end
    if type(p.getName) == "function" then
        local ok, name = pcall(function() return p:getName() end)
        if ok and type(name) == "string" then return name end
    end
    return tostring(p)
end

local function listBarns()
    local out = {}
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or f.grazingRegistry == nil then return out end
    for barnIdx, barn in f.grazingRegistry:iterBarns() do
        out[#out + 1] = { idx = barnIdx, barn = barn }
    end
    return out
end

function PHMenuMapFrame.new(target, mt)
    local self = PHMenuMapFrame:superClass().new(target, mt or PHMenuMapFrame_mt)
    self.name = "PHMenuMapFrame"
    self.hasCustomMenuButtons = true
    self.backButtonInfo = { inputAction = InputAction.MENU_BACK }
    self.menuButtonInfo = { self.backButtonInfo }
    self._wired = false
    self._barns = {}       -- array of { idx, barn }
    self._pastures = {}    -- placeables of the selected barn
    self._ctxActions = {}  -- context-popup rows: { label, fn }
    self.selectedBarnIdx = nil
    self.selectedPastureIdx = nil
    return self
end

-- ── Left panel: barn PAGER (◄ ►) on top, that barn's pastures listed below ───
function PHMenuMapFrame:getNumberOfSections(list) return 1 end

function PHMenuMapFrame:getNumberOfItemsInSection(list, section)
    if list == self.contextButtonList then return #(self._ctxActions or {}) end
    if list == self.pasturesList then return #self._pastures end
    return 0
end

local function setCellIcon(cell, sliceId)
    local icon = cell:getAttribute("icon")
    if icon == nil then return end
    if type(icon.setImageSlice) == "function" then
        pcall(function() icon:setImageSlice(nil, sliceId) end)
    end
    icon.imageSliceId = sliceId
end

function PHMenuMapFrame:populateCellForItemInSection(list, section, index, cell)
    if list == self.contextButtonList then
        local action = self._ctxActions and self._ctxActions[index]
        local textElem = cell:getAttribute("text")
        if action and textElem and type(textElem.setText) == "function" then
            textElem:setText(action.label)
        end
        return
    end
    if list ~= self.pasturesList then return end
    local pasture = self._pastures[index]
    if pasture == nil then return end
    local nameElem = cell:getAttribute("name")
    if nameElem and type(nameElem.setText) == "function" then
        nameElem:setText(placeableName(pasture) .. "   P" .. index)
    end
    local activityElem = cell:getAttribute("activity")
    if activityElem and type(activityElem.setText) == "function" then
        local barn = self:_currentBarn()
        local activeIdx = barn and barn.activeIndex or 0
        activityElem:setText(activeIdx == index and "ACTIVE" or "")
    end
    setCellIcon(cell, "gui.icon_others_farmland")
end

function PHMenuMapFrame:_currentBarn()
    for _, entry in ipairs(self._barns) do
        if entry.idx == self.selectedBarnIdx then return entry.barn end
    end
    return nil
end

-- Fill the barn pager and point it at the selected barn.
function PHMenuMapFrame:_seedBarnSelector()
    local picker = self.barnSelector
    if picker == nil or type(picker.setTexts) ~= "function" then return end
    local labels, defaultState = {}, 1
    for i, entry in ipairs(self._barns) do
        labels[i] = placeableName(entry.barn.placeable) .. "   B" .. entry.idx
        if entry.idx == self.selectedBarnIdx then defaultState = i end
    end
    if #labels == 0 then labels[1] = "(no barns registered)" end
    picker:setTexts(labels)
    if type(picker.setState) == "function" then picker:setState(defaultState, true) end
end

function PHMenuMapFrame:_reloadBarns()
    self._barns = listBarns()
    if self.selectedBarnIdx == nil and self._barns[1] ~= nil then
        self.selectedBarnIdx = self._barns[1].idx
    end
    self:_seedBarnSelector()
end

function PHMenuMapFrame:_reloadPastures()
    local barn = self:_currentBarn()
    self._pastures = {}
    if barn ~= nil then
        for _, p in ipairs(barn.pastures) do self._pastures[#self._pastures + 1] = p end
    end
    if self.selectedPastureIdx ~= nil and self.selectedPastureIdx > #self._pastures then
        self.selectedPastureIdx = nil
    end
    if self.pasturesList ~= nil and type(self.pasturesList.reloadData) == "function" then
        self.pasturesList:reloadData()
    end
end

-- Barn pager cycled (◄ ► / click) -> switch selected barn, reload its pastures.
function PHMenuMapFrame:onClickBarnSelector(stateIndex, element)
    local entry = self._barns[stateIndex]
    if entry ~= nil then
        self.selectedBarnIdx = entry.idx
        self.selectedPastureIdx = nil
        self:_reloadPastures()
    end
end

function PHMenuMapFrame:onPastureSelectionChanged(list, section, index)
    self.selectedPastureIdx = (self._pastures[index] ~= nil) and index or nil
end

function PHMenuMapFrame.setupGui()
    if g_gui == nil then return end
    local xmlPath = Utils.getFilename("gui/frames/PHMenuMapFrame.xml", modDirectory)
    g_gui:loadGui(xmlPath, "PHMenuMapFrame", PHMenuMapFrame.new(), true)
end

function PHMenuMapFrame:initialize() end

-- Nearest husbandry placeable (barn or pasture) to a world (x,z), within radius.
function PHMenuMapFrame:_findHusbandryNear(wx, wz, radius)
    local sys = g_currentMission and g_currentMission.placeableSystem
    if sys == nil or sys.placeables == nil then return nil end
    local best, bestD = nil, radius or 40
    for _, p in pairs(sys.placeables) do
        if type(p) == "table" and p.spec_husbandryAnimals ~= nil
           and p.rootNode ~= nil and p.rootNode ~= 0 then
            local x, _, z = getWorldTranslation(p.rootNode)
            if x ~= nil then
                local dx, dz = x - wx, z - wz
                local d = math.sqrt(dx * dx + dz * dz)
                if d < bestD then bestD = d; best = p end
            end
        end
    end
    return best
end

-- Clicking a hotspot: identify the husbandry under it and open the context
-- popup (register / options) for it.
function PHMenuMapFrame:onClickHotspot(element, hotspot)
    if hotspot == nil then return false end
    local wx, wz
    if type(hotspot.getWorldPosition) == "function" then
        local ok, a, b = pcall(function() return hotspot:getWorldPosition() end)
        if ok and type(a) == "number" then wx, wz = a, b end
    end
    -- Resolve the EXACT husbandry from the clicked hotspot itself (each hotspot
    -- carries its placeable). This is what makes staggered/overlapping icons in a
    -- multi-husbandry shed individually selectable — proximity alone can't tell
    -- two barns in one shed apart. Fall back to nearest only if no placeable.
    local placeable = hotspot.placeable
    if placeable == nil and type(wx) == "number" then
        placeable = self:_findHusbandryNear(wx, wz, 40)
    end
    if placeable == nil then return false end
    self._clickedPlaceable = placeable
    -- Icon's on-screen position (same world->screen transform IngameMapElement
    -- uses in panToHotspot) so the popup can open right next to it. Fall back to the
    -- placeable's own position if the hotspot gave none.
    if type(wx) ~= "number" and placeable.rootNode ~= nil and getWorldTranslation ~= nil then
        wx, _, wz = getWorldTranslation(placeable.rootNode)
    end
    if type(wx) == "number" then
        self._clickSX, self._clickSY = self:_screenPosForWorld(wx, wz)
    end
    -- Mirror the click into the left panel selection (before the popup, so its
    -- barn-relative actions use the new selection too).
    self:_syncPanelSelectionTo(placeable)
    self:_openContextFor(placeable)
    return true
end

-- Point the left panel (barn pager + pasture list) at the clicked barn/pasture.
-- For a pasture: keep the current barn if it owns it (shared pastures), else
-- switch to its primary owner. No-op for unregistered husbandries.
function PHMenuMapFrame:_syncPanelSelectionTo(placeable)
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    local ref = reg and reg.byPlaceable and reg.byPlaceable[placeable]
    if ref == nil then return end

    if ref.kind == "barn" then
        -- Switch the pager to this barn. (setState fires onClickBarnSelector,
        -- which nils selectedPastureIdx — fine, we want no pasture selected.)
        self.selectedBarnIdx = ref.barnIdx
        self:_seedBarnSelector()
        self:_reloadPastures()
        self.selectedPastureIdx = nil
        self:_applyListSelection()
        self._pendingListSelect = 0
        return
    end

    -- Pasture: only select it if the CURRENTLY-shown barn owns it. If it belongs
    -- to a different barn, leave the panel where it is so the popup can offer to
    -- SHARE this pasture into the current barn (the add-to-second-barn workflow).
    -- Don't switch barns here — that would hide the share option.
    local selBarn = reg:getBarn(self.selectedBarnIdx)
    local ownIdx
    if selBarn ~= nil then
        for i, p in ipairs(selBarn.pastures) do if p == placeable then ownIdx = i; break end end
    end
    if ownIdx ~= nil then
        self.selectedPastureIdx = ownIdx
        self:_applyListSelection()
        self._pendingListSelect = ownIdx
    end
end

-- Highlight the selected pasture row (or clear it when selectedPastureIdx is nil).
function PHMenuMapFrame:_applyListSelection()
    local list = self.pasturesList
    if list == nil or type(list.setSelectedItem) ~= "function" then return end
    local idx = self.selectedPastureIdx
    if type(idx) == "number" and idx >= 1 and idx <= #self._pastures then
        pcall(function() list:setSelectedItem(1, idx, true) end)
    else
        pcall(function() list:setSelectedItem(0, 0, true) end)
    end
end

-- World (x,z) -> normalized screen (x,y) via the live map's extension transform.
-- Returns nil if the map object isn't wired or a factor is missing.
function PHMenuMapFrame:_screenPosForWorld(wx, wz)
    local im = self._mapObj
    if im == nil or im.layout == nil or type(im.layout.getMapObjectPosition) ~= "function" then
        return nil
    end
    if not (im.worldSizeX and im.worldSizeX ~= 0 and im.worldSizeZ and im.worldSizeZ ~= 0) then
        return nil
    end
    local objX = (wx + (im.worldCenterOffsetX or 0)) / im.worldSizeX
        * (im.mapExtensionScaleFactor or 1) + (im.mapExtensionOffsetX or 0)
    local objZ = (wz + (im.worldCenterOffsetZ or 0)) / im.worldSizeZ
        * (im.mapExtensionScaleFactor or 1) + (im.mapExtensionOffsetZ or 0)
    local ok, sx, sy = pcall(function() return im.layout:getMapObjectPosition(objX, objZ, 0, 0) end)
    if not ok or type(sx) ~= "number" then return nil end
    return sx, sy
end

-- ── Context popup (register-barn / register-pasture / per-barn & per-pasture
--    options) — this Map tab doubles as the mod's visual SETUP menu. ──────────
local function framework() return rawget(_G, "g_pastureHerding") end

-- Build the action rows for the clicked husbandry, based on its registration
-- state, then show the popup. Registration goes straight to the registry with
-- the clicked placeable (no stand-here distance gate — the map IS the target).
function PHMenuMapFrame:_openContextFor(placeable)
    local f = framework()
    local reg = f and f.grazingRegistry
    if reg == nil then return end
    local ref = reg.byPlaceable and reg.byPlaceable[placeable]
    local actions, subtitle = {}, nil

    if ref == nil then
        subtitle = "Not registered"
        actions[#actions + 1] = { label = "Register as Barn", fn = function()
            local idx, err = reg:registerBarn(placeable)
            self:_afterChange(idx ~= nil,
                idx and ("Registered barn #" .. idx) or ("Register failed: " .. tostring(err)),
                idx)
        end }
        -- "Register as Pasture" targets the barn currently shown in the left
        -- pager; only offer it when there IS a selected barn.
        if self.selectedBarnIdx ~= nil then
            local barn = reg:getBarn(self.selectedBarnIdx)
            local barnName = placeableName(barn and barn.placeable)
            actions[#actions + 1] = { label = "Register as Pasture", fn = function()
                local pIdx, err = reg:addPastureToBarn(self.selectedBarnIdx, placeable)
                if pIdx ~= nil then
                    local f = framework()
                    if f ~= nil and type(f.applyInitialSoilQuality) == "function" then
                        f:applyInitialSoilQuality(self.selectedBarnIdx, pIdx)
                    end
                end
                self:_afterChange(pIdx ~= nil,
                    pIdx and string.format("Added pasture #%d to %s", pIdx, barnName)
                         or ("Add failed: " .. tostring(err)),
                    self.selectedBarnIdx)
            end }
        end
    elseif ref.kind == "barn" then
        subtitle = "Registered barn #" .. tostring(ref.barnIdx)
        -- Herd movement (send out / bring in) lives in the Grazing tab now; the
        -- Map tab is for setup only.
        actions[#actions + 1] = { label = "Rename", fn = function()
            self:_promptRename(placeable)
        end }
        actions[#actions + 1] = { label = "Remove Barn", fn = function()
            self:_confirm("Remove Barn",
                "Unregister this barn from the grazing system?\n\nIts pastures stay in the game; any shared pastures remain linked to their other barns. You can re-register it later.",
                function()
                    local okR = reg:unregister(placeable)
                    self.selectedBarnIdx = nil  -- indices shift; let reload re-pick
                    self:_afterChange(okR == true, okR and "Barn removed" or "Remove failed", nil)
                end)
        end }
    elseif ref.kind == "pasture" then
        -- Barn-relative: act on this pasture w.r.t. the barn shown in the left
        -- pager. If that barn already owns it -> set active; if not -> offer to
        -- SHARE it into that barn (Part B: one pasture under two barns).
        local selIdx = self.selectedBarnIdx or ref.barnIdx
        -- If we're mid-recording THIS exact pasture, resolve in the recording
        -- barn's context so Finish/Cancel are reachable no matter what the pager
        -- is currently showing.
        local recT = (type(f.getRecordingTarget) == "function") and f:getRecordingTarget() or nil
        if recT ~= nil then
            local rb = reg:getBarn(recT.barnIdx)
            if rb ~= nil and rb.pastures[recT.pastureIdx] == placeable then
                selIdx = recT.barnIdx
            end
        end
        local selBarn = reg:getBarn(selIdx)
        local ownIdx
        if selBarn ~= nil then
            for i, p in ipairs(selBarn.pastures) do
                if p == placeable then ownIdx = i; break end
            end
        end
        if ownIdx ~= nil then
            subtitle = string.format("Pasture #%d of barn #%d", ownIdx, selIdx)
            -- If we're mid-recording THIS barn/pasture, offer Finish/Cancel
            -- instead of the normal actions.
            local recTarget = (type(f.getRecordingTarget) == "function") and f:getRecordingTarget() or nil
            -- Recording "this" if either endpoint of the path being recorded is
            -- this pasture (pastureIdx = to, fromIdx = from for pasture->pasture).
            local recordingThis = recTarget ~= nil and recTarget.barnIdx == selIdx
                and (recTarget.pastureIdx == ownIdx or recTarget.fromIdx == ownIdx)
            if recordingThis then
                subtitle = "Recording walk path..."
                actions[#actions + 1] = { label = "Finish Path", fn = function()
                    local n = f:finishPathRecording()
                    self:_afterChange(true, string.format("Walk path saved (%d waypoints)", n), selIdx)
                end }
                actions[#actions + 1] = { label = "Cancel Recording", fn = function()
                    f:cancelPathRecording()
                    self:_afterChange(true, "Recording cancelled", selIdx)
                end }
            else
                -- Set-active and herd movement live on the Grazing tab; the Map
                -- tab is for setup only (register / rename / share / paths).
                local hasPath = false
                local pth = reg.getPath and reg:getPath(selIdx, ownIdx)
                if type(pth) == "table" and #pth >= 4 then hasPath = true end
                -- Record a walk path INTO this pasture (pick the start next: the
                -- barn, or another pasture for a pasture->pasture route). Not
                -- offered while another recording is already in progress.
                local recording = type(f.isRecordingPath) == "function" and f:isRecordingPath()
                if type(f.beginPathRecording) == "function" and not recording then
                    actions[#actions + 1] = {
                        label = "Record Walk Path",
                        fn = function() self:_showPathStartPicker(selIdx, ownIdx) end,
                    }
                end
                if hasPath then
                    actions[#actions + 1] = { label = "Clear Path", fn = function()
                        self:_confirm("Clear Path",
                            "Delete the recorded walk path for this pasture?\n\nThe herd falls back to a direct route until you record a new one.",
                            function()
                                reg:clearPath(selIdx, ownIdx)
                                self:_afterChange(true, "Path cleared", selIdx)
                            end)
                    end }
                end
                -- Pasture->pasture routes are stored separately (and may be shared
                -- with other barns). Offer to clear them if any exist for this
                -- pasture.
                local pairTargets = reg.listPairPathTargets and reg:listPairPathTargets(selIdx, ownIdx) or {}
                if #pairTargets > 0 then
                    actions[#actions + 1] = { label = "Clear Pasture-to-Pasture Path", fn = function()
                        self:_showClearPairPicker(selIdx, ownIdx, pairTargets)
                    end }
                end
                actions[#actions + 1] = { label = "Remove Pasture", fn = function()
                    self:_confirm("Remove Pasture",
                        "Remove this pasture from this barn?\n\nIf it's shared with other barns it stays linked to them; otherwise it's fully removed (its recorded path is deleted). You can re-register it later.",
                        function()
                            local okR = reg:removePastureFromBarn(selIdx, ownIdx)
                            self:_afterChange(okR == true, okR and "Pasture removed" or "Remove failed", selIdx)
                        end)
                end }
            end
        elseif selBarn ~= nil then
            local barnName = placeableName(selBarn.placeable)
            subtitle = string.format("Registered to barn #%d", ref.barnIdx)
            actions[#actions + 1] = { label = "Also Register to " .. barnName, fn = function()
                local pIdx, err = reg:addPastureToBarn(selIdx, placeable)
                self:_afterChange(pIdx ~= nil,
                    pIdx and string.format("Shared pasture into %s (P%d)", barnName, pIdx)
                         or ("Add failed: " .. tostring(err)), selIdx)
            end }
        end
        actions[#actions + 1] = { label = "Rename", fn = function()
            self:_promptRename(placeable)
        end }
    end

    actions[#actions + 1] = { label = "Close", fn = function() self:_closeContext() end }

    self._ctxActions = actions
    -- Cache the clicked husbandry's field-boundary polygon so the draw callback
    -- can trace its outline on the map while this popup is open (nil for barns /
    -- pens without an outdoor contour).
    self._outlinePoly = nil
    if ContourReader ~= nil and type(ContourReader.readOutdoorContourPolygon) == "function" then
        local ok, poly = pcall(function() return ContourReader.readOutdoorContourPolygon(placeable) end)
        if ok and type(poly) == "table" and #poly >= 9 then self._outlinePoly = poly end
    end
    self:_setContextImage(placeable)
    if self.ctxTitle ~= nil and type(self.ctxTitle.setText) == "function" then
        self.ctxTitle:setText(placeableName(placeable))
    end
    if self.ctxSubtitle ~= nil and type(self.ctxSubtitle.setText) == "function" then
        self.ctxSubtitle:setText(subtitle or "")
    end
    if self.contextButtonList ~= nil and type(self.contextButtonList.reloadData) == "function" then
        self.contextButtonList:reloadData()
    end
    if self.contextBox ~= nil and type(self.contextBox.setVisible) == "function" then
        self.contextBox:setVisible(true)
    end
    self:_positionBoxNear(self._clickSX, self._clickSY)
end

-- Fill the popup's image slot with the husbandry's store icon (same as the base
-- hotspot popup). Resolve the store item from the placeable's config XML — the
-- base-proven path — and use its imageFilename. Hidden if none resolves.
function PHMenuMapFrame:_setContextImage(placeable)
    local img = self.ctxImage
    if img == nil then return end
    local si
    if placeable ~= nil and placeable.configFileName ~= nil and g_storeManager ~= nil then
        local ok, item = pcall(function()
            return g_storeManager:getItemByXMLFilename(placeable.configFileName)
        end)
        if ok then si = item end
    end
    if si == nil and placeable ~= nil then si = placeable.storeItem end
    local filename = si and si.imageFilename or nil
    -- The store <image> can point at a missing/not-yet-rendered .png while a .dds
    -- sibling (or vice-versa) is the file that actually exists — a non-existent
    -- path makes the engine draw the orange "missing texture" box. Prefer the
    -- sibling that is really on disk.
    if filename ~= nil and type(fileExists) == "function" and not fileExists(filename) then
        local alt
        if filename:sub(-4):lower() == ".png" then
            alt = filename:sub(1, -5) .. ".dds"
        elseif filename:sub(-4):lower() == ".dds" then
            alt = filename:sub(1, -5) .. ".png"
        end
        if alt ~= nil and fileExists(alt) then filename = alt end
    end
    if filename ~= nil and filename ~= "" and type(img.setImageFilename) == "function" then
        -- fs25_mapContextImage extends baseReference, which sets imageSliceId=
        -- gui.colorPreset + the UI-atlas filename. Just swapping the filename keeps
        -- that slice's 1px UVs, so the store image samples a single pixel = the
        -- "orange box". Clear the slice and reset UVs to the full texture.
        if img.overlay ~= nil then img.overlay.sliceId = nil end
        pcall(function() img:setImageFilename(filename) end)
        if type(img.setImageUVs) == "function" then
            pcall(function() img:setImageUVs(nil, 0, 0, 0, 1, 1, 0, 1, 1) end)
        end
        if type(img.setImageColor) == "function" then
            pcall(function() img:setImageColor(nil, 1, 1, 1, 1) end)
        end
        if type(img.setVisible) == "function" then img:setVisible(true) end
    elseif type(img.setVisible) == "function" then
        img:setVisible(false)
    end
end

-- Place the box next to the icon at normalized screen (sx,sy). setAbsolutePosition
-- sets the box's bottom-left corner (GUI space is y-up, origin bottom-left), so
-- center it vertically on the icon and sit it to the icon's right (flip left if
-- it would run off-screen). Clamped on-screen and right of the left panel.
function PHMenuMapFrame:_positionBoxNear(sx, sy)
    local box = self.contextBox
    if box == nil or type(box.setAbsolutePosition) ~= "function" then return end
    if type(sx) ~= "number" or type(sy) ~= "number" then return end -- keep XML default
    local px = g_pixelSizeX or (1 / 1920)
    local gap = 100 * px   -- buffer between the icon and the popup edge
    local w = (box.absSize and box.absSize[1]) or (276 * px)
    local h = (box.absSize and box.absSize[2]) or (330 * (g_pixelSizeY or (1 / 1080)))
    local bx = sx + gap
    if bx + w > 1 then bx = sx - gap - w end
    local by = sy - h * 0.5
    local minX = self._panelRight or 0
    bx = math.max(minX, math.min(bx, 1 - w))
    by = math.max(0, math.min(by, 1 - h))
    pcall(function() box:setAbsolutePosition(bx, by) end)
end

-- Called after a setup action commits. Notifies, refreshes the panel (a new
-- barn/pasture changes the pager + list), and closes the popup. If a barn/
-- pasture was just created, point the pager at its barn so it's visible.
function PHMenuMapFrame:_afterChange(ok, msg, focusBarnIdx)
    local f = framework()
    if f ~= nil and type(f.pushNotification) == "function" then
        local color = ok and { 0.2, 1.0, 0.4, 1.0 } or { 1.0, 0.4, 0.2, 1.0 }
        pcall(function() f:pushNotification(color, msg, 6000) end)
    end
    if ok and focusBarnIdx ~= nil then self.selectedBarnIdx = focusBarnIdx end
    self:_reloadBarns()
    self:_reloadPastures()
    self:_closeContext()
end

-- Rename: open the game's text-input dialog seeded with the current name.
-- FS25 exposes this as the static TextInputDialog.show(callback, target,
-- defaultText, dialogPrompt, maxCharacters, confirmText, args, disableFilter) —
-- same (callback, target, ...) convention as ColorPickerDialog.show.
function PHMenuMapFrame:_promptRename(placeable)
    if placeable == nil then return end
    -- NB: reach the engine class by DIRECT global access, not rawget(_G,...) —
    -- the mod env resolves engine globals through an __index proxy that rawget
    -- bypasses (same reason g_gui is used directly everywhere else).
    if TextInputDialog == nil or type(TextInputDialog.show) ~= "function" then
        PastureHerdingLog.warning("[PHMap] TextInputDialog.show unavailable")
        return
    end
    self._renamePlaceable = placeable
    -- Signature: (callback, target, defaultText, dialogPrompt, imePrompt,
    -- maxCharacters, confirmText, disableFilter). The imePrompt slot (5) must be
    -- present so maxCharacters lands on the NUMBER slot (6) — otherwise the input
    -- element crashes doing `textLength < maxCharacters` against a string.
    TextInputDialog.show(
        self.onRenameEntered,
        self,
        placeableName(placeable),
        "Enter a name",
        nil,
        30,
        (g_i18n ~= nil and g_i18n:getText("button_ok")) or "OK",
        true
    )
end

-- Text-input dialog result: (text, clickOk). Commit the new name and refresh.
function PHMenuMapFrame:onRenameEntered(text, clickOk)
    local placeable = self._renamePlaceable
    self._renamePlaceable = nil
    if clickOk ~= true or placeable == nil then return end
    local f = framework()
    local reg = f and f.grazingRegistry
    if reg ~= nil and type(reg.setCustomName) == "function" then
        reg:setCustomName(placeable, text)
    end
    self:_reloadBarns()
    self:_reloadPastures()
    self:_closeContext()
end

-- Record Walk Path step 1: pick the START point for a path that ENDS at
-- finishIdx. Options are the barn (barn->pasture) or any other pasture in the
-- barn (pasture->pasture). Reuses the context popup's button list as the picker.
function PHMenuMapFrame:_showPathStartPicker(barnIdx, finishIdx)
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    local barn = reg and reg:getBarn(barnIdx)
    if barn == nil or barn.pastures[finishIdx] == nil then return end
    local finishPlaceable = barn.pastures[finishIdx]
    local actions = {}
    actions[#actions + 1] = { label = "From: " .. placeableName(barn.placeable), fn = function()
        self:_confirmRecordPath(barnIdx, finishIdx)
    end }
    for i, p in ipairs(barn.pastures) do
        if i ~= finishIdx then
            local from = i
            actions[#actions + 1] = { label = "From: " .. placeableName(p), fn = function()
                self:_confirmRecordPathPair(barnIdx, from, finishIdx)
            end }
        end
    end
    actions[#actions + 1] = { label = "Cancel", fn = function()
        self:_openContextFor(finishPlaceable)  -- back to the pasture popup
    end }
    self._ctxActions = actions
    if self.ctxTitle ~= nil and type(self.ctxTitle.setText) == "function" then
        self.ctxTitle:setText("Record Path To " .. placeableName(finishPlaceable))
    end
    if self.ctxSubtitle ~= nil and type(self.ctxSubtitle.setText) == "function" then
        self.ctxSubtitle:setText("Choose the START point")
    end
    if self.contextButtonList ~= nil and type(self.contextButtonList.reloadData) == "function" then
        self.contextButtonList:reloadData()
    end
    if self.contextBox ~= nil and type(self.contextBox.setVisible) == "function" then
        self.contextBox:setVisible(true)
    end
end

-- Clear pasture->pasture path picker: lists every pasture this one has a pair
-- route to (plus "Clear ALL"), reusing the context popup's button list. Each
-- clear removes the route wherever it's stored (shared routes included).
function PHMenuMapFrame:_showClearPairPicker(barnIdx, pastureIdx, targets)
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    local barn = reg and reg:getBarn(barnIdx)
    if barn == nil or barn.pastures[pastureIdx] == nil then return end
    local ownPlaceable = barn.pastures[pastureIdx]
    local actions = {}
    for _, t in ipairs(targets) do
        local toIdx = t.idx
        actions[#actions + 1] = { label = "To: " .. placeableName(t.placeable), fn = function()
            self:_confirm("Clear Path",
                "Delete the pasture-to-pasture route between these two pastures?\n\nIf the route is shared with another barn, it's removed for them too.",
                function()
                    reg:clearPairPathAnywhere(barnIdx, pastureIdx, toIdx)
                    self:_afterChange(true, "Pasture-to-pasture path cleared", barnIdx)
                end)
        end }
    end
    if #targets > 1 then
        actions[#actions + 1] = { label = "Clear ALL pasture paths", fn = function()
            self:_confirm("Clear All Paths",
                "Delete EVERY pasture-to-pasture route from this pasture?",
                function()
                    for _, t in ipairs(targets) do
                        reg:clearPairPathAnywhere(barnIdx, pastureIdx, t.idx)
                    end
                    self:_afterChange(true, "Pasture-to-pasture paths cleared", barnIdx)
                end)
        end }
    end
    actions[#actions + 1] = { label = "Cancel", fn = function()
        self:_openContextFor(ownPlaceable)  -- back to the pasture popup
    end }
    self._ctxActions = actions
    if self.ctxTitle ~= nil and type(self.ctxTitle.setText) == "function" then
        self.ctxTitle:setText("Clear Path From " .. placeableName(ownPlaceable))
    end
    if self.ctxSubtitle ~= nil and type(self.ctxSubtitle.setText) == "function" then
        self.ctxSubtitle:setText("Choose the route to delete")
    end
    if self.contextButtonList ~= nil and type(self.contextButtonList.reloadData) == "function" then
        self.contextButtonList:reloadData()
    end
    if self.contextBox ~= nil and type(self.contextBox.setVisible) == "function" then
        self.contextBox:setVisible(true)
    end
end

-- Step 2a: barn -> pasture. Step 2b: pasture -> pasture. Both confirm via a
-- Yes/No dialog then start recording and exit the menu.
function PHMenuMapFrame:_confirmRecordPath(barnIdx, pastureIdx)
    self._recBarnIdx, self._recPastureIdx, self._recFromIdx = barnIdx, pastureIdx, nil
    self:_showRecordConfirm("Record Walk Path",
        "Stand at the BARN, then press Yes.\n\nThe menu will close so you can walk the route to the pasture. A flashing banner shows while recording — when you reach the pasture, open this menu again and choose Finish Path.")
end

function PHMenuMapFrame:_confirmRecordPathPair(barnIdx, fromIdx, toIdx)
    self._recBarnIdx, self._recPastureIdx, self._recFromIdx = barnIdx, toIdx, fromIdx
    local reg = (rawget(_G, "g_pastureHerding") or {}).grazingRegistry
    local barn = reg and reg:getBarn(barnIdx)
    local fromName = (barn and placeableName(barn.pastures[fromIdx])) or ("pasture #" .. fromIdx)
    self:_showRecordConfirm("Record Pasture Path", string.format(
        "Stand at %s, then press Yes.\n\nThe menu will close so you can walk to the destination pasture. When you arrive, open this menu again and choose Finish Path.",
        fromName))
end

-- Shared Yes/No confirm using the mod's proven g_gui:showDialog(...).target
-- pattern (see PastureHerding startup alert).
function PHMenuMapFrame:_showRecordConfirm(title, text)
    local shown = false
    if g_gui ~= nil and type(g_gui.showDialog) == "function" then
        local ok = pcall(function()
            local dialog = g_gui:showDialog("YesNoDialog")
            if dialog ~= nil and dialog.target ~= nil then
                local t = dialog.target
                if type(t.setTitle) == "function" then t:setTitle(title) end
                if type(t.setText) == "function" then t:setText(text) end
                if type(t.setCallback) == "function" then
                    t:setCallback(self.onConfirmRecordPath, self)
                    shown = true
                end
            end
        end)
        if not ok then shown = false end
    end
    if not shown then self:onConfirmRecordPath(true) end  -- no dialog -> just start
end

-- YesNoDialog result (yes = true when the player confirms).
function PHMenuMapFrame:onConfirmRecordPath(yes)
    if yes ~= true then return end
    local f = framework()
    if f == nil then return end
    local started
    if self._recFromIdx ~= nil and type(f.beginPathRecordingPair) == "function" then
        started = f:beginPathRecordingPair(self._recBarnIdx, self._recFromIdx, self._recPastureIdx)
    elseif type(f.beginPathRecording) == "function" then
        started = f:beginPathRecording(self._recBarnIdx, self._recPastureIdx)
    end
    if not started then return end
    if type(f.pushNotification) == "function" then
        pcall(function()
            f:pushNotification({ 1.0, 0.85, 0.2, 1.0 },
                "Recording walk path — walk to the destination, then reopen the menu and choose Finish Path.", 8000)
        end)
    end
    self:_closeContext()
    -- Exit the whole menu so the player can move (the game is paused while it's open).
    if g_phMenu ~= nil and type(g_phMenu.exitMenu) == "function" then
        pcall(function() g_phMenu:exitMenu() end)
    elseif g_gui ~= nil and type(g_gui.showGui) == "function" then
        pcall(function() g_gui:showGui("") end)
    end
end

-- Generic Yes/No confirm before a destructive action. Runs onYes() on confirm.
function PHMenuMapFrame:_confirm(title, text, onYes)
    self._pendingConfirm = onYes
    local shown = false
    if g_gui ~= nil and type(g_gui.showDialog) == "function" then
        local ok = pcall(function()
            local dialog = g_gui:showDialog("YesNoDialog")
            if dialog ~= nil and dialog.target ~= nil then
                local t = dialog.target
                if type(t.setTitle) == "function" then t:setTitle(title) end
                if type(t.setText) == "function" then t:setText(text) end
                if type(t.setCallback) == "function" then
                    t:setCallback(self.onConfirmResult, self)
                    shown = true
                end
            end
        end)
        if not ok then shown = false end
    end
    if not shown then self:onConfirmResult(true) end  -- no dialog -> just do it
end

function PHMenuMapFrame:onConfirmResult(yes)
    local fn = self._pendingConfirm
    self._pendingConfirm = nil
    if yes == true and type(fn) == "function" then fn() end
end

function PHMenuMapFrame:_closeContext()
    self._ctxActions = {}
    self._outlinePoly = nil
    if self.contextButtonList ~= nil and type(self.contextButtonList.reloadData) == "function" then
        self.contextButtonList:reloadData()
    end
    if self.contextBox ~= nil and type(self.contextBox.setVisible) == "function" then
        self.contextBox:setVisible(false)
    end
end

-- A popup row was clicked -> run its action. Defensive about arg order, matching
-- onBarnSelectionChanged: some FS25 versions pass (section, index) flipped.
function PHMenuMapFrame:onClickContextButton(list, section, index)
    local idx = index
    if type(section) == "number" and type(index) ~= "number" then idx = section end
    local action = self._ctxActions and self._ctxActions[idx]
    if action ~= nil and type(action.fn) == "function" then action.fn() end
end

-- Drawn every frame AFTER the map + its (base-game) hotspots. MapHotspot is
-- sandboxed out of our scope, so we draw our own barn/pasture markers here using
-- the world->screen transform the map object exposes. First pass = text labels
-- (B1 / P2); icon overlays can replace these once positioning is confirmed.
function PHMenuMapFrame:onDrawPostIngameMapHotspots(element, ingameMap)
    -- Trace the clicked pasture's field boundary on the map while its popup is
    -- open (this is a draw callback — renderText/renderOverlay are legal here).
    if self._outlinePoly ~= nil then self:_drawPastureOutline() end
    self:_drawIconLabels()
end

-- Draw each registered barn/pasture's NAME just under its map icon.
function PHMenuMapFrame:_drawIconLabels()
    if renderText == nil then return end
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    if reg == nil or type(reg.iterBarns) ~= "function" then return end
    local minX = self._panelRight or 0
    local ALIGN_CENTER = (RenderText and RenderText.ALIGN_CENTER) or 1
    local ALIGN_LEFT = (RenderText and RenderText.ALIGN_LEFT) or 0
    local size = 0.010
    local yBelow = 0.023   -- drop the label below the icon centre

    local function label(placeable)
        if placeable == nil then return end
        -- Follow the staggered icon position (multi-husbandry sheds) so the label
        -- sits under its fanned-out icon, not the stacked original spot.
        local wx, wz
        local st = self._staggerByPlaceable and self._staggerByPlaceable[placeable]
        if st ~= nil then
            wx, wz = st.sx, st.sz
        else
            local node = placeable.rootNode
            if node == nil or node == 0 or not entityExists(node) then return end
            wx, _, wz = getWorldTranslation(node)
        end
        local sx, sy = self:_screenPosForWorld(wx, wz)
        if sx == nil or sx < minX or sx > 1 or sy < 0 or sy > 1 then return end
        local ty = sy - yBelow
        local name = placeableName(placeable)
        setTextAlignment(ALIGN_CENTER)
        setTextBold(false)
        setTextColor(0, 0, 0, 0.85)                 -- shadow for legibility
        renderText(sx + 0.0007, ty - 0.0009, size, name)
        setTextColor(1, 1, 1, 1)
        renderText(sx, ty, size, name)
    end

    for _, barn in reg:iterBarns() do
        label(barn.placeable)
        for _, p in ipairs(barn.pastures) do label(p) end
    end
    -- Restore default render state.
    setTextAlignment(ALIGN_LEFT)
    setTextColor(1, 1, 1, 1)
end

-- Lazily build a plain fillable-rectangle overlay (base UI atlas -> solid
-- colorPreset slice) for the boundary dots.
function PHMenuMapFrame:_ensureLineOverlay()
    if self._lineOverlay ~= nil then return end
    self._lineOverlay = 0
    if createImageOverlay == nil or g_baseUIFilename == nil then return end
    local ov = createImageOverlay(g_baseUIFilename)
    if ov == nil or ov == 0 then return end
    if g_overlayManager ~= nil and type(g_overlayManager.getSliceInfoById) == "function"
       and setOverlayUVs ~= nil then
        local slice = g_overlayManager:getSliceInfoById("gui.colorPreset")
        if slice ~= nil and slice.uvs ~= nil then
            pcall(function() setOverlayUVs(ov, unpack(slice.uvs)) end)
        end
    end
    self._lineOverlay = ov
end

-- Draw the cached field-boundary polygon as a dotted outline on the map: world
-- vertices -> screen, then small squares stepped along each edge (closed loop).
-- Clipped to the map area (right of the left panel, on-screen).
function PHMenuMapFrame:_drawPastureOutline()
    local poly = self._outlinePoly
    if type(poly) ~= "table" or #poly < 9 then return end
    if renderOverlay == nil or setOverlayColor == nil then return end
    self:_ensureLineOverlay()
    local ov = self._lineOverlay
    if ov == nil or ov == 0 then return end

    -- World (x,z) -> screen for each vertex.
    local pts = {}
    for i = 1, #poly - 2, 3 do
        local sx, sy = self:_screenPosForWorld(poly[i], poly[i + 2])
        if sx ~= nil then pts[#pts + 1] = sx; pts[#pts + 1] = sy end
    end
    local n = #pts / 2
    if n < 2 then return end

    local px = g_pixelSizeX or (1 / 1920)
    local py = g_pixelSizeY or (1 / 1080)
    local dotW, dotH = 6 * px, 6 * py     -- ~6px thick
    local stepPx = 2                       -- 2px between samples => overlaps = solid
    local minX = self._panelRight or 0
    setOverlayColor(ov, 1.0, 0.95, 0.2, 1.0)   -- bright yellow outline

    local function dot(x, y)
        if x < minX or x > 1 or y < 0 or y > 1 then return end
        renderOverlay(ov, x - dotW * 0.5, y - dotH * 0.5, dotW, dotH)
    end
    for i = 1, n do
        local ax, ay = pts[i * 2 - 1], pts[i * 2]
        local j = (i % n) + 1                 -- next vertex, wrapping to close
        local bx, by = pts[j * 2 - 1], pts[j * 2]
        local dx, dy = bx - ax, by - ay
        -- Step by PIXEL distance so dots overlap uniformly on every edge angle
        -- (mixing normalized x/y directly leaves gaps on near-vertical edges).
        local lenPx = math.sqrt((dx / px) ^ 2 + (dy / py) ^ 2)
        local steps = math.max(1, math.ceil(lenPx / stepPx))
        for k = 0, steps do
            local t = k / steps
            dot(ax + dx * t, ay + dy * t)
        end
    end
end

-- Try the known ways to reach the mission's live IngameMap object. Logs which
-- one hit so we can hard-wire it afterwards.
local function resolveMissionMap()
    local m = g_currentMission
    if m == nil then return nil, "no g_currentMission" end
    local hud = m.hud
    if hud ~= nil then
        if type(hud.getIngameMap) == "function" then
            local ok, im = pcall(function() return hud:getIngameMap() end)
            if ok and im ~= nil then return im, "hud:getIngameMap()" end
        end
        if hud.ingameMap ~= nil then return hud.ingameMap, "hud.ingameMap" end
    end
    if m.inGameMenu ~= nil then
        local pg = m.inGameMenu.pageMapOverview
        if pg ~= nil and pg.ingameMap ~= nil and pg.ingameMap.ingameMap ~= nil then
            return pg.ingameMap.ingameMap, "inGameMenu.pageMapOverview.ingameMap.ingameMap"
        end
    end
    return nil, "no accessor matched"
end

-- Wire the map object into the <InGameMap> element. Returns true once done.
-- Called from onFrameOpen AND (as a fallback) from update, because the element
-- draws/updates every frame and crashes if its .ingameMap is nil.
function PHMenuMapFrame:_wireMap()
    if self._wired then return true end
    local mapEl = self.ingameMap -- resolved by id="ingameMap" in the XML
    if mapEl == nil then
        if not self._loggedNoEl then
            PastureHerdingLog.warning("[PHMap] no <InGameMap> element (id=ingameMap) on frame")
            self._loggedNoEl = true
        end
        return false
    end
    local missionMap, how = resolveMissionMap()
    if missionMap == nil then
        if not self._loggedNoMap then
            PastureHerdingLog.warning("[PHMap] could not resolve mission ingame map (%s)", tostring(how))
            self._loggedNoMap = true
        end
        return false
    end
    local terrainSize = (g_currentMission and g_currentMission.terrainSize) or 2048
    if type(mapEl.setIngameMap) == "function" then pcall(function() mapEl:setIngameMap(missionMap) end) end
    if type(mapEl.setTerrainSize) == "function" then pcall(function() mapEl:setTerrainSize(terrainSize) end) end
    self._mapObj = missionMap
    self:_hideBaseHotspots(missionMap)
    -- Clip the whole map (imagery + hotspots) to the region RIGHT of the left
    -- panel, using the panel element's real bounds — otherwise field labels draw
    -- over the panel, especially while panning. Saved/restored (shared map obj).
    if missionMap.clipHotspots ~= nil then
        self._savedClipHotspots = missionMap.clipHotspots
    end
    missionMap.clipHotspots = true
    self._panelRight = 0
    local panel = self.barnFilterBox
    if panel ~= nil and panel.absPosition ~= nil and panel.absSize ~= nil then
        self._panelRight = panel.absPosition[1] + panel.absSize[1]
        -- setMapClipArea takes CORNERS (x1,y1,x2,y2), not (x,y,w,h): clip from the
        -- panel's right edge to the screen's right/top corner.
        if type(missionMap.setMapClipArea) == "function" then
            local px = self._panelRight
            pcall(function() missionMap:setMapClipArea(px, 0, 1, 1) end)
        end
    end
    self._wired = true
    return true
end

-- The shared mission map draws EVERY registered hotspot (all placeables, POIs,
-- vehicles...). We only want our own barn/pasture markers on this tab, so blank
-- the map's per-category hotspot filter while the tab is open and restore it on
-- close (the object is global — the pause-menu map must be unaffected).
-- Hotspot categories to KEEP visible on this tab; everything else is hidden so
-- the map stays clean. Discovered on this map: 1 = fields (context), 2 = animals/
-- husbandries — the animal (cow) icons mark the barns/pastures, which is what we
-- show now instead of our own B#/P# text markers.
local KEEP_CATEGORIES = { [1] = true, [2] = true }

-- Tinting/scaling of the base husbandry (cow) hotspots: barns one colour,
-- pastures another; the selected barn + selected pasture scaled up a touch. The
-- visible glyph is the hotspot's `icon`/`iconSmall` Overlay tables (which carry
-- their own r,g,b,a + width/height) — MapHotspot:setColor/setScale tint/scale a
-- background the husbandry icon doesn't use, so we recolour the icon overlays
-- directly and resize via the hotspot's width/height. Uses the hotspot's direct
-- .placeable ref to map to the registry. Originals restored on close (the
-- hotspot list is the shared mission object — the pause map must be unaffected).
local HOTSPOT_BARN_COLOR = { 0.30, 0.62, 1.00, 1.0 }   -- blue = home barn
local HOTSPOT_PASTURE_COLOR = { 0.40, 0.95, 0.35, 1.0 } -- green = pasture
local HOTSPOT_SCALE_BASE = 0.5      -- shrink all registered icons to half size
local HOTSPOT_SCALE_SELECTED = 1.35 -- selected gets an extra bump on top of base
-- Natural husbandry map-icon size (probed; constant across zoom & placeables).
-- Sizing against this FIXED reference — instead of each icon's own captured
-- width — keeps loaded and freshly-registered icons identical (a per-icon
-- snapshot can differ between the two).
local ICON_NATURAL_W = 0.026041666666666668
local ICON_NATURAL_H = 0.046296296296296294

local function snapshotOverlay(ov)
    if type(ov) ~= "table" then return nil end
    return { r = ov.r, g = ov.g, b = ov.b, a = ov.a, width = ov.width, height = ov.height }
end
local function tintOverlay(ov, c)
    if type(ov) == "table" then ov.r, ov.g, ov.b, ov.a = c[1], c[2], c[3], c[4] end
end
-- Set the icon overlay's draw size to the FIXED natural size × s (uniform for
-- every registered icon regardless of how/when it was registered).
local function sizeIcon(ov, s)
    if type(ov) == "table" then ov.width = ICON_NATURAL_W * s; ov.height = ICON_NATURAL_H * s end
end
local function restoreOverlay(ov, snap)
    if type(ov) ~= "table" or snap == nil then return end
    ov.r, ov.g, ov.b, ov.a = snap.r, snap.g, snap.b, snap.a
    if snap.width ~= nil then ov.width = snap.width end
    if snap.height ~= nil then ov.height = snap.height end
end

function PHMenuMapFrame:_applyHotspotStyling()
    local im = self._mapObj
    if im == nil or type(im.hotspots) ~= "table" then return end
    -- De-cluster overlapping husbandry icons first (independent of registration).
    self:_applyHotspotStagger()
    local f = rawget(_G, "g_pastureHerding")
    local reg = f and f.grazingRegistry
    if reg == nil or reg.byPlaceable == nil then return end
    self._styledHotspots = self._styledHotspots or {}
    local selBarn = (type(reg.getBarn) == "function") and reg:getBarn(self.selectedBarnIdx) or nil
    for _, h in ipairs(im.hotspots) do
        local okC, cat = pcall(function() return h:getCategory() end)
        if okC and cat == 2 and h.placeable ~= nil then
            local ref = reg.byPlaceable[h.placeable]
            if ref ~= nil then
                if self._styledHotspots[h] == nil then
                    self._styledHotspots[h] = {
                        icon = snapshotOverlay(h.icon),
                        iconSmall = snapshotOverlay(h.iconSmall),
                    }
                end
                local orig = self._styledHotspots[h]
                local color, selected
                if ref.kind == "barn" then
                    color = HOTSPOT_BARN_COLOR
                    selected = (ref.barnIdx == self.selectedBarnIdx)
                else
                    color = HOTSPOT_PASTURE_COLOR
                    selected = selBarn ~= nil and self.selectedPastureIdx ~= nil
                        and selBarn.pastures[self.selectedPastureIdx] == h.placeable
                end
                -- Colour always; size the icon to a FIXED base scale (uniform for
                -- loaded + new icons), with the selected one bumped up on top.
                tintOverlay(h.icon, color)
                tintOverlay(h.iconSmall, color)
                local s = HOTSPOT_SCALE_BASE * (selected and HOTSPOT_SCALE_SELECTED or 1)
                sizeIcon(h.icon, s)
                sizeIcon(h.iconSmall, s)
            end
        end
    end
end

function PHMenuMapFrame:_restoreHotspotStyling()
    if self._styledHotspots == nil then return end
    for h, orig in pairs(self._styledHotspots) do
        restoreOverlay(h.icon, orig.icon)
        restoreOverlay(h.iconSmall, orig.iconSmall)
    end
    self._styledHotspots = {}
end

-- Multi-husbandry sheds place several barns at nearly the same spot, so their map
-- hotspots stack and only the top one is clickable. Fan out each overlapping cluster
-- of husbandry icons onto a small ring around its centre so every one is separately
-- clickable (and its label readable). Applies to ALL husbandry hotspots, registered
-- or not (you need to click unregistered ones to register them). Restored on close.
-- The plan is computed ONCE from the ORIGINAL positions, then re-asserted each pass.
PHMenuMapFrame.STAGGER_CLUSTER_FACTOR = 0.02   -- cluster hotspots within this * terrainSize
PHMenuMapFrame.STAGGER_RING_FACTOR    = 0.011  -- fan-out ring radius as * terrainSize

function PHMenuMapFrame:_applyHotspotStagger()
    local im = self._mapObj
    if im == nil or type(im.hotspots) ~= "table" then return end

    if self._hotspotStagger == nil then
        local terrainSize = (g_currentMission and g_currentMission.terrainSize) or 2048
        local clusterR = terrainSize * PHMenuMapFrame.STAGGER_CLUSTER_FACTOR
        local ringR = terrainSize * PHMenuMapFrame.STAGGER_RING_FACTOR

        local items = {}
        for _, h in ipairs(im.hotspots) do
            local okC, cat = pcall(function() return h:getCategory() end)
            if okC and cat == 2 and h.placeable ~= nil
               and type(h.getWorldPosition) == "function" and type(h.setWorldPosition) == "function" then
                local okP, wx, wz = pcall(function() return h:getWorldPosition() end)
                if okP and type(wx) == "number" and type(wz) == "number" then
                    items[#items + 1] = { h = h, ox = wx, oz = wz, used = false }
                end
            end
        end

        local plan, byPlaceable = {}, {}
        for i, it in ipairs(items) do
            if not it.used then
                local cluster = { it }
                it.used = true
                for j = i + 1, #items do
                    local jt = items[j]
                    if not jt.used then
                        local dx, dz = jt.ox - it.ox, jt.oz - it.oz
                        if (dx * dx + dz * dz) <= (clusterR * clusterR) then
                            jt.used = true
                            cluster[#cluster + 1] = jt
                        end
                    end
                end
                if #cluster >= 2 then
                    local cx, cz = 0, 0
                    for _, c in ipairs(cluster) do cx = cx + c.ox; cz = cz + c.oz end
                    cx, cz = cx / #cluster, cz / #cluster
                    for k, c in ipairs(cluster) do
                        local ang = (2 * math.pi) * (k - 1) / #cluster
                        local sx = cx + ringR * math.cos(ang)
                        local sz = cz + ringR * math.sin(ang)
                        plan[c.h] = { ox = c.ox, oz = c.oz, sx = sx, sz = sz }
                        byPlaceable[c.h.placeable] = { sx = sx, sz = sz }
                    end
                end
            end
        end
        self._hotspotStagger = plan
        self._staggerByPlaceable = byPlaceable
    end

    for h, p in pairs(self._hotspotStagger) do
        pcall(function() h:setWorldPosition(p.sx, p.sz) end)
    end
end

function PHMenuMapFrame:_restoreHotspotStagger()
    if self._hotspotStagger ~= nil then
        for h, p in pairs(self._hotspotStagger) do
            pcall(function() h:setWorldPosition(p.ox, p.oz) end)
        end
    end
    self._hotspotStagger = nil
    self._staggerByPlaceable = nil
end

function PHMenuMapFrame:_hideBaseHotspots(im)
    if im == nil or type(im.filter) ~= "table" then return end
    if self._savedFilter == nil then
        self._savedFilter = {}
        for k, v in pairs(im.filter) do self._savedFilter[k] = v end
    end
    for k, _ in pairs(im.filter) do
        im.filter[k] = (KEEP_CATEGORIES[k] == true)
    end
end

function PHMenuMapFrame:_restoreBaseHotspots()
    local im = self._mapObj
    if im ~= nil and type(im.filter) == "table" and self._savedFilter ~= nil then
        for k, v in pairs(self._savedFilter) do im.filter[k] = v end
    end
    if im ~= nil and self._savedClipHotspots ~= nil then
        im.clipHotspots = self._savedClipHotspots
    end
    if im ~= nil and type(im.setMapClipArea) == "function" then
        pcall(function() im:setMapClipArea(nil, nil, nil, nil) end)
    end
    self._savedFilter = nil
    self._savedClipHotspots = nil
end

function PHMenuMapFrame:onFrameOpen()
    -- Wire the map into the element BEFORE the base opens it — the base's open
    -- path touches the element's .ingameMap (setFullscreen etc.), so it must be
    -- set first or the element is left nil and crashes every frame.
    self:_wireMap()
    self:_reloadBarns()
    self:_reloadPastures()
    PHMenuMapFrame:superClass().onFrameOpen(self)
    -- If a walk path is being recorded, auto-open that pasture's popup so the
    -- player can click Finish without hunting for the icon.
    self:_autoOpenRecordingPopup()
end

-- Open the context popup for the pasture currently being recorded (if any), and
-- point the left panel at it. Called on frame open so reopening the menu
-- mid-recording lands straight on Finish/Cancel.
function PHMenuMapFrame:_autoOpenRecordingPopup()
    local f = rawget(_G, "g_pastureHerding")
    if f == nil or type(f.getRecordingTarget) ~= "function" then return end
    local rt = f:getRecordingTarget()
    if rt == nil then return end
    local reg = f.grazingRegistry
    local barn = reg and reg:getBarn(rt.barnIdx)
    local placeable = barn and barn.pastures[rt.pastureIdx]
    if placeable == nil then return end
    self._clickedPlaceable = placeable
    if placeable.rootNode ~= nil and placeable.rootNode ~= 0 and entityExists(placeable.rootNode) then
        local wx, _, wz = getWorldTranslation(placeable.rootNode)
        self._clickSX, self._clickSY = self:_screenPosForWorld(wx, wz)
    end
    -- Point the pager + list at the recording barn/pasture, then open its popup.
    self.selectedBarnIdx = rt.barnIdx
    self:_seedBarnSelector()
    self:_reloadPastures()
    self.selectedPastureIdx = rt.pastureIdx
    self:_applyListSelection()
    self._pendingListSelect = rt.pastureIdx or 0
    self:_openContextFor(placeable)
end

function PHMenuMapFrame:update(dt)
    PHMenuMapFrame:superClass().update(self, dt)
    if not self._wired then self:_wireMap() end
    -- Re-tint/scale the barn & pasture icons to match the current selection +
    -- registration. Throttled to ~4/sec: selection/registration change rarely
    -- and this scans the full mission hotspot list, so there's no need to redo
    -- it every frame (the menu is paused anyway).
    if self._wired then
        self._hotspotStyleAcc = (self._hotspotStyleAcc or 999) + (dt or 0)
        if self._hotspotStyleAcc >= 250 then
            self._hotspotStyleAcc = 0
            self:_applyHotspotStyling()
        end
    end
    -- Deferred list highlight from a map-icon click (the list can still be
    -- settling the frame the click lands). Apply once, then clear.
    if self._pendingListSelect ~= nil then
        self:_applyListSelection()
        self._pendingListSelect = nil
    end
    -- Keep the open popup glued to its icon: survives any relayout snap-back and
    -- lets the box follow the icon while the map is panned/zoomed.
    local box = self.contextBox
    if box ~= nil and type(box.getIsVisible) == "function" and box:getIsVisible()
       and self._clickedPlaceable ~= nil then
        local node = self._clickedPlaceable.rootNode
        if node ~= nil and node ~= 0 and entityExists(node) then
            local wx, _, wz = getWorldTranslation(node)
            local sx, sy = self:_screenPosForWorld(wx, wz)
            if sx ~= nil then
                self:_positionBoxNear(sx, sy)
            end
        end
    end
end

function PHMenuMapFrame:onFrameClose()
    self:_closeContext()
    self:_restoreHotspotStagger()   -- put shed icons back before leaving the tab
    self:_restoreHotspotStyling()
    self:_restoreBaseHotspots()
    self._wired = false
    PHMenuMapFrame:superClass().onFrameClose(self)
end
