---@class SoilMapOverlay
SoilMapOverlay = {}
local SoilMapOverlay_mt = Class(SoilMapOverlay)

-- ── i18n helper ───────────────────────────────────────────
local SF_MOD_NAME = g_currentModName

local function tr(key, fallback)
    local modEnv = g_modEnvironments and g_modEnvironments[SF_MOD_NAME]
    local i18n   = (modEnv and modEnv.i18n) or g_i18n
    if i18n then
        local text = i18n:getText(key)
        if text and text ~= "" and text ~= ("$l10n_" .. key) then
            return text
        end
    end
    return fallback or key
end

-- ── Constants ─────────────────────────────────────────────
SoilMapOverlay.LAYER_COUNT    = 10
SoilMapOverlay.ALPHA          = 0.72

-- Sampling constants
SoilMapOverlay.SAMPLE_UPDATE_INTERVAL_MS = 4500
SoilMapOverlay.POLYGON_STEP     = 10     -- world-unit grid spacing for polygon sampling (meters)
-- Point budgets per density level (1=Low, 2=Medium, 3=High).
-- These are the BASE values for a standard 2048m map. At runtime the budget is
-- scaled up proportionally with terrain size so large maps (4x, 16x, 64x) get the
-- same visual coverage density without hitting the cap mid-field-list.
SoilMapOverlay.DENSITY_POINTS   = {8000, 20000, 40000}

-- Status colors (match SoilHUD palette)
SoilMapOverlay.C_POOR = {0.88, 0.25, 0.25}
SoilMapOverlay.C_FAIR = {0.90, 0.82, 0.18}
SoilMapOverlay.C_GOOD = {0.25, 0.85, 0.25}

-- Per-layer accent color
SoilMapOverlay.LAYER_ACCENT = {
    [0] = {0.45, 0.45, 0.45},  -- Off:     grey
    [1] = {0.20, 0.55, 1.00},  -- N:       blue
    [2] = {1.00, 0.55, 0.10},  -- P:       orange
    [3] = {0.65, 0.25, 0.90},  -- K:       purple
    [4] = {0.10, 0.78, 0.75},  -- pH:      teal
    [5] = {0.60, 0.35, 0.10},  -- OM:      brown
    [6] = {0.95, 0.25, 0.25},  -- Urgency: red
    [7] = {0.20, 0.70, 0.20},  -- Weed:    dark green
    [8] = {0.85, 0.75, 0.10},  -- Pest:    amber
    [9] = {0.80, 0.10, 0.80},  -- Disease: magenta
    [10] = {0.55, 0.30, 0.10}, -- Compaction: dark brown/orange
}

-- i18n key per layer index (0 = Off)
SoilMapOverlay.LAYER_KEYS = {
    [0] = "sf_map_layer_off",
    [1] = "sf_map_layer_n",
    [2] = "sf_map_layer_p",
    [3] = "sf_map_layer_k",
    [4] = "sf_map_layer_ph",
    [5] = "sf_map_layer_om",
    [6] = "sf_map_layer_urgency",
    [7] = "sf_map_layer_weed",
    [8] = "sf_map_layer_pest",
    [9] = "sf_map_layer_disease",
    [10] = "sf_map_layer_compaction",
}

-- Inverted layers: high value = bad (urgency / pressures)
SoilMapOverlay.INVERTED_LAYERS = {[6]=true,[7]=true,[8]=true,[9]=true,[10]=true}

-- ── Constructor ───────────────────────────────────────────

---@param soilSystem SoilFertilitySystem
---@param settings Settings
---@return SoilMapOverlay
function SoilMapOverlay.new(soilSystem, settings)
    local self = setmetatable({}, SoilMapOverlay_mt)
    self.soilSystem    = soilSystem
    self.settings      = settings

    self.samplePoints = {}
    self.displayValues = nil
    self.nextSampleUpdateTime = 0
    self.isMapOpen = false
    self.isReady = false

    -- Cache of polygon fill points per field: fieldId → array of {x, z} world coords.
    -- Populated lazily in getFieldFillPoints; cleared on requestRefresh.
    self.fieldPolyCache = {}

    -- Manual Button Rects for click detection
    self.buttonRects = {}

    return self
end

-- ── Initialize ────────────────────────────────────────────

function SoilMapOverlay:initialize()
    SoilLogger.info("SoilMapOverlay: initialized (DMF Heatmap Mode)")
end

-- ── Delete ────────────────────────────────────────────────

function SoilMapOverlay:delete()
    self.samplePoints = {}
    SoilLogger.info("SoilMapOverlay: deleted")
end

-- ── PDA Integration ───────────────────────────────────────

function SoilMapOverlay:getDisplayValues()
    -- Return empty table to keep native code happy, but we draw manually
    return {}
end

