--***********************************************************
--** TM_TrapManagerWindow.lua
--***********************************************************
-- Trap Manager window using TM_ResizableColumns composite:
--  - Scrollable list with a fixed header just below the title bar
--  - Resizable columns (drag header separators)
--  - Right-click header context menu with checked options
--  - Refresh button next to the Close button
--  - Hourly auto-refresh while the window is visible
--
-- Adds columns:
--  - Chunks: (cDX,cDY) distance in chunks (8x8 squares)
--  - Eligible: "Yes"/"No" depending on active area rule (>9 chunks both axes)
--  - Chance: placeholder to plug wiki-based probabilities in the future

require "ISUI/ISCollapsableWindow"
require "Map/CGlobalObjectSystem"
-- require "server/Traps/STrapSystem"
require "ISUI/ISButton"
require "ISUI/ISComboBox"
require "ISUI/ISToolTip"
require "Traps/CTrapSystem"
require "Traps/CTrapGlobalObject"
require "Traps/TrapDefinition"
require "TM_ResizableColumns"

local RGB_g = " <RGB:0.3,1,0.3> "
local RGB_r = " <RGB:1,0.3,0.3> "
local RGB_w = " <RGB:1,1,1> "

-- Allow forcing the alpha of an ISToolTip's background per instance
do
    if not ISToolTip._TM_forceAlpha_wrap then
        ISToolTip._TM_forceAlpha_wrap = true
        local _origRender = ISToolTip.render

        function ISToolTip:render()
            if self._tm_forceRectAlpha then
                -- Interceptar SOLO el rectángulo grande del fondo (alpha=0.7, gris 0.05)
                local _origDrawRect = self.drawRect
                self.drawRect = function(s, x, y, w, h, a, r, g, b)
                    if a == 0.7 and r == 0.05 and g == 0.05 and b == 0.05 then
                        a = tonumber(self._tm_forceRectAlpha) or a
                    end
                    return _origDrawRect(s, x, y, w, h, a, r, g, b)
                end

                local ok, res = pcall(_origRender, self)
                self.drawRect = _origDrawRect
                if not ok then error(res) end
                return res
            else
                return _origRender(self)
            end
        end
    end
end

-- Ensure combo dropdowns are opaque regardless of the combo's closed skin
-- (vanilla copies colors in ISComboBoxPopup:setComboBox)
do
    local _origSetComboBox = ISComboBoxPopup.setComboBox
    function ISComboBoxPopup:setComboBox(combo)
        _origSetComboBox(self, combo)

        -- Force an opaque background/border for the popup (the list itself)
        self.backgroundColor = self.backgroundColor or { r=0, g=0, b=0, a=1 }
        self.borderColor     = self.borderColor     or { r=1, g=1, b=1, a=1 }
        self.backgroundColor.a = 1.0
        self.borderColor.a     = 1.0
    end
end

-- === Remove the hard 9-lines cap in ISComboBoxPopup ===
-- Respect a configurable "numVisible" from the parent combo (default back to 9).
do
    local _origPrerender = ISComboBoxPopup.prerender
    function ISComboBoxPopup:prerender()
        -- Keep the original visibility guard
        if not self.parentCombo:isReallyVisible() then
            self:removeFromUIManager()
            return
        end

        self.tooWide = nil

        -- Compute number of visible rows (considering optional text filter)
        local numVisible = self:size()
        if self.parentCombo:hasFilterText() then
            local filterText = self.parentCombo:getFilterText():lower()
            for i = 1, self:size() do
                local text = self.items[i].text:lower()
                if not text:contains(filterText) then
                    numVisible = numVisible - 1
                end
            end
        end

        -- Set scroll height to total rows and the popup height to the clamped visible rows
        self:setScrollHeight(numVisible * self.itemheight)

        -- *** key change: read preferred limit from the parent combo (e.g., 11 for Skill 0..10) ***
        local limit = tonumber(self.parentCombo and self.parentCombo.numVisible) or 9
        limit = math.max(1, math.floor(limit))
        self:setHeight(math.min(numVisible, limit) * self.itemheight)

        -- Keep vscroll in sync and call the original rendering bits
        self.vscroll:setHeight(self.height)
        ISScrollingListBox.prerender(self)
    end
end

TrapManagerWindow = ISCollapsableWindow:derive("TrapManagerWindow");
TrapManagerWindow.instance = nil;

------------------------------------------------------------
-- Optional debug flag and logger
--  - We avoid colon chqaracters in log messages because the game truncates at colon
--  - Use TM_DEBUG = true if you want to see runtime geometry and resize traces
------------------------------------------------------------
local TM_DEBUG = false
local function TMLog(msg)
    if TM_DEBUG then
        -- Replace colon with double greater-than to prevent truncation
        print("[TM DBG] " .. tostring(msg):gsub(":", " >> "))
    end
end

local function TMLogKV(tag, t)
    if not TM_DEBUG then return end
    local parts = { "["..tag.."]" }
    for k,v in pairs(t or {}) do table.insert(parts, tostring(k).."="..tostring(v)) end
    print("[TM DBG] " .. table.concat(parts, "  "))
end

local function TMBool(b) return b and "T" or "F" end

-- Text truncation adapted from Neat UI helper.
-- Ensures headers fit their current column width without overflowing.
local function TM_TruncateText(text, maxWidth, font, suffix)
    if not text or text == "" then return "" end
    font = font or UIFont.Small
    suffix = suffix or "..."
    local tm = getTextManager()
    local originalWidth = tm:MeasureStringX(font, text)
    if originalWidth <= maxWidth then return text end
    local suffixWidth = tm:MeasureStringX(font, suffix)
    if suffixWidth >= maxWidth then return "" end
    local textMaxWidth = maxWidth - suffixWidth
    local left, right, bestLength = 1, #text, 0
    while left <= right do
        local mid = math.floor((left + right) / 2)
        local truncated = string.sub(text, 1, mid)
        local w = tm:MeasureStringX(font, truncated)
        if w <= textMaxWidth then bestLength = mid; left = mid + 1 else right = mid - 1 end
    end
    if bestLength == 0 then return suffix end
    return string.sub(text, 1, bestLength) .. suffix
end

--==== Localization helper ====================================================
-- _T("KEY","fallback",args...) -> try getText(KEY,args...), else fallback.
-- IMPORTANT (Lua 5.1): You cannot use "..." inside nested functions.
-- Capture varargs into a table FIRST, then use unpack(args) inside closures.
local function _T(key, fallback, ...)
    -- Capture varargs so we can safely forward them inside the pcall closure
    local args = { ... }
    local _unpack = unpack or (table and table.unpack)  -- Lua 5.1 has global unpack

    -- Try game's translation table with the captured args
    local ok, res = pcall(function()
        if _unpack then
            return getText(key, _unpack(args))
        else
            -- Very defensive fallback if unpack is missing
            return getText(key)
        end
    end)
    if ok and res and res ~= key then return tostring(res) end

    -- Fallback: manually replace %1, %2... with arguments
    local s = tostring(fallback or key)
    for i, v in ipairs(args) do
        s = s:gsub("%%"..i, tostring(v))
    end
    return s
end

-- Read ModOptions' "explanatory tooltips" preference safely from UI code.
local function TM_VerboseTipsEnabled()
    if type(TM_AreVerboseTipsEnabled) == "function" then
        return TM_AreVerboseTipsEnabled()
    end
    return (type(TM)=="table" and TM.DEFAULT_VERBOSE) or true
end

-- Safe setter to apply or clear a tooltip on any ISUI widget
local function _setTip(w, textOrNil)
    if not w then return end
    -- nil or "" => treat as "no tooltip"
    local txt = (textOrNil and textOrNil ~= "") and textOrNil or nil

    if w.setTooltip then
        w:setTooltip(txt or "")
    else
        w.tooltip = txt
    end

    -- IMPORTANT: when clearing the tooltip, also close any live tooltip UI
    if not txt and w.tooltipUI then
        pcall(function()
            if w.tooltipUI.getIsVisible and w.tooltipUI:getIsVisible() then
                w.tooltipUI:setVisible(false)
            end
            if w.tooltipUI.removeFromUIManager then
                w.tooltipUI:removeFromUIManager()
            end
        end)
        w.tooltipUI = nil
    end
end

-- Keep any live tooltip above the window (header, list, or grid-level)
function TrapManagerWindow:_bringTooltipsToTop()
    if not self.grid then return end
    local cand = {}
    if self.grid.tooltipUI then table.insert(cand, self.grid.tooltipUI) end
    if self.grid.header and self.grid.header.tooltipUI then table.insert(cand, self.grid.header.tooltipUI) end
    if self.grid.list   and self.grid.list.tooltipUI   then table.insert(cand, self.grid.list.tooltipUI) end
    for _,ui in ipairs(cand) do
        if ui and ui.bringToTop then pcall(function() ui:bringToTop() end) end
    end
end

-- Opacidades for tooltips
local TM_TT_ALPHA_BG      = 0.92  -- fondo grande (antes era 0.70)
local TM_TT_ALPHA_INNERBG = 0.85  -- fondo del rich text interno
local TM_TT_ALPHA_INNERBR = 0.35  -- borde del rich text interno

function TrapManagerWindow:_tmApplyTooltipSkin(tt)
    if not tt or tt._tmSkinApplied then return end
    -- Activate the switch that understands ISToolTip:render()
    tt._tm_forceRectAlpha = TM_TT_ALPHA_BG

    -- Harden the inner text panel (without touching other tooltips)
    if tt.descriptionPanel then
        tt.descriptionPanel.backgroundColor = tt.descriptionPanel.backgroundColor or {r=0,g=0,b=0,a=0}
        tt.descriptionPanel.backgroundColor.a = TM_TT_ALPHA_INNERBG

        tt.descriptionPanel.borderColor = tt.descriptionPanel.borderColor or {r=1,g=1,b=1,a=1}
        tt.descriptionPanel.borderColor.a = TM_TT_ALPHA_INNERBR
    end

    tt._tmSkinApplied = true
end

function TrapManagerWindow:_reinforceTooltipOpacity()
    -- Candidates where a tooltip may be active (add/remove if needed)
    local widgets = {
        self.grid,
        self.grid and self.grid.header,
        self.grid and self.grid.list,
        self.refreshButton, self.autoButton, self.vectorButton,
        self.fullButton, self.resetButton, self.btnShowAll,
        self.simAnimal, self.simTrap, self.simBait, self.simSkill, self.simZone,
        self.simChanceBtn,
    }
    for _, w in ipairs(widgets) do
        local tt = w and w.tooltipUI
        if tt and tt.isReallyVisible and tt:isReallyVisible() then
            self:_tmApplyTooltipSkin(tt)
        end
    end
end


-- Keep header and titlebar tooltips out of the way when verbose tooltips are OFF.
function TrapManagerWindow:_clearVerboseOnlyTooltips()
    if TM_VerboseTipsEnabled() then return end

    -- Small helper: clear any classic tooltip or tooltip map attached to a widget
    local function _clearWidgetTip(w)
        if not w then return end
        if w.setTooltip then w:setTooltip("") end
        w.tooltip = nil
        if w.tooltipUI then
            pcall(function()
                if w.tooltipUI.getIsVisible and w.tooltipUI:getIsVisible() then
                    w.tooltipUI:setVisible(false)
                end
                if w.tooltipUI.removeFromUIManager then
                    w.tooltipUI:removeFromUIManager()
                end
            end)
            w.tooltipUI = nil
        end
        if w.setToolTipMap then w:setToolTipMap(nil) end
    end

    -- (1) Header tooltip: remove any lingering UI so no empty rectangles appear
    if self.grid and self.grid.header then
        _clearWidgetTip(self.grid.header)
    end

    -- (2) Titlebar buttons ONLY (refresh/auto/vector/full/reset)
    _clearWidgetTip(self.refreshButton)
    _clearWidgetTip(self.autoButton)
    _clearWidgetTip(self.vectorButton)
    _clearWidgetTip(self.fullButton)
    _clearWidgetTip(self.resetButton)
end

--=== Localized item display names (traps & baits) ===========================
-- Purpose: turn "Base.MonarchCaterpillar" into a clear, localized display name.
-- If the localized name is overly generic (e.g., many items -> "Caterpillar"),
-- append a friendly disambiguator derived from the full type:
--   "Caterpillar (Monarch)", "Caterpillar (American Lady)", etc.
local function L_ItemName(fullType)
    if not fullType or fullType == "" then return "Unknown" end

    -- 1) Try the straightforward helper (already localized)
    local localized = nil
    if type(getItemNameFromFullType) == "function" then
        local ok, name = pcall(getItemNameFromFullType, fullType)
        if ok and name and name ~= "" then localized = tostring(name) end
    end

    -- 2) Fallback via ScriptManager (DisplayName is localized in most cases)
    if (not localized) and type(getScriptManager) == "function" then
        local okSM, sm = pcall(getScriptManager)
        if okSM and sm then
            local okIt, scriptItem = pcall(sm.FindItem, sm, fullType)
            if okIt and scriptItem and scriptItem.getDisplayName then
                local okDN, dn = pcall(scriptItem.getDisplayName, scriptItem)
                if okDN and dn and dn ~= "" then localized = tostring(dn) end
            end
        end
    end

    -- Short id for heuristics, e.g., "MonarchCaterpillar"
    local short = tostring(fullType):gsub("^.-%.", "")

    -- Helper: split CamelCase into "Camel Case" (basic heuristic)
    local function splitCamel(s)
        -- "AmericanLady" -> "American Lady", "SilkMoth" -> "Silk Moth"
        s = s:gsub("(%l)(%u)", "%1 %2")
        -- Tidy multiple spaces just in case
        s = s:gsub("%s+", " ")
        return s
    end

    -- If we already have a localized label, keep it, but disambiguate common generics.
    if localized and localized ~= "" then
        -- Disambiguate the "Caterpillar" cluster found in vanilla EN
        -- (AmericanLadyCaterpillar, MonarchCaterpillar, SilkMothCaterpillar, etc.)
        if localized == "Caterpillar" then
            local stem = short:gsub("Caterpillar$", ""):gsub("Larva$", "")
            if stem ~= "" then
                return localized .. " (" .. splitCamel(stem) .. ")"
            end
        end
        -- Otherwise, just return the localized label
        return localized
    end

    -- 3) Last-resort: show a human-friendly version of the short id
    local pretty = splitCamel(short)
    return (pretty ~= "" and pretty) or tostring(fullType)
end

--=== Helpers: localize animals & zones ======================================
-- Try to fetch translated text for a key; return nil if not found.
-- (We keep it small and safe: pcall -> avoid crashes if getText isn't ready)
local function _tryGetText(key)
    local ok, res = pcall(getText, key)
    if ok and res and res ~= key then return tostring(res) end
    return nil
end

-- L_AnimalName (prefer mod overrides first; then vanilla keys; finally a readable fallback)
-- This version looks up our custom translation keys BEFORE falling back to the game's keys.
-- Custom keys supported:
--   - UI_TM_AnimalType_<raw>  (exact type id, e.g., "rabbuck")
--   - UI_TM_Animal_<base>     (normalized species id, e.g., "rabbit", "mouse")
-- If none exists, try the vanilla keys: IGUI_Animal_Group_<base>, IGUI_AnimalType_Global_<base>, IGUI_AnimalType_<raw>.
-- Last resort: prettify the base id (capitalize).
local function L_AnimalName(animalId)
    -- Guard: localized "None" for empty ids
    if not animalId or animalId == "" then
        return tostring(getText("UI_None"))
    end

    local raw = tostring(animalId)

    -- Safe getText that returns nil when key is missing
    local function tryText(key)
        local ok, s = pcall(getText, key)
        if ok and s and s ~= key then return tostring(s) end
        return nil
    end

    -- Normalize common variant ids to a base species:
    -- "rabbuck","rabdoe","mousepups","ratfemale","deerbuck","turkeyhen", etc.
    local function normalizeBase(id)
        local s = tostring(id):lower()

        -- Prefix-based families first (cheap & effective)
        if s:match("^rab")      then return "rabbit"  end
        if s:match("^raccoon")  then return "raccoon" end
        if s:match("^mouse")    then return "mouse"   end
        if s:match("^rat")      then return "rat"     end
        if s:match("^deer")     then return "deer"    end
        if s:match("^chicken")  then return "chicken" end
        if s:match("^turkey")   then return "turkey"  end

        -- Strip common age/gender suffixes and retry
        s = s:gsub("(buck|doe|pups|pup|baby|kitten|kit|boar|sow|cow|bull|ram|ewe|lamb)$", "")
        return s
    end

    local base = normalizeBase(raw)

    -- (0) Our mod overrides take precedence.
    -- Exact-type (rare but useful if you want special labels for variants):
    local customSpec = tryText("UI_TM_AnimalType_" .. raw)
    if customSpec then return customSpec end

    -- Base species (these are the ones you will usually define: UI_TM_Animal_mouse, etc.)
    local customBase = tryText("UI_TM_Animal_" .. base)
    if customBase then return customBase end

    -- (1) Vanilla keys, species-level preferred (avoid gendered variants for plain rat/mouse)
    local grp  = tryText("IGUI_Animal_Group_" .. base)         -- e.g., "Rat", "Mouse"
    if grp then return grp end

    local glob = tryText("IGUI_AnimalType_Global_" .. base)    -- fallback some animals use
    if glob then return glob end

    -- (2) Specific type as a last vanilla option.
    --     Avoid "rat" / "mouse" bare ids (they tend to return "Male Rat (Buck)" in some locales).
    if not (base == "rat" or base == "mouse") or (raw ~= base) then
        local spec = tryText("IGUI_AnimalType_" .. raw)
        if spec then return spec end
    end

    -- (3) Readable fallback: capitalize the base species (or the raw id if empty)
    return (base ~= "" and base or raw):gsub("^%l", string.upper)
end

-- Return localized zone display name from its canonical id (e.g., "Forest", "Nav").
-- Try vanilla keys first, then our mod fallback map, with a special-case
-- for "Nav" that prefers the vanilla translation when available.
local function L_ZoneName(z)
    -- Guard: handle empty ids with a localized "Unknown"
    local id = tostring(z or "")
    if id == "" then return _T("UI_TM_Unknown","Unknown") end

    -- Special case: world uses "Nav" for roads. Prefer the vanilla key
    -- when present; otherwise fall back to our own "Road" string.
    if id == "Nav" then
        local t = _tryGetText("IGUI_SearchMode_Zone_Names_Nav")
        return t or _T("UI_TM_Road","Road")
    end

    -- Candidates in priority order:
    -- 1) Vanilla SearchMode zones (some locales incomplete)
    -- 2) Our mod's fallback map (UI_TM_Zone_*), so translators can fill gaps
    local candidates = {
        "IGUI_SearchMode_Zone_Names_" .. id,
        "UI_TM_Zone_" .. id,
    }
    for _, key in ipairs(candidates) do
        local t = _tryGetText(key)
        if t then return t end
    end

    -- Last resort: show the raw id
    return id
end

-- Sorted list for { [fullType]=percent } with localized display names.
-- Returns array of { k=fullType, disp=localizedName, v=number } sorted by v desc.
local function _sortedPairsPctListLocalizeItems(tbl)
    local arr = {}
    if type(tbl) ~= "table" then return arr end
    for k,v in pairs(tbl) do
        table.insert(arr, { k=tostring(k), disp=L_ItemName(tostring(k)), v=tonumber(v) or 0 })
    end
    table.sort(arr, function(a,b) return a.v > b.v end)
    return arr
end

--==== Tooltip line helpers (localized, with <LINE>) =========================
-- local function L_ChanceFactorsTitle() return _T("UI_TM_ChanceFactors","Chance Factors:") end
-- local function L_FormulaTitle()       return _T("UI_TM_FormulaPerZone","Formula per zone:") end
-- local function L_FormulaEq()          return _T("UI_TM_FormulaEquation",
    -- "- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100") end
-- local function L_TimeWindow(minH,maxH,ok) return _T("UI_TM_TimeWindow","Time: %1:00 - %2:00 -> %3",minH,maxH,ok) end

-- local function L_TrapFactorsFor(an) return _T("UI_TM_TrapFactorsFor","Trap factors for %1:", an) end
-- local function L_BaitFactorsFor(an) return _T("UI_TM_BaitFactorsFor","Bait factors for %1:", an) end
-- local function L_ZoneFactorsFor(an) return _T("UI_TM_ZoneFactorsFor","Zone factors for %1:", an) end

-- local function L_TrapLine(name,val) return _T("UI_TM_TrapFactorLine","- Trap [%1]: %2%%", name, val) end
-- local function L_BaitLine(name,val) return _T("UI_TM_BaitFactorLine","- Bait [%1]: %2%%", name, val) end
-- local function L_SkillLine(s,bonus)  return _T("UI_TM_SkillFactorLine","- Skill [%1]: +%2%% (applied twice)", s, string.format("%.1f", bonus)) end
-- local function L_ZoneLine(name,val)  return _T("UI_TM_ZoneFactorLine","- Zone [%1]: %2%%", name, val) end


-- Preconditions tooltip (green/red OK/NO with proper spacing and safe rich-text)
local function L_Preconds(distOK, baitOK, freshOK, zoneOK)
    -- Color helpers (always close with white to avoid leaking color)
    local function ok(b)
        return b and (RGB_g .. _T("UI_TM_OK","OK") .. RGB_w)
                 or (RGB_r .. _T("UI_TM_NO","NO") .. RGB_w)
    end

    -- 3 or 4 lines, each uses "Label: %1" (note the space after colon)
    if type(zoneOK) == "boolean" then
        return _T("UI_TM_Preconditions_Zone",
            "Preconditions: <LINE> - Distance: %1 <LINE> - Bait placed: %2 <LINE> - Fresh bait: %3 <LINE> - Zone listed for animal: %4",
            ok(distOK), ok(baitOK), ok(freshOK), ok(zoneOK))
    else
        return _T("UI_TM_Preconditions",
            "Preconditions: <LINE> - Distance: %1 <LINE> - Bait placed: %2 <LINE> - Fresh bait: %3",
            ok(distOK), ok(baitOK), ok(freshOK))
    end
end

-- local function L_CurrentBait(label) return _T("UI_TM_CurrentBait","Current bait: %1", label) end
-- local function L_CurrentBaitNone()  return _T("UI_TM_CurrentBait_None","Current bait: None") end
-- local function L_CombinedAllZones(v) return _T("UI_TM_CombinedAllZones","Combined all zones: %1", v) end
-- local function L_SimIgnores()       return _T("UI_TM_SimulatorIgnores","(simulator ignores distance & bait freshness preconditions)") end

-- Returns the localized sentinel for "Choose..." used by all combos
local function ChooseText()
    -- If the key is missing, fallback to plain English "Choose..."
    return _T("UI_TM_Choose", "Choose...")
end

--==== Persistent layout (ModData) ===========================================
local TM_SAVE_KEY = "TrapManager_Settings_v1"

local function TM_LoadSettings()
    local md = ModData.getOrCreate(TM_SAVE_KEY)
    md.window      = md.window      or {}   -- { x, y, w, h }
    md.columns     = md.columns     or {}   -- [key] = { width=number, visible=bool }
    md.showAll     = md.showAll     or false
    md.rowShow     = md.rowShow     or {}
    if md.autoEnabled == nil then md.autoEnabled = false end
	md.sim         = md.sim         or {}   -- { animal=string|nil, trap=string|nil, bait=string|nil, zone=string|nil, skill=number|nil }
    return md
end

local function TM_SaveSettings(md)
    if ModData.transmit then ModData.transmit(TM_SAVE_KEY) end
end

-- Selected animal type from the simulator combobox (nil or string)
TrapManagerWindow.selectedAnimalType = nil

function TrapManagerWindow:getSelectedAnimalType()
    return self.selectedAnimalType
end

function TrapManagerWindow:onAnimalComboChanged(combo)
    local selData = nil
    if combo and combo.getOptionData then
        local ok, d = pcall(function() return combo:getOptionData(combo.selected) end)
        if ok then selData = d end
    end
    if not selData or selData == "" or selData == ChooseText() then
        self.selectedAnimalType = nil
    else
        self.selectedAnimalType = selData  -- canonical
    end

    -- repopulate dependents
    if self._simPopulateFromAnimal then self:_simPopulateFromAnimal() end
    if self._simUpdateChance then self:_simUpdateChance() end
	if self._simRefreshTooltips then self:_simRefreshTooltips() end
    -- Persist simulator state after any change
    if self._simSaveCurrentToMD then self:_simSaveCurrentToMD() end
	
end

-- ==== Helpers for localization and chance estimation ====

-- Build column config with measured default widths (UIFont.Small)
-- Returns the sum of VISIBLE column widths (after applying saved overrides).
function TrapManagerWindow:buildColumnConfig()
    local fontEnum = UIFont.Small
    local tm = getTextManager()
    local function w(txt) return tm:MeasureStringX(fontEnum, txt) + 16 end
	local function wg(txt) return tm:MeasureStringX(fontEnum, getText(txt)) + 16 end
	local function trunc(text, maxWidth) return  TM_TruncateText(text, maxWidth, fontEnum, "...") end

    -- #, Distance, Chunks, Pre., Animal, AliveHour, Trap, Bait, Days Until Stale, Skill, Player, Zone, Chance
	-- Column config with localized headers
	local cfg = {
		{ name = _T("UI_TM_Col_ShowHide","Show"), 		key = "showhide",  		width = w(_T("UI_TM_ShowAll","Show all")), visible = true },
		{ name = _T("UI_TM_Col_Number","Number"), 		key = "name",      		width = w("aaa"),         		visible = true  },
		{ name = _T("UI_TM_Col_Distance","Distance"),	key = "distance",  		width = w("Distance_"),   visible = true  },
		{ name = _T("UI_TM_Col_Chunks","Chunks"), 		key = "chunks",    		width = w("Chunks_"),     visible = true  },
		{ name = _T("UI_TM_Col_Precond","Pre."), 		key = "eligible",  		width = w("Pre._"),       visible = true  },

		{ name = _T("UI_TM_Col_Animal","Animal"), 	    key = "animal",			width = w("squirrel (T)__"), visible = true  },
		{ name = _T("UI_TM_Col_AliveHour","Hours"), 	key = "aliveHour",		width = w("Hours__"), visible = false },

		{ name = _T("UI_TM_Col_Trap","Trap"), 	   		key = "trapType",		width = w("Base.TrapCrate__"), visible = true  },
		{ name = _T("UI_TM_Col_Bait","Bait"), 			key = "bait",			width = w("Base.Processedcheese_"), visible = true  },

		{ name = _T("UI_TM_Col_DUS","Days"), 			key = "daysUntilStale", width = w("Days__"), visible = false },

		{ name = _T("UI_TM_Col_Skill","Skill"),			key = "skill",     		width = w("Skill__"),     visible = true  },
		{ name = _T("UI_TM_Col_Player","Player"), 		key = "player",    		width = w("Player________"), visible = false },

		{ name = _T("UI_TM_Col_Zone","Zone"), 		 	key = "zone",      		width = w("BirchMixForest, Forest_"), visible = true  },
		{ name = _T("UI_TM_Col_Chance","Chance"), 		key = "chance",    		width = w("Chance_"),     visible = true  },
	}

    local md = TM_LoadSettings()
    local savedCols = md.columns or {}

    local totalVisibleWidth = 0
    for _,col in ipairs(cfg) do
        local sv = savedCols[col.key]
        if sv then
            if type(sv.width) == "number" and sv.width > 10 then col.width = sv.width end
            if type(sv.visible) == "boolean" then col.visible = sv.visible end
        end
        if col.visible then totalVisibleWidth = totalVisibleWidth + col.width end
    end

    self.columnConfig = cfg
    return totalVisibleWidth
end

-- Return the integer chunk index for a world coordinate
local function chunkOf(x) return math.floor(x / 8) end

-- Convert a canonical animal type to its localized display name.
-- Uses IGUI_AnimalType_* keys with safe fallbacks.
local function displayAnimalType(animalType)
    if not animalType or animalType == "" then
        return tostring(getText("UI_None"))
    end
    return L_AnimalName(animalType)
end

-- Build localized freshness suffix using game translations
local function localizedFreshnessSuffix(baitFresh)
    local t = baitFresh and "Tooltip_food_Fresh" or "Tooltip_food_Rotten"
    return " (" .. tostring(getText(t)) .. ")"
end

-- Time window check, matching STrapGlobalObject:checkTime
local function timeWindowOK(animalDef)
    if not animalDef then return false end
    if animalDef.minHour == animalDef.maxHour then return true end -- 24/7
    local h = getGameTime():getHour()
    if animalDef.minHour < animalDef.maxHour then
        return animalDef.minHour <= h and h <= animalDef.maxHour
    else
        return h >= animalDef.minHour or h <= animalDef.maxHour
    end
end

-- ==== Probability helpers (per zone + combined) ====

-- Returns probability for a check "ZombRand(100) < T" using ceil (exact PZ behavior)
local function probFromThresholdCeil(T)
    -- clamp 0..100 to be safe; ceil models integer outcomes 0..99
    local c = math.ceil(math.max(0, math.min(100, T)))
    return c / 100.0
end

-- Compute per-zone chance
-- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100
local function estimateChancePercent(animalDef, trapType, zoneType, baitName, trappingSkill)
    if not animalDef or not trapType or trapType == "" or not zoneType or not animalDef.zone[zoneType] then return 0 end
	
    if not baitName or baitName == "" then return 0 end

    local trapsTbl = animalDef.traps or {}
    local baitsTbl = animalDef.baits or {}
    local zoneTbl  = animalDef.zone  or {}

    local skillBonus = (tonumber(trappingSkill) or 0) * 1.5
    local T1 = (trapsTbl[trapType] or 0) + (baitsTbl[baitName] or 0) + skillBonus
    local T2 = (zoneTbl[zoneType]  or 0) + skillBonus

    local p1 = probFromThresholdCeil(T1)
    local p2 = probFromThresholdCeil(T2)
    local p  = p1 * p2
    return math.floor(p * 10000 + 0.5) / 100 -- % with 2 decimals
end

-- Compute per-zone chances and the combined chance 1 - Π(1 - pz).
-- Returns: zonesOrdered(array of {name, baseZoneVal, chancePct}), primaryIndex, combinedPct
local function computeZoneChances(animalDef, trapType, baitName, trappingSkill, data)
    local zonesList = {}

    -- 1) Build the object's zone list
    if type(data.zones) == "table" then
        for k,_ in pairs(data.zones) do table.insert(zonesList, tostring(k)) end
        table.sort(zonesList)
    end
    -- Fallback if empty: use data.zone if it exists
    if #zonesList == 0 then
        table.insert(zonesList, tostring(data.zone or "Unknown"))
    end

    -- 2) Calculate pz per zone (if zone does not exist for the animal => base=0)
    local results = {}
    local bestIdx, bestChance = 1, -1
    for _,z in ipairs(zonesList) do
        local pz = estimateChancePercent(animalDef, trapType, z, baitName, trappingSkill) -- %
        local baseZ = (animalDef.zone and animalDef.zone[z]) or 0
        table.insert(results, { name = z, base = baseZ, chance = pz })
        if pz > bestChance then bestChance = pz; bestIdx = #results end
    end

    -- 3) Combined: 1 - ∏(1 - pz)
    local prod = 1.0
    for _,entry in ipairs(results) do
        prod = prod * (1.0 - (entry.chance / 100.0))
    end
    local combined = math.floor((1.0 - prod) * 10000 + 0.5) / 100
    return results, bestIdx, combined
end

--=== Simulator row helpers (UI above the header) =============================

-- Compute X/Width for a given visible column key
function TrapManagerWindow:_getColumnRectByKey(colKey)
    if not self.grid or not self.grid.getColumns then return nil end
    local cols = self.grid:getColumns()
    local x = 0
    for _,c in ipairs(cols) do
        if c.visible ~= false then
            if c.key == colKey then
                return { x = x, w = c.width }
            end
            x = x + c.width
        end
    end
    return nil
end

-- Read current selections from simulator combos
function TrapManagerWindow:_simRead()
    local animal = self.simAnimal and self.simAnimal.options and self.simAnimal.selected and self.simAnimal.options[self.simAnimal.selected] or nil
    animal = animal and animal.text or nil
    if animal == ChooseText() then animal = nil end

	-- Use canonical DATA when available (display may be localized)
	local trap = _cbVal(self.simTrap)
	if trap == ChooseText() then trap = nil end

	local bait = _cbVal(self.simBait)
	if bait == ChooseText() then bait = nil end

	local zone = _cbVal(self.simZone)  -- zones usually don't use addOptionWithData, _cbVal falls back to text
	if zone == ChooseText() then zone = nil end

    local skill = 0
    if self.simSkill and self.simSkill.selected and self.simSkill.options then
        local t = self.simSkill.options[self.simSkill.selected]
        if t and t.text then skill = tonumber(t.text) or 0 end
    end

    return animal, trap, bait, zone, skill
end

--==== Simulator persistence (ModData) =======================================

-- Select by option TEXT (returns true if found)
local function _selectComboByText(cb, want)
    if not cb or not want or want == "" then return false end
    for i=1,(cb.options and #cb.options or 0) do
        local ok, txt = pcall(function() return cb:getOptionText(i) end)
        if ok and txt == want then cb.selected = i; return true end
    end
    return false
end

-- Select by option DATA (returns true if found)
local function _selectComboByData(cb, wantData)
    if not cb or not wantData or wantData == "" then return false end
    if not cb.getOptionData then return false end
    for i=1,(cb.options and #cb.options or 0) do
        local ok, d = pcall(function() return cb:getOptionData(i) end)
        if ok and d == wantData then cb.selected = i; return true end
    end
    return false
end

-- Read simulator state from ModData (safe defaults)
function TrapManagerWindow:_readSimFromMD()
    local md = TM_LoadSettings()
    local s  = md.sim or {}
    return {
        animal = (type(s.animal)=="string" and s.animal or nil),
        trap   = (type(s.trap)  =="string" and s.trap   or nil),
        bait   = (type(s.bait)  =="string" and s.bait   or nil),
        zone   = (type(s.zone)  =="string" and s.zone   or nil),
        skill  = (type(s.skill) =="number" and s.skill  or 0),
    }
end

-- Returns the text of the selected option (string or table)
local function _cbText(cb)
    if not cb or not cb.selected or cb.selected == 0 then return nil end
    -- Uses the vanilla API first
    local ok, txt = pcall(function() return cb:getOptionText(cb.selected) end)
    if ok and txt ~= nil then return txt end
    -- Defensive fallback
    local opt = cb.options and cb.options[cb.selected]
    if type(opt) == "table" then return opt.text
    elseif type(opt) == "string" then return opt
    end
    return nil
end

-- Returns data if it exists; otherwise, the text as a backup.
local function _cbVal(cb)
    if not cb or not cb.selected or cb.selected == 0 then return nil end
    if cb.getOptionData then
        local ok, data = pcall(function() return cb:getOptionData(cb.selected) end)
        if ok and data ~= nil then return data end
    end
    return _cbText(cb)
end

-- Write simulator state into ModData using current UI selections
function TrapManagerWindow:_simSaveCurrentToMD()
    local md = TM_LoadSettings()
    md.sim = md.sim or {}

    -- animal: prefer canonical data (type), fallback to text
    local aData = _cbVal(self.simAnimal)       -- canonical animal type if set
    local aText = _cbText(self.simAnimal)
    if aText == ChooseText() then aText = nil end
    if aData == ChooseText() then aData = nil end

    local tText = _cbText(self.simTrap);  if tText == ChooseText() then tText = nil end
    local bText = _cbText(self.simBait);  if bText == ChooseText() then bText = nil end
    local zText = _cbText(self.simZone);  if zText == ChooseText() then zText = nil end
    local sNum  = tonumber(_cbText(self.simSkill) or "0") or 0

    md.sim.animal = aData or aText or nil
    md.sim.trap   = tText
    md.sim.bait   = bText
    md.sim.zone   = zText
    md.sim.skill  = sNum

    TM_SaveSettings(md)
end

-- Apply saved simulator state to the UI (order matters: animal -> populate -> trap/bait/zone -> skill)
function TrapManagerWindow:_applySimFromMD()
    if not (self.simAnimal and self.simTrap and self.simBait and self.simZone and self.simSkill) then return end
    local s = self:_readSimFromMD()

    self._suspendSimEvents = true  -- avoid onChange storms while restoring

    -- 1) Animal by DATA (canonical), fallback to TEXT
    if not _selectComboByData(self.simAnimal, s.animal) then
        _selectComboByText(self.simAnimal, s.animal)
    end
    -- Rebuild dependent combos
    if self._simPopulateFromAnimal then self:_simPopulateFromAnimal() end

    -- 2) Trap/Bait/Zone by TEXT (only after populate)
    _selectComboByText(self.simTrap, s.trap)
    _selectComboByText(self.simBait, s.bait)
    _selectComboByText(self.simZone, s.zone)

    -- 3) Skill (text values "0".."10")
    _selectComboByText(self.simSkill, tostring(s.skill or 0))

    self._suspendSimEvents = false

    -- Final recompute + tooltips once
    if self._simUpdateChance then self:_simUpdateChance() end
    if self._simRefreshTooltips then self:_simRefreshTooltips() end
end

-- Set simulator UI to defaults (Animal/Trap/Bait/Zone -> "Choose...", Skill -> "0")
function TrapManagerWindow:_applySimDefaults()
    if not (self.simAnimal and self.simTrap and self.simBait and self.simZone and self.simSkill) then return end
    self._suspendSimEvents = true

    -- Default indexes: 1 = "Choose..." for combos, "0" is at index 1 for Skill
    self.simAnimal.selected = 1
    -- Reset dependents via populate (this will set them to "Choose...")
    if self._simPopulateFromAnimal then self:_simPopulateFromAnimal() end
    self.simTrap.selected = 1
    self.simBait.selected = 1
    self.simZone.selected = 1
    self.simSkill.selected = 1 -- "0"

    self._suspendSimEvents = false

    if self._simUpdateChance then self:_simUpdateChance() end
    if self._simRefreshTooltips then self:_simRefreshTooltips() end
end