function SoilMapOverlay:getAverageHealth()
    if not self.soilSystem or not self.soilSystem.fieldData then return 0.75 end
    
    local sum = 0
    local count = 0
    for farmlandId, _ in pairs(self.soilSystem.fieldData) do
        sum = sum + self.soilSystem:getFieldUrgency(farmlandId)
        count = count + 1
    end
    
    if count == 0 then return 1.0 end
    
    local avgUrgency = sum / count
    return math.clamp(1.0 - (avgUrgency / 100), 0, 1)
end

function SoilMapOverlay:getDefaultFilterState()
    return {}
end

function SoilMapOverlay:getSelectedFilterCount(filterStates)
    return 0
end

function SoilMapOverlay:requestRefresh()
    self.nextSampleUpdateTime = 0
    self.fieldPolyCache = {}
end

-- ── Layer selection ───────────────────────────────────────

function SoilMapOverlay:setLayer(layerIdx)
    if self.settings.activeMapLayer == layerIdx then return end
    self.settings.activeMapLayer = layerIdx
    self:requestRefresh()
    SoilLogger.info("SoilMapOverlay: layer set to %d (%s)", layerIdx, g_i18n:getText(SoilMapOverlay.LAYER_KEYS[layerIdx] or "unknown"))
end

function SoilMapOverlay:cycleLayer()
    local active = self.settings.activeMapLayer or 0
    local next = (active % SoilMapOverlay.LAYER_COUNT) + 1
    self:setLayer(next)
end

-- Alias used by SoilMapFrame; equivalent to requestRefresh
function SoilMapOverlay:requestGenerate()
    self:requestRefresh()
end

-- ── Sidebar Clicks ────────────────────────────────────────

function SoilMapOverlay:onSideBarClick(posX, posY)
    for _, rect in ipairs(self.buttonRects) do
        if posX >= rect.x1 and posX <= rect.x2 and posY >= rect.y1 and posY <= rect.y2 then
            if rect.action == "report" then
                if SoilPDAScreen then SoilPDAScreen.toggle() end
                return true
            elseif rect.action == "treatment" then
                if SoilPDAScreen then SoilPDAScreen.showTreatment() end
                return true
            elseif rect.action == "disable" then
                self:setLayer(0)
                return true
            elseif rect.index then
                -- Toggle: re-clicking the active layer turns the overlay off
                local newIdx = (self.settings.activeMapLayer == rect.index) and 0 or rect.index
                self:setLayer(newIdx)
                return true
            end
        end
    end
    return false
end

-- ── Polygon Fill Helpers ──────────────────────────────────

-- Ray-casting point-in-polygon test (2D, XZ plane).
-- verts is an array of {x, z} tables.
local function isPointInPoly(px, pz, verts)
    local n = #verts
    if n < 3 then return false end
    local inside = false
    local j = n
    for i = 1, n do
        local xi, zi = verts[i].x, verts[i].z
        local xj, zj = verts[j].x, verts[j].z
        if ((zi > pz) ~= (zj > pz)) and
           (px < (xj - xi) * (pz - zi) / (zj - zi) + xi) then
            inside = not inside
        end
        j = i
    end
    return inside
end

--- Return an array of world {x, z} sample points that fill the field polygon.
--- Results are cached in self.fieldPolyCache keyed by fsField.fieldId + step.
--- Points outside the polygon are rejected.
---@param fsField table FS25 Field object with polygonPoints and fieldId
---@param step    number  World-unit grid spacing in meters (caller-computed)
---@return table Array of {x, z}
function SoilMapOverlay:getFieldFillPoints(fsField, step)
    step = step or SoilMapOverlay.POLYGON_STEP
    local cacheKey = (fsField.fieldId or tostring(fsField)) .. "@" .. step
    if self.fieldPolyCache[cacheKey] then
        return self.fieldPolyCache[cacheKey]
    end

    local pts = {}

    -- Collect polygon vertices from the i3d node array
    local polyNodes = fsField.polygonPoints
    local verts = {}
    if polyNodes and #polyNodes > 0 then
        for i = 1, #polyNodes do
            local nodeId = polyNodes[i]
            if nodeId and nodeId ~= 0 then
                local ok, wx, _, wz = pcall(getWorldTranslation, nodeId)
                if ok and wx then
                    table.insert(verts, {x = wx, z = wz})
                end
            end
        end
    end

    -- Fallback: if polygon data unavailable, return the single centroid point
    if #verts < 3 then
        if fsField.posX and fsField.posZ then
            table.insert(pts, {x = fsField.posX, z = fsField.posZ})
        end
        self.fieldPolyCache[cacheKey] = pts
        return pts
    end

    -- Compute bounding box
    local minX, maxX = verts[1].x, verts[1].x
    local minZ, maxZ = verts[1].z, verts[1].z
    for i = 2, #verts do
        if verts[i].x < minX then minX = verts[i].x end
        if verts[i].x > maxX then maxX = verts[i].x end
        if verts[i].z < minZ then minZ = verts[i].z end
        if verts[i].z > maxZ then maxZ = verts[i].z end
    end

    -- Grid-sample the bounding box, keep points inside the polygon
    -- (step is the caller-supplied world-unit spacing, already terrain-scaled)
    -- Offset start by half-step so points land near field centre, not edges
    local startX = minX + step * 0.5
    local startZ = minZ + step * 0.5
    local x = startX
    while x <= maxX do
        local z = startZ
        while z <= maxZ do
            if isPointInPoly(x, z, verts) then
                table.insert(pts, {x = x, z = z})
            end
            z = z + step
        end
        x = x + step
    end

    -- Ensure at least the centroid if the grid produced nothing
    -- (can happen for very small or narrow fields)
    if #pts == 0 and fsField.posX and fsField.posZ then
        table.insert(pts, {x = fsField.posX, z = fsField.posZ})
    end

    self.fieldPolyCache[cacheKey] = pts
    return pts