-- Populate Trap/Bait/Zone combos based on selected animal (robust against nil)
function TrapManagerWindow:_simPopulateFromAnimal()
    if not self.simTrap or not self.simBait or not self.simZone then return end

    -- Helper: clear a combo and insert the sentinel option
    local function resetCombo(cb)
        cb.options = {}
        cb:addOption(ChooseText())
        cb.selected = 1
    end

    -- Always reset all dependent combos first
    resetCombo(self.simTrap)
    resetCombo(self.simBait)
    resetCombo(self.simZone)

    -- Resolve currently selected canonical animal (may be nil when "Choose..." is picked)
    local animalSel = self.selectedAnimalType
    local def = nil

    -- Find animal definition only if a valid animal is selected
    if animalSel and type(_G.TrapAnimals) == "table" then
        local want = tostring(animalSel):lower()
        for _, v in ipairs(_G.TrapAnimals) do
            if tostring(v.type or ""):lower() == want then
                def = v
                break
            end
        end
    end

    -- If no animal is selected (def == nil), we just leave the combos at "Choose..."
    if not def then
        -- Recompute chance (will show "..." with generic tooltip)
        if self._simUpdateChance then self:_simUpdateChance() end
        return
    end

	-- Fill Trap options (localized display, canonical data)
	if type(def.traps) == "table" then
		local arr = {}
		for k,_ in pairs(def.traps) do table.insert(arr, tostring(k)) end
		table.sort(arr, function(a,b) return L_ItemName(a):lower() < L_ItemName(b):lower() end)
		for _,t in ipairs(arr) do
			local disp = L_ItemName(t)
			if self.simTrap.addOptionWithData then
				self.simTrap:addOptionWithData(disp, t)  -- display (localized), data (canonical)
			else
				self.simTrap:addOption(disp)
			end
		end
	end

	-- Fill Bait options (localized display, canonical data)
	if type(def.baits) == "table" then
		local arr = {}
		for k,_ in pairs(def.baits) do table.insert(arr, tostring(k)) end
		table.sort(arr, function(a,b) return L_ItemName(a):lower() < L_ItemName(b):lower() end)
		for _,b in ipairs(arr) do
			local disp = L_ItemName(b)
			if self.simBait.addOptionWithData then
				self.simBait:addOptionWithData(disp, b)  -- display (localized), data (canonical)
			else
				self.simBait:addOption(disp)
			end
		end
	end

	-- Fill Zone options (localized display text, canonical id in 'data')
	-- Sorting by localized display improves UX when UI language is not English.
	if type(def.zone) == "table" then
		local arr = {}
		for k,_ in pairs(def.zone) do table.insert(arr, tostring(k)) end
		-- sort by localized text, not raw id
		table.sort(arr, function(a,b) return L_ZoneName(a):lower() < L_ZoneName(b):lower() end)
		for _,z in ipairs(arr) do
			local disp = L_ZoneName(z)
			if self.simZone.addOptionWithData then
				self.simZone:addOptionWithData(disp, z)  -- display (localized), data (canonical)
			else
				self.simZone:addOption(disp)             -- fallback: no data API available
			end
		end
	end

    -- Auto-select first real option only if it exists (index 2 = first after "Choose...")
    if self.simTrap.options and #self.simTrap.options > 1 then self.simTrap.selected = 2 end
    if self.simBait.options and #self.simBait.options > 1 then self.simBait.selected = 2 end
    if self.simZone.options and #self.simZone.options > 1 then self.simZone.selected = 2 end

    -- Recompute chance based on new auto-filled selections
    if self._simUpdateChance then self:_simUpdateChance() end
	if self._simRefreshTooltips then self:_simRefreshTooltips() end
	-- Persist auto-filled dependent selections
    if self._simSaveCurrentToMD then self:_simSaveCurrentToMD() end
end

-- Update the Chance label (and its tooltip) from current selections (robust)
function TrapManagerWindow:_simUpdateChance()
    if not self.simChanceBtn then return end
	TMLog("simUpdateChance ENTER")

	local a = _cbVal(self.simAnimal)
	local t = _cbVal(self.simTrap)
	local b = _cbVal(self.simBait)
	local z = _cbVal(self.simZone)
	local s = tonumber(_cbText(self.simSkill) or "0") or 0
	TMLogKV("simRead", {A=a, T=t, B=b, Z=z, S=s})

    if a == ChooseText() then a = nil end
    if t == ChooseText() then t = nil end
    if b == ChooseText() then b = nil end
    if z == ChooseText() then z = nil end

	local missing = {}
	if not a then table.insert(missing,"Animal") end
	if not t or t==ChooseText() then table.insert(missing,"Trap") end
	if not b or b==ChooseText() then table.insert(missing,"Bait") end
	if not z or z==ChooseText() then table.insert(missing,"Zone") end
	if #missing>0 then
		TMLog("simUpdateChance -> missing "..table.concat(missing,", "))
	end
	
	local tDisp = t and L_ItemName(t) or "?"
	local bDisp = b and L_ItemName(b) or "?"

    -- Sync animal (for header tooltips)
    self.selectedAnimalType = a or nil

    local text = "..."
	local tip  = _T("UI_TM_Sim_PickAll",
	  "Pick Animal, Trap, Bait, Zone, and Skill to preview per-zone chance.")
    if a and t and b and z then
		TMLog("simUpdateChance has all fields; searching def")
        local def = nil
        if type(_G.TrapAnimals)=="table" then
            local want = tostring(a):lower()
            for _,v in ipairs(_G.TrapAnimals) do
                if tostring(v.type or ""):lower() == want then def = v; break end
            end
        end
		if def then
			TMLogKV("defOK", { type=def.type })
		else
			TMLog("def MISSING for animal="..tostring(a))
		end
        if def then
            local pct = estimateChancePercent(def, t, z, b, s)
            text = string.format("%.2f%%", pct)

            -- Build a grid-like tooltip for the simulator "Chance" button
            -- (mirrors the per-cell style: factors, time, formula with numbers)
            local trapsTbl = def.traps or {}
            local baitsTbl = def.baits or {}
            local zoneTbl  = def.zone  or {}
            local skillBonus = (tonumber(s) or 0) * 1.5
            local trapVal = trapsTbl[t] or 0
            local baitVal = baitsTbl[b] or 0
            local zoneVal = zoneTbl[z] or 0
            local T1 = trapVal + baitVal + skillBonus
            local T2 = zoneVal + skillBonus

            -- Primary zone result (same math we use in the grid)
            local primary = math.floor(((math.ceil(T1)/100.0) * (math.ceil(T2)/100.0)) * 10000 + 0.5) / 100

            -- Time window line: same as grid (OK/NO evaluated at current game hour)
            local timeOK = timeWindowOK(def) and "OK" or "NO"
			
			local minS = def.minSize
			local maxS = def.maxSize

			local parts = {
				_T("UI_TM_ChanceFactors","Chance Factors:"), " <LINE> ",
				_T("UI_TM_TrapFactorLine","- Trap [%1]: %2%", tDisp, trapVal), " <LINE> ",
				_T("UI_TM_BaitFactorLine","- Bait [%1]: %2%", bDisp, baitsTbl[b] or 0), " <LINE> ",
				_T("UI_TM_SkillFactorLine","- Skill [%1]: +%2% (applied twice)", s, string.format("%.1f", skillBonus)), " <LINE> ",
				_T("UI_TM_ZoneFactorLine","- Zone [%1]: %2%", z, zoneVal), " <LINE> ",
				_T("UI_TM_TimeWindow","Time: %1:00 - %2:00 -> %3", def.minHour or 0, def.maxHour or 0, timeOK), " <LINE> ",
			}

			-- NEW: insertar Size range inmediatamente después de Time
			if minS and maxS then
				table.insert(parts, _T("UI_TM_SizeRange","- Size range: %1 - %2", minS, maxS) .. " <LINE> ")
			end

			-- Resto igual que antes
			table.insert(parts, " <LINE> ")
			table.insert(parts, _T("UI_TM_FormulaPerZone","Formula per zone:"))
			table.insert(parts, " <LINE> ")
			table.insert(parts, _T("UI_TM_FormulaEquation", "- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100"))
			table.insert(parts, " <LINE> ")
			table.insert(parts, string.format("- Chance = ceil(%.1f)/100 * ceil(%.1f)/100 = %.2f%%", T1, T2, primary))
			table.insert(parts, " <LINE> ")
			table.insert(parts, _T("UI_TM_SimulatorIgnores","(simulator ignores distance & bait freshness preconditions)"))

			tip = table.concat(parts)
        end
    end

	TMLogKV("simResult", {text=text})
	-- Set label and apply tooltip only when verbose tips are ON
	self.simChanceBtn:setTitle(text)
	_setTip(self.simChanceBtn, tip)

end

-- Returns true if any simulator combo is currently open (drop-down visible)
function TrapManagerWindow:_isAnyComboOpen()
    local function isOpen(cb)
        if not cb then return false end
        -- Flag interno de la combo
        if cb.expanded == true then return true end
        -- Popup compartido: cuenta sólo si está realmente visible en UI
        local p = cb.popup
        if p and p.isReallyVisible and p:isReallyVisible() then
            return true
        end
        return false
    end
    return isOpen(self.simAnimal) or isOpen(self.simTrap) or isOpen(self.simBait)
        or isOpen(self.simSkill) or isOpen(self.simZone)
end

-- Position simulator controls to sit above their respective columns
function TrapManagerWindow:_layoutSimulatorControls()
    if not self.simRow or not self.grid then return end

    -- Read header handle width if available (fallback to 6)
    local handleW = 10
    if self.grid and self.grid.header and self.grid.header.handleW then
        handleW = tonumber(self.grid.header.handleW) or handleW
    end

    -- Hide/show each control based on column visibility, and align X/Width
    local function placeOver(colKey, ctrl)
        if not ctrl then return end
        local r = self:_getColumnRectByKey(colKey)
        if not r then
            ctrl:setVisible(false)
            return
        end

        -- Left-align over the column, but width policy depends on control
        local xLeft = r.x + handleW + 2
        ctrl:setX(xLeft)
        ctrl:setVisible(true)

        if ctrl == self.btnShowAll then
            -- Button "Show all": width must depend on its own localized label,
            -- not on the column width (so it won't get clipped in languages like ES).
            local tm   = getTextManager()
            local font = UIFont.Small
            local padX = 16
            local label = (ctrl.title) or (ctrl.getTitle and ctrl:getTitle()) or _T("UI_TM_ShowAll","Show all")
            local w = math.max(60, tm:MeasureStringX(font, tostring(label)) + padX)
            ctrl:setWidth(w)
        else
            -- Default: match the column width minus the header handle
            ctrl:setWidth(math.max(40, r.w - handleW - 4))
        end
    end

	placeOver("showhide", self.btnShowAll)
    placeOver("animal",   self.simAnimal)
    placeOver("trapType", self.simTrap)
    placeOver("bait",     self.simBait)
    placeOver("skill",    self.simSkill)
    placeOver("zone",     self.simZone)
    placeOver("chance",   self.simChanceBtn)
end



-- Build a stable key anchored to world coordinates (x,y,z) for per-row visibility
-- Single key per trap (ignores animal/trap/bait) used by _getRowShowWanted(rowKey)
local function makeTrapKey(data)
    local x = tonumber(data and data.x) or 0
    local y = tonumber(data and data.y) or 0
    local z = tonumber(data and data.z) or 0
    return tostring(x) .. "," .. tostring(y) .. "," .. tostring(z)
end
-- -- Includes animal/trap/bait so each candidate row keeps its own toggle (alternative to "makeTrapKey" )
-- local function makeRowKey(data, animalType, trapType, baitName)
    -- -- Defensive: fall back to 0 if missing
    -- local x = tonumber(data and data.x) or 0
    -- local y = tonumber(data and data.y) or 0
    -- -- If your data table doesn't carry z, keep 0 (ground). If it does, use it.
    -- local z = tonumber(data and data.z) or 0
    -- return table.concat({
        -- (tostring(x) .. "," .. tostring(y) .. "," .. tostring(z)),
        -- tostring(animalType or ""),
        -- tostring(trapType or ""),
        -- tostring(baitName or "")
    -- }, "|")
-- end

function TrapManagerWindow:_getShowAll()
    local md = TM_LoadSettings()
    self._showAll = (md.showAll == true)
    return self._showAll
end

function TrapManagerWindow:_setShowAll(v)
    local md = TM_LoadSettings()
    md.showAll = (v == true)
    self._showAll = md.showAll
    TM_SaveSettings(md)
    self:_refreshShowAllButtonSkin()
end

function TrapManagerWindow:_getRowShowWanted(rowKey)
    local md = TM_LoadSettings()
    local m = md.rowShow or {}
    -- Default is true (visible) if unknown
    local v = m[rowKey]
    if v == nil then return true end
    return v == true
end

function TrapManagerWindow:_setRowShowWanted(rowKey, want)
    if not rowKey or rowKey == "" then return end
    local md = TM_LoadSettings()
    md.rowShow = md.rowShow or {}
    md.rowShow[rowKey] = (want == true)
    TM_SaveSettings(md)
end

function TrapManagerWindow:_toggleRowShowWanted(rowKey)
    local cur = self:_getRowShowWanted(rowKey)
    self:_setRowShowWanted(rowKey, not cur)
end

function TrapManagerWindow:_refreshShowAllButtonSkin()
    if not self.btnShowAll then return end
    if self:_getShowAll() then
        -- ON (green)
        self.btnShowAll.backgroundColor = { r=0.2, g=0.8, b=0.2, a=0.35 }
        self.btnShowAll.backgroundColorMouseOver = { r=0.2, g=0.8, b=0.2, a=0.55 }
    else
        -- OFF (grey)
        self.btnShowAll.backgroundColor = { r=0, g=0, b=0, a=0.15 }
        self.btnShowAll.backgroundColorMouseOver = { r=0.3, g=0.3, b=0.3, a=0.35 }
    end
    self.btnShowAll.borderColor.a = 0.0
	self.btnShowAll.title = _T("UI_TM_ShowAll","Show all")
	if self._applyButtonTooltips then self:_applyButtonTooltips(true) end
end

-- Apply long/concise tooltips to titlebar + "Show all" only when verbose tips are enabled.
function TrapManagerWindow:_applyButtonTooltips(force)
    local v = TM_VerboseTipsEnabled()
    if (self._lastVerboseButtonsApplied == v) and (not force) then return end
    self._lastVerboseButtonsApplied = v

    -- Build or clear tooltips
    local tipRefresh = v and _T("UI_TM_Tip_Btn_Refresh",
        "Refresh the list right now.") or nil
    local tipAuto = v and _T("UI_TM_Tip_Btn_Auto",
        "Toggle live auto-refresh while the window is open.") or nil
    local tipVector = v and (_T("UI_TM_Tip_Btn_Vector",
        "Draw a line from you to the trap under the mouse. The line persists even when the cursor leaves the window.")
        or _T("UI_TM_Vector_Tooltip",
        "When enabled, draws a line from you to the trap under the mouse. <LINE> The line persists even if the cursor leaves the window.")) or nil
    local tipFull = v and _T("UI_TM_Tip_Btn_Full",
        "Show all columns and resize window to fit.") or nil
    local tipReset = v and _T("UI_TM_Tip_Btn_Reset",
        "Reset layout to defaults (keeps row visibility).") or nil
    local tipShowAll = v and _T("UI_TM_Tip_Btn_ShowAll",
        "Temporarily ignore hidden rows. When OFF, only rows marked Shown are visible.") or nil

    _setTip(self.refreshButton, tipRefresh)
    _setTip(self.autoButton,    tipAuto)
    _setTip(self.vectorButton,  tipVector)
    _setTip(self.fullButton,    tipFull)
    _setTip(self.resetButton,   tipReset)
    _setTip(self.btnShowAll,    tipShowAll)
end

-- Column header tooltips: only show long/explanatory text when verbose tips are enabled.
function TrapManagerWindow:buildHeaderTooltipFor(columnKey)
    -- If verbose tooltips are disabled, return nil so headers show no tooltip at all.
    if not TM_VerboseTipsEnabled() then return nil end

    -- Helper to return the long translation or nil if missing
    local function L(key) local s = _T(key, ""); return (s ~= "" and s) or nil end

    -- Non-dynamic columns (just return their LONG strings)
    if columnKey == "showhide"      then return L("UI_TM_Hdr_Show_Long") end
    if columnKey == "name"          then return L("UI_TM_Hdr_Num_Long") end
    if columnKey == "distance"      then return L("UI_TM_Hdr_Distance_Long") end
    if columnKey == "chunks"        then return L("UI_TM_Hdr_Chunks_Long") end
    if columnKey == "eligible"      then return L("UI_TM_Hdr_Pre_Long") end
	if columnKey == "animal"        then return L("UI_TM_Hdr_Animal_Long") end
    if columnKey == "aliveHour"     then return L("UI_TM_Hdr_HoursAlive_Long") end
    if columnKey == "player"        then return L("UI_TM_Hdr_Player_Long") end
    if columnKey == "daysUntilStale"then return L("UI_TM_Hdr_DUS_Long") end
    if columnKey == "skill"         then return L("UI_TM_Hdr_Skill_Long") end
	if columnKey == "skill"         then
		local perk = _T("IGUI_perks_Trapping","Trapping")
		return _T("UI_TM_Hdr_Skill_Long","Player %1 level when the trap was placed.", perk)
	end

    -- Dynamic columns can show per-animal details when an animal is selected in the simulator
    local at = self.selectedAnimalType
    local TA = _G.TrapAnimals
    local function generic(col)
        if col == "trapType" then return L("UI_TM_Hdr_Trap_Long")
        elseif col == "bait" then return L("UI_TM_Hdr_Bait_Long")
        elseif col == "zone" then return L("UI_TM_Hdr_Zone_Long")
        elseif col == "chance" then return L("UI_TM_Hdr_Chance_Long")
        end
        return nil
    end
    if not at or at == "" or type(TA) ~= "table" then
        return generic(columnKey)
    end

    -- Find animal def
    local def = nil
    local want = tostring(at):lower()
    for _,v in ipairs(TA) do
        if tostring(v.type or ""):lower() == want then def = v; break end
    end
    if not def then return generic(columnKey) end

    -- Long, detailed variants
    if columnKey == "trapType" then
        local lines = { _T("UI_TM_TrapFactorsFor","Trap factors for %1:", L_AnimalName(at)) .. " <LINE> " }
        for _,it in ipairs(_sortedPairsPctListLocalizeItems(def.traps)) do
            table.insert(lines, _T("UI_TM_GenericFactorLine","- %1: %2%", it.disp, it.v) .. " <LINE> ")
        end
        table.insert(lines, " <LINE> " .. _T("UI_TM_TrapStrengthNote","Note: trap strength affects bait loss and break chances"))
        return table.concat(lines)

    elseif columnKey == "bait" then
        local lines = { _T("UI_TM_BaitFactorsFor","Bait factors for %1:", L_AnimalName(at)) .. " <LINE> " }
        for _,it in ipairs(_sortedPairsPctListLocalizeItems(def.baits)) do
            table.insert(lines, _T("UI_TM_GenericFactorLine","- %1: %2%", it.disp, it.v) .. " <LINE> ")
        end
        return table.concat(lines)

    elseif columnKey == "zone" then
        local arr = {}
        for z,v in pairs(def.zone or {}) do table.insert(arr, {k=z,v=v}) end
        table.sort(arr, function(a,b) return a.v>b.v end)
        local lines = { _T("UI_TM_ZoneFactorsFor","Zone factors for %1:", L_AnimalName(at)) .. " <LINE> " }
        for _,it in ipairs(arr) do
            table.insert(lines, _T("UI_TM_GenericFactorLine","- %1: %2%", L_ZoneName(it.k), it.v) .. " <LINE> ")
        end
        return table.concat(lines)

    elseif columnKey == "chance" then
        local minH = def.minHour or 0
        local maxH = def.maxHour or 0
        return table.concat({
            _T("UI_TM_ChanceFactors","Chance Factors:"), " <LINE> ",
            _T("UI_TM_TimeWindow","Time: %1:00 - %2:00 -> %3",
               minH, maxH, (timeWindowOK(def) and _T("UI_TM_OK","OK") or _T("UI_TM_NO","NO"))), " <LINE> ",
            " <LINE> ", _T("UI_TM_FormulaPerZone","Formula per zone:"), " <LINE> ",
            _T("UI_TM_FormulaEquation","- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100")
        })
    end

    return generic(columnKey)
end

--==== Simulator tooltips =====================================================

-- Return trap strength (number) for a trap type from _G.Traps, or nil
local function _lookupTrapStrength(trapType)
    if type(_G.Traps) ~= "table" or not trapType then return nil end
    for _, T in ipairs(_G.Traps) do
        if T.type == trapType then return tonumber(T.trapStrength) end
    end
    return nil
end

-- Return true if the trapped type belongs to the same animal "family" as the definition (v).
-- This makes the "* " capture mark robust (works for variants like "rabbuck", "ratbaby", etc.).
local function _isTrappedFamily(trappedType, animalDef)
    if not trappedType or not animalDef then return false end
    if trappedType == animalDef.type then return true end
    if type(animalDef.aliveAnimals) == "table" then
        for _,id in ipairs(animalDef.aliveAnimals) do
            if tostring(id) == tostring(trappedType) then return true end
        end
    end
    return false
end

-- Build a sorted "- key: val%" list from a { [key]=number } table
local function _sortedPairsPctList(tbl)
    if type(tbl) ~= "table" then return {} end
    local arr = {}
    for k,v in pairs(tbl) do table.insert(arr, {k=tostring(k), v=tonumber(v) or 0}) end
    table.sort(arr, function(a,b) return a.v > b.v end)
    return arr
end

-- Trap factors tooltip (localized names; marks the picked trap)
local function _tooltipTrapForAnimal(animalDef, animalName, trapPicked)
    local lines = {}
    table.insert(lines, _T("UI_TM_TrapFactorsFor","Trap factors for %1:", animalName) .. " <LINE> ")
	-- List trap options and mark the selected one with "* "
    -- Match by canonical key (it.k) OR by localized display text (it.disp)
    for _, it in ipairs(_sortedPairsPctListLocalizeItems(animalDef.traps)) do
        local isSelected = (trapPicked and trapPicked ~= ChooseText()) and (it.k == trapPicked or it.disp == trapPicked)
        local mark = isSelected and tostring(RGB_g .. "* ") or ""
        table.insert(lines, string.format("%s%s: %d%% <LINE> " .. RGB_w, mark, it.disp, it.v))
    end
    if trapPicked and trapPicked ~= ChooseText() then
        local strength = _lookupTrapStrength(trapPicked)
        if type(strength) == "number" and strength > 0 then
            local loseBaitProb = 1.0 / (strength + 10.0)
            local breakProb    = (1.0 / 40.0) * (1.0 / strength)
            table.insert(lines, " <LINE> " .. _T("UI_TM_TrapStrength","Trap strength: %1", strength) .. " <LINE> ")
            table.insert(lines, _T("UI_TM_LoseBaitPerHour","- Lose bait per hour: 1/(%1 + 10) = %2%", strength, string.format("%.2f", loseBaitProb * 100.0)) .. " <LINE> ")
            table.insert(lines, _T("UI_TM_BreakPerHour","- Break per hour offscreen: 1/(40 * %1) = %2%", strength, string.format("%.2f", breakProb * 100.0)) .. " <LINE> ")
        end
    end
    return table.concat(lines)
end

-- Bait factors tooltip (localized names; marks the picked bait)
local function _tooltipBaitForAnimal(animalDef, animalName, baitPicked)
    local lines = {}
    table.insert(lines, _T("UI_TM_BaitFactorsFor","Bait factors for %1:", animalName) .. " <LINE> ")
    -- List bait options and mark the selected one with "* "
    -- Match by canonical key (it.k) OR by localized display text (it.disp)
    for _, it in ipairs(_sortedPairsPctListLocalizeItems(animalDef.baits)) do
        local isSelected = (baitPicked and baitPicked ~= ChooseText()) and (it.k == baitPicked or it.disp == baitPicked)
        local mark = isSelected and tostring(RGB_g .. "* ") or ""
        table.insert(lines, string.format("%s%s: %d%% <LINE> " .. RGB_w, mark, it.disp, it.v))
    end
    return table.concat(lines)
end

-- Build zone factors tooltip for a given animal, marking the picked zone.
-- (Localizes both the animal title and each zone line.)
local function _tooltipZoneForAnimal(animalDef, animalTypeOrName, zonePicked)
    -- Ensure title uses localized animal name (type preferred; 'animalTypeOrName' can be either)
    local anTitle = L_AnimalName(animalTypeOrName)
    local lines = {}
    table.insert(lines, _T("UI_TM_ZoneFactorsFor","Zone factors for %1:", anTitle) .. " <LINE> ")
    -- List zone options and mark the selected one with "* "
    -- zonePicked may be the canonical id OR a localized text; we match against both.
    for _, it in ipairs(_sortedPairsPctList(animalDef.zone)) do
        local zId   = tostring(it.k or "")
        local zDisp = L_ZoneName(zId)
        local isSelected = (zonePicked and zonePicked ~= ChooseText()) and (zId == tostring(zonePicked) or zDisp == tostring(zonePicked))
        local mark = isSelected and (RGB_g .. "* ") or ""
        table.insert(lines, string.format("%s%s: %d%% <LINE> " .. RGB_w, mark, zDisp, it.v))
    end
    return table.concat(lines)
end

-- Detailed chance tooltip for the simulator (localized trap/bait display)
local function _tooltipSimChanceDetailed(animalDef, animalName, trapType, baitName, zoneName, skill)
    local trapsTbl = animalDef.traps or {}
    local baitsTbl = animalDef.baits or {}
    local zoneTbl  = animalDef.zone  or {}
    local s = tonumber(skill) or 0
    local skillBonus = s * 1.5
    local T1 = (trapsTbl[trapType] or 0) + (baitsTbl[baitName] or 0) + skillBonus
    local T2 = (zoneTbl[zoneName]  or 0) + skillBonus
    local primary = math.floor(((math.ceil(T1)/100.0)*(math.ceil(T2)/100.0))*10000+0.5)/100

    local tDisp = L_ItemName(trapType)
    local bDisp = L_ItemName(baitName)

    local lines = {}
    table.insert(lines, "Chance Factors: <LINE> ")
    table.insert(lines, string.format("- Trap [%s]: %d%% <LINE> ", tDisp, trapsTbl[trapType] or 0))
    table.insert(lines, string.format("- Bait [%s]: %d%% <LINE> ", bDisp, baitsTbl[baitName] or 0))
    table.insert(lines, string.format("- Skill [%d]: +%.1f%% (applied twice) <LINE> ", s, skillBonus))
	table.insert(lines, string.format("- Zone [%s]: %d%% <LINE> ", L_ZoneName(zoneName or "?"), zoneTbl[zoneName] or 0))

    table.insert(lines, " <LINE> Formula per zone <LINE> ")
    table.insert(lines, "- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100 <LINE> ")
    table.insert(lines, string.format("- Chance = ceil(%.1f)/100 * ceil(%.1f)/100 = %.2f%%", T1, T2, primary))
    return table.concat(lines)
end

-- Rebuild per-control tooltips using ISComboBox's tooltip map (shown when closed)
function TrapManagerWindow:_simRefreshTooltips()

    -- Read selections
    local aTxt = self.simAnimal and (self.simAnimal.getOptionData and self.simAnimal:getOptionData(self.simAnimal.selected)) or _cbText(self.simAnimal)
    local tTxt = _cbText(self.simTrap)
    local bTxt = _cbText(self.simBait)
    local zTxt = _cbVal(self.simZone)
    local sTxt = _cbText(self.simSkill) or "0"

    if aTxt == ChooseText() then aTxt = nil end
    if tTxt == ChooseText() then tTxt = nil end
    if bTxt == ChooseText() then bTxt = nil end
    if zTxt == ChooseText() then zTxt = nil end

    -- Resolve animal def (if any)
    local def, animalName = nil, (aTxt or "?")
    if aTxt and type(_G.TrapAnimals) == "table" then
        local want = tostring(aTxt):lower()
        for _, v in ipairs(_G.TrapAnimals) do
            if tostring(v.type or ""):lower() == want then def = v; break end
        end
    end

    -- ANIMAL combo: generic help
    if self.simAnimal and self.simAnimal.setToolTipMap then
		self.simAnimal:setToolTipMap({
			defaultTooltip = _T("UI_TM_Sim_PickAnimal",
				"Pick an animal. <LINE> - It will auto-fill trap/bait/zone dropdowns with valid values. <LINE> - It also enables detailed tooltips on those controls.")
		})
    end

    -- TRAP combo
    if self.simTrap and self.simTrap.setToolTipMap then
        local tip = ""
        if not def then
            tip = _T("UI_TM_Sim_PickTrap", "Choose the trap type placed. <LINE> Choose an animal first.")
        else
            tip = _tooltipTrapForAnimal(def, animalName, tTxt)
        end
        self.simTrap:setToolTipMap({
            defaultTooltip = tip
        })
    end

    -- BAIT combo
    if self.simBait and self.simBait.setToolTipMap then
        local tip = ""
        if not def then
            tip = self:buildHeaderTooltipFor("bait") or
                  _T("UI_TM_Sim_PickBait", "Choose the bait. <LINE> Select an animal to see bait factors.")
        else
            tip = _tooltipBaitForAnimal(def, animalName, bTxt)
        end
        self.simBait:setToolTipMap({
            defaultTooltip = tip
        })
    end

    -- ZONE combo
    if self.simZone and self.simZone.setToolTipMap then
        local tip = ""
        if not def then
			tip = self:buildHeaderTooltipFor("zone") or
				_T("UI_TM_Tip_Zone_Column","Zone column: <LINE> - Shows the zones the trap is in. <LINE> Select an animal in the simulator row above to see per-zone base chances.")
        else
            tip = _tooltipZoneForAnimal(def, animalName, zTxt)
        end
        self.simZone:setToolTipMap({
            defaultTooltip = tip
        })
    end

    -- CHANCE button tooltip is handled in _simUpdateChance()
end

-- Execute the standard reaction after changing a simulator combo
function TrapManagerWindow:_simAfterChange()
    if self._suspendSimEvents then return end
    if self._simUpdateChance     then self:_simUpdateChance()     end
    if self._simRefreshTooltips  then self:_simRefreshTooltips()  end
    if self._simSaveCurrentToMD  then self:_simSaveCurrentToMD()  end
end

-- Ensures the Skill combo shows 0..10 without scrolling (popup sizing)
function TrapManagerWindow:_setupSimSkillPopupTuning()
    local cb = self.simSkill
    if not cb then return end

    local wantVisible = 11 -- 0..10

    -- Prefer the official setter when available
    if type(cb.setMaxLines) == "function" then
        pcall(function() cb:setMaxLines(wantVisible) end)
    end
    -- Fallback: internal field commonly used by the list popup
    cb.numVisible = wantVisible

    -- If the popup already exists (re-open scenario), patch its list height safely
    if cb.popup and cb.popup.list then
        cb.popup.list.numVisible = wantVisible
        local ih = cb.popup.list.itemheight or 18
        cb.popup.list:setHeight(ih * wantVisible)
        cb.popup:setHeight(ih * wantVisible)
    end

    -- Defensive: when opening later, reapply height/visible count immediately
    local _origOnMouseDown = cb.onMouseDown
    cb.onMouseDown = function(selfCb, x, y)
        if _origOnMouseDown then _origOnMouseDown(selfCb, x, y) end
        if selfCb.popup and selfCb.popup.list then
            selfCb.popup.list.numVisible = wantVisible
            local ih = selfCb.popup.list.itemheight or 18
            selfCb.popup.list:setHeight(ih * wantVisible)
            selfCb.popup:setHeight(ih * wantVisible)
        end
    end
end

-- Applies a slightly translucent skin to simulator combos and the chance "label"
function TrapManagerWindow:_softenSimulatorSkin()
    local function softenCombo(cb)
        if not cb then return end
        cb.backgroundColor = cb.backgroundColor or {r=0,g=0,b=0,a=0}
        cb.backgroundColor.a = 0.35
        cb.backgroundColorMouseOver = cb.backgroundColorMouseOver or {r=0,g=0,b=0,a=0}
        cb.backgroundColorMouseOver.a = 0.50
        cb.borderColor = cb.borderColor or {r=1,g=1,b=1,a=1}
        cb.borderColor.a = 0.35
        if cb.textColor then cb.textColor.a = 0.85 end
    end

    -- Combos
    softenCombo(self.simAnimal)
    softenCombo(self.simTrap)
    softenCombo(self.simBait)
    softenCombo(self.simSkill)
    softenCombo(self.simZone)

    -- Chance "label" (button made to look like a label)
    if self.simChanceBtn then
        self.simChanceBtn.backgroundColor = self.simChanceBtn.backgroundColor or {r=0,g=0,b=0,a=0}
        self.simChanceBtn.backgroundColor.a = 0.0
        self.simChanceBtn.backgroundColorMouseOver = self.simChanceBtn.backgroundColorMouseOver or {r=0,g=0,b=0,a=0}
        self.simChanceBtn.backgroundColorMouseOver.a = 0.15
        self.simChanceBtn.borderColor = self.simChanceBtn.borderColor or {r=1,g=1,b=1,a=1}
        self.simChanceBtn.borderColor.a = 0.0
    end
end

-- Forces the combo's popup/list to be fully opaque when opened
function TrapManagerWindow:_ensureOpaqueComboPopups()
    local function patch(cb)
        if not cb then return end
        local prev = cb.onMouseDown

        cb.onMouseDown = function(selfCb, x, y)
            -- Call the previous handler first so the popup exists
            if type(prev) == "function" then
                prev(selfCb, x, y)
            else
                -- Fallback to the class method just in case.
                if ISComboBox and type(ISComboBox.onMouseDown) == "function" then
                    ISComboBox.onMouseDown(selfCb, x, y)
                end
            end

            -- Now the popup should be created; force an opaque background
            if selfCb.popup then
                -- Popup container background/border
                selfCb.popup.backgroundColor = selfCb.popup.backgroundColor or { r=0, g=0, b=0, a=1 }
                selfCb.popup.backgroundColor.a = 1.0
                selfCb.popup.borderColor = selfCb.popup.borderColor or { r=1, g=1, b=1, a=1 }
                selfCb.popup.borderColor.a = 1.0

                -- Popup list background/border (the visible dropdown)
                if selfCb.popup.list then
                    local list = selfCb.popup.list
                    list.backgroundColor = list.backgroundColor or { r=0, g=0, b=0, a=1 }
                    list.backgroundColor.a = 1.0
                    list.borderColor = list.borderColor or { r=1, g=1, b=1, a=1 }
                    list.borderColor.a = 1.0
                end
            end
        end
    end

    -- Patch all simulator combos
    patch(self.simAnimal)
    patch(self.simTrap)
    patch(self.simBait)
    patch(self.simSkill)
    patch(self.simZone)
end

-- --- Distance/Direction helpers -------------------------------------------
-- Simple sign helper returning -1, 0, or +1.
local function _sgn(x)
    if x > 0 then return 1 elseif x < 0 then return -1 else return 0 end
end

-- Round to nearest integer (no bankers rounding)
local function _round(n)
    if n >= 0 then return math.floor(n + 0.5) else return math.ceil(n - 0.5) end
end

-- Robust angle-from-North (degrees) using acos + sign(dx):
-- - dx > 0 = East, dy > 0 = South (PZ coordinates)
-- - 0 deg = North, +90 = East, -90 = West, +/-180 = South
local function _angleFromNorthDeg_acos(dx, dy)
    local mod = math.sqrt(dx*dx + dy*dy)
    if mod == 0 then return 0 end

    -- Handle dx == 0 explicitly so sign is well-defined and avoid NaN.
    if dx == 0 then
        -- North (dy < 0) -> 0; South (dy > 0) -> 180
        return (dy > 0) and 180 or 0
    end

    -- Clamp input to acos to [-1,1] to avoid numeric drift.
    local c = -dy / mod
    if c > 1 then c = 1 elseif c < -1 then c = -1 end

    -- Base angle in [0,pi], then extend sign with sign(dx) to (-pi,pi].
    local angRad = math.acos(c) * _sgn(dx)
    local angDeg = angRad * 57.29577951308232 -- 180/pi
    -- Normalize to [-180,180]
    if angDeg > 180 then angDeg = angDeg - 360 end
    if angDeg <= -180 then angDeg = angDeg + 360 end
    return angDeg
end

-- Map heading (deg) to 8-way cardinal string.
local function _cardinal8(deg)
    local a = deg % 360; if a < 0 then a = a + 360 end
    if a < 22.5 then return "N"
    elseif a < 67.5 then return "NE"
    elseif a < 112.5 then return "E"
    elseif a < 157.5 then return "SE"
    elseif a < 202.5 then return "S"
    elseif a < 247.5 then return "SW"
    elseif a < 292.5 then return "W"
    elseif a < 337.5 then return "NW"
    else return "N" end
end

-- Build tooltip for Distance cell using *float* deltas (more precise near the trap).
local function _buildDistanceTooltip(dxF, dyF)
    local mod = math.sqrt(dxF*dxF + dyF*dyF)
    if mod == 0 then
        return _T("UI_TM_DistanceHere","Distance: 0 tiles <LINE> - Direction: here")
    end
    local ang = _angleFromNorthDeg_acos(dxF, dyF)
    local dir = _cardinal8(ang)
    -- Use "deg" (ASCII) to avoid encoding issues with the degree symbol.
	return _T("UI_TM_DistanceLine",
			  "Distance: %1 tiles <LINE> - Direction: %2 deg (%3) <LINE> (0 = North, 90 = East, -90 = West)",
			  _round(mod), _round(ang), dir)
end

-----------------------------------------------------------------------------
-- Safe getter for item script's DaysFresh (returns number or nil)
local function getItemDaysFresh(itemType)
    if not itemType or itemType == "" then return nil end
    -- Try the vanilla helper first
    local ok1, itm = pcall(function() return getItem(itemType) end)
    if ok1 and itm and itm.DaysFresh then
        return tonumber(itm.DaysFresh)
    end
    -- Fallback via ScriptManager
    local ok2, sm = pcall(function() return getScriptManager() end)
    if ok2 and sm then
        local ok3, scriptItem = pcall(function() return sm:FindItem(itemType) end)
        if ok3 and scriptItem and scriptItem:getDaysFresh() then
            return tonumber(scriptItem:getDaysFresh())
        end
    end
    return nil
end

-- Player display name (username if available; otherwise player index)
local function getPlayerDisplayName(ply)
	-- if ply then return "P1" end  -- debug test successfully done
    if not ply then return "P0" end
    local name = nil
    local ok, v = pcall(function() return ply:getUsername() end)
    if ok and v and v ~= "" then name = v end
    if not name then
        local ok2, idx = pcall(function() return ply:getPlayerNum() end)
        name = ok2 and tostring(idx) or "P0"
    end
    return name
end

-- ============================================================

local function TM_GetTrapSystem()
    -- In singleplayer, prefer the authoritative server-side trap system when it is
    -- available in the shared Lua environment. This avoids relying on the client
    -- cache for far-away traps.
    if (not isClient()) and STrapSystem and STrapSystem.instance and STrapSystem.instance.system then
        return STrapSystem.instance.system
    end

    -- In multiplayer, the client-side trap system is the only one available to UI code.
    if CTrapSystem and CTrapSystem.instance and CTrapSystem.instance.system then
        return CTrapSystem.instance.system
    end

    -- Defensive fallback for builds / contexts where the server-side system is exposed.
    if STrapSystem and STrapSystem.instance and STrapSystem.instance.system then
        return STrapSystem.instance.system
    end
    return nil
end

local function TM_RefreshTrapLuaObject(luaObject)
    if not luaObject then return luaObject end

    -- Client-side GlobalObjects are not always updated eagerly when the backing
    -- IsoObject modData changes. Refresh from the IsoObject when possible so the
    -- panel sees the latest nearby state without requiring a right-click.
    if type(luaObject.updateFromIsoObject) == "function" then
        pcall(function() luaObject:updateFromIsoObject() end)
    end

    return luaObject
end

function TrapManagerWindow:initialise()
    ISCollapsableWindow.initialise(self);
	self.title = _T("UI_TrapManager_Title", "Trap Manager")

    self.trapSystem = TM_GetTrapSystem()

	-- Default columns (localized headers)
	self.columnConfig = {
		{ name = _T("UI_TM_Col_ShowHide","Show"), 		   key = "showhide",  width = 90,  visible = true },
		{ name = _T("UI_TM_Col_Number","Number"),      key = "name",      width = 70,  visible = true },
		{ name = _T("UI_TM_Col_Distance","Distance"),  key = "distance",  width = 110, visible = true },
		{ name = _T("UI_TM_Col_Chunks","Chunks"),      key = "chunks",    width = 110, visible = true },
		{ name = _T("UI_TM_Col_Precond","Precond."),   key = "eligible",  width = 80,  visible = true },
		{ name = _T("UI_TM_Col_Trap","Trap"),          key = "trapType",  width = 120, visible = true },
		{ name = _T("UI_TM_Col_Skill","Skill"),        key = "skill",     width = 60,  visible = true },
		{ name = _T("UI_TM_Col_Zone","Zone"),          key = "zone",      width = 120, visible = true },
		{ name = _T("UI_TM_Col_Animal","Animal"),      key = "animal",    width = 120, visible = true },
		{ name = _T("UI_TM_Col_Bait","Bait"),          key = "bait",      width = 180, visible = true },
		{ name = _T("UI_TM_Col_Chance","Chance"),      key = "chance",    width = 80,  visible = true },
	}

    -- Internal flags/handlers
    self._hourlySubscribed = false
    self._hourlyHandler = nil
    self.didAutoFit = false
	self._autoEnabled = false
	self._autoHandler = nil
	self._autoCounter = 0	
end

-- --- Minimum height calculator (title + simulator + header + resize bar) ---
-- Keep this near other small helpers, so it's easy to reuse from multiple places.
function TrapManagerWindow:_recalcMinHeight()
    -- Title bar height (top chrome)
    local th = self:titleBarHeight()
    -- Bottom resize bar height (status area)
    local rh = self:resizeWidgetHeight()
    -- Simulator row height (just below title)
    local simH = (self.simRow and self.simRow.height) or 0
    -- Grid header height (fixed header above the list)
    local headH = (self.grid and self.grid.headerHeight) or 0

	local rowH = (self.grid and self.grid.list and self.grid.list.itemheight) or 0
	local rowsVisible = 0 -- custom minimum number of visible rows
	
    -- The "+2" is a tiny safety pad to avoid fencepost off-by-one issues.
    self._minWindowHeight = math.ceil(th + simH + headH + rowH * rowsVisible + rh + 2)
end

-- Patch a single ISResizeWidget instance so min size + logging only apply to this window.
local function _TM_patchResizeWidgetFor(win, w)
    if not w or w._tmPatched then return end
    w._tmPatched = true

    -- Keep previous handlers to preserve vanilla behavior.
    local _md = w.onMouseDown
    local _mm = w.onMouseMove
    local _mu = w.onMouseUp
    local _muo = w.onMouseUpOutside

    -- Small utility to enforce min size and persist layout.
    local function _applyMinSizeIfAny(p)
        if not p then return end
        -- Width clamp mirrors your window-level logic; harmless if yonly==true.
        if type(p._minWindowWidth) == "number" and p.width < p._minWindowWidth then
            p:setWidth(p._minWindowWidth)
        end
        if type(p._minWindowHeight) == "number" and p.height < p._minWindowHeight then
            p:setHeight(p._minWindowHeight)
        end
        if p._layoutTitlebarRightButtons then p:_layoutTitlebarRightButtons() end
        if p.saveLayout then p:saveLayout() end
    end

    function w:onMouseDown(x, y)
        -- Debug log (safe, cheap)
        TMLog("ISResizeWidget onMouseDown yonly="..tostring(self.yonly)..
              " absX="..self:getAbsoluteX().." absY="..self:getAbsoluteY()..
              " w="..self.width.." h="..self.height)
        return _md and _md(self, x, y)
    end

    function w:onMouseMove(dx, dy)
        -- Live trace while dragging
        if self.resizing then
            TMLog("ISResizeWidget onMouseMove resizing yonly="..tostring(self.yonly))
        end
        local r = _mm and _mm(self, dx, dy)

        -- Live-enforce min width/height on the parent window as you drag
        local p = self.parent
        if self.resizing and p then
            if type(p._minWindowHeight) == "number" and p.height < p._minWindowHeight then
                p:setHeight(p._minWindowHeight)
            end
            if type(p._minWindowWidth) == "number" and p.width < p._minWindowWidth then
                p:setWidth(p._minWindowWidth)
                if p._layoutTitlebarRightButtons then p:_layoutTitlebarRightButtons() end
            end
        end
        return r
    end

    function w:onMouseUp(x, y)
        TMLog("ISResizeWidget onMouseUp yonly="..tostring(self.yonly))
        local r = _mu and _mu(self, x, y)
        -- Snap to minimum once drag ends; also persist layout.
        _applyMinSizeIfAny(self.parent)
        return r
    end

    function w:onMouseUpOutside(x, y)
        TMLog("ISResizeWidget onMouseUpOutside yonly="..tostring(self.yonly))
        local r = _muo and _muo(self, x, y)
        -- Same safeguard if mouse is released outside.
        _applyMinSizeIfAny(self.parent)
        return r
    end
end


function TrapManagerWindow:createChildren()
    if self.grid then return end
    ISCollapsableWindow.createChildren(self)

    -- Only patch this window's resize handles (no global side effects).
    _TM_patchResizeWidgetFor(self, self.resizeWidget)
    _TM_patchResizeWidgetFor(self, self.resizeWidget2)

    local titleBarHeight  = self:titleBarHeight()
    local resizeWidgetHgt = self:resizeWidgetHeight()

    -- Load saved window rect (posición/tamaño)
    local md = TM_LoadSettings()
    local hadSavedSize = (md.window and md.window.w and md.window.h)

    if hadSavedSize then
        self:setX(md.window.x or self.x)
        self:setY(md.window.y or self.y)
        self:setWidth(md.window.w)
        self:setHeight(md.window.h)
    end

    -- Columnas + ancho ideal
    local sumVisible = self:buildColumnConfig()
    local padding = 12
    local idealW = sumVisible + padding
    if not hadSavedSize then
        self:setWidth(math.max(idealW, 200))
    end

    -- Client height
    local simH = 22
    local clientHeight = math.max(0, self.height - titleBarHeight - resizeWidgetHgt - simH)

    --=== Grid (header + list) ==============================================
    self.grid = TM_ResizableColumns:new(0, titleBarHeight + simH, self.width, clientHeight, self)
    self.grid:initialise()
    self.grid:setColumns(self.columnConfig)
    self.grid:setAnchorRight(true)
    self.grid:setAnchorBottom(true)
    self:addChild(self.grid)

    --=== Simulator row (combos above header) =======================
    if not self.simRow then
        self.simRow = ISPanel:new(0, titleBarHeight, self.width, simH)
        self.simRow.background = false
        self.simRow.borderColor.a = 0
        self.simRow:setAnchorRight(true)
        self:addChild(self.simRow)

        -- ANIMAL ------------------------------------------------------------

        self.simAnimal = ISComboBox:new(0, 2, 100, simH-4, self, TrapManagerWindow.onAnimalComboChanged)
        self.simAnimal:initialise(); self.simAnimal:instantiate()
        self.simRow:addChild(self.simAnimal)
        self.simAnimal:addOption(ChooseText())
        if type(_G.TrapAnimals) == "table" then
            for _,v in ipairs(_G.TrapAnimals) do
				-- Display with translated name and initial capitalization
                local display = L_AnimalName(v.type or "Unknown") or tostring(v.type or "Unknown")
                display = tostring(display):gsub("^%l", string.upper) -- por si viniera en minúsculas
                local value   = tostring(v.type or "")
                if self.simAnimal.addOptionWithData then
                    self.simAnimal:addOptionWithData(display, value)
                else
                    self.simAnimal:addOption(display)
                end
            end
        end
        self.simAnimal.selected = 1
        -- Refresh tooltips and logic
		self.simAnimal.onChange = function(target, combo)
			TrapManagerWindow.onAnimalComboChanged(target, combo)
		end
        self.simAnimal.target = self

        -- TRAP --------------------------------------------------------------
        self.simTrap = ISComboBox:new(0, 2, 100, simH-4, self, nil)
        self.simTrap:initialise(); self.simTrap:instantiate()
        self.simRow:addChild(self.simTrap)
        self.simTrap:addOption(ChooseText())
        self.simTrap.selected = 1
		self.simTrap.onChange = function(target)
			TMLog("simTrap onChange sel="..tostring(self.simTrap.selected).." txt="..tostring(_cbText(self.simTrap)))
			target:_simAfterChange()
		end
        self.simTrap.target = self

        -- BAIT --------------------------------------------------------------
        self.simBait = ISComboBox:new(0, 2, 120, simH-4, self, nil)
        self.simBait:initialise(); self.simBait:instantiate()
        self.simRow:addChild(self.simBait)
        self.simBait:addOption(ChooseText())
        self.simBait.selected = 1
		self.simBait.onChange = function(target)
			TMLog("simBait onChange sel="..tostring(self.simBait.selected).." txt="..tostring(_cbText(self.simBait)))
			target:_simAfterChange()
		end
        self.simBait.target = self

        -- SKILL -------------------------------------------------------------
        self.simSkill = ISComboBox:new(0, 2, 60, simH-4, self, nil)
        self.simSkill:initialise(); self.simSkill:instantiate()
        self.simRow:addChild(self.simSkill)
        for i=0,10 do self.simSkill:addOption(tostring(i)) end
        self.simSkill.selected = 1
		self.simSkill.onChange = function(target)
			TMLog("simSkill onChange sel="..tostring(self.simSkill.selected).." txt="..tostring(_cbText(self.simSkill)))
			target:_simAfterChange()
		end
        self.simSkill.target = self
		-- Ensure Skill popup shows 0..10 without scrolling
		self:_setupSimSkillPopupTuning()
		-- Make combo popups opaque when expanded
		self:_ensureOpaqueComboPopups()

        -- ZONE --------------------------------------------------------------
        self.simZone = ISComboBox:new(0, 2, 140, simH-4, self, nil)
        self.simZone:initialise(); self.simZone:instantiate()
        self.simRow:addChild(self.simZone)
        self.simZone:addOption(ChooseText())
        self.simZone.selected = 1
		self.simZone.onChange = function(target)
			TMLog("simZone onChange sel="..tostring(self.simZone.selected).." txt="..tostring(_cbText(self.simZone)))
			target:_simAfterChange()
		end
        self.simZone.target = self

        -- CHANCE (button) ----------------------------------------------------
        self.simChanceBtn = ISButton:new(0, 2, 90, simH-4, "...", self, function() end)
        self.simChanceBtn:initialise(); self.simChanceBtn:instantiate()
        self.simChanceBtn.borderColor.a = 0.0
        self.simChanceBtn.backgroundColor.a = 0.0
        self.simChanceBtn.backgroundColorMouseOver.a = 0.2
        self.simRow:addChild(self.simChanceBtn)
		-- Apply a softer skin to simulator controls
		self:_softenSimulatorSkin()

        -- SHOW ALL ----------------------------------------------------------
		self.btnShowAll = ISButton:new(0, 2, 90, simH-4, _T("UI_TM_ShowAll","Show all"), self, function(win)
			win:_setShowAll(not win:_getShowAll())
			win:updateTraps()
		end)
        self.btnShowAll:initialise(); self.btnShowAll:instantiate()
        self.btnShowAll.borderColor.a = 0.0
        self.simRow:addChild(self.btnShowAll)
        self:_getShowAll()
        self:_refreshShowAllButtonSkin()
		
        -- Restore simulator selections from ModData (after all controls exist)
        if self._applySimFromMD then self:_applySimFromMD() end		
    end

    -- Persist layout when changing columns
    if self.grid.setOnColumnsChanged then
        self.grid:setOnColumnsChanged(self, function(_, cols)
            self:saveLayout()
            self:_layoutSimulatorControls()
        end)
    end

    -- Ensure simulator z-order
    if self.simRow then
        self.simRow:bringToTop()
        if self.simAnimal    then self.simAnimal:bringToTop()    end
        if self.simTrap      then self.simTrap:bringToTop()      end
        if self.simBait      then self.simBait:bringToTop()      end
        if self.simSkill     then self.simSkill:bringToTop()     end
        if self.simZone      then self.simZone:bringToTop()      end
        if self.simChanceBtn then self.simChanceBtn:bringToTop() end
    end

	-- Titlebar buttons (Refresh, Auto, Vector, Reset, Full)
	self:_ensureTitlebarButtons()

    -- Enforce min width and decide initial title visibility after first layout
    if self._updateTitleVisibilityAndMinWidth then
        self:_updateTitleVisibilityAndMinWidth()
    end

    -- Initial layout and first calculation
    self:_layoutSimulatorControls()
    self:_simUpdateChance()
    self:_simRefreshTooltips()
	
	-- Recompute and enforce a sane minimum height on first open.
    -- This protects us from tiny sizes that were saved in ModData previously.
    if self._recalcMinHeight then self:_recalcMinHeight() end
    self:setHeight(math.max(self.height, self._minWindowHeight or self.height))
end


function TrapManagerWindow:new(x, y, width, height)
    local o = ISCollapsableWindow:new(x, y, width, height);
    setmetatable(o, self);
    self.__index = self;
    o.x = x;
    o.y = y;
    o.width = width;
    o.height = height;
	o.title = _T("UI_TrapManager_Title", "Trap Manager")
    o:initialise();
    TrapManagerWindow.instance = o;
    return o;
end

-- Toggle with DIVIDE ("/")
function TrapManagerWindow.OnOpenPanel()
    if TrapManagerWindow.instance and TrapManagerWindow.instance:getIsVisible() then
        TrapManagerWindow.instance:unsubscribeHourly()
        TrapManagerWindow.instance:setVisible(false)
    else
        if not TrapManagerWindow.instance then
            local x = getCore():getScreenWidth() / 2 - 400
            local y = getCore():getScreenHeight() / 2 - 300
            local width, height = 800, 600
            TrapManagerWindow.instance = TrapManagerWindow:new(x, y, width, height)
        end

        local win = TrapManagerWindow.instance
        win:setVisible(true)
        win:addToUIManager()
        if not win.grid then win:createChildren() end
        win:updateTraps()
		win:_syncVectorFromMD()   -- keep Vector button and any existing target in sync
		win:_syncAutoFromMD() 	-- Ensure Auto matches persisted state when reopening

        if win._simUpdateChance then win:_simUpdateChance() end
        win:subscribeHourly()
        -- ensure hourly matches Mod Options
        if win.updateHourlySubscriptionFromOptions then win:updateHourlySubscriptionFromOptions() end
    end
end

-- Manual refresh
function TrapManagerWindow:onRefresh()
    self:updateTraps()
end

-- Teleport callback (same logic as ISMiniMapInner:onTeleport)
function TrapManagerWindow:onTeleport(worldX, worldY, worldZ)
    -- Guard: need a player
    local playerObj = getSpecificPlayer(0)
    if not playerObj then return end

    -- Multiplayer-safe: use the server command on MP and a direct teleport on SP
    if isClient() then
        SendCommandToServer("/teleportto " .. tostring(worldX) .. "," .. tostring(worldY) .. "," .. tostring(worldZ or 0))
    else
        playerObj:teleportTo(worldX, worldY, tonumber(worldZ) or 0.0)
    end
end


-- Subscribe hourly while visible
function TrapManagerWindow:subscribeHourly()
    if self._hourlySubscribed then return end
    if not self._hourlyHandler then
        self._hourlyHandler = function()
            if self:getIsVisible() then
                self:updateTraps()
            end
        end
    end
    Events.EveryHours.Add(self._hourlyHandler)
    self._hourlySubscribed = true
end

-- Unsubscribe
function TrapManagerWindow:unsubscribeHourly()
    if not self._hourlySubscribed or not self._hourlyHandler then return end
    Events.EveryHours.Remove(self._hourlyHandler)
    self._hourlySubscribed = false
end

-- Sync hourly subscription with Mod Options (called on open and during prerender)
function TrapManagerWindow:updateHourlySubscriptionFromOptions()
    -- Defensive: require helper from TM_ModOptions.lua
    local want = true
    if type(TM_IsHourlyEnabled) == "function" then
        local ok, v = pcall(TM_IsHourlyEnabled)
        if ok then want = v end
    end

    if want and not self._hourlySubscribed then
        self:subscribeHourly()
    elseif (not want) and self._hourlySubscribed then
        self:unsubscribeHourly()
    end
end

-- AutoRefresh handler
function TrapManagerWindow:_refreshAutoButtonSkin()
    if not self.autoButton then return end
    if self._autoEnabled then
        -- ON: light green, slightly translucent
        self.autoButton.backgroundColor = { r=0.2, g=0.8, b=0.2, a=0.35 }
        self.autoButton.backgroundColorMouseOver = { r=0.2, g=0.8, b=0.2, a=0.55 }
        self.autoButton.borderColor.a = 0.0
        self.autoButton.title = _T("UI_TM_Btn_Auto","Auto")
    else
        -- OFF: neutral gray
        self.autoButton.backgroundColor = { r=0, g=0, b=0, a=0.15 }
        self.autoButton.backgroundColorMouseOver = { r=0.3, g=0.3, b=0.3, a=0.35 }
        self.autoButton.borderColor.a = 0.0
        self.autoButton.title = _T("UI_TM_Btn_Auto","Auto")
    end
end

-- Toggle Auto and persist the new value
function TrapManagerWindow:_toggleAuto()
    self:_applyAuto(not self._autoEnabled, true)
end

-- Read persisted Auto state from ModData (returns true/false)
function TrapManagerWindow:_readAutoFromMD()
    local md = TM_LoadSettings()
    return (md.autoEnabled == true)
end

-- Write current Auto state to ModData (true/false)
function TrapManagerWindow:_writeAutoToMD(v)
    local md = TM_LoadSettings()
    md.autoEnabled = (v == true)
    TM_SaveSettings(md)
end

-- Apply the Auto state to UI and event subscriptions.
-- If 'persist' is true, also save the state to ModData.
function TrapManagerWindow:_applyAuto(enabled, persist)
    self._autoEnabled = (enabled == true)
    self:_refreshAutoButtonSkin()

    -- Ensure the handler exists once; it throttles updates via _autoCounter.
    if not self._autoHandler then
        self._autoHandler = function()
            if not self:getIsVisible() then return end
            self._autoCounter = (self._autoCounter or 0) + 1
            -- ~ every 30 ticks (~0.5–1s depending on FPS)
            if self._autoCounter >= 30 then
                self._autoCounter = 0
                self:updateTraps()
            end
        end
    end

    -- Track subscription idempotently to avoid double add/remove.
    self._autoSubscribed = self._autoSubscribed or false
    if self._autoEnabled and not self._autoSubscribed then
        Events.OnTick.Add(self._autoHandler)
        self._autoSubscribed = true
    elseif (not self._autoEnabled) and self._autoSubscribed then
        Events.OnTick.Remove(self._autoHandler)
        self._autoSubscribed = false
        self._autoCounter = 0
    end

    if persist then
        self:_writeAutoToMD(self._autoEnabled)
    end
end

-- Sync UI + subscription from ModData (no write).
function TrapManagerWindow:_syncAutoFromMD()
    local want = self:_readAutoFromMD()
    self:_applyAuto(want, false)
end

--=== Vector feature (toggle + last-hover target propagation) ===============

-- Push current vector state to player's ModData (so the renderer can read it)
function TrapManagerWindow:_pushVectorStateToMD(enabled, targetXYZ)
    local ply = getSpecificPlayer(0)
    if not ply then return end
    local md = ply:getModData()
    md.TM_VectorEnabled = (enabled == true)
    if enabled and targetXYZ and targetXYZ.x and targetXYZ.y and targetXYZ.z then
        md.TM_VectorTrap = { x = targetXYZ.x, y = targetXYZ.y, z = targetXYZ.z }
    elseif not enabled then
        md.TM_VectorTrap = nil
    end
end

-- Keep the "Vector" button skin consistent with ON/OFF (mirrors Auto style)
function TrapManagerWindow:_refreshVectorButtonSkin()
    if not self.vectorButton then return end
    if self._vectorEnabled then
        self.vectorButton.backgroundColor = { r=0.2, g=0.8, b=0.2, a=0.35 }
        self.vectorButton.backgroundColorMouseOver = { r=0.2, g=0.8, b=0.2, a=0.55 }
        self.vectorButton.borderColor.a = 0.0
        self.vectorButton.title = _T("UI_TM_Btn_Vector","Vector")
    else
        self.vectorButton.backgroundColor = { r=0, g=0, b=0, a=0.15 }
        self.vectorButton.backgroundColorMouseOver = { r=0.3, g=0.3, b=0.3, a=0.35 }
        self.vectorButton.borderColor.a = 0.0
        self.vectorButton.title = _T("UI_TM_Btn_Vector","Vector")
    end
end

-- Toggle handler for the Vector button
function TrapManagerWindow:_toggleVector()
    self._vectorEnabled = not self._vectorEnabled
    self:_refreshVectorButtonSkin()
    -- When turning ON, push last hovered trap (if any); when OFF, clear target
    self:_pushVectorStateToMD(self._vectorEnabled, self._lastHoverTarget)
end

-- Consume hover events from the list to remember the last row under mouse.
-- When Vector is ON, publish target coords to ModData so the renderer draws the line
function TrapManagerWindow:_noteHoverRow(item)
    -- Rows now carry world coords as __wx/__wy/__wz (set in updateTraps)
    local x, y, z = item and item.__wx, item and item.__wy, item and item.__wz
    if type(x) == "number" and type(y) == "number" and type(z) == "number" then
        self._lastHoverTarget = { x = x, y = y, z = z } -- persist even if mouse leaves window
        if self._vectorEnabled then
            self:_pushVectorStateToMD(true, self._lastHoverTarget)
        end
    end
end

-- Sync button state from player's ModData (useful when reopening the window)
function TrapManagerWindow:_syncVectorFromMD()
    local ply = getSpecificPlayer(0)
    if not ply then return end
    local md = ply:getModData()
    self._vectorEnabled = (md and md.TM_VectorEnabled == true) or false
    -- If there is a previously set target, remember it locally
    if md and type(md.TM_VectorTrap) == "table" then
        self._lastHoverTarget = { x = md.TM_VectorTrap.x, y = md.TM_VectorTrap.y, z = md.TM_VectorTrap.z }
    end
    self:_refreshVectorButtonSkin()
end

-- Creates/repositions the titlebar buttons: Refresh, Auto, Vector, Reset, Full.
function TrapManagerWindow:_ensureTitlebarButtons()
    local titleBarHeight = self:titleBarHeight()

    -- Refresh (to the right of Close)
    if self.closeButton and not self.refreshButton then
        local th = titleBarHeight
        local btnW, btnH = 60, 18
        local btnX = self.closeButton.x + (th + 5)
        local btnY = math.floor((th - btnH) / 2)
		self.refreshButton = ISButton:new(btnX, btnY, btnW, btnH,
			_T("UI_TM_Btn_Refresh","Refresh"), self, TrapManagerWindow.onRefresh)
        self.refreshButton:initialise(); self.refreshButton:instantiate()
        self.refreshButton.borderColor.a = 0.0
        self.refreshButton.backgroundColor.a = 0.0
        self.refreshButton.backgroundColorMouseOver.a = 0.7
        self:addChild(self.refreshButton)
        self.refreshButton:bringToTop()
    end

    -- Auto (to the right of Refresh)
    if self.refreshButton and not self.autoButton then
        local th = titleBarHeight
        local btnW, btnH = 50, 18
        local gap = 5
        local btnX = self.refreshButton.x + self.refreshButton.width + gap
        local btnY = math.floor((th - btnH) / 2)
		self.autoButton = ISButton:new(btnX, btnY, btnW, btnH,
			_T("UI_TM_Btn_Auto","Auto"), self, function(win) win:_toggleAuto() end)
        self.autoButton:initialise(); self.autoButton:instantiate()
        self.autoButton.borderColor.a = 0.0
        self.autoButton.backgroundColor.a = 0.15
        self.autoButton.backgroundColorMouseOver.a = 0.35
        self:addChild(self.autoButton)
        self.autoButton:bringToTop()
        self:_refreshAutoButtonSkin()
        self:_syncAutoFromMD()
    end

    -- Vector (to the right of Auto)
    if self.autoButton and not self.vectorButton then
        local th = titleBarHeight
        local btnW, btnH = 60, 18
        local gap = 5
        local btnX = self.autoButton.x + self.autoButton.width + gap
        local btnY = math.floor((th - btnH) / 2)
		self.vectorButton = ISButton:new(btnX, btnY, btnW, btnH,
			_T("UI_TM_Btn_Vector","Vector"), self, function(win) win:_toggleVector() end)
        self.vectorButton:initialise(); self.vectorButton:instantiate()
        self.vectorButton.borderColor.a = 0.0
        self.vectorButton.backgroundColor.a = 0.15
        self.vectorButton.backgroundColorMouseOver.a = 0.35
        self:addChild(self.vectorButton)
        self.vectorButton:bringToTop()
        self:_refreshVectorButtonSkin()
    end

    -- Reset (left side, near pin/collapse)
    if not self.resetButton then
        local th = self:titleBarHeight()
        local btnH = th - 2
        local btnW = 60
        local gap  = 5
        local rightRefX = self.pinButton and self.pinButton:getX() or (self.width - 1 - btnH)
        local btnX = rightRefX - gap - btnW
        local btnY = 1
		self.resetButton = ISButton:new(btnX, btnY, btnW, btnH,
			_T("UI_TM_Btn_Reset","Reset"), self, TrapManagerWindow.onClickReset)
        self.resetButton.anchorRight = true
        self.resetButton.anchorLeft  = false
        self.resetButton:initialise()
        self.resetButton.borderColor.a = 0.0
        self.resetButton.backgroundColor.a = 0.0
        self.resetButton.backgroundColorMouseOver.a = 0.7
        self:addChild(self.resetButton)
        self.resetButton:bringToTop()
    end

    -- Full (to the left of Reset)
    if self.resetButton and not self.fullButton then
        local gap  = 5
        local btnW = 50
        local btnH = self.resetButton.height
        local btnX = self.resetButton.x - gap - btnW
        local btnY = self.resetButton.y
		self.fullButton = ISButton:new(btnX, btnY, btnW, btnH,
			_T("UI_TM_Btn_Full","Full"), self, TrapManagerWindow.onClickFull)
        self.fullButton.anchorRight = true
        self.fullButton.anchorLeft  = false
        self.fullButton:initialise()
        self.fullButton.borderColor.a = 0.0
        self.fullButton.backgroundColor.a = 0.0
        self.fullButton.backgroundColorMouseOver.a = 0.7
        self:addChild(self.fullButton)
        self.fullButton:bringToTop()
    end

    -- Keep window chrome above the grid
    if self.resizeWidget  then self.resizeWidget:bringToTop()  end
    if self.resizeWidget2 then self.resizeWidget2:bringToTop() end
    if self.closeButton   then self.closeButton:bringToTop()   end
    if self.collapseButton then self.collapseButton:bringToTop() end
    if self.pinButton     then self.pinButton:bringToTop()     end
	
	if self._layoutTitlebarRightButtons then
        self:_layoutTitlebarRightButtons()
    end
    -- Keep title visibility and minimum width consistent with current labels
    if self._updateTitleVisibilityAndMinWidth then
        self:_updateTitleVisibilityAndMinWidth()
    end
	-- Apply/clear button tooltips based on Verbose Tips setting
    if self._applyButtonTooltips then self:_applyButtonTooltips(true) end
end

-- Keep right-side titlebar buttons aligned relative to the pin button,
-- with the Reset button's RIGHT edge 5px LEFT of the pin button,
-- and the Full button 5px LEFT of Reset. Widths are derived from localized text.
function TrapManagerWindow:_layoutTitlebarRightButtons()
    if not self.resetButton then return end

    local th   = self:titleBarHeight()
    local btnH = th - 2
    local gap  = 5
    local tm   = getTextManager()
    local font = UIFont.Small
    local padX = 16 -- horizontal padding around the text

    -- Fallback reference X if pinButton is missing (uses a square title widget width)
    local pinLeftX = self.pinButton and self.pinButton:getX() or (self.width - 1 - btnH)

    -- Measure Reset text width (localized)
    local resetLabel = (self.resetButton.title)
        or (self.resetButton.getTitle and self.resetButton:getTitle())
        or _T("UI_TM_Btn_Reset","Reset")

    local resetW = math.max(40, tm:MeasureStringX(font, tostring(resetLabel)) + padX)
    self.resetButton:setHeight(btnH)
    self.resetButton:setWidth(resetW)
    self.resetButton:setY(1)
    -- Reset right edge = pinLeftX - gap
    self.resetButton:setX(pinLeftX - gap - resetW)

    -- Full button sits 5px to the LEFT of Reset
    if self.fullButton then
        local fullLabel = (self.fullButton.title)
            or (self.fullButton.getTitle and self.fullButton:getTitle())
            or _T("UI_TM_Btn_Full","Full")
        local fullW = math.max(36, tm:MeasureStringX(font, tostring(fullLabel)) + padX)
        self.fullButton:setHeight(btnH)
        self.fullButton:setWidth(fullW)
        self.fullButton:setY(1)
        self.fullButton:setX(self.resetButton.x - gap - fullW)
    end
end

-- Measure left and right titlebar buttons (based on localized labels)
-- and enforce: (a) window minimum width, (b) title visibility vs Vector.
function TrapManagerWindow:_updateTitleVisibilityAndMinWidth()
    local tm, font = getTextManager(), UIFont.Small
    local padX, gap = 16, 5
    local th = self:titleBarHeight()

    local function label(btn, key, fallback)
        if not btn then return _T(key, fallback) end
        return tostring(btn.title or (btn.getTitle and btn:getTitle()) or _T(key, fallback))
    end
    local function tw(s) return tm:MeasureStringX(font, s) + padX end

    local closeW = (self.closeButton and self.closeButton.width) or th
    local pinW   = (self.pinButton   and self.pinButton.width)   or th

    local minW = closeW + gap
               + tw(label(self.refreshButton,"UI_TM_Btn_Refresh","Refresh")) + gap
               + tw(label(self.autoButton,   "UI_TM_Btn_Auto",   "Auto"))    + gap
               + tw(label(self.vectorButton, "UI_TM_Btn_Vector", "Vector"))  + gap
               + tw(label(self.fullButton,   "UI_TM_Btn_Full",   "Full"))    + gap
               + tw(label(self.resetButton,  "UI_TM_Btn_Reset",  "Reset"))   + gap
               + pinW

    self._minWindowWidth = math.ceil(minW)

    -- Mostrar/ocultar título según colisión con "Vector" (sin tocar el ancho)
    self._baseTitle = self._baseTitle or _T("UI_TrapManager_Title", "Trap Manager")
    local titleW = tm:MeasureStringX(font, self._baseTitle)
    local titleLeft = (self.width - titleW) / 2
    local vectorRight = (self.vectorButton and (self.vectorButton.x + self.vectorButton.width)) or 0
    self.title = (titleLeft > vectorRight) and self._baseTitle or ""
end


-- Reset handler: wipe ModData + rebuild default layout and columns
function TrapManagerWindow:onClickReset()
    local key = "TrapManager_Settings_v1"

    -- 0) Preserve visibility-related state we DON'T want to lose
    --    - showAll: global toggle
    --    - rowShow: per-trap visibility map (rowKey -> true means "Shown" when Show all is OFF)
    local prevShowAll, prevRowShow = false, {}
    do
        local mdPrev = ModData.getOrCreate(key)
        prevShowAll = (mdPrev.showAll == true)
        if type(mdPrev.rowShow) == "table" then
            for k, v in pairs(mdPrev.rowShow) do
                prevRowShow[k] = v
            end
        end
    end

    -- 1) Reset layout-related data while restoring the preserved visibility state
    --    We wipe window/columns so they rebuild from defaults, but we keep showAll/rowShow.
    ModData.remove(key)
    local md = ModData.getOrCreate(key)
    md.window  = {}          -- will be rebuilt by saveLayout()/createChildren()
    md.columns = {}          -- will be rebuilt by buildColumnConfig()
    md.showAll = prevShowAll -- keep current Show all state (set false here if you prefer to force OFF)
    md.rowShow = prevRowShow -- keep per-trap Shown/Hidden choices
    md.sim = { animal=nil, trap=nil, bait=nil, zone=nil, skill=0 }	-- Reset simulator persistence to defaults
    if ModData.transmit then ModData.transmit(key) end

    -- 2) Reset runtime-only flags that should be recalculated
    self.selectedAnimalType = nil
    self.didAutoFit = false

	-- 3) Rebuild column configuration from defaults
	local sumVisible = self:buildColumnConfig()

	-- Override visibility with what is marked in "Default columns"
	if type(TM_ShouldShowColumnOnReset) == "function" then
		for _, c in ipairs(self.columnConfig or {}) do
			local pref = TM_ShouldShowColumnOnReset(c.key)
			if pref ~= nil then
				c.visible = (pref == true)
			end
		end
	end

	-- 3b) Apply to the grid with visibility already updated
	if self.grid then
		self.grid:setColumns(self.columnConfig)
		if self.grid.header then
			self.grid.header.tooltipUI = nil
		end
		self.grid:refresh()
	end

    -- 4) Adjust the width to the sum of the columns to be displayed according to "Default columns"
	do
		local sum = 0
		for _, c in ipairs(self.columnConfig or {}) do
			if c.visible ~= false then
				sum = sum + (tonumber(c.width) or 80)
			end
		end
		self:setWidth(math.max(sum + 12, 200))
	end

    -- 5) Sync the "Show all" button skin to the preserved value
    self._showAll = prevShowAll
    if self.btnShowAll then self:_refreshShowAllButtonSkin() end

    -- 6) Refresh rows (this will respect the preserved per-trap Shown/Hidden)
    self:updateTraps()
    -- Reset simulator UI to defaults and persist
	if self._applySimDefaults then self:_applySimDefaults() end
	if self._layoutSimulatorControls then self:_layoutSimulatorControls() end
    if self._simSaveCurrentToMD then self:_simSaveCurrentToMD() end	
	if self.saveLayout then self:saveLayout() end

    -- 7) Keep titlebar widgets on top (same housekeeping as before)
    if self.resizeWidget  then self.resizeWidget:bringToTop()  end
    if self.resizeWidget2 then self.resizeWidget2:bringToTop() end
    if self.closeButton   then self.closeButton:bringToTop()   end
    if self.collapseButton then self.collapseButton:bringToTop() end
    if self.pinButton     then self.pinButton:bringToTop()     end
    if self.resetButton   then self.resetButton:bringToTop()   end
    if self.fullButton    then self.fullButton:bringToTop()    end