end

-- ── Point Sampling (DMF Pattern) ─────────────────────────

-- Extract the per-cell value for a given overlay layer index (1-5 only).
-- Must be defined before updateSamplePoints to be in scope as an upvalue.
local function getCellLayerValue(cell, layerIdx)
    if layerIdx == 1 then return cell.N
    elseif layerIdx == 2 then return cell.P
    elseif layerIdx == 3 then return cell.K
    elseif layerIdx == 4 then return cell.pH
    elseif layerIdx == 5 then return cell.OM
    end
    return nil
end

function SoilMapOverlay:updateSamplePoints(force)
    local now = (g_currentMission and g_currentMission.time) or g_time or 0
    if not force and now < self.nextSampleUpdateTime then
        return
    end

    self.nextSampleUpdateTime = now + SoilMapOverlay.SAMPLE_UPDATE_INTERVAL_MS

    self.samplePoints = {}

    local layerIdx = self.settings.activeMapLayer or 0
    if layerIdx <= 0 then
        SoilLogger.debug("SoilMapOverlay: No active layer selected")
        return
    end

    if g_currentMission == nil or g_fieldManager == nil then
        SoilLogger.info("SoilMapOverlay: Sampling aborted - mission or fieldManager nil")
        return
    end

    -- Fill each field polygon with a grid of coloured sample points.
    -- We match fields to our soil data via farmland.id (the key fieldData uses).
    -- getFieldFillPoints() handles the grid sampling and caching; it falls back to
    -- a single centroid point for very small fields or when polygon data is absent.
    local fields = g_fieldManager.fields
    if fields == nil then
        SoilLogger.info("SoilMapOverlay: g_fieldManager.fields is nil")
        return
    end

    -- Scale sampling step proportional to terrain size so large maps
    -- (4x, 16x, 64x) get the same screen-pixel density as a standard 2048m map.
    local terrainSize = (g_currentMission and g_currentMission.terrainSize) or 2048
    local mapScale    = math.max(1.0, terrainSize / 2048.0)
    local scaledStep  = SoilMapOverlay.POLYGON_STEP * mapScale

    -- Resolve point budget from the player's density setting (localOnly, default Medium).
    -- Scale the budget proportionally with map area (mapScale²) so large maps don't exhaust
    -- the cap before all fields are drawn — a 4x map needs ~16x the points of a standard map.
    local densityLevel = (self.settings and self.settings.overlayDensity) or 2
    local basePoints   = SoilMapOverlay.DENSITY_POINTS[densityLevel] or SoilMapOverlay.DENSITY_POINTS[2]
    local maxPoints    = math.floor(basePoints * mapScale * mapScale)

    local totalPoints = 0
    for _, fsField in ipairs(fields) do
        if fsField and fsField.farmland then
            local farmlandId = fsField.farmland.id
            if farmlandId and farmlandId > 0 then
                local info = self.soilSystem:getFieldInfo(farmlandId)
                if info then
                    local polyPts = self:getFieldFillPoints(fsField, scaledStep)
                    -- Per-pixel path: when GRLE density map layers are available (layers 1-5),
                    -- read the soil value at each sample point directly from the layer so that
                    -- sprayed sub-areas show different colours from unsprayed areas.
                    -- Falls back to per-field average for layers 6-9 or when layers are absent.
                    local layerSystem = self.soilSystem and self.soilSystem.layerSystem
                    local grleLayerName = layerSystem and layerSystem.available and LAYER_GRLE_NAME[layerIdx]
                    if grleLayerName then
                        -- GRLE per-pixel path: maps that ship custom density-map info layers
                        for _, pt in ipairs(polyPts) do
                            if totalPoints < maxPoints then
                                local val = layerSystem:readValueAtWorld(grleLayerName, pt.x, pt.z)
                                local r, g, b
                                if val ~= nil then
                                    r, g, b = self:valueToLayerColor(layerIdx, val)
                                else
                                    r, g, b = self:getLayerColor(layerIdx, info, farmlandId)
                                end
                                table.insert(self.samplePoints, {x = pt.x, z = pt.z, r = r, g = g, b = b})
                                totalPoints = totalPoints + 1
                            end
                        end
                    elseif layerIdx >= 1 and layerIdx <= 5 then
                        -- zoneData per-cell path: standard maps, layers 1-5 (N/P/K/pH/OM).
                        -- Cells that have been sprayed show their local value; unvisited cells
                        -- fall back to the field average so the map is always fully coloured.
                        local fieldEntry = self.soilSystem.fieldData and self.soilSystem.fieldData[farmlandId]
                        local zoneData = fieldEntry and fieldEntry.zoneData
                        local zone = SoilConstants.ZONE
                        for _, pt in ipairs(polyPts) do
                            if totalPoints < maxPoints then
                                local r, g, b
                                if zoneData then
                                    local cx = math.floor(pt.x / zone.CELL_SIZE)
                                    local cz = math.floor(pt.z / zone.CELL_SIZE)
                                    local cell = zoneData[cx .. "_" .. cz]
                                    if cell then
                                        local val = getCellLayerValue(cell, layerIdx)
                                        if val then r, g, b = self:valueToLayerColor(layerIdx, val) end
                                    end
                                end
                                if not r then r, g, b = self:getLayerColor(layerIdx, info, farmlandId) end
                                table.insert(self.samplePoints, {x = pt.x, z = pt.z, r = r, g = g, b = b})
                                totalPoints = totalPoints + 1
                            end
                        end
                    else
                        -- Field-average path: layers 6-9 (urgency, weed, pest, disease)
                        local r, g, b = self:getLayerColor(layerIdx, info, farmlandId)
                        for _, pt in ipairs(polyPts) do
                            if totalPoints < maxPoints then
                                table.insert(self.samplePoints, {x = pt.x, z = pt.z, r = r, g = g, b = b})
                                totalPoints = totalPoints + 1
                            end
                        end
                    end
                end
            end
        end
    end

    if totalPoints > 0 then
        SoilLogger.info("SoilMapOverlay: Sampled %d polygon fill points for layer %d (fields: %d)",
                        totalPoints, layerIdx, #fields)
    else
        SoilLogger.info("SoilMapOverlay: No fields found to sample (fields count: %d)", #fields)
    end
end

-- ── Draw (called by hook) ────────────────────────────────

function SoilMapOverlay:onDraw(frame, mapElement, ingameMap, pageIndex)
    self:updateSamplePoints(false)

    if #self.samplePoints == 0 then return end

    -- Use the layout-based bounds (DFF pattern) — ingameMap:getPosition() does not exist.
    local mapX, mapY, mapWidth, mapHeight = self:getMapRenderBounds(frame, ingameMap)
    if mapX == nil or mapWidth == nil or mapHeight == nil then return end

    local layerIdx = self.settings.activeMapLayer or 0
    if layerIdx <= 0 then return end

    local mapMaxX = mapX + mapWidth
    local mapMaxY = mapY + mapHeight

    -- Compute tile size from world-to-screen scale so tiles fill edge-to-edge at any zoom level.
    -- Use the same terrain-scaled step as the sampler so tiles match sample density exactly.
    local terrainSz = (g_currentMission and g_currentMission.terrainSize) or 2048
    local drawStep  = SoilMapOverlay.POLYGON_STEP * math.max(1.0, terrainSz / 2048.0)
    local sizeX, sizeY
    local probeX, probeZ = 0, 0
    local ax, ay = self:worldToScreenPosition(ingameMap, probeX, probeZ)
    local bx, by = self:worldToScreenPosition(ingameMap, probeX + drawStep, probeZ)
    local cx, cy = self:worldToScreenPosition(ingameMap, probeX, probeZ + drawStep)
    if ax and bx and cx then
        local dxX = math.abs(bx - ax)
        local dyZ = math.abs(cy - ay)
        -- Add 15% overlap so adjacent tiles don't leave hairline gaps
        sizeX = math.max(dxX * 1.15, 0.0005)
        sizeY = math.max(dyZ * 1.15, 0.0005)
    else
        sizeX, sizeY = getNormalizedScreenValues(10, 10)
    end
    local halfX, halfY = sizeX * 0.5, sizeY * 0.5

    -- Derive affine transform coefficients from the 3 probe points.
    -- The map is a linear projection so this is exact at any zoom/pan level.
    -- Replaces a per-point worldToScreenPosition() engine call with arithmetic,
    -- cutting ~40k Lua→C++ calls per frame down to the 3 probes above.
    local scaleXX = (bx - ax) / drawStep
    local scaleYX = (by - ay) / drawStep
    local scaleXZ = (cx - ax) / drawStep
    local scaleYZ = (cy - ay) / drawStep

    for _, point in ipairs(self.samplePoints) do
        local screenX = ax + point.x * scaleXX + point.z * scaleXZ
        local screenY = ay + point.x * scaleYX + point.z * scaleYZ
        if screenX >= mapX and screenX <= mapMaxX
           and screenY >= mapY and screenY <= mapMaxY then
            drawFilledRect(screenX - halfX, screenY - halfY, sizeX, sizeY,
                           point.r, point.g, point.b, SoilMapOverlay.ALPHA)
        end
    end
end

function SoilMapOverlay:worldToScreenPosition(ingameMap, worldX, worldZ)
    if ingameMap == nil then return nil, nil end
    -- Use fullScreenLayout when available (matches getMapRenderBounds), fall back to active layout
    local layout = ingameMap.fullScreenLayout or ingameMap.layout
    if layout == nil or layout.getMapObjectPosition == nil then return nil, nil end

    local worldSizeX = ingameMap.worldSizeX or g_currentMission.terrainSize or 2048
    local worldSizeZ = ingameMap.worldSizeZ or g_currentMission.terrainSize or 2048

    if worldSizeX == 0 or worldSizeZ == 0 then return nil, nil end

    -- DFF pattern: use worldCenterOffsetX/Z directly (0 for centered maps)
    local objectX = (worldX + (ingameMap.worldCenterOffsetX or 0)) / worldSizeX
    local objectZ = (worldZ + (ingameMap.worldCenterOffsetZ or 0)) / worldSizeZ

    objectX = objectX * (ingameMap.mapExtensionScaleFactor or 1) + (ingameMap.mapExtensionOffsetX or 0)
    objectZ = objectZ * (ingameMap.mapExtensionScaleFactor or 1) + (ingameMap.mapExtensionOffsetZ or 0)

    return layout:getMapObjectPosition(objectX, objectZ, 0, 0)
end

-- ── Sidebar Rendering ─────────────────────────────────────

function SoilMapOverlay:getSidebarBounds(frame)
    local minW, _ = getNormalizedScreenValues(230, 0)
    local marginX, marginY = getNormalizedScreenValues(8, 8)
    local safeX, safeY = getNormalizedScreenValues(6, 6)
    
    local panelX = safeX + marginX
    local panelWidth = minW

    if frame.filterList then
        panelX = frame.filterList.absPosition[1]
        panelWidth = frame.filterList.absSize[1]
    end

    -- Top Y starts below the selector - added extra margin to avoid dots clipping
    local topY = 0.82 
    if frame.mapOverviewSelector then
        local _, extraMargin = getNormalizedScreenValues(0, 45) -- Push buttons down
        topY = frame.mapOverviewSelector.absPosition[2] - extraMargin
    end

    return panelX, topY, panelWidth
end

function SoilMapOverlay:onDrawHud(frame)
    self.buttonRects = {}
    
    local panelX, topY, panelWidth = self:getSidebarBounds(frame)
    local _, buttonH = getNormalizedScreenValues(0, 38)
    local _, marginY = getNormalizedScreenValues(0, 4)
    local _, textSize = getNormalizedScreenValues(0, 15)
    local padX, _ = getNormalizedScreenValues(10, 0)
    local accentW, _ = getNormalizedScreenValues(4, 0)

    local activeIdx = self.settings.activeMapLayer or 0

    -- 1. Draw 9 Nutrient Buttons (from top down)
    local currentY = topY - buttonH
    for i = 1, SoilMapOverlay.LAYER_COUNT do
        local isActive = (i == activeIdx)
        
        local bgR, bgG, bgB = 0.05, 0.05, 0.05
        if isActive then bgR, bgG, bgB = 0.12, 0.12, 0.12 end
        drawFilledRect(panelX, currentY, panelWidth, buttonH, bgR, bgG, bgB, 0.85)
        
        local color = SoilMapOverlay.LAYER_ACCENT[i]
        drawFilledRect(panelX, currentY, accentW, buttonH, color[1], color[2], color[3], 1.0)
        
        if isActive then
            self:drawThinBorder(panelX, currentY, panelWidth, buttonH, 0.8, 0.8, 0.8, 0.5)
        end

        local key = SoilMapOverlay.LAYER_KEYS[i]
        local name = (g_i18n and g_i18n:getText(key)) or key
        
        setTextBold(isActive)
        setTextColor(isActive and 1 or 0.8, isActive and 1 or 0.8, isActive and 1 or 0.8, 1)
        setTextAlignment(RenderText.ALIGN_LEFT)
        renderText(panelX + padX + accentW, currentY + buttonH * 0.3, textSize, name)
        
        table.insert(self.buttonRects, {
            x1 = panelX, y1 = currentY, 
            x2 = panelX + panelWidth, y2 = currentY + buttonH,
            index = i
        })

        currentY = currentY - buttonH - marginY
    end

    -- 2. Separator line
    local _, sepH    = getNormalizedScreenValues(0, 1)
    local _, sepGap  = getNormalizedScreenValues(0, 6)
    currentY = currentY - sepGap
    drawFilledRect(panelX, currentY, panelWidth, sepH, 0.45, 0.45, 0.45, 0.45)
    currentY = currentY - sepH - sepGap

    -- 3. Action buttons
    local _, actionH      = getNormalizedScreenValues(0, 30)
    local _, actionMargin = getNormalizedScreenValues(0, 3)

    local actionButtons = {
        { key = "sf_map_btn_report",    label = "Farm Overview",  action = "report"    },
        { key = "sf_map_btn_treatment", label = "Treatment Plan", action = "treatment" },
        { key = "sf_map_btn_disable",   label = "Disable Overlay",action = "disable"   },
    }

    for _, btn in ipairs(actionButtons) do
        drawFilledRect(panelX, currentY, panelWidth, actionH, 0.07, 0.07, 0.13, 0.88)
        drawFilledRect(panelX, currentY, accentW, actionH, 0.55, 0.55, 0.78, 1.0)
        self:drawThinBorder(panelX, currentY, panelWidth, actionH, 0.4, 0.4, 0.6, 0.55)

        setTextBold(false)
        setTextColor(0.80, 0.80, 1.0, 1)
        setTextAlignment(RenderText.ALIGN_LEFT)
        renderText(panelX + padX + accentW, currentY + actionH * 0.28, textSize,
                   tr(btn.key, btn.label))

        table.insert(self.buttonRects, {
            x1 = panelX, y1 = currentY,
            x2 = panelX + panelWidth, y2 = currentY + actionH,
            action = btn.action,
        })

        currentY = currentY - actionH - actionMargin
    end

    -- 4. Color legend (only when a layer is active)
    if activeIdx > 0 then
        self:drawLegend(panelX, currentY - sepGap, panelWidth)
    end

    -- 5. Draw Health Summary (Anchored to BOTTOM area per DMF pattern)
    local _, summaryH = getNormalizedScreenValues(0, 74)
    local _, panelMargin = getNormalizedScreenValues(0, 8)
    local _, safeY = getNormalizedScreenValues(0, 6)
    local _, upOffset = getNormalizedScreenValues(0, 15) -- Extra push up
    
    local summaryY = safeY + panelMargin + upOffset
    -- Check if native buttons exist and are visible
    if frame.buttonDeselectAllText ~= nil and frame.buttonDeselectAllText:getIsVisible() then
        summaryY = frame.buttonDeselectAllText.absPosition[2] + frame.buttonDeselectAllText.absSize[2] + panelMargin + upOffset
    elseif frame.buttonHelpText ~= nil and frame.buttonHelpText:getIsVisible() then
        summaryY = frame.buttonHelpText.absPosition[2] + frame.buttonHelpText.absSize[2] + panelMargin + upOffset
    end
    
    self:drawSummaryAt(frame, panelX, summaryY, panelWidth, summaryH)
end

function SoilMapOverlay:drawSummaryAt(frame, panelX, panelY, panelWidth, panelHeight)
    local padX, padY = getNormalizedScreenValues(11, 9)
    local _, titleSize = getNormalizedScreenValues(0, 16)
    local _, statusSize = getNormalizedScreenValues(0, 13)
    local _, barHeight = getNormalizedScreenValues(0, 11)
    local _, rowGap = getNormalizedScreenValues(0, 8)
    local indicatorWidth, indicatorHeightPad = getNormalizedScreenValues(2, 2)

    local health = self:getAverageHealth()
    local healthPercent = math.floor(health * 100 + 0.5)
    
    local barX = panelX + padX
    local barY = panelY + padY + statusSize + rowGap
    local barWidth = panelWidth - padX * 2
    local headerY = barY + barHeight + rowGap
    local statusY = panelY + padY

    drawFilledRect(panelX, panelY, panelWidth, panelHeight, 0.03, 0.03, 0.03, 0.86)
    self:drawThinBorder(panelX, panelY, panelWidth, panelHeight, 0.62, 0.62, 0.62, 0.78)

    drawFilledRect(barX, barY, barWidth, barHeight, 0.12, 0.12, 0.12, 0.94)
    self:drawHealthGradientBar(barX, barY, barWidth, barHeight)
    self:drawThinBorder(barX, barY, barWidth, barHeight, 0.82, 0.82, 0.82, 0.76)

    local markerX = barX + barWidth * health
    drawFilledRect(markerX - indicatorWidth * 0.5, barY - indicatorHeightPad, indicatorWidth, barHeight + indicatorHeightPad * 2, 1, 1, 1, 0.94)

    setTextBold(true)
    setTextColor(0.93, 0.93, 0.93, 1)
    setTextAlignment(RenderText.ALIGN_LEFT)
    renderText(barX, headerY, titleSize, g_i18n:getText("sf_map_health_overall") or "Average Soil Health")

    setTextAlignment(RenderText.ALIGN_RIGHT)
    renderText(barX + barWidth, headerY, titleSize, string.format("%d%%", healthPercent))

    local statusText = "Growth Conditions: Optimal"
    if health < 0.4 then statusText = "Growth Conditions: Poor"
    elseif health < 0.7 then statusText = "Growth Conditions: Fair" end

    setTextBold(false)
    setTextColor(0.80, 0.80, 0.80, 0.95)
    setTextAlignment(RenderText.ALIGN_LEFT)
    renderText(barX, statusY, statusSize, statusText)
    
    setTextColor(1, 1, 1, 1)
end

-- ── Color Legend ─────────────────────────────────────────

function SoilMapOverlay:drawLegend(panelX, bottomY, panelWidth)
    local _, legendH   = getNormalizedScreenValues(0, 24)
    local padX, _      = getNormalizedScreenValues(10, 0)
    local dotSzX, dotSzY = getNormalizedScreenValues(9, 9)
    local _, textSz    = getNormalizedScreenValues(0, 11)
    local dotGapX, _   = getNormalizedScreenValues(4, 0)

    local legendY = bottomY - legendH

    drawFilledRect(panelX, legendY, panelWidth, legendH, 0.04, 0.04, 0.04, 0.80)
    self:drawThinBorder(panelX, legendY, panelWidth, legendH, 0.35, 0.35, 0.35, 0.5)

    local items = {
        { r = SoilMapOverlay.C_POOR[1], g = SoilMapOverlay.C_POOR[2], b = SoilMapOverlay.C_POOR[3],
          key = "sf_pda_map_legend_poor", label = "Poor" },
        { r = SoilMapOverlay.C_FAIR[1], g = SoilMapOverlay.C_FAIR[2], b = SoilMapOverlay.C_FAIR[3],
          key = "sf_pda_map_legend_fair", label = "Fair" },
        { r = SoilMapOverlay.C_GOOD[1], g = SoilMapOverlay.C_GOOD[2], b = SoilMapOverlay.C_GOOD[3],
          key = "sf_pda_map_legend_good", label = "Good" },
    }

    local colWidth = (panelWidth - padX * 2) / #items
    local dotCenterY = legendY + (legendH - dotSzY) * 0.5

    for i, item in ipairs(items) do
        local itemX = panelX + padX + (i - 1) * colWidth
        drawFilledRect(itemX, dotCenterY, dotSzX, dotSzY, item.r, item.g, item.b, 0.92)
        self:drawThinBorder(itemX, dotCenterY, dotSzX, dotSzY, 0, 0, 0, 0.5)
        setTextBold(false)
        setTextColor(0.72, 0.72, 0.72, 1)
        setTextAlignment(RenderText.ALIGN_LEFT)
        renderText(itemX + dotSzX + dotGapX, dotCenterY, textSz, tr(item.key, item.label))
    end
end

function SoilMapOverlay:drawThinBorder(x, y, width, height, r, g, b, a)
    local borderX, borderY = getNormalizedScreenValues(1, 1)
    drawFilledRect(x - borderX, y - borderY, width + borderX * 2, borderY, r, g, b, a)
    drawFilledRect(x - borderX, y + height, width + borderX * 2, borderY, r, g, b, a)
    drawFilledRect(x - borderX, y, borderX, height, r, g, b, a)
    drawFilledRect(x + width, y, borderX, height, r, g, b, a)
end

function SoilMapOverlay:drawHealthGradientBar(x, y, width, height)
    local steps = 34
    local stepWidth = width / steps
    for i = 0, steps - 1 do
        local t = (i + 0.5) / steps
        local r, g, b
        if t <= 0.5 then
            local alpha = t / 0.5
            r = 0.88 + (0.90 - 0.88) * alpha
            g = 0.25 + (0.82 - 0.25) * alpha
            b = 0.25 + (0.18 - 0.25) * alpha
        else
            local alpha = (t - 0.5) / 0.5
            r = 0.90 + (0.25 - 0.90) * alpha
            g = 0.82 + (0.85 - 0.82) * alpha
            b = 0.18 + (0.25 - 0.18) * alpha
        end
        drawFilledRect(x + i * stepWidth, y, stepWidth + 0.00001, height, r, g, b, 0.94)
    end
end

function SoilMapOverlay:getMapRenderBounds(frame, ingameMap)
    local layout = nil
    if frame ~= nil and frame.ingameMapBase ~= nil and frame.ingameMapBase.fullScreenLayout ~= nil then
        layout = frame.ingameMapBase.fullScreenLayout
    elseif ingameMap ~= nil and ingameMap.fullScreenLayout ~= nil then
        layout = ingameMap.fullScreenLayout
    end

    if layout == nil or layout.getMapSize == nil or layout.getMapPosition == nil then
        return nil, nil, nil, nil
    end

    local mapX, mapY = layout:getMapPosition()
    local mapW, mapH = layout:getMapSize()
    return mapX, mapY, mapW, mapH
end

-- ── Layer density-map layer names (indices 1-5 have GRLE layers) ─────────────
-- Maps overlay layer index → SoilLayerSystem layer name.
-- Layers 6-9 are computed values (urgency, weed, pest, disease) with no GRLE.
local LAYER_GRLE_NAME = {
    [1] = "infoLayer_soilN",
    [2] = "infoLayer_soilP",
    [3] = "infoLayer_soilK",
    [4] = "infoLayer_soilPH",
    [5] = "infoLayer_soilOM",
}

-- Convert a raw decoded value (from the density map layer) to a colour.
-- Mirrors the same thresholds used in getLayerColor so the per-pixel path
-- matches the per-field fallback path exactly.
---@param layerIdx integer  1-5 (soil nutrient layers)
---@param val      number   Decoded semantic float from readValueAtWorld
function SoilMapOverlay:valueToLayerColor(layerIdx, val)
    local POOR = SoilMapOverlay.C_POOR
    local FAIR = SoilMapOverlay.C_FAIR
    local GOOD = SoilMapOverlay.C_GOOD
    local T    = SoilConstants.STATUS_THRESHOLDS

    if layerIdx == 1 then
        if val < T.nitrogen.poor     then return POOR[1], POOR[2], POOR[3]
        elseif val < T.nitrogen.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                              return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 2 then
        if val < T.phosphorus.poor     then return POOR[1], POOR[2], POOR[3]
        elseif val < T.phosphorus.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                                return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 3 then
        if val < T.potassium.poor     then return POOR[1], POOR[2], POOR[3]
        elseif val < T.potassium.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                               return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 4 then
        if val >= 6.5 and val <= 7.0   then return GOOD[1], GOOD[2], GOOD[3]
        elseif val >= 5.5 and val <= 7.5 then return FAIR[1], FAIR[2], FAIR[3]
        else                                  return POOR[1], POOR[2], POOR[3] end
    elseif layerIdx == 5 then
        if val >= 4.0     then return GOOD[1], GOOD[2], GOOD[3]
        elseif val >= 2.5 then return FAIR[1], FAIR[2], FAIR[3]
        else                   return POOR[1], POOR[2], POOR[3] end
    end

    return GOOD[1], GOOD[2], GOOD[3]
end

-- ── Layer color logic ─────────────────────────────────────

function SoilMapOverlay:getLayerColor(layerIdx, info, farmlandId)
    local POOR = SoilMapOverlay.C_POOR
    local FAIR = SoilMapOverlay.C_FAIR
    local GOOD = SoilMapOverlay.C_GOOD
    local T    = SoilConstants.STATUS_THRESHOLDS

    if layerIdx == 1 then
        local v = info.nitrogen and info.nitrogen.value or 0
        if v < T.nitrogen.poor     then return POOR[1], POOR[2], POOR[3]
        elseif v < T.nitrogen.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                            return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 2 then
        local v = info.phosphorus and info.phosphorus.value or 0
        if v < T.phosphorus.poor     then return POOR[1], POOR[2], POOR[3]
        elseif v < T.phosphorus.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                              return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 3 then
        local v = info.potassium and info.potassium.value or 0
        if v < T.potassium.poor     then return POOR[1], POOR[2], POOR[3]
        elseif v < T.potassium.fair then return FAIR[1], FAIR[2], FAIR[3]
        else                             return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 4 then
        local pH = info.pH or 7.0
        if pH >= 6.5 and pH <= 7.0 then     return GOOD[1], GOOD[2], GOOD[3]
        elseif pH >= 5.5 and pH <= 7.5 then return FAIR[1], FAIR[2], FAIR[3]
        else                                return POOR[1], POOR[2], POOR[3] end
    elseif layerIdx == 5 then
        local om = info.organicMatter or 0
        if om >= 4.0     then return GOOD[1], GOOD[2], GOOD[3]
        elseif om >= 2.5 then return FAIR[1], FAIR[2], FAIR[3]
        else                  return POOR[1], POOR[2], POOR[3] end
    elseif layerIdx == 6 then
        local u = self.soilSystem:getFieldUrgency(farmlandId)
        if u > 66     then return POOR[1], POOR[2], POOR[3]
        elseif u > 33 then return FAIR[1], FAIR[2], FAIR[3]
        else               return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 7 then
        local v = info.weedPressure or 0
        if v > 50     then return POOR[1], POOR[2], POOR[3]
        elseif v > 20 then return FAIR[1], FAIR[2], FAIR[3]
        else               return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 8 then
        local v = info.pestPressure or 0
        if v > 50     then return POOR[1], POOR[2], POOR[3]
        elseif v > 20 then return FAIR[1], FAIR[2], FAIR[3]
        else               return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 9 then
        local v = info.diseasePressure or 0
        if v > 50     then return POOR[1], POOR[2], POOR[3]
        elseif v > 20 then return FAIR[1], FAIR[2], FAIR[3]
        else               return GOOD[1], GOOD[2], GOOD[3] end
    elseif layerIdx == 10 then
        local v = info.compaction or 0
        if v > 60     then return POOR[1], POOR[2], POOR[3]
        elseif v > 20 then return FAIR[1], FAIR[2], FAIR[3]
        else               return GOOD[1], GOOD[2], GOOD[3] end
    end

    return GOOD[1], GOOD[2], GOOD[3]
end

SoilLogger.info("SoilMapOverlay loaded")