end

function TrapManagerWindow:onClickFull()
    if not self.grid then return end
    -- 1) Make all columns visible in the current runtime
    local cols = self.grid:getColumns()
    for _, c in ipairs(cols) do
        c.visible = true
    end

    -- 2) Refresh the grid
    self.grid:refresh()

    -- 3) Save layout (persists md.columns with visible=true)
    self:saveLayout()

    -- 4) Resize window to fit all columns
    local padding = 12
    local sum = 0
    for _, c in ipairs(cols) do
        if c.visible ~= false then
            sum = sum + (tonumber(c.width) or 80)
        end
    end
    self:setWidth(math.max(sum + padding, 200))

    -- 5) Re-align simulator controls (in case visibility changes)
    if self._layoutSimulatorControls then
        self:_layoutSimulatorControls()
    end
end

-- Build a future hook for wiki-based chances (stub)
local function computeChanceStub(luaObject, animalType, baitName, eligible)
    if not eligible then return "0%" end
    return "-"
end

-- Return localized zone name; includes special handling for "Nav"->"Road".
local function prettyZoneName(z)
    return L_ZoneName(z)
end

-- Build both the calculation zone IDs and the human-readable display tokens.
-- Rules:
--  - If the trap already carries zone(s) (data.zones or data.zone), show those zones by name.
--  - Only when the trap truly has NO zone, display "None -> TownZone" (translated)
--    and use "TownZone" for the math.
--  - As a last resort before TownZone, we read the zone type from the world square
--    (e.g., Nav/Road, Water) to avoid false "None -> TownZone".
local function computeTrapZoneInfo(data)
    -- 1) Gather raw zone ids from modData
    local list = {}
    local hadAny = false

    -- Collect from data.zones (map of zoneId -> true) if present
    if type(data.zones) == "table" then
        for k, _ in pairs(data.zones) do
            local z = tostring(k or "")
            if z ~= "" then
                table.insert(list, z)
            end
        end
        hadAny = (#list > 0)
    end

    -- Fallback to single data.zone if nothing was found yet
    if (not hadAny) and data.zone and tostring(data.zone) ~= "" then
        table.insert(list, tostring(data.zone))
        hadAny = true
    end

    -- Deduplicate + sort for stable output
    if hadAny then
        local set, out = {}, {}
        for _, z in ipairs(list) do
            if not set[z] then set[z] = true; table.insert(out, z) end
        end
        table.sort(out)  -- raw-id sort; display is localized later
        list = out
    end

    -- 2) LAST-RESORT: detect the zone from the world square
    -- Only if we still have no zone information in modData.
    if not hadAny then
        local zx = tonumber(data.x) or 0
        local zy = tonumber(data.y) or 0
        local zz = tonumber(data.z) or 0

        local okCell, cell = pcall(getCell)
        if okCell and cell and cell.getGridSquare then
            local okSq, sq = pcall(cell.getGridSquare, cell, zx, zy, zz)
            if okSq and sq then
                local zType = nil
                -- Preferred: sq:getZone():getType()
                if sq.getZone then
                    local okZ, zObj = pcall(sq.getZone, sq)
                    if okZ and zObj and zObj.getType then
                        local okT, t = pcall(zObj.getType, zObj)
                        if okT and t and t ~= "" then zType = tostring(t) end
                    end
                end
                -- Fallback: some builds expose sq:getZoneType()
                if (not zType) and sq.getZoneType then
                    local okT2, t2 = pcall(sq.getZoneType, sq)
                    if okT2 and t2 and t2 ~= "" then zType = tostring(t2) end
                end

                if zType and zType ~= "" then
                    list   = { zType }
                    hadAny = true
                end
            end
        end
    end

    -- 3) Exceptional case: no zone at all -> use TownZone for calculations
    local usedTownFallback = false
    if not hadAny then
        list = { "TownZone" }      -- used for chance math
        usedTownFallback = true
    end

	-- 4) Build display tokens (localized)
	local displayTokens = {}
	if usedTownFallback then
		-- ANTES: "None -> TownZone"
		-- AHORA: solo "None" (traducido)
		table.insert(displayTokens, _T("UI_TM_None","None"))
	else
		for _, z in ipairs(list) do
			table.insert(displayTokens, L_ZoneName(z))
		end
	end

    return {
        calcZones        = list,               -- canonical ids used for chance math
        displayTokens    = displayTokens,      -- localized names for the Zone column
        usedTownFallback = usedTownFallback,   -- true only when we had no zone and forced TownZone
    }
end

function TrapManagerWindow:updateTraps()
    -- Guard: grid must exist to receive rows
    if not self.grid then return end

    -- Prepare rows and fetch player reference
    local items = {}
    local player = getSpecificPlayer(0)
    if not player then
        self.grid:setItems(items)
        self.grid:refresh()
        return
    end

    local showAll = self:_getShowAll()

    -- Player position (tile + chunk)
    local pX, pY = player:getX(), player:getY()
    local pCX, pCY = chunkOf(math.floor(pX)), chunkOf(math.floor(pY))

    -- Helper: render 0 as "0%"
    local function fmtChancePct(n)
        if not n or n == 0 or n == 0.0 then return "0%" end
        return string.format("%.2f%%", n)
    end

    self.trapSystem = self.trapSystem or TM_GetTrapSystem()

    if self.trapSystem then
        local objectCount = self.trapSystem:getObjectCount()

        for i = 0, objectCount - 1 do
            local obj  = self.trapSystem:getObjectByIndex(i)
            local data = obj and obj:getModData() or nil
            data = TM_RefreshTrapLuaObject(data)
            if data then
                ----------------------------------------------------------------
                -- (1) Geometry, prerequisites and display-friendly labels
                ----------------------------------------------------------------

                -- Float deltas used for angle/distance math in tooltips
                local dxF = (data.x - pX)
                local dyF = (data.y - pY)

                -- Integer deltas for the cell text
                local dx = data.x - math.floor(pX)
                local dy = data.y - math.floor(pY)

                -- Distance in chunks (signed per axis) and distance precondition
                local cDX = chunkOf(data.x) - pCX
                local cDY = chunkOf(data.y) - pCY
                local eligibleDist = (math.abs(cDX) >= 10 or math.abs(cDY) >= 10)

                -- Trap type (canonical) & placer skill
                local trapType      = data.trapType or ""
                local trappingSkill = tonumber(data.trappingSkill) or 0

                -- Destroyed state: special-case bait/eligibility/labels/tooltips
                local isDestroyed = (data.destroyed == true)

                -- Zones present at trap position (calculation + display tokens)
                local zInfo = computeTrapZoneInfo(data)

                -- Column label for Zone:
                -- - If trap had no real zone, show only "None" (localized).
                -- - Otherwise show comma-separated list of zone display names.
                local zoneColumnLabel = (function()
                    if zInfo.usedTownFallback then
                        return _T("UI_TM_None","None")
                    else
                        local parts = {}
                        for _, zid in ipairs(zInfo.calcZones or {}) do
                            table.insert(parts, L_ZoneName(zid))
                        end
                        return table.concat(parts, ", ")
                    end
                end)()

                -- Bait data (canonical) + freshness
                local baitName    = (data.trapBait ~= "" and data.trapBait or "")
                local baitPresent = (baitName ~= "")
                local baitFresh   = (baitPresent and isItemFresh(baitName, data.trapBaitDay)) or false

                -- Destroyed: bait is considered lost / not fresh
                if isDestroyed then
                    baitPresent = false
                    baitFresh   = false
                end

                -- Localized display names for trap & bait (UI only)
                local trapDisp = (trapType ~= "" and L_ItemName(trapType)) or ""
                local baitDisp = (baitPresent and L_ItemName(baitName)) or nil

                -- Bait column label (localized + freshness suffix, or N/A on destroyed)
                local baitLabel
                if isDestroyed then
                    baitLabel = _T("UI_TM_NotAvailable","N/A")
                elseif not baitPresent then
                    baitLabel = tostring(getText("UI_None"))
                else
                    baitLabel = tostring(baitDisp) .. localizedFreshnessSuffix(baitFresh)
                end

                -- Days-Until-Stale (only when there is actual bait)
                local dusText, dusVal = "", nil
                if baitPresent then
                    local daysFreshNum = getItemDaysFresh(baitName) or 0
                    local baitAgeNum   = tonumber(data.trapBaitDay) or 0
                    dusVal  = daysFreshNum - baitAgeNum
                    dusText = string.format("%.2f", dusVal)
                end

                -- Current trapped animal (if any)
                local trappedType = data.animal and data.animal.type or nil
                local trappedNow  = (trappedType ~= nil)

                -- Base eligibility without zone (extended later per-animal)
                local eligibleBase = (eligibleDist and baitPresent and baitFresh)

                -- Capture animalAliveHour always, even with empty trap.
                -- This way we can monitor the vanilla bug that carries this value across catches. 
                local aliveH = tonumber(data.animalAliveHour) or 0

                ----------------------------------------------------------------
                -- (2) One row per candidate animal (valid trap+bait).
                --     IMPORTANT: if there is NO real zone (usedTownFallback),
                --     we DO NOT use TownZone for math; force 0% instead.
                ----------------------------------------------------------------
                local trapAnimals = _G.TrapAnimals
                local anyRow = false

                if (not isDestroyed) and type(trapAnimals) == "table" then
                    for _, v in ipairs(trapAnimals) do
                        local supportsTrap = (v.traps and v.traps[trapType])
                        local supportsBait = (v.baits and baitPresent and v.baits[baitName])

                        if supportsTrap and supportsBait then
                            anyRow = true

                            -- Treat "no real zone" as not listed -> zero chance
                            local usedNoZone = (zInfo.usedTownFallback == true)

                            -- Build calculation zone set:
                            -- - normal case -> the trap's actual zones
                            -- - no-zone     -> empty set to force 0%
                            local calcZones = usedNoZone and {} or (zInfo.calcZones or {})

                            -- Zone listed guard
                            local hasListedZone = false
                            if not usedNoZone and type(v.zone) == "table" then
                                for _, z in ipairs(calcZones) do
                                    if v.zone[z] ~= nil then
                                        hasListedZone = true
                                        break
                                    end
                                end
                            end
                            -- If there is no real zone at all, it's not listed by definition
                            if usedNoZone then hasListedZone = false end

                            -- Extended eligibility: distance & bait freshness & listed zone
                            local eligibleAll = (eligibleBase and hasListedZone)

                            ----------------------------------------------------------------
                            -- Per-zone and combined chance (force 0% when no-zone)
                            ----------------------------------------------------------------
                            local zonesData = {}
                            local bestIdx, bestChance = 1, -1
                            for _, z in ipairs(calcZones) do
                                local pz = estimateChancePercent(v, trapType, z, baitName, trappingSkill) -- %
                                local baseZ = (v.zone and v.zone[z]) or 0
                                table.insert(zonesData, { name = z, base = baseZ, chance = pz })
                                if pz > bestChance then bestChance = pz; bestIdx = #zonesData end
                            end

                            local combinedPct
                            if usedNoZone then
                                combinedPct = 0
                            else
                                local prod = 1.0
                                for _,entry in ipairs(zonesData) do
                                    prod = prod * (1.0 - (entry.chance / 100.0))
                                end
                                combinedPct = math.floor((1.0 - prod) * 10000 + 0.5) / 100
                                if not hasListedZone then combinedPct = 0 end
                            end

                            local zonePrimary = (not usedNoZone and zonesData[bestIdx] and zonesData[bestIdx].name)
                                                or nil

                            -- Localized animal name; mark with "*" if currently trapped family
                            local animalName = displayAnimalType(v.type)
                            local caughtThis = trappedNow and _isTrappedFamily(trappedType, v)
                            if caughtThis then animalName = (RGB_g .. "* ") .. animalName .. RGB_w end

                            -- Formula preview (primary zone) - zero out when no-zone
                            local skillBonus   = trappingSkill * 1.5
                            local trapVal      = (v.traps[trapType] or 0)
                            local baitVal      = (v.baits[baitName] or 0)
                            local zoneValPrim  = (zonePrimary and v.zone and v.zone[zonePrimary]) or 0
                            if usedNoZone then zoneValPrim = 0 end
                            local T1           = trapVal + baitVal + skillBonus
                            local T2           = zoneValPrim + skillBonus
                            local p1           = math.ceil(T1) / 100.0
                            local p2           = math.ceil(T2) / 100.0
                            local primary      = usedNoZone and 0 or math.floor((p1 * p2) * 10000 + 0.5) / 100

                            -- Zones block for Chance tooltip
                            local function buildZonesBlockForTip()
                                if usedNoZone then
                                    -- Explicit "None" with 0% -> NO
                                    return _T("UI_TM_ZoneFactorLine",
                                             "- Zone [%1]: %2%",
                                             _T("UI_TM_None","None"),
                                             "0% -> " .. tostring(RGB_r .. _T("UI_TM_NO","NO") .. RGB_w)) .. " <LINE> "
                                end
                                local lines = {}
                                for _, ZD in ipairs(zonesData) do
                                    local listed = (v.zone and v.zone[ZD.name] ~= nil)
                                    local chS = fmtChancePct(ZD.chance)
                                    if not listed then chS = "0% -> " .. tostring(RGB_r .. _T("UI_TM_NO","NO") .. RGB_w) end
                                    table.insert(lines, _T("UI_TM_ZoneFactorLine",
                                        "- Zone [%1]: %2%", L_ZoneName(ZD.name), chS) .. " <LINE> ")
                                end
                                return table.concat(lines)
                            end

                            -- Combined breakdown text (when multiple zones)
                            local combinedBreakdown = nil
                            if (not usedNoZone) and #zonesData > 1 then
                                local terms = {}
                                for _,ZD in ipairs(zonesData) do
                                    local dec = ZD.chance / 100.0
                                    table.insert(terms, string.format("(1-%.4f)", dec))
                                end
                                combinedBreakdown = string.format("1 - %s = %.2f%%", table.concat(terms, "*"), combinedPct)
                            end

                            -- Chance tooltip (preconditions first when NOT eligible)
                            local timeOK = timeWindowOK(v) and tostring(RGB_g .. _T("UI_TM_OK","OK") .. RGB_w) or tostring(RGB_r .. _T("UI_TM_NO","NO") .. RGB_w)
                            local chanceTooltip
                            if eligibleAll then
                                chanceTooltip = table.concat({
                                    _T("UI_TM_ChanceFactors","Chance Factors:"), " <LINE> ",
                                    _T("UI_TM_TrapFactorLine","- Trap [%1]: %2%", trapDisp, trapVal), " <LINE> ",
                                    _T("UI_TM_BaitFactorLine","- Bait [%1]: %2%", baitDisp or "?", baitVal or 0), " <LINE> ",
                                    _T("UI_TM_SkillFactorLine","- Skill [%1]: +%2% (applied twice)", trappingSkill, string.format("%.1f", skillBonus)), " <LINE> ",
                                    buildZonesBlockForTip(),
                                    _T("UI_TM_TimeWindow", "Time: %1:00 - %2:00 -> %3",
                                       v.minHour or 0, v.maxHour or 0, timeOK), " <LINE> ",
                                    (v.minSize and v.maxSize) and (_T("UI_TM_SizeRange","- Size range: %1 - %2", v.minSize, v.maxSize) .. " <LINE> ") or "",
                                    L_Preconds(eligibleDist, baitPresent, baitFresh, hasListedZone), " <LINE> ",
                                    _T("UI_TM_FormulaPerZone","Formula per zone:"), " <LINE> ",
                                    _T("UI_TM_FormulaEquation", "- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100"), " <LINE> ",
                                    string.format("- Chance = ceil(%.1f)/100 * ceil(%.1f)/100 = %.2f%%", T1, T2, primary)
                                })
                            else
                                chanceTooltip = table.concat({
                                    _T("UI_TM_ChanceFactors","Chance Factors:"), " <LINE> ",
                                    L_Preconds(eligibleDist, baitPresent, baitFresh, hasListedZone), " <LINE> ",
                                    _T("UI_TM_TrapFactorLine","- Trap [%1]: %2%", trapDisp, trapVal), " <LINE> ",
                                    _T("UI_TM_BaitFactorLine","- Bait [%1]: %2%", baitDisp or "?", baitVal or 0), " <LINE> ",
                                    _T("UI_TM_SkillFactorLine","- Skill [%1]: +%2% (applied twice)", trappingSkill, string.format("%.1f", skillBonus)), " <LINE> ",
                                    buildZonesBlockForTip(),
                                    _T("UI_TM_TimeWindow", "Time: %1:00 - %2:00 -> %3",
                                       v.minHour or 0, v.maxHour or 0, timeOK), " <LINE> ",
                                    (v.minSize and v.maxSize) and (_T("UI_TM_SizeRange","- Size range: %1 - %2", v.minSize, v.maxSize) .. " <LINE> ") or "",
                                    _T("UI_TM_FormulaPerZone","Formula per zone:"), " <LINE> ",
                                    _T("UI_TM_FormulaEquation", "- Chance = ceil(Trap + Bait + 1.5*Skill)/100 * ceil(Zone + 1.5*Skill)/100"), " <LINE> ",
                                    string.format("- Chance = ceil(%.1f)/100 * ceil(%.1f)/100 = %.2f%%", T1, T2, primary)
                                })
                            end
                            if combinedBreakdown then
                                chanceTooltip = chanceTooltip .. " <LINE>   "
                                    .. _T("UI_TM_CombinedAllZones","Combined all zones: %1", combinedBreakdown)
                            end

                            -- Extra (optional) columns data
                            local placerName = data.player or getPlayerDisplayName(player)

                            -- Chance text in the cell (force 0% when no real zone)
                            local chanceText = fmtChancePct(combinedPct)

                            -- === Row definition for a candidate animal ===
                            local row = {
                                name     = i + 1,
                                distance = string.format("(%d,%d)", dx, dy),
                                chunks   = string.format("(%d,%d)", cDX, cDY),

                                -- Eligibility: extended rule
                                eligible = eligibleAll and _T("UI_TM_Yes","Yes") or _T("UI_TM_No","No"),

                                animal   = animalName,

                                -- Always show the raw hour counter, even if nothing is trapped (vanilla bug monitoring)
                                aliveHour = tostring(aliveH),

                                trapType = trapDisp,
                                bait     = baitLabel,
                                daysUntilStale = dusText,
                                skill    = tostring(trappingSkill),
                                player   = (placerName or ""),

                                -- Zone column shows names (or "None" when there was no real zone)
                                zone     = zoneColumnLabel,

                                chance   = chanceText,

                                -- world coords (for Vector feature)
                                __wx = data.x,
                                __wy = data.y,
                                __wz = data.z or 0,

                                __colors = {},
                                __tooltips = {
                                    distance = _buildDistanceTooltip(dxF, dyF),
                                    chunks   = _T("UI_TM_ChunksHelp",
                                        "Chunks 8x8 tiles: <LINE> Rule -> trap is far enough if EITHER axis (absolute-value) > 9 chunks"),

                                    eligible = (function()
                                        return L_Preconds(eligibleDist, baitPresent, baitFresh, hasListedZone)
                                    end)(),
									
									-- Trap factors tooltip (localized, marks current trap; includes strength odds if available)
                                    trapType = (function()
                                        local lines = {}
                                        table.insert(lines, _T("UI_TM_TrapFactorsFor","Trap factors for %1:", displayAnimalType(v.type)) .. " <LINE> ")
                                        if v.traps then
                                            local trapsList = {}
                                            for tname, perc in pairs(v.traps) do
                                                table.insert(trapsList, { t=tname, p=tonumber(perc) or 0, disp=L_ItemName(tname) })
                                            end
                                            table.sort(trapsList, function(a, b) return a.p > b.p end)
                                            for _, it in ipairs(trapsList) do
                                                local mark = (trapType and it.t == trapType) and (RGB_g .. "* ") or ""
                                                table.insert(lines, mark .. it.disp .. ": " .. tostring(it.p) .. "% <LINE> ".. RGB_w)
                                            end
                                        end
                                        local strength = nil
                                        if type(_G.Traps) == "table" then
                                            for _, T in ipairs(_G.Traps) do
                                                if T.type == trapType then strength = T.trapStrength; break end
                                            end
                                        end
                                        if type(strength) == "number" and strength > 0 then
                                            local loseBaitProb = 1.0 / (strength + 10.0)
                                            local breakProb    = (1.0 / 40.0) * (1.0 / strength)
                                            table.insert(lines, " <LINE> " .. _T("UI_TM_TrapStrength","Trap strength: %1", strength) .. " <LINE> ")
                                            table.insert(lines, _T("UI_TM_LoseBaitPerHour","- Lose bait per hour: 1/(%1 + 10) = %2%", strength, string.format("%.2f", loseBaitProb * 100.0)) .. " <LINE> ")
                                            table.insert(lines, _T("UI_TM_BreakPerHour","- Break per hour offscreen: 1/(40 * %1) = %2%", strength, string.format("%.2f", breakProb * 100.0)) .. " <LINE> ")
                                        end
                                        return table.concat(lines)
                                    end)(),

                                    -- Bait factors tooltip (localized; marks the picked bait)
                                    bait = (function()
                                        local lines = {}
                                        table.insert(lines, _T("UI_TM_BaitFactorsFor","Bait factors for %1:", displayAnimalType(v.type)) .. " <LINE> ")
                                        if v.baits then
                                            local bList = {}
                                            for bname, perc in pairs(v.baits) do
                                                table.insert(bList, { b = bname, p = tonumber(perc) or 0, disp = L_ItemName(bname) })
                                            end
                                            table.sort(bList, function(a,b) return a.p > b.p end)
                                            for _, it in ipairs(bList) do
                                                local mark = (baitPresent and (it.b == baitName)) and (RGB_g .. "* ") or ""
                                                table.insert(lines, string.format("%s%s: %d%% <LINE> " .. RGB_w, mark, it.disp, it.p))
                                            end
                                        end
                                        if baitPresent then
                                            table.insert(lines, " <LINE> " .. _T("UI_TM_CurrentBait","Current bait: %1",
                                                tostring(baitDisp) .. (baitFresh and localizedFreshnessSuffix(true) or localizedFreshnessSuffix(false))))
                                        else
                                            table.insert(lines, " <LINE> " .. _T("UI_TM_CurrentBait_None","Current bait: None"))
                                        end
                                        return table.concat(lines)
                                    end)(),

                                    -- Days remaining tooltip with explicit numbers
                                    daysUntilStale = (function()
                                        if baitPresent and dusVal ~= nil then
                                            local daysFreshNum = getItemDaysFresh(baitName) or 0
                                            local baitAgeNum   = tonumber(data.trapBaitDay) or 0
                                            return _T("UI_TM_DUS_Tip",
                                                "Days remaining = DaysFresh (%1) - Bait age (%2) = %3",
                                                string.format("%.2f", daysFreshNum),
                                                string.format("%.2f", baitAgeNum),
                                                string.format("%.2f", dusVal))
                                        end
                                        return nil
                                    end)(),

                                    -- Zone tooltip (lists animal zones; marks trap zones). With no real zone, nothing is marked.
                                    zone = (function()
                                        local lines = {}
                                        table.insert(lines, _T("UI_TM_ZoneFactorsFor","Zone factors for %1:", displayAnimalType(v.type)) .. " <LINE> ")
                                        if v.zone then
                                            local inSet = {}
                                            if (not zInfo.usedTownFallback) then
                                                if type(data.zones) == "table" then
                                                    for k,_ in pairs(data.zones) do inSet[tostring(k)] = true end
                                                elseif data.zone then
                                                    inSet[tostring(data.zone)] = true
                                                end
                                            end
                                            local zList = {}
                                            for zname, perc in pairs(v.zone) do
                                                table.insert(zList, { z=zname, p=tonumber(perc) or 0, inTrap=inSet[zname]==true })
                                            end
                                            table.sort(zList, function(a,b) return a.p > b.p end)
                                            for _, it in ipairs(zList) do
                                                local mark = it.inTrap and (RGB_g .. "* ") or ""
                                                table.insert(lines, mark .. L_ZoneName(it.z) .. ": " .. tostring(it.p) .. "% <LINE> " .. RGB_w)
                                            end
                                        end
                                        return table.concat(lines)
                                    end)(),

                                    -- Chance tooltip assembled above
                                    chance = chanceTooltip,
                                },
                            }



                            -- Cell coloring rules
                            if not eligibleDist then
                                row.__colors["chunks"] = {1, 0.2, 0.2, 1}
                            end
                            if (not eligibleBase) or (not hasListedZone) then
                                row.__colors["eligible"] = {1, 0.2, 0.2, 1}
                            end
                            if caughtThis then
                                row.__colors["animal"] = {0.2, 1.0, 0.2, 1}
                            end
                            -- Color Animal "None" in orange to signal no suitable animal
                            if row.animal == tostring(getText("UI_None")) then
                                row.__colors["animal"] = { 1.0, 0.6, 0.2, 1.0 }
                            end
                            if (not baitPresent) or (not baitFresh) then
                                row.__colors["bait"] = {1, 0.2, 0.2, 1}
                            end
                            if baitPresent and dusVal and dusVal < 0 then
                                row.__colors["daysUntilStale"] = {1, 0.2, 0.2, 1}
                            end
                            if not hasListedZone then
                                row.__colors["zone"] = { 1.0, 0.2, 0.2, 1.0 }
                            end

                            -- -- Optional severity coloring for Hours -- TO-DO: check warning ranges, depends on animalType and geneticDisorder
                            -- if aliveH > 30 then
                                -- row.__colors["aliveHour"] = { 1.0, 0.4, 0.4, 1.0 }   -- red
                            -- elseif aliveH > 15 then
                                -- row.__colors["aliveHour"] = { 1.0, 0.6, 0.2, 1.0 }   -- orange
                            -- end

                            -- Per-row Show/Hide state & filtering
                            local rowKey = makeTrapKey(data)
                            local want   = self:_getRowShowWanted(rowKey) -- default true
                            row.__rowKey     = rowKey
                            row.__showWanted = want
                            row.showhide = want and _T("UI_TM_Shown","Shown") or _T("UI_TM_Hidden","Hidden")
                            row.__tooltips.showhide = _T("UI_TM_ShowHideTip",
                                "Click to toggle visibility. When 'Show all' is OFF: Shown=visible, Hidden=hidden.")

                            if showAll or want then
                                table.insert(items, row)
                            end
                        end
                    end
                end

                ----------------------------------------------------------------
                -- (3) Fallback row: no valid animal candidates OR destroyed trap
                ----------------------------------------------------------------
                if (isDestroyed) or (not anyRow) then
                    local animalName = displayAnimalType(trappedType)
                    if trappedNow then animalName = "* " .. animalName end

                    -- Eligibility tooltip: call out destroyed state explicitly
                    local eligTip
                    if isDestroyed then
                        eligTip = _T("UI_TM_DestroyedNoCatch","Trap destroyed: cannot catch animals.")
                                  .. " <LINE> " .. L_Preconds(eligibleDist, false, false)
                    else
                        eligTip = L_Preconds(eligibleDist, baitPresent, baitFresh)
                    end

                    -- Bait tooltip adapted to destroyed state
                    local baitTip
                    if isDestroyed then
                        baitTip = _T("UI_TM_DestroyedLostBait","Destroyed: bait is lost.") .. " <LINE> "
                    else
                        local lines = {}
                        table.insert(lines, _T("UI_TM_BaitFactorsTitle","Bait factors:") .. " <LINE> ")
                        if not baitPresent then
                            table.insert(lines, _T("UI_TM_CurrentBait_None","Current bait: None"))
                        else
                            local label = tostring(baitDisp or "?") ..
                                (baitFresh and localizedFreshnessSuffix(true) or localizedFreshnessSuffix(false))
                            table.insert(lines, _T("UI_TM_CurrentBait","Current bait: %1", label) .. " <LINE> ")
                            if not baitFresh then
                                table.insert(lines, RGB_r .. _T("UI_TM_RottenBaitZero","- Rotten bait. Chance: 0%") .. RGB_w .. " <LINE> ")
                            else
                                table.insert(lines, RGB_r .. _T("UI_TM_BaitNotSuitable","- Bait not suitable for this trap. Chance: 0%") .. RGB_w .. " <LINE> ")
                            end
                        end
                        baitTip = table.concat(lines)
                    end

                    -- Zone tooltip (no arrow to TownZone; show "None" if no real zone)
                    local zoneTip = (function()
                        local lines = {}
                        table.insert(lines, _T("UI_TM_ZoneFactorsTitle","Zone factors:") .. " <LINE> ")
                        if zInfo.usedTownFallback then
                            table.insert(lines, "  " .. _T("UI_TM_None","None") .. " <LINE> ")
                        else
                            for _, zid in ipairs(zInfo.calcZones or {}) do
                                table.insert(lines, "  " .. L_ZoneName(zid) .. " <LINE> ")
                            end
                        end
                        if not isDestroyed then
                            table.insert(lines, " <LINE> " .. RGB_r .. _T("UI_TM_BaitNotSuitable","- Bait not suitable for this trap. Chance: 0%") .. RGB_w .. " <LINE> ")
                        end
                        return table.concat(lines)
                    end)()

                    -- Chance tooltip for fallback row
                    local chanceTip
                    if isDestroyed then
                        chanceTip = _T("UI_TM_DestroyedNoCatch","Trap destroyed: cannot catch animals.")
                    else
                        local zonesHeader
                        if zInfo.usedTownFallback then
                            zonesHeader = _T("UI_TM_ZonesBases","- Zones: %1", _T("UI_TM_None","None"))
                        else
                            zonesHeader = _T("UI_TM_ZonesBases","- Zones: %1", zoneColumnLabel)
                        end
                        chanceTip = _T("UI_TM_ChanceFactors","Chance Factors:") .. " <LINE> "
                                   .. zonesHeader .. " <LINE> "
                                   .. L_Preconds(eligibleDist, baitPresent, baitFresh) .. " <LINE> "
                                   .. _T("UI_TM_CombinedAllZones","Combined all zones: %1", "0%")
                    end

                    local placerName = data.player or getPlayerDisplayName(player)

                    -- === Fallback row definition ===
                    local row = {
                        name     = i + 1,
                        distance = string.format("(%d,%d)", dx, dy),
                        chunks   = string.format("(%d,%d)", cDX, cDY),

                        -- Eligibility: base rule only; destroyed -> "No"
                        eligible = (eligibleBase) and _T("UI_TM_Yes","Yes") or _T("UI_TM_No","No"),

                        trapType = trapDisp,
                        skill    = tostring(trappingSkill),
                        zone     = zoneColumnLabel, -- "None" when no real zone
                        animal   = animalName,

                        -- Show counter on fallback row as well (vanilla bug monitoring)
                        aliveHour = tostring(aliveH),

                        bait     = baitLabel,
                        player   = (placerName or ""),

                        -- Chance cell text: N/A for destroyed, otherwise 0%
                        chance   = (isDestroyed and _T("UI_TM_NotAvailable","N/A") or "0%"),

                        __wx = data.x,
                        __wy = data.y,
                        __wz = data.z or 0,

                        __colors = {},
                        __tooltips = {
                            distance = _buildDistanceTooltip(dxF, dyF),
                            chunks   = _T("UI_TM_ChunksHelp",
                                "Chunks 8x8 tiles: <LINE> Rule -> trap is far enough if EITHER axis (absolute-value) > 9 chunks"),
                            eligible = eligTip,
                            trapType = (function()
                                local lines = {}
                                table.insert(lines, _T("UI_TM_TrapFactors","Trap factors") .. " <LINE> ")
                                if isDestroyed then
                                    table.insert(lines, _T("UI_TM_DestroyedNoCatch","Trap destroyed: cannot catch animals.") .. " <LINE> ")
                                else
                                    table.insert(lines, _T("UI_TM_InvalidBaitNoFactors","- No bait or invalid bait: no per-animal factors") .. " <LINE> ")
                                end
                                -- Trap strength details (if available)
                                local strength = nil
                                if type(_G.Traps) == "table" then
                                    for _, T in ipairs(_G.Traps) do
                                        if T.type == (data.trapType or "") then strength = T.trapStrength; break end
                                    end
                                end
                                if type(strength) == "number" and strength > 0 then
                                    local loseBaitProb = 1.0 / (strength + 10.0)
                                    local breakProb    = (1.0 / 40.0) * (1.0 / strength)
                                    table.insert(lines, " <LINE> " .. _T("UI_TM_TrapStrength","Trap strength: %1", strength) .. " <LINE> ")
                                    table.insert(lines, _T("UI_TM_LoseBaitPerHour","- Lose bait per hour: 1/(%1 + 10) = %2%", strength, string.format("%.2f", loseBaitProb*100.0)) .. " <LINE> ")
                                    table.insert(lines, _T("UI_TM_BreakPerHour","- Break per hour (far from player): 1/(40 * %1) = %2%", strength, string.format("%.2f", breakProb * 100.0)) .. " <LINE> ")
                                end
                                return table.concat(lines)
                            end)(),
                            bait     = baitTip,
                            daysUntilStale = (function()
                                if (not isDestroyed) and baitPresent and dusVal ~= nil then
                                    local daysFreshNum = getItemDaysFresh(baitName) or 0
                                    local baitAgeNum   = tonumber(data.trapBaitDay) or 0
                                    return _T("UI_TM_DUS_Tip",
                                        "Days remaining = DaysFresh (%1) - Bait age (%2) = %3",
                                        string.format("%.2f", daysFreshNum),
                                        string.format("%.2f", baitAgeNum),
                                        string.format("%.2f", dusVal))
                                end
                                return nil
                            end)(),
                            zone     = zoneTip,
                            chance   = chanceTip,
                        },
                    }

                    -- Cell colors (destroyed marked; others as usual)
                    if not eligibleDist then row.__colors["chunks"] = {1, 0.2, 0.2, 1} end
                    if (not eligibleBase) then
                        row.__colors["eligible"] = {1, 0.2, 0.2, 1}
                        if (not isDestroyed) then
                            row.__colors["bait"] = {1, 0.2, 0.2, 1}
                        end
                    end
                    if trappedNow then row.__colors["animal"] = {0.2, 1.0, 0.2, 1} end
                    if isDestroyed then
                        row.__colors["trapType"] = {1, 0.4, 0.4, 1}
                        row.__colors["chance"]   = {1, 0.4, 0.4, 1}
                        row.__colors["eligible"] = {1, 0.2, 0.2, 1}
                    end
                    -- Color Animal "None" in orange to signal no suitable animal
                    if row.animal == tostring(getText("UI_None")) then
                        row.__colors["animal"] = { 1.0, 0.6, 0.2, 1.0 }
                    end
                    -- Keep Bait colored red when bait is present+fresh but unsuitable for any animal
                    do
                        local baitUnsuitable = (not isDestroyed) and baitPresent and baitFresh and (not anyRow)
                        if baitUnsuitable then
                            row.__colors["bait"] = { 1, 0.2, 0.2, 1 }
                        end
                    end

                    -- If NONE of the trap's zones is valid for ANY animal, color Zone ORANGE
                    do
                        local zones = (zInfo and (zInfo.usedTownFallback and {} or zInfo.calcZones)) or {}
                        local TA = _G.TrapAnimals
                        local anySupported = false

                        if type(TA) == "table" then
                            for _, zid in ipairs(zones) do
                                for _, def in ipairs(TA) do
                                    if def.zone and def.zone[zid] ~= nil then
                                        anySupported = true
                                        break
                                    end
                                end
                                if anySupported then break end
                            end
                        end

                        if not anySupported then
                            row.__colors["zone"] = { 1.0, 0.6, 0.2, 1.0 } -- orange
                        end
                    end

                    -- -- Optional severity coloring for Hours -- TO-DO: check warning ranges, depends on animalType and geneticDisorder
                    -- if aliveH > 30 then
                        -- row.__colors["aliveHour"] = { 1.0, 0.4, 0.4, 1.0 }
                    -- elseif aliveH > 15 then
                        -- row.__colors["aliveHour"] = { 1.0, 0.6, 0.2, 1.0 }
                    -- end

                    -- Per-row Show/Hide state & filtering
                    local rowKey = makeTrapKey(data)
                    local want   = self:_getRowShowWanted(rowKey)
                    row.__rowKey     = rowKey
                    row.__showWanted = want
                    row.showhide = want and _T("UI_TM_Shown","Shown") or _T("UI_TM_Hidden","Hidden")

                    if showAll or want then
                        table.insert(items, row)
                    end
                end
            end
        end
    end

    ------------------------------------------------------------------------
    -- (4) Push rows to grid and repaint (with one-time auto-fit height)
    ------------------------------------------------------------------------
    self.grid:setItems(items)
    self.grid:refresh()

    -- First-open auto-fit: header + rows without exceeding 600px client
    if not self.didAutoFit then
        local titleH  = self:titleBarHeight()
        local resizeH = self:resizeWidgetHeight()
        local rowH    = self.grid.list.itemheight or 22
        local desiredClient = self.grid.headerHeight + (#items + 1) * rowH
        local maxClient     = 600 - titleH - resizeH
        local newClient     = math.min(desiredClient, maxClient)
        self:setHeight(newClient + titleH + resizeH)
        self.didAutoFit = true
    end
end

function TrapManagerWindow:prerender()
    -- Let the parent draw frame, title bar and status bar first
    ISCollapsableWindow.prerender(self)
	
	-- Keep min-height up to date in case fonts/UI scale/rows change dynamically.
    if self._recalcMinHeight then self:_recalcMinHeight() end

    -- Recompute client rects considering the simulator row
    if self.grid then
        local th  = self:titleBarHeight()
        local rh  = self:resizeWidgetHeight()
        local simH = self.simRow and self.simRow.height or 0

        -- Grow/shrink simRow width to match window
        if self.simRow then
            if self.simRow.x ~= 0 or self.simRow.y ~= th or self.simRow.width ~= self.width then
                self.simRow:setX(0); self.simRow:setY(th); self.simRow:setWidth(self.width)
            end
        end

        local gh  = math.max(0, self.height - th - rh - simH)
        local gy  = th + simH

		if self.grid.y ~= gy or self.grid.height ~= gh or self.grid.width ~= self.width then
			self.grid:setX(0)
			self.grid:setY(gy)
			self.grid:setWidth(self.width)
			self.grid:setHeight(gh)
		end

        -- Keep simulator controls aligned to visible columns
        self:_layoutSimulatorControls()
    end

    -- Recalculate Chance and refresh simulator tooltips if any selection changed
    do
        self._simLast = self._simLast or {}
        local la = _cbText(self.simAnimal); if la == ChooseText() then la=nil end
        local lt = _cbText(self.simTrap);   if lt == ChooseText() then lt=nil end
        local lb = _cbText(self.simBait);   if lb == ChooseText() then lb=nil end
        local lz = _cbText(self.simZone);   if lz == ChooseText() then lz=nil end
        local ls = _cbText(self.simSkill)

        if la ~= self._simLast.a or lt ~= self._simLast.t or lb ~= self._simLast.b or
           lz ~= self._simLast.z or ls ~= self._simLast.s then
            -- Update chance label (number)
            if self._simUpdateChance then self:_simUpdateChance() end
            -- Update tooltips to match current selections
            if self._simRefreshTooltips then self:_simRefreshTooltips() end
			
			if self._applyButtonTooltips then self:_applyButtonTooltips(false) end

            -- Remember last snapshot
            self._simLast.a, self._simLast.t, self._simLast.b = la, lt, lb
            self._simLast.z, self._simLast.s = lz, ls
        end
    end

    -- Z-order: show simulator + its dropdowns above the grid
    if self.simRow then
        self.simRow:bringToTop()
        if self.simAnimal    then self.simAnimal:bringToTop()    end
        if self.simTrap      then self.simTrap:bringToTop()      end
        if self.simBait      then self.simBait:bringToTop()      end
        if self.simSkill     then self.simSkill:bringToTop()     end
        if self.simZone      then self.simZone:bringToTop()      end
        if self.simChanceBtn then self.simChanceBtn:bringToTop() end
    end

    -- Keep resize widgets above everything else
    if self.resizeWidget  then self.resizeWidget:bringToTop()  end
    if self.resizeWidget2 then self.resizeWidget2:bringToTop() end

    -- Keep Reset/Full aligned 5px from pin button regardless of language/font
	self._lastW = self._lastW or self.width
	if self.width ~= self._lastW then
		if self._layoutTitlebarRightButtons then
			self:_layoutTitlebarRightButtons()
		end
	end
	self._lastW = self.width

    -- Re-enforce min width and title visibility on every frame
    if self._updateTitleVisibilityAndMinWidth then
        self:_updateTitleVisibilityAndMinWidth()
    end
	
	if self._bringTooltipsToTop then self:_bringTooltipsToTop() end
	if self._reinforceTooltipOpacity then self:_reinforceTooltipOpacity() end

	-- Kill any header/simulator leftover tooltips when verbose tips are OFF
	if self._clearVerboseOnlyTooltips then self:_clearVerboseOnlyTooltips() end

    -- Keep hourly subscription aligned with Mod Options at runtime
    if self.updateHourlySubscriptionFromOptions then
        self:updateHourlySubscriptionFromOptions()
    end
	
	-- Keep button descriptions in sync with preferences (ON/OFF)
    if self._applyButtonTooltips then self:_applyButtonTooltips(false) end

end

-- Serialize current window rect and columns into ModData
function TrapManagerWindow:saveLayout()
    local md = TM_LoadSettings()

    -- Window rect
    md.window = {
        x = self:getX(),
        y = self:getY(),
        w = self:getWidth(),
        h = self:getHeight(),
    }

    -- Columns (map by key)
    md.columns = md.columns or {}
    local cols = self.grid and self.grid:getColumns() or self.columnConfig or {}
    for _,c in ipairs(cols) do
        md.columns[c.key] = { width = c.width, visible = (c.visible ~= false) }
    end

    TM_SaveSettings(md)
end

-- Start move only if drag begins on the title bar
function TrapManagerWindow:onMouseDown(x, y)
    if not self:getIsVisible() then return end

    local rh = self:resizeWidgetHeight()
    local th = self:titleBarHeight()

    -- Forward clicks on the status bar to the appropriate resize widget
    if y >= self.height - rh then
        if x >= self.width - rh then
            local lx = x - self.resizeWidget.x
            local ly = y - self.resizeWidget.y
            TMLog("forward corner onMouseDown")
            self.resizeWidget:onMouseDown(lx, ly)
        else
            local lx = x - self.resizeWidget2.x
            local ly = y - self.resizeWidget2.y
            TMLog("forward bottom onMouseDown")
            self.resizeWidget2:onMouseDown(lx, ly)
        end
        self.moving = false
        return
    end

    -- Only allow moving when pressed on the title bar
    if y <= th then
        self._dragFromTitle = true
        ISCollapsableWindow.onMouseDown(self, x, y)
    else
        self._dragFromTitle = false
        self.moving = false
    end
end

function TrapManagerWindow:onGridMouseDown(grid, x, y)
    TMLog("GRID onMouseDown localY="..tostring(y).." h="..tostring(grid.height))
end

-- Do not allow move if drag did not start on title
function TrapManagerWindow:onMouseMove(dx, dy)
    if self.moving and not self._dragFromTitle then
        self.moving = false
    end
    ISCollapsableWindow.onMouseMove(self, dx, dy)
end

function TrapManagerWindow:onMouseUp(x, y)
    self._dragFromTitle = false
    ISCollapsableWindow.onMouseUp(self, x, y)
    self:saveLayout()
end

function TrapManagerWindow:onMouseUpOutside(x, y)
    self._dragFromTitle = false
    ISCollapsableWindow.onMouseUpOutside(self, x, y)
    self:saveLayout()
end

function TrapManagerWindow:close()
    self:unsubscribeHourly()
    self:saveLayout()

    -- Unsubscribe from Auto OnTick while the window is closed,
    -- but DO NOT change self._autoEnabled or ModData preference.
    if self._autoHandler and self._autoSubscribed then
        Events.OnTick.Remove(self._autoHandler)
        self._autoSubscribed = false
        self._autoCounter = 0
    end

    ISCollapsableWindow.close(self)
end

----------------------------------------------------------------
-- TM DEBUG helpers and small hooks
----------------------------------------------------------------

-- Geometry logger on width/height change
if not TrapManagerWindow._TM_geom_wrap then
    TrapManagerWindow._TM_geom_wrap = true

    local function logGeom(self, tag)
        local th = self:titleBarHeight()
        local rh = self:resizeWidgetHeight()
        TMLog(tag .. " win pos=("..self.x..","..self.y..") size=("..self.width.."x"..self.height..") th="..th.." rh="..rh)
        if self.grid then
            TMLog(tag .. " grid abs=("..self.grid:getAbsoluteX()..","..self.grid:getAbsoluteY()..") size=("..self.grid.width.."x"..self.grid.height..")")
        end
        if self.resizeWidget2 then
            TMLog(tag .. " rwBottom abs=("..self.resizeWidget2:getAbsoluteX()..","..self.resizeWidget2:getAbsoluteY()..") size=("..self.resizeWidget2.width.."x"..self.resizeWidget2.height..")")
        end
    end

	-- --- Clamp width before applying it (symmetric to setHeight clamp) ---
	local _sw = TrapManagerWindow.setWidth
	function TrapManagerWindow:setWidth(w)
		-- Clamp to computed minimum width when available.
		if type(self._minWindowWidth) == "number" then
			w = math.max(w, self._minWindowWidth)
		end
		_sw(self, w)

		-- Keep right-side titlebar buttons aligned and title visibility up-to-date.
		if self._layoutTitlebarRightButtons then self:_layoutTitlebarRightButtons() end
		if self._updateTitleVisibilityAndMinWidth then self:_updateTitleVisibilityAndMinWidth() end

		-- Optional geometry log
		TMLog("setWidth clamped="..tostring(w))
		if logGeom then logGeom(self, "setWidth") end
	end
end
