-- Sablerock
-- Puncture.lua
-- Dont steal!! Just ask.

FS25_Puncture = FS25_Puncture or {}
_G.FS25_Puncture = FS25_Puncture -- This makes it visible to the whole game

Puncture = Puncture or {}
_G.Puncture = Puncture

-- Puncture = {}

local modDir  = g_currentModDirectory
local modName = g_currentModName or "FS25_Puncture"

Puncture.vehicles = {}
Puncture.debug = false
Puncture.farmStartPeriod = nil
Puncture.farmTotalCount = 0
Puncture.puncturedThisPeriod = nil
Puncture.tickover = false
Puncture.warningIcon = modDir .. "modules/Puncture/Icons/warning_yellow.png"
Puncture.overlay = Overlay.new(Puncture.warningIcon, 0, 0, 0, 0)


local function log(fmt, ...)
    if Puncture.debug then
        print(("[FS25_Puncture] " .. fmt):format(...))
    end
end

local random = math.random
local max    = math.max
local abs    = math.abs

local function dumpTable(name, t, maxDepth, depth, visited)
    depth    = depth or 0
    maxDepth = maxDepth or 3
    visited  = visited or {}

    -- Not a table
    if type(t) ~= "table" then
        log("%s = %s (type=%s, not a table)", name, tostring(t), type(t))
        return
    end

    -- Already seen
    if visited[t] then
        log("%s = %s (already visited)", name, tostring(t))
        return
    end
    visited[t] = true

    -- Header
    log("==== table: %s", tostring(name))
    if depth >= maxDepth then
        log("%s (maxDepth %d reached, not descending further)", name, maxDepth)
        return
    end

    local hasEntries = false

    for k, v in pairs(t) do
        hasEntries = true
        local keyName = string.format("%s[%s]", name, tostring(k))
        if type(v) == "table" then
			log(">>>> sub-table following")
		
            dumpTable(keyName, v, maxDepth, depth + 1, visited)
        else
            log("%s = %s", keyName, tostring(v))
        end
    end

    if not hasEntries then
        log("%s is an empty table (no Lua-visible keys)", name)
    end
end


function FS25_Puncture.registerEventListeners(vehicleType)
    
    SpecializationUtil.registerEventListener(vehicleType, "onLoad", FS25_Puncture)
    SpecializationUtil.registerEventListener(vehicleType, "onPostLoad", FS25_Puncture)
    SpecializationUtil.registerEventListener(vehicleType, "onUpdate", FS25_Puncture)
    SpecializationUtil.registerEventListener(vehicleType, "onPostUpdate", FS25_Puncture)
    SpecializationUtil.registerEventListener(vehicleType, "onEnterVehicle", FS25_Puncture)
    -- SpecializationUtil.registerEventListener(vehicleType, "onReadStream", FS25_Puncture)
    -- SpecializationUtil.registerEventListener(vehicleType, "onWriteStream", FS25_Puncture)
    
	SpecializationUtil.registerEventListener(vehicleType, "saveToXMLFile", FS25_Puncture)	
	SpecializationUtil.registerEventListener(vehicleType, "onDeactivate", FS25_Puncture)	
	SpecializationUtil.registerEventListener(vehicleType, "onDraw", FS25_Puncture)	
end

function FS25_Puncture:onPostUpdate(dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
end

function FS25_Puncture.showCenterWarning(self, text, duration)
-- Store this in the main mod table so every vehicle can see it
    FS25_Puncture.activeWarning = {
        text = text,
        expiry = g_currentMission.time + (duration or 3000)
    }
	-- local spec = self.spec_puncture
    -- if not spec then return end
	-- spec.warningText = text
	-- spec.warningTimer = g_currentMission.time + (duration or 3000)   
	log("FS25_Puncture.showCenterWarning text=%s", text)
	return 
end


function FS25_Puncture:onEnterVehicle(isControlling)
    if not isControlling then log("FS25_Puncture:onEnterVehicle not isControlling") end
    FS25_Puncture.activeWarning = {
        text = nil,
        expiry = 0
    }
	
    local spec = self.spec_puncture
    if spec ~= nil and spec.active then
        local pos = spec.tyrePosition or "UNKNOWN"
        local pType = (spec.punctureType == 1) and g_i18n:getText("puncture_slowLeak") or g_i18n:getText("puncture_burst")
        
        log("FS25_Puncture:onEnterVehicle pos=%s pType=%s", pos, pType)
        -- local msg = string.format(g_i18n:getText("puncture_warning"), g_i18n:getText(pos), pType)
        local msg = g_i18n:getText("puncture_detected")
	  -- Reset the choice when someone gets in
		self.spec_puncture.playerSaidNo = false
		self.spec_puncture.dialogTriggered = false -- Reset the 4s delay too 
	  -- only show message if new vehicle entered
        -- if not spec_alreadyinside then
		FS25_Puncture.showCenterWarning(self, msg, 4000)
		-- end
    end
end

--==================================================
-- SAFE HELPERS
--==================================================

local function safeGetShaderParameter(node, name)
    if type(node) ~= "number" or node == 0 or name == nil then return nil end
    local ok, x, y, z, w = pcall(getShaderParameter, node, name)
    if not ok then return nil end
    return x, y, z, w
end

local function safeSetShaderParameter(node, name, x, y, z, w)
    if type(node) ~= "number" or node == 0 or name == nil then return end
    pcall(setShaderParameter, node, name, x or 0, y or 0, z or 0, w or 0, false)
end

local function nodeExists(node)
    if type(node) ~= "number" or node == 0 then return false end
    if entityExists ~= nil then
        local ok, e = pcall(entityExists, node)
        return ok and e == true
    end
    return true
end

local function isShapeNode(node)
    if not nodeExists(node) or getHasClassId == nil or ClassIds == nil or ClassIds.SHAPE == nil then return false end
    local ok, has = pcall(getHasClassId, node, ClassIds.SHAPE)
    return ok and has == true
end

local function collectShapes(rootNode, out)
    if rootNode == nil or out == nil then return end
    if isShapeNode(rootNode) then
        table.insert(out, rootNode)
        return
    end
    if getNumOfChildren == nil then return end
    local ok, n = pcall(getNumOfChildren, rootNode)
    if not ok or n == nil then return end
    for i = 0, n - 1 do
        local ok2, child = pcall(getChildAt, rootNode, i)
        if ok2 and child ~= nil then collectShapes(child, out) end
    end
end

local function resolveNode(v)
    if v == nil then return nil end
    if type(v) == "number" then return v end
    if type(v) == "table" then
        return resolveNode(v.node) or resolveNode(v.nodeId)
            or resolveNode(v.transformId) or resolveNode(v.shape)
            or resolveNode(v.i3dNode)
    end
    return nil
end

local function getVisualPartNode(vp)
    if vp == nil then return nil end
    return resolveNode(vp.node) or resolveNode(vp.rootNode)
        or resolveNode(vp.visualNode) or resolveNode(vp.transform)
        or resolveNode(vp.i3dNode)
end

--==================================================
-- CONSTANTS
--==================================================

local SHADER_PARAM          = "morphPos"
local SHADER_PARAM_PREV     = "prevMorphPos"
local OVERRIDE_Z            = 0.85
local DELTA_Y_MAX_ADD_FRONT = 0.06

-- Per-size config keyed on physics.radius AND physics.width (both metres).
-- flatWMult : origW * this = flatW at full deflation
-- radiusFrac: fraction of radius to reduce physics.radius by
-- dynBuffer : extra drop added on top of radDrop
-- ORDER MATTERS: checked top-to-bottom, first match wins.
-- More specific (narrower width range) entries must come BEFORE broader ones.
local REAR_TYRE_CONFIGS = {
    -- Car/small (radius < 0.75m): e.g. 235/55R18, ATV
    { minR=0.00, maxR=0.75, minW=0.00, maxW=99.0, flatWMult=1.5, radiusFrac=0.05, deltaYAdd=0.02, tiltComp=0.00, dynBuffer=0.02 },

    -- 34" narrow (width < 0.55m): e.g. 480/70R34, 520/70R34
    { minR=0.75, maxR=0.92, minW=0.00, maxW=0.55, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.06 },
    -- 34" mid (width 0.55-0.70m): e.g. 580/70R34, 600/70R34
    { minR=0.75, maxR=0.92, minW=0.55, maxW=0.70, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.06 },
    -- 26-28" wide (width 0.70-0.90m): e.g. 750/65R26 — small origW needs high flatWMult
    { minR=0.75, maxR=0.92, minW=0.70, maxW=0.90, flatWMult=10.0, radiusFrac=0.10, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.06 },
    -- 34" extra wide (width > 0.90m): e.g. 1050/50R32
    { minR=0.75, maxR=0.92, minW=0.90, maxW=99.0, flatWMult=7.0, radiusFrac=0.10, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.10 },

    -- 46-50" narrow (width 0.45-0.55m): e.g. 480/80R46, 480/80R50 — high origW, low mult
    { minR=0.92, maxR=1.15, minW=0.45, maxW=0.55, flatWMult=2.5, radiusFrac=0.08, deltaYAdd=0.18, tiltComp=0.00, dynBuffer=0.00 },
    -- 38" narrow (width < 0.45m): e.g. 420/85R38, 460/85R38
    { minR=0.92, maxR=1.10, minW=0.00, maxW=0.45, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.18, tiltComp=0.00, dynBuffer=0.06 },
    -- 38" mid (width 0.55-0.70m): e.g. 520/70R38, 540/65R38, 580/70R38
    { minR=0.92, maxR=1.10, minW=0.55, maxW=0.70, flatWMult=5.5, radiusFrac=0.10, deltaYAdd=0.22, tiltComp=0.00, dynBuffer=0.06 },
    -- 38" wide (width 0.70-0.90m): e.g. 650/75R38, 710/70R38, 800/70R38
    { minR=0.92, maxR=1.10, minW=0.70, maxW=0.90, flatWMult=6.0, radiusFrac=0.10, deltaYAdd=0.22, tiltComp=0.00, dynBuffer=0.06 },
    -- 38" extra wide (width > 0.90m): e.g. 1050/50R38 on harvesters
    { minR=0.92, maxR=1.10, minW=0.90, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.22, tiltComp=0.00, dynBuffer=0.10 },

    -- 42" all widths: e.g. 710/75R42, 650/85R42
    { minR=1.10, maxR=1.25, minW=0.00, maxW=99.0, flatWMult=5.5, radiusFrac=0.10, deltaYAdd=0.20, tiltComp=0.00, dynBuffer=0.06 },
}

-- FRONT tyre configs — completely separate from rear.
-- radiusFrac and dynBuffer tuned independently from rear.
-- ORDER MATTERS: more specific entries must come before broader ones.
local FRONT_TYRE_CONFIGS = {
    -- Car/small (radius < 0.60m): e.g. 235/55R18, pickups, vans
    { minR=0.00, maxR=0.60, minW=0.00, maxW=99.0, flatWMult=1.5, radiusFrac=0.05, deltaYAdd=0.02, tiltComp=0.10, dynBuffer=0.02 },

    -- 28-30" front (radius 0.60-0.80m): e.g. 480/70R28, 600/70R30
    { minR=0.60, maxR=0.80, minW=0.00, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.07 },
	
    -- 34" extra wide (width > 0.90m): e.g. 1050/50R32
    { minR=0.75, maxR=0.92, minW=0.90, maxW=99.0, flatWMult=7.0, radiusFrac=0.10, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.10 },
	
    -- 34" front (radius 0.80-0.92m): e.g. 600/65R34, 650/60R34
    { minR=0.80, maxR=0.92, minW=0.00, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.06 },

    -- 46-50" front narrow (width 0.45-0.55m): e.g. 480/80R46, 480/80R50 — high origW, low mult
    { minR=0.92, maxR=1.15, minW=0.45, maxW=0.55, flatWMult=2.5, radiusFrac=0.08, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.00 },
    -- 38" front extra wide (width > 0.90m): e.g. 1050/50R32 on harvesters
    { minR=0.92, maxR=1.10, minW=0.90, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.10 },
    -- 38" front (width 0.55-0.90m): e.g. 600/70R38, 650/65R38
    { minR=0.92, maxR=1.10, minW=0.00, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.07 },

    -- 42"+ front (radius > 1.10m)
    { minR=1.10, maxR=99.0, minW=0.00, maxW=99.0, flatWMult=5.0, radiusFrac=0.10, deltaYAdd=0.06, tiltComp=0.40, dynBuffer=0.06 },
}

local TYRE_CONFIG_FALLBACK = { flatWMult=5.0, radiusFrac=0.08, deltaYAdd=0.08, tiltComp=0.00, dynBuffer=0.06 }

local function getTyreConfig(radius, width, isFront)
    local configs = isFront and FRONT_TYRE_CONFIGS or REAR_TYRE_CONFIGS
    for _, cfg in ipairs(configs) do
        if radius >= cfg.minR and radius < cfg.maxR
        and width  >= cfg.minW and width  < cfg.maxW then
            log("TyreConfig match: radius=%.4f width=%.4f -> flatWMult=%.1f radiusFrac=%.3f deltaYAdd=%.3f",
                radius, width, cfg.flatWMult, cfg.radiusFrac, cfg.deltaYAdd)
            return cfg
        end
    end
    log("TyreConfig FALLBACK for radius=%.4f width=%.4f", radius, width)
    return TYRE_CONFIG_FALLBACK
end


-- TEMP: set true to only ever puncture a rear wheel for testing.
-- Set false before release.
-- local TEST_REAR_ONLY = false
-- local TEST_FRONT_ONLY = true

--==================================================
-- VEHICLE DATA
--==================================================

local function getVehicleData(vehicle)
    local id = vehicle.uniqueId
    if Puncture.vehicles[id] == nil then
        Puncture.vehicles[id] = {
            active          = false,
            wheelIndex      = nil,
            punctureType    = nil,
            pressure        = 1.0,
            alpha           = 0.0,
            tireVisualParts = {},
            cacheBuilt      = false,
            puncturedPhys   = nil,
            puncturedWheel  = nil,
            oppositePhys    = nil,
            isFront         = false,
            logCount        = 0,
            sinkLogged      = false,
            tyrePosition    = "",
        }
    end
    return Puncture.vehicles[id]
end

--==================================================
-- TIRE CACHE (punctured wheel only)
--==================================================

local function buildTireCacheForWheel(wheel, data, isFront)
    local tireParts = {}
    local seen      = {}
    local cfg       = (data.puncturedPhys and data.puncturedPhys.cfg) or TYRE_CONFIG_FALLBACK
    local multiplier = cfg.flatWMult
    local floor      = isFront and 0.10 or 0.12

    local function addTirePart(vp)
        if vp == nil then return end
        if vp.isa == nil or not vp:isa(WheelVisualPartTire) then return end
        if seen[vp] then return end
        seen[vp] = true

        local rootNode = getVisualPartNode(vp)
        local shapes   = {}
        if rootNode ~= nil then collectShapes(rootNode, shapes) end

        local shapeData = {}
        for _, shape in ipairs(shapes) do
            local ox, oy, oz, ow = safeGetShaderParameter(shape, SHADER_PARAM)
            if ox ~= nil then
                local flatW = math.max(ow * multiplier, floor)
                log("Cached shape '%s' node=%d: origW=%.4f flatW=%.4f (isFront=%s)",
                    getName(shape), shape, ow, flatW, tostring(isFront))
                table.insert(shapeData, {
                    node  = shape,
                    origX = ox,
                    origY = oy,
                    origZ = oz,
                    origW = ow,
                    flatW = flatW,
                })
            end
        end

        if #shapeData > 0 then
            table.insert(tireParts, { shapeData = shapeData })
        end
    end

    local function scanVW(vw)
        if vw ~= nil and vw.visualParts ~= nil then
            for _, vp in ipairs(vw.visualParts) do addTirePart(vp) end
        end
    end

    if wheel.visualWheel  ~= nil then scanVW(wheel.visualWheel) end
    if wheel.visualWheels ~= nil then
        for _, vw in ipairs(wheel.visualWheels) do scanVW(vw) end
    end
    if wheel.visualParts ~= nil then
        for _, vp in ipairs(wheel.visualParts) do addTirePart(vp) end
    end

    -- Fallback: if no visual parts found, scan repr node directly for shader shapes
    -- This handles front wheels that don't use WheelVisualPartTire
    if #tireParts == 0 and wheel.repr ~= nil then
        local shapes = {}
        collectShapes(wheel.repr, shapes)
        local shapeData = {}
        for _, shape in ipairs(shapes) do
            local ox, oy, oz, ow = safeGetShaderParameter(shape, SHADER_PARAM)
            if ox ~= nil then
                local flatW = math.max(ow * multiplier, floor)
                log("Cached repr shape '%s' node=%d: origW=%.4f flatW=%.4f",
                    getName(shape), shape, ow, flatW)
                table.insert(shapeData, {
                    node=shape, origX=ox, origY=oy, origZ=oz, origW=ow, flatW=flatW
                })
            end
        end
        if #shapeData > 0 then
            table.insert(tireParts, { shapeData = shapeData })
            log("Cached %d shapes from repr node fallback", #shapeData)
        end
    end

    data.tireVisualParts = tireParts
    data.tyrePosition    = ""

    if #tireParts > 1 then
        -- Sort by abs(X) of shape node's grandparent — outermost (largest abs X) first.
        local function getPartAbsX(part)
            if part.shapeData and part.shapeData[1] then
                local node = part.shapeData[1].node
                local p1 = getParent(node)
                local p2 = (p1 ~= nil and p1 ~= 0) and getParent(p1) or nil
                local check = p2 or p1 or node
                if check ~= nil then
                    local tx, _, _ = getTranslation(check)
                    return math.abs(tx or 0)
                end
            end
            return 0
        end
        table.sort(tireParts, function(a, b)
            return getPartAbsX(a) > getPartAbsX(b)
        end)

        local picked = random(1, #tireParts)
        data.tireVisualParts = { tireParts[picked] }
		-- Map the picked index to your L10n keys
        if #tireParts == 2 then
            data.tyrePosition = (picked == 1) and "puncture_outer" or "puncture_inner"
        elseif #tireParts == 3 then
            if picked == 1 then 
                data.tyrePosition = "puncture_outer"
            elseif picked == 2 then 
                data.tyrePosition = "puncture_middle"
            else 
                data.tyrePosition = "puncture_inner"
            end
        end    
		log("buildTireCacheForWheel: %d visual parts, picked=%d (%s)", #tireParts, picked, data.tyrePosition)
    else
		-- If it's a single tire, we leave it empty so the message just says "front left tire"
		data.tyrePosition = ""
        log("buildTireCacheForWheel: %d visual parts", #tireParts)
    end
    data.cacheBuilt = (#data.tireVisualParts > 0)
end

--==================================================
-- WHEEL POSITION LABEL
--==================================================

local function getWheelPositionLabel(vehicle, wheelIndex)
    local wheelsSpec = vehicle.spec_wheels
    if wheelsSpec == nil or wheelsSpec.wheels == nil then return "unknown", "unknown", false end

    local minZ, maxZ = math.huge, -math.huge
    for _, w in ipairs(wheelsSpec.wheels) do
        -- Use startPositionZ if set, otherwise fall back to physics.positionZ
        local z = (w.startPositionZ ~= nil and w.startPositionZ ~= 0) and w.startPositionZ
                  or (w.physics and w.physics.positionZ) or 0
        if z < minZ then minZ = z end
        if z > maxZ then maxZ = z end
    end
    local midZ = (minZ + maxZ) * 0.5

    local thisWheel = wheelsSpec.wheels[wheelIndex]
    if thisWheel == nil then return "unknown", "unknown", false end

    local posX = (thisWheel.startPositionX ~= nil and thisWheel.startPositionX ~= 0) and thisWheel.startPositionX
                 or (thisWheel.physics and thisWheel.physics.positionX) or 0
    local posZ = (thisWheel.startPositionZ ~= nil and thisWheel.startPositionZ ~= 0) and thisWheel.startPositionZ
                 or (thisWheel.physics and thisWheel.physics.positionZ) or 0

    local isFront = (posZ > midZ)
    -- Use isLeft flag for reliable left/right — posX varies across manufacturers
    local isRight = (thisWheel.isLeft == false)
    local axle    = isFront and "puncture_front" or "puncture_rear"
    local lat     = isRight and "puncture_right" or "puncture_left"

    log("Wheel isFront=%s %d: posX=%.3f posZ=%.3f midZ=%.3f -> %s %s", isFront, wheelIndex, posX, posZ, midZ, lat, axle)

    return lat, axle, isFront
end

--==================================================
-- BUILD CACHES
--==================================================

local function buildCaches(vehicle, data)
    local wheelsSpec = vehicle.spec_wheels
    if wheelsSpec == nil or wheelsSpec.wheels == nil then
        log("buildCaches: no spec_wheels")
        return false
    end

    local wheel = wheelsSpec.wheels[data.wheelIndex]
    if wheel == nil then
        log("buildCaches: wheel %d not found", data.wheelIndex)
        return false
    end

    data.puncturedWheel = wheel
	-- 1. Capture all three return values from your position helper
    local lat, axle, isFront = getWheelPositionLabel(vehicle, data.wheelIndex) 
	
	-- 2. Store them in the data (spec) table as L10n keys
    data.isFront    = (isFront == true)
    data.l10n_axle  = axle  -- Stores "puncture_front" or "puncture_rear"
    data.l10n_lat   = lat   -- Stores "puncture_left" or "puncture_right"	

    -- TEMP DIAGNOSTIC
    log("=== WHEEL %d FIELDS ===", data.wheelIndex)
    for k, v in pairs(wheel) do
        local t = type(v)
        if t == "number" or t == "string" or t == "boolean" then
            log("  wheel.%s = %s", tostring(k), tostring(v))
        elseif t == "table" then
            log("  wheel.%s = [table]", tostring(k))
        end
    end
    if wheel.physics ~= nil then
        log("  --- wheel.physics fields ---")
        for k, v in pairs(wheel.physics) do
            local t = type(v)
            if t == "number" or t == "string" or t == "boolean" then
                log("  physics.%s = %s", tostring(k), tostring(v))
            end
        end
    end
    log("=== END WHEEL FIELDS ===")

    if wheel.physics ~= nil then
        local p   = wheel.physics
        local r0  = p.radiusOriginal or p.radius
        local dy0 = p.deltaYOriginal or p.deltaY or 0
        local w0  = p.width or 0
        local cfg = getTyreConfig(r0, w0, data.isFront)

        -- dynDY compensates for radius reduction so tyre stays on ground.
        -- Cap total (dy0 + dynDY) to 0.30 to prevent sinking on tractors
        -- with high dy0 (e.g. JD 9RX dy0=0.176 vs typical 0.105).
        local radDrop  = r0 * cfg.radiusFrac
        local rawDynDY = radDrop + (cfg.dynBuffer or cfg.deltaYAdd)
        local maxTotal = 0.30
        local dynDY    = math.min(rawDynDY, math.max(radDrop, maxTotal - dy0))
        log("Wheel %d: radius=%.4f width=%.4f deltaY=%.4f axleH=%.4f dynDY=%.4f",
            data.wheelIndex, r0, w0, dy0, wheel.startPositionY or 0, dynDY)
        data.puncturedPhys = { physics = p, r0 = r0, dy0 = dy0, cfg = cfg, dynDY = dynDY }
    end

    -- Find opposite wheel on same axle for tilt compensation
    data.oppositePhys = nil
    local thisZ = (wheel.startPositionZ ~= nil and wheel.startPositionZ ~= 0) and wheel.startPositionZ
                  or (wheel.physics and wheel.physics.positionZ) or 0
    local thisX = (wheel.startPositionX ~= nil and wheel.startPositionX ~= 0) and wheel.startPositionX
                  or (wheel.physics and wheel.physics.positionX) or 0
    -- If posX is near zero, use isLeft flag to determine side
    if math.abs(thisX) < 0.1 then
        thisX = wheel.isLeft and -1 or 1
    end
    log("Opp search: thisX=%.3f thisZ=%.3f wheelIndex=%d", thisX, thisZ, data.wheelIndex)
    for i, w in ipairs(wheelsSpec.wheels) do
        if i ~= data.wheelIndex then
            local wZ = (w.startPositionZ ~= nil and w.startPositionZ ~= 0) and w.startPositionZ
                       or (w.physics and w.physics.positionZ) or 0
            local wX = (w.startPositionX ~= nil and w.startPositionX ~= 0) and w.startPositionX
                       or (w.physics and w.physics.positionX) or 0
            if math.abs(wX) < 0.1 then
                wX = w.isLeft and -1 or 1
            end
            log("Opp candidate %d: wX=%.3f wZ=%.3f dZ=%.3f product=%.3f", i, wX, wZ, math.abs(wZ-thisZ), thisX*wX)
            if math.abs(wZ - thisZ) < 0.5 and w.isLeft ~= wheel.isLeft then
                if w.physics ~= nil then
                    local op   = w.physics
                    local ody0 = op.deltaYOriginal or op.deltaY or 0
                    -- Dynamic deltaY for opposite wheel from its own axle height
                    local pcfg   = (data.puncturedPhys and data.puncturedPhys.cfg) or TYRE_CONFIG_FALLBACK
                    local or0    = op.radiusOriginal or op.radius or 1.0
                    local oAxleH = w.startPositionY or (op.positionY or or0)
                    local oDy0   = op.deltaYOriginal or op.deltaY or 0
                    local oDynDY = or0 * pcfg.radiusFrac * 0.50
                    log("Opp dynDY=%.4f (axleH=%.4f r0=%.4f dy0=%.4f contact=%s)",
                        oDynDY, oAxleH, or0, oDy0, tostring(op.hasGroundContact))
                    -- Also find the opposite wheel's mesh node for setTranslation drop
                    local oppMeshNode  = nil
                    local oppMeshBaseY = nil
                    local oppShapeData = {}
                    local function findOppMeshNode(vw)
                        if vw == nil or vw.visualParts == nil then return end
                        for _, vp in ipairs(vw.visualParts) do
                            if vp.isa ~= nil and vp:isa(WheelVisualPartTire) then
                                local rn = getVisualPartNode(vp)
                                if rn ~= nil then
                                    local p1 = getParent(rn)
                                    local p2 = (p1 ~= nil and p1 ~= 0) and getParent(p1) or nil
                                    oppMeshNode = p2 or p1 or rn
                                    local _, ty, _ = getTranslation(oppMeshNode)
                                    oppMeshBaseY = ty
                                    log("Opposite meshNode=%d '%s' baseY=%.4f", oppMeshNode, getName(oppMeshNode), oppMeshBaseY)
                                    -- Also cache shader shapes for opposite wheel deformation
                                    local shapes = {}
                                    collectShapes(rn, shapes)
                                    log("Opp collectShapes: rn=%d '%s' found=%d", rn, getName(rn), #shapes)
                                    for _, shape in ipairs(shapes) do
                                        local ox, oy, oz, ow = safeGetShaderParameter(shape, SHADER_PARAM)
                                        if ox ~= nil then
                                            -- flatW at 15% of full puncture deformation
                                            local pcfg = (data.puncturedPhys and data.puncturedPhys.cfg) or TYRE_CONFIG_FALLBACK
                                            local fullFlatW = math.max(ow * pcfg.flatWMult, 0.12)
                                            local oppFlatW  = ow + (fullFlatW - ow) * 0.60
                                            table.insert(oppShapeData, { node=shape, origX=ox, origY=oy, origZ=oz, origW=ow, flatW=oppFlatW })
                                            log("Opp shape cached: node=%d origW=%.4f flatW=%.4f", shape, ow, oppFlatW)
                                        end
                                    end
                                end
                                return
                            end
                        end
                    end
                    if w.visualWheel ~= nil then
                        log("Opp: checking visualWheel")
                        findOppMeshNode(w.visualWheel)
                    end
                    if oppMeshNode == nil and w.visualWheels ~= nil then
                        log("Opp: checking visualWheels count=%d", #w.visualWheels)
                        for _, vw in ipairs(w.visualWheels) do
                            findOppMeshNode(vw)
                            if oppMeshNode ~= nil then break end
                        end
                    end
                    if oppMeshNode == nil and w.visualParts ~= nil then
                        log("Opp: checking visualParts count=%d", #w.visualParts)
                        findOppMeshNode({ visualParts = w.visualParts })
                    end
                    if oppMeshNode == nil then
                        log("Opp: meshNode NOT found — trying repr node fallback")
                        if w.repr ~= nil then
                            oppMeshNode  = w.repr
                            local _, ty, _ = getTranslation(oppMeshNode)
                            oppMeshBaseY = ty
                            log("Opp: using repr=%d baseY=%.4f", oppMeshNode, oppMeshBaseY)
                        end
                    end
                    data.oppositePhys = { physics = op, dy0 = ody0, r0 = op.radiusOriginal or op.radius, dynDY = oDynDY, meshNode = oppMeshNode, meshBaseY = oppMeshBaseY, shapeData = oppShapeData }
                    log("Opposite wheel found: index=%d posX=%.3f posZ=%.3f deltaY=%.4f",
                        i, wX, wZ, ody0)
                end
                break
            end
        end
    end

    buildTireCacheForWheel(wheel, data, data.isFront)
    return data.cacheBuilt
end

--==================================================
-- APPLY PHYSICS RADIUS + DELTA Y
--==================================================

local function applyPhysicsRadius(data, alpha)
    local e = data.puncturedPhys
    if e == nil then return end
    local p = e.physics
    if p == nil then return end

    local cfg = e.cfg or TYRE_CONFIG_FALLBACK

    -- Radius reduction
    local r0   = e.r0
    local rNew = r0 - (r0 * cfg.radiusFrac * alpha)
    if p.radius == nil or abs(p.radius - rNew) > 0.00005 then
        p.radius          = rNew
        -- p.radiusOriginal  = rNew  -- keep rotation speed calc in sync, prevents drag
        p.isPositionDirty = true
    end

    -- deltaY: drop tyre to ground
    local dy0   = e.dy0
    local dyAdd = e.dynDY or cfg.deltaYAdd
    local dyNew = dy0 + (dyAdd * alpha)
    if p.deltaY ~= nil then
        p.deltaY          = dyNew
        p.isPositionDirty = true
    end

    -- Drop opposite wheel by 50% of punctured wheel's deltaY to balance tilt
    local opp = data.oppositePhys
    if opp ~= nil and opp.physics ~= nil then
        if opp.physics.deltaY ~= nil then
            local oppDyNew = opp.dy0 + ((opp.dynDY or (dyAdd * 0.50 + 0.05)) * alpha)
            opp.physics.deltaY          = oppDyNew
            opp.physics.isPositionDirty = true
        end
        if alpha > 0.9 and not data.oppPhysLogged then
            data.oppPhysLogged = true
            log("OPP PHYSICS: deltaY=%.4f punctured_rNew=%.4f alpha=%.3f",
                opp.physics.deltaY or -1, rNew, alpha)
        end
    else
        if alpha > 0.9 and not data.oppPhysLogged then
            data.oppPhysLogged = true
            log("OPP PHYSICS: skipped — opp=%s physics=%s deltaY=%s",
                tostring(opp ~= nil),
                tostring(opp ~= nil and opp.physics ~= nil),
                tostring(opp ~= nil and opp.physics ~= nil and opp.physics.deltaY ~= nil))
        end
    end

    -- Log once at flat
    if alpha > 0.9 and not data.sinkLogged then
        data.sinkLogged = true
        log("FLAT STATE: punctured radius=%.4f deltaY=%.4f | opp radius=%.4f",
            p.radius, p.deltaY or -1,
            (data.oppositePhys and data.oppositePhys.physics and data.oppositePhys.physics.radius) or -1)
    end
end

--==================================================
-- APPLY SHADER
--==================================================

local function applyShaderDeformation(data)
    if not data.cacheBuilt then return end

    local alpha = data.alpha or 0

    data.logCount = (data.logCount or 0) + 1
    if data.logCount % 120 == 1 then
        if alpha ~= 1.00 then
			log("shader: alpha=%.3f pressure=%.3f", alpha, data.pressure or 0)
        end
    end

    for _, tirePart in ipairs(data.tireVisualParts) do
        for _, sd in ipairs(tirePart.shapeData) do
            local newW = sd.origW + (sd.flatW - sd.origW) * alpha
            safeSetShaderParameter(sd.node, SHADER_PARAM,      sd.origX, sd.origY, OVERRIDE_Z, newW)
            safeSetShaderParameter(sd.node, SHADER_PARAM_PREV,  sd.origX, sd.origY, OVERRIDE_Z, newW)
        end
    end

end

--==================================================
-- PUNCTURE MESSAGE
--==================================================

local LEAK_MESSAGES = {
    [1] = "puncture_slowLeak",
    [2] = "puncture_fastLeak",
    [3] = "puncture_blownOut",
}

local function showPunctureMessage(vehicle, wheelIndex, punctureType, tyrePosition)
    if not vehicle.isClient then return "" end
    -- local player = g_localPlayer
    -- if player == nil or player:getCurrentVehicle() ~= vehicle then return end 
    if g_currentMission == nil then return end
	
	-- 1. Access the 'data' (spec) table
    local data = vehicle.spec_puncture
    if data == nil then return "" end	

    -- 2. Use the L10n keys we just saved in buildCaches/buildTireCache
    local axlePart = g_i18n:getText(data.l10n_axle or "puncture_rear")
    local sidePart = g_i18n:getText(data.l10n_lat or "puncture_left")
    
    -- 3. Handle the 'inner/outer' position key
    local posPart = ""
    if data.tyrePosition ~= nil and data.tyrePosition ~= "" then
        posPart = " " .. g_i18n:getText(data.tyrePosition)
    end

    -- 4. Get the leak description
    local leakDesc = g_i18n:getText(LEAK_MESSAGES[punctureType] or "puncture_hasAPuncture")

    -- 5. Build the final translated message
    -- XML: "Your %s %s tire %s! MAX SPEED REDUCED"
    local msg = string.format(g_i18n:getText("puncture_blowoutMessage"), 
        axlePart, 
        sidePart .. posPart, 
        leakDesc
    )	

    log("MESSAGE: %s", msg)
	return msg
end

local function getCurrentYear()
    if g_currentMission and g_currentMission.environment then
        return g_currentMission.environment.currentYear or 1
    end
    return 1
end

local function getCurrentDay()
    if g_currentMission and g_currentMission.environment then
        return g_currentMission.environment.currentDay or 1
    end
    return 1
end

--==================================================
-- TRIGGER
--==================================================

local function triggerPuncture(vehicle, data)
    local wheelsSpec = vehicle.spec_wheels
    if wheelsSpec == nil or wheelsSpec.wheels == nil or #wheelsSpec.wheels == 0 then
        log("triggerPuncture: no wheels")
        return
    end

    local candidates = {}
    for i, w in ipairs(wheelsSpec.wheels) do
        -- Skip tracks (tireType 4 = track)
        if w.physics and w.physics.tireType == 4 then
            -- skip
        else
            table.insert(candidates, i)
        end
    end
    if #candidates == 0 then candidates = {1} end
    data.wheelIndex     = candidates[random(1, #candidates)]
    data.punctureType   = 3
    data.pressure       = 1.0
    data.alpha          = 0.0
    data.active         = true
    data.cacheBuilt     = false
    data.puncturedPhys  = nil
    data.puncturedWheel = nil
    data.oppositePhys   = nil
    data.logCount       = 0
    data.sinkLogged     = false
    data.tyrePosition   = ""
    data.messageSent    = false
    data._deferredDone  = true  -- no deferred init for fresh puncture
    log("PUNCTURE triggered on wheel %d", data.wheelIndex)
end

local function getLeakSpeed(t)
    if     t == 1 then return 0.0005
    elseif t == 2 then return 0.003
    else               return 0.02
    end
end

--==================================================
-- SPECIALIZATION
--==================================================

function FS25_Puncture:onLoad(savegame)
    log("FS25_Puncture:onLoad for %s", self:getName() and self:getName() or "unknown")
    local fullName = modName .. ".FS25_Puncture"
    self.spec_puncture = self["spec_" .. fullName]

    if self.spec_puncture == nil then
        log("onLoad: spec_%s not found, scanning all spec_ keys:", fullName)
        for k, v in pairs(self) do
            if type(k) == "string" and k:sub(1,5) == "spec_" then
                log("  found: %s", k)
            end
        end
        log("ERROR: spec_puncture is NIL — cannot initialise")
        return
    end

    local spec = self.spec_puncture
	
	spec.dialogDelayTimer = 0
    spec.dialogTriggered = false -- To make sure it only counts down once per puncture	
	
    spec.active          = false
    spec.pressure        = 1.0
    spec.alpha           = 0.0
    spec.wheelIndex      = 1
    spec.punctureType    = 3
    spec.tyrePosition    = ""
    spec.cacheBuilt      = false
    spec.messageSent     = false
    spec.logCount        = 0
    spec.sinkLogged      = false
    spec.limitReached    = false
    spec.tireVisualParts = {}
	spec.new_speed 		 = nil -- Starts as NIL so the truck drives normally	
    spec.puncturedPhys   = nil
    spec.oppositePhys    = nil
    spec.isFront         = false
    spec._deferredMs     = 500
	spec.playerSaidNo 	 = false	
	spec.distanceToPuncture = 0 -- Reset to 0
    spec.isPuncturePending = false -- New flag: "A nail is in the tire, but hasn't leaked yet"	
    spec.workerStopHandled  = false
    spec._deferredDone   = false
	spec.isRestoring     = false
    spec.savedPressure   = 1.0
    spec.restoreSpeedMul = 5.0
    spec.isRestored      = false -- Make sure this is false too!	
    log("onLoad: spec_puncture initialised OK")
end

function FS25_Puncture:onPostLoad(savegame)
    local spec = self.spec_puncture
    if spec == nil then 
        log("onPostLoad: spec_puncture NIL for %s", self:getName()) 
        return 
    end
    
    if savegame == nil then
        log("onPostLoad: savegame is NIL for %s", self:getName())
        return
    end
    
    local xmlFile = savegame.xmlFile
    local specKey = savegame.key .. ".FS25_Puncture.FS25_Puncture"
    
    log("onPostLoad: key=%s resetVehicles=%s", tostring(specKey), tostring(savegame.resetVehicles))

    spec.active = xmlFile:getBool(specKey .. "#punctureActive", false)
    spec.pressure = xmlFile:getFloat(specKey .. "#puncturePressure", 1.0)
    spec.wheelIndex = xmlFile:getInt(specKey .. "#punctureWheelIndex", 1)
    spec.punctureType = xmlFile:getInt(specKey .. "#punctureType", 3)
    spec.tyrePosition = xmlFile:getString(specKey .. "#punctureTyrePos", "")
    spec.lastDeformation = xmlFile:getFloat(specKey .. "#lastDeformation", 0)    
    
    log("onPostLoad: punctureActive=%s for %s", tostring(spec.active), self:getName())
    
	if spec.active then
        -- CRITICAL: Store the "Goal" pressure from the save
        spec.savedPressure = xmlFile:getFloat(specKey .. "#puncturePressure", 0.2)
        spec.wheelIndex = xmlFile:getInt(specKey .. "#punctureWheelIndex", 1)
        
        -- Start at 1.0 so it "squashes" down visually to the saved level
        spec.pressure = 1.0 
        spec.isRestoring = true
        spec.restoreSpeedMul = 5.0
        -- spec.messageSent = true
        spec._deferredMs = 50 -- Very short delay
        spec._deferredDone = false
        log("onPostLoad: restored wheel=%d pressure=%.3f for %s", spec.wheelIndex, spec.savedPressure, self:getName())
    end
end

function FS25_Puncture:saveToXMLFile(xmlFile, key, usedModNames)
    -- local spec = self.spec_puncture
    -- if spec == nil then return end

    -- log("saveToXMLFile CALLED: %s key=%s active=%s pressure=%.3f wheel=%d", 
        -- self:getName(), tostring(key), tostring(spec.active), spec.pressure or 0, spec.wheelIndex or 0)
	

    -- xmlFile:setBool(key .. "#punctureActive", spec.active or false)
    -- xmlFile:setBool(key .. "#limitReached", spec.limitReached or false)
    -- xmlFile:setFloat(key .. "#puncturePressure", spec.pressure or 1.0)
    -- xmlFile:setInt(key .. "#punctureWheelIndex", spec.wheelIndex or 1)
    -- xmlFile:setInt(key .. "#punctureType", spec.punctureType or 3)
    -- xmlFile:setString(key .. "#punctureTyrePos", spec.tyrePosition or "")

    -- if spec.lastDeformation ~= nil then
        -- xmlFile:setFloat(key .. "#lastDeformation", spec.lastDeformation)
    -- end
end

function FS25_Puncture:onDeactivate()
	-- log("function FS25_Puncture:onDeactivate()")
    FS25_Puncture.activeWarning = {
        text = nil,
        expiry = 0
    }
  
    -- Reset session flags
    if self.spec_puncture ~= nil then
        self.spec_puncture.messageSentThisSession = false
        self.spec_puncture.dialogActive = false
    end
end

function FS25_Puncture:repairTire()
    local spec = self.spec_puncture
    
    -- 1. KILL THE SPEED LIMIT
    spec.active = false
    spec.isRestoring = false
    
    -- 2. RESET DATA
    spec.pressure = 1.0
    spec.alpha = 0.0
    
    -- 3. RESET PHYSICS (Stop the leaning)
    if spec.cacheBuilt then
        applyPhysicsRadius(spec, 0.0) 
    end
    
    -- 4. RESET VISUALS
    applyShaderDeformation(spec)
	
	spec.messageSent = false
    spec.cacheBuilt = false
    spec.punctureType = nil
    -- Clear the global warning immediately so it doesn't linger
    FS25_Puncture.activeWarning = nil
    
    log("Tire repaired on %s. Speed limit restored.", self:getName())
end

function FS25_Puncture:getRepairCost()
    local spec = self.spec_puncture
    local wheel = self.spec_wheels.wheels[spec.wheelIndex]
    local radius = wheel.physics.radius
    local width  = wheel.physics.width

    -- Cost tiers by tyre radius:
    -- ATV/small (r < 0.45):        ~$150-250
    -- Car/pickup (r 0.45-0.60):    ~$300-500
    -- Small tractor (r 0.60-0.80): ~$600-900
    -- Mid tractor (r 0.80-1.00):   ~$1000-1800
    -- Large tractor (r > 1.00):    ~$2000-2800
    local basePrice, multiplier
    if radius < 0.45 then
        basePrice  = 80
        multiplier = 800
    elseif radius < 0.60 then
        basePrice  = 150
        multiplier = 1200
    elseif radius < 0.80 then
        basePrice  = 200
        multiplier = 1800
    elseif radius < 1.00 then
        basePrice  = 300
        multiplier = 2200
    else
        basePrice  = 400
        multiplier = 3000
    end
    local sizeFactor = (radius * width) * multiplier
    local cost = math.floor(basePrice + sizeFactor)

    log("getRepairCost: radius=%.3f width=%.3f cost=$%d", radius, width, cost)
    return cost
end

function Puncture.generateYearlySchedule()
    local start = Puncture.farmStartPeriod or 1
    -- Two danger periods spread across the 12-period year
    local offset1 = math.random(0, 5)
    Puncture.scheduledPeriod1 = ((start + offset1 - 1) % 12) + 1
    local offset2 = math.random(6, 11)
    Puncture.scheduledPeriod2 = ((start + offset2 - 1) % 12) + 1
    log("NEW CYCLE: Started Period %d. Danger Periods: %d and %d offset1=%s offset2=%s" ,
        start, Puncture.scheduledPeriod1, Puncture.scheduledPeriod2, offset1, offset2)
end

function FS25_Puncture.checkEligibility(self, dt)
    local spec = self.spec_puncture
    
    -- 1. Constant Exit Conditions
    if spec.active then 
    -- if spec.active or not self:getIsEntered() then 
        return false 
    end
	
	-- engine must be running
    local engineOn = (spec.getIsMotorStarted and spec:getIsMotorStarted()) or false
    if not engineOn then
        return
    end

    local currentPeriod = g_currentMission.environment.currentPeriod
	
	-- NEW: Check if the current month is actually a "Danger Month"
	local isPuncturePeriod = (currentPeriod == Puncture.scheduledPeriod1 or currentPeriod == Puncture.scheduledPeriod2)
	-- log("currentPeriod=%s", currentPeriod)

	if not isPuncturePeriod then
		-- It's a safe month! No distance tracking or log spam needed.
		return false 
	end

    -- 2. The 2-Puncture Limit Check
    if Puncture.farmTotalCount >= 2 then
        -- log("puncture quota limit reached for year")
        return false -- Stop here, no more punctures allowed this year
    end
    -- 3. Only one vehicle per danger period
    if Puncture.puncturedThisPeriod == currentPeriod then
        return false
    end
    -- 4. cant puncture the same vehicle again in period of 12 months
    if Puncture.veh_id ~= nil and Puncture.veh_id == self.uniqueId then
		-- log("cannot puncture same vehicle in year")
		return false
    end

    if (spec.distanceToPuncture or 0) == 0 then 
        -- spec.distanceToPuncture = 30 -- testing only!1
        spec.distanceToPuncture = math.random(1000, 2000)
        spec.metersDrivenSinceNail = 0 -- Ensure we start at zero
        log("NEW TARGET SET: Puncture in %.1fm", spec.distanceToPuncture)
    end
	
    -- 3. Distance Tracking
    local moveDist = self.lastMovedDistance or 0
	-- this to catch those creeping vehicles	
    if moveDist < 0.01 then
		return false
	end
	
    spec.metersDrivenSinceNail = (spec.metersDrivenSinceNail or 0) + moveDist
	local togo = spec.distanceToPuncture - spec.metersDrivenSinceNail

    -- 4. 3-Second Heartbeat Log
    spec.logTimer = (spec.logTimer or 0) - dt
    if spec.logTimer <= 0 then
        spec.logTimer = 3000
		log("FUSE2: name=%s Target=%s moveDist=%s Current=%s Remaining=%s", self:getFullName(),
			spec.distanceToPuncture, 
			moveDist				,
			spec.metersDrivenSinceNail,
			togo
		)
    end        

    -- 5. The Trigger
    if spec.metersDrivenSinceNail >= spec.distanceToPuncture then
        log("!!! TARGET REACHED !!! Distance covered.")
        spec.distanceToPuncture = 0  -- Reset for next time
        spec.metersDrivenSinceNail = 0
        return true -- Tell onUpdate to triggerPuncture()
    end

    return false
end

function FS25_Puncture:onUpdate(dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
	if not self.isServer then return end
    local spec = self.spec_puncture
    if spec == nil or self.spec_wheels == nil then return end


	-- 2. IDENTIFY OCCUPANCY (The Universal Check)
	local isEntered = self:getIsEntered()
	local isBaseAI  = (self.getIsAIActive ~= nil and self:getIsAIActive())
	local isCP      = (self.getIsCpActive ~= nil and self:getIsCpActive())
	local isAD      = (self.ad ~= nil and self.ad.stateModule ~= nil and self.ad.stateModule:isActive())
	-- 3. CHECK JOB TYPES (1, 4, 5)
	local isJob = false
	local currentJob = (self.getAIJob ~= nil) and self:getAIJob() or nil
	if currentJob ~= nil then
		typeIndex = currentJob.jobTypeIndex
		if typeIndex == 1 or typeIndex == 4 or typeIndex == 5 then
			log("JOB typeIndex=%s", typeIndex)
			isJob = true
		end
	end

	-- The "Master Gate": Stay alive if someone is driving OR if the tire is already flat
    local isOccupied = isBaseAI or isCP or isAD or isJob
    -- local isOccupied = isEntered or isBaseAI or isCP or isAD or isJob
    -- if not isOccupied and not spec.active then 
        -- return 
    -- end
	
	local player = g_localPlayer
    local speed = self:getLastSpeed() 
	
	-- now proceed normally

	-----------------------------------------------------------
    -- 2. EMERGENCY WORKER STOP (If Puncture is Active)
    -----------------------------------------------------------
    if spec.active and not spec.workerStopHandled then
		-- if isEntered	then
			log("isBaseAI=%s isCP=%s isAD=%s isJob=%s typeIndex=%s", isBaseAI, isCP, isAD, isJob, typeIndex)
		-- end
		
		if isCP then
			local msg = "about to..." .. g_i18n:getText("puncture_warning_CP_stop") .. "\n" .. self:getFullName()
			log(msg)
			local isCPActive = self.getIsCpActive ~= nil and self:getIsCpActive()
			if isCPActive then
				if self.stopCurrentAIJob ~= nil then 
					self:stopCurrentAIJob(AIMessageSuccessStoppedByUser.new()) 
				end
			end
		end		
		
        -- Stop Base Game AI
		if (self.getIsAIActive ~= nil and self:getIsAIActive() and not isCP) then
			local msg = "about to..." .. g_i18n:getText("puncture_warning_ai_stop") .. "\n" .. self:getFullName()
			log(msg)
		
			if self.stopCurrentAIJob ~= nil then
				self:stopCurrentAIJob()
			elseif self.stopAIFieldWorker ~= nil then
				self:stopAIFieldWorker(AIJobResultMessage.SUCCESS)
			end
        end

        -- Stop AutoDrive
        if isAD and self.stopAutoDrive ~= nil then
			local msg = "about to..." .. g_i18n:getText("puncture_warning_AD_stop") .. "\n" .. self:getFullName()
			log(msg)
            self:stopAutoDrive()
        end

        -- Stop Job System (Types 1, 4, 5)
        -- if isJob and currentJob ~= nil and g_currentMission.aiJobManager ~= nil then
			-- if self.stopCurrentAIJob ~= nil then
				-- self:stopCurrentAIJob()
			-- elseif self.stopAIFieldWorker ~= nil then
				-- self:stopAIFieldWorker(AIJobResultMessage.SUCCESS)
			-- end
        -- end
		-- dumpTable("vehicle", self, 3)
		log("2: isBaseAI=%s isCP=%s isAD=%s isJob=%s", isBaseAI, isCP, isAD, isJob)
		
		local warningText = g_i18n:getText("puncture_warning_ai_stop") -- Default		
		if isJob then
			warningText = g_i18n:getText("puncture_warning_JOB_stop")
		elseif isCP then
			warningText = g_i18n:getText("puncture_warning_CP_stop")
		elseif isAD then
			warningText = g_i18n:getText("puncture_warning_AD_stop")
		end
	log("5: detected=%s warningText=%s getFullName=%s", g_i18n:getText("puncture_detected"), warningText, self:getFullName() )

		-- 2. Combine the cleaned versions
		local msg = g_i18n:getText("puncture_detected") .. "\n" .. warningText .. ": " .. self:getFullName()
		log("3: msg=%s", msg)

		-- Trigger the display
		local isEntered = self:getIsEntered()
		if not isEntered then
			log("4:is not entered about to show msg=%s", msg)
		
			FS25_Puncture.showCenterWarning(self, msg, 4000)
		end	
        spec.workerStopHandled = true -- Prevent re-running stop logic every frame 
    end

    -- Reset the handle when the tire is fixed
    if not spec.active then
        spec.workerStopHandled = false
    end
    
    local currentPeriod = g_currentMission.environment.currentPeriod
	if Puncture.lastLoggedPeriod ~= currentPeriod then
		Puncture.lastLoggedPeriod = currentPeriod
		Puncture.tickover = true
		log("month tickover Puncture.farmStartPeriod=%s currentPeriod=%s", Puncture.farmStartPeriod, currentPeriod)
	end	
	
	-----------------------------------------------------------
    -- 0. ANNUAL RESET CHECK
    -----------------------------------------------------------
    if g_currentMission and g_currentMission.environment then
        local currentPeriod = g_currentMission.environment.currentPeriod
        
        -- Trigger reset only on the anniversary AND if we haven't done it yet this month
        if Puncture.farmStartPeriod ~= nil and currentPeriod == Puncture.farmStartPeriod then
            if not Puncture.hasResetThisYear and Puncture.tickover then
                log("ANNUAL RESET: New 12-period cycle starting.")
                Puncture.farmTotalCount      = 0
                Puncture.puncturedThisPeriod = nil
                Puncture.generateYearlySchedule()
                
                Puncture.hasResetThisYear    = true
                Puncture.tickover            = false
            end
        else
            -- ONLY reset the gate when we are NO LONGER in the anniversary month
            Puncture.hasResetThisYear = false
        end
    end	
	
    -----------------------------------------------------------
    -- EARLY EXIT: nothing to do if not active and not entered
    -----------------------------------------------------------
    -- if not spec.active then return end
	
	-----------------------------------------------------------
    -- 1. DIALOG & WARNING PHASE (Only for Player in Seat)
    -----------------------------------------------------------
	if isEntered and spec.active and not spec.dialogActive and not spec.playerSaidNo then        
        -- Start the 4-second delay if we just entered or puncture just happened
        if not spec.dialogTriggered then
            spec.dialogDelayTimer = 4000 
            spec.dialogTriggered = true
        end

        -- Tick down the timer
        if spec.dialogDelayTimer > 0 then
            spec.dialogDelayTimer = spec.dialogDelayTimer - dt
        else
            -- Timer hit 0: Show the Repair Dialog
            spec.dialogActive = true
			
			local cost = FS25_Puncture.getRepairCost(self)
			spec.pendingRepairCost = cost
			local msg = string.format(g_i18n:getText("puncture_repairDialog"), cost)
			-- local msg = string.format("Would you like to replace this tire at a cost of $%d labor included?", cost)
			YesNoDialog.show(FS25_Puncture.onYesNoTarget, self, title, msg)        
       end
    end 

    -----------------------------------------------------------
    -- 2. HIGH-SPEED RESTORATION (Pressure syncing)
    -----------------------------------------------------------
    if spec.active and spec.isRestoring then
        local restoreDelta = (getLeakSpeed(spec.punctureType) * (dt / 1000)) * spec.restoreSpeedMul
        spec.pressure = math.max(spec.savedPressure, spec.pressure - restoreDelta)
        spec.alpha = 1.0 - spec.pressure

        applyShaderDeformation(spec)

        if not spec.cacheBuilt then
            spec._deferredMs = spec._deferredMs - dt
            if spec._deferredMs <= 0 then
                buildCaches(self, spec)
                spec._deferredDone = true
            end
        end

        if spec.cacheBuilt then
            applyShaderDeformation(spec)
            applyPhysicsRadius(spec, spec.alpha)
            
            if spec.pressure <= spec.savedPressure + 0.01 then
                spec.pressure = spec.savedPressure
                spec.alpha = 1.0 - spec.pressure
                applyShaderDeformation(spec)
                applyPhysicsRadius(spec, spec.alpha)
                spec.isRestoring = false
                log("Restore perfectly synced for %s", self:getName())
            end
        end     
        return -- Block normal update until restore is done
    end
    
	-----------------------------------------------------------
    -- 3. THE SCANNER (Distance Tracking)
    -----------------------------------------------------------
    -- now check for eligible running vehicles
    if not spec.active then
    -- if not spec.active and isOccupied then
        if FS25_Puncture.checkEligibility(self, dt) then
            local speed = self:getLastSpeed() 
            -- log("speed=%s", speed) 
            if speed >= 9.0 then
                Puncture.puncturedThisPeriod = g_currentMission.environment.currentPeriod
				Puncture.farmTotalCount = Puncture.farmTotalCount + 1
                triggerPuncture(self, spec)
            end
        end
    end
	
    -- If not active, we can stop here for this vehicle
    if not spec.active then return end
	
	-- use this so that same vehicle cant be punctured twice in a year
	if  Puncture.veh_id == nil then
		Puncture.veh_id = self.uniqueId
	end

    -----------------------------------------------------------
    -- 4. LIVE PUNCTURE PROCESSING (Deflation & Physics)
    -----------------------------------------------------------
    spec.pressure = math.max(0, spec.pressure - (getLeakSpeed(spec.punctureType) * (dt / 1000)))
    spec.alpha = 1.0 - spec.pressure

    if not spec.cacheBuilt then
        local wheel = self.spec_wheels.wheels[spec.wheelIndex]
        local hasContact = wheel and wheel.physics and wheel.physics.hasGroundContact
        
        if hasContact then
            buildCaches(self, spec)
            if not spec.messageSent then
                spec.messageSent = true
                local msg = showPunctureMessage(self, spec.wheelIndex, spec.punctureType, spec.tyrePosition)
				msg = msg .. "\n" .. "           " .. g_i18n:getText("puncture_check_outside")
                log("live punc about to message".. msg)
                FS25_Puncture.showCenterWarning(self, msg, 4000) -- This matches your 4s timer
            end
        end
    end

    -- Apply Visuals & Physics
    applyShaderDeformation(spec) 
    if spec.cacheBuilt then
        applyPhysicsRadius(spec, spec.alpha)
    end
end

function FS25_Puncture.onYesNoTarget(self, clickYes)
	-- log("Puncture:onYesNoTarget(clickYes)")
	-- log("clickYes self=%s clickYes=%s" , self, clickYes)	
	local spec = self.spec_puncture
    local cost = spec.pendingRepairCost or 0
	if clickYes == true or clickYes == 1 then	
        spec.playerSaidNo = false 

		local ownerFarmId = self:getOwnerFarmId() or g_currentMission:getFarmId()
		local money = g_currentMission:getMoney(ownerFarmId)

		if money >= cost then
			g_currentMission:addMoney(-cost, ownerFarmId, MoneyType.VEHICLE_RUNNING_COSTS, true, true)

			spec.new_speed = 0.0

			FS25_Puncture.repairTire(self)
		else
			g_currentMission:showBlinkingWarning(g_i18n:getText("puncture_insufficientFunds"), 3000)
		end
	end
	if not clickYes == true then	
        -- THIS is the key: it kills the update loop logic until they re-enter
        spec.playerSaidNo = true 
    end	
	
	spec.dialogActive = false
end

function Puncture:draw()
	-- log("function Puncture:draw()")
-- 1. Check if there is a global warning active
    local warning = FS25_Puncture.activeWarning
    if not warning then return end

    -- 2. Check if the timer has expired
    if g_currentMission.time > warning.expiry then
        FS25_Puncture.activeWarning = nil -- Clear it globally
		-- log("g_currentMission.time > warning.expiry")		
        return
    end

    -- 3. Use the global text for the rest of drawing logic
    local text = warning.text	
    if text == nil then
		log("Puncture:draw() text == nil")		
        return
    end
	
    local textSize = 0.022
    local iconSize = textSize * 1.9 
    local iconTextSpacing = textSize * 0.7
    local paddingX = textSize * 1.1
    
    -- ASPECT RATIO FIX (Forces the icon to be a circle, not an oval)
    local aspectRatio = g_screenAspectRatio
    local iconH = iconSize
    local iconW = iconH / aspectRatio 
    
    setTextAlignment(RenderText.ALIGN_LEFT)
    setTextBold(true)
    
    -- Content Math
    local textWidth = getTextWidth(textSize, text)
    local textHeight = getTextHeight(textSize, text)
    local contentWidth = iconW + iconTextSpacing + textWidth
    
    local boxW = contentWidth + (paddingX * 2)
    local boxH = 0.11
    local x = 0.5 - (boxW / 2)
    local y = 0.65
    local midY = y + (boxH / 2)

    -----------------------------------------------------------
    -- ⬛ THE BACKGROUND
    -----------------------------------------------------------
    local b = 0.002 
    drawFilledRectRound(x - b, y - b, boxW + (b*2), boxH + (b*2), 0.5, 0, 0, 0, 0.8)
    drawFilledRectRound(x, y, boxW, boxH, 0.5, 0.02, 0.02, 0.02, 0.5)

    -----------------------------------------------------------
    -- ⚠️ CUSTOM ICON
    -----------------------------------------------------------
	local warningIcon = Puncture.overlay
    
    if warningIcon ~= nil then
        local iconX = x + paddingX
        local iconY = midY - (iconH / 2)
        
        warningIcon:setPosition(iconX, iconY)
        warningIcon:setDimension(iconW, iconH)
        
        -- Set to (1, 1, 1, 1) to show your yellow icon's natural colors
        warningIcon:setColor(1, 1, 1, 1)
        warningIcon:render()
    end

    -----------------------------------------------------------
    -- ✍️ THE TEXT
    -----------------------------------------------------------
    setTextColor(0.8, 0, 0, 1)
    
    local textX = x + paddingX + iconW + iconTextSpacing
    local textY = midY + (textHeight / 2) - (textSize * 0.8)
    
    renderText(textX, textY, textSize, text)

    -- Cleanup
    setTextColor(1, 1, 1, 1)
    setTextBold(false)
    setTextAlignment(RenderText.ALIGN_LEFT)
end
-- function FS25_Puncture:onDraw()
	-- return
	-- -- log("function FS25_Puncture:onDraw()")
-- -- 1. Check if there is a global warning active
    -- local warning = FS25_Puncture.activeWarning
    -- if not warning then return end

    -- -- 2. Check if the timer has expired
    -- if g_currentMission.time > warning.expiry then
        -- FS25_Puncture.activeWarning = nil -- Clear it globally
        -- return
    -- end

    -- -- 3. Use the global text for the rest of your drawing logic
    -- local text = warning.text	
    -- if text == nil then
        -- return
    -- end
	
    -- -- local spec = self.spec_puncture
    -- -- if not spec or spec.warningText == nil then log("ondraw spec.warningText == nil") return end

    -- -- if g_currentMission.time > (spec.warningTimer or 0) then
        -- -- spec.warningText = nil
-- -- log("ondraw g_currentMission.time > (spec.warningTimer or 0) ")		
        -- -- return
    -- -- end

    -- -- 1. Setup & Dynamic Scaling
    -- -- local text = spec.warningText
    -- local textSize = 0.022
    -- local iconSize = textSize * 1.9 
    -- local iconTextSpacing = textSize * 0.7
    -- local paddingX = textSize * 1.1
    
    -- -- ASPECT RATIO FIX (Forces the icon to be a circle, not an oval)
    -- local aspectRatio = g_screenAspectRatio
    -- local iconH = iconSize
    -- local iconW = iconH / aspectRatio 
    
    -- setTextAlignment(RenderText.ALIGN_LEFT)
    -- setTextBold(true)
    
    -- -- Content Math
    -- local textWidth = getTextWidth(textSize, text)
    -- local textHeight = getTextHeight(textSize, text)
    -- local contentWidth = iconW + iconTextSpacing + textWidth
    
    -- local boxW = contentWidth + (paddingX * 2)
    -- local boxH = 0.11
    -- local x = 0.5 - (boxW / 2)
    -- local y = 0.65
    -- local midY = y + (boxH / 2)

    -- -----------------------------------------------------------
    -- -- ⬛ THE BACKGROUND
    -- -----------------------------------------------------------
    -- local b = 0.002 
    -- drawFilledRectRound(x - b, y - b, boxW + (b*2), boxH + (b*2), 0.5, 0, 0, 0, 0.8)
    -- drawFilledRectRound(x, y, boxW, boxH, 0.5, 0.02, 0.02, 0.02, 0.5)

    -- -----------------------------------------------------------
    -- -- ⚠️ YOUR CUSTOM ICON
    -- -----------------------------------------------------------
    -- -- if spec.warningIcon == nil then
        -- -- We use self.baseDirectory to point to your mod folder
	-- -- local iconPath = Puncture.warningIcon
	-- local warningIcon = Puncture.overlay
    -- -- end
    
    -- if warningIcon ~= nil then
        -- local iconX = x + paddingX
        -- local iconY = midY - (iconH / 2)
        
        -- warningIcon:setPosition(iconX, iconY)
        -- warningIcon:setDimension(iconW, iconH)
        
        -- -- Set to (1, 1, 1, 1) to show your yellow icon's natural colors
        -- warningIcon:setColor(1, 1, 1, 1)
        -- warningIcon:render()
    -- end

    -----------------------------------------------------------
    -- ✍️ THE TEXT
    -----------------------------------------------------------
    -- setTextColor(0.8, 0, 0, 1)
    
    -- local textX = x + paddingX + iconW + iconTextSpacing
    -- local textY = midY + (textHeight / 2) - (textSize * 0.8)
    
    -- renderText(textX, textY, textSize, text)

    -- -- Cleanup
    -- setTextColor(1, 1, 1, 1)
    -- setTextBold(false)
    -- setTextAlignment(RenderText.ALIGN_LEFT)
-- end

function FS25_Puncture:onDraw()
end


local function getXMLPath()
    local path = g_currentMission.missionInfo.savegameDirectory
    if path ~= nil then
        return path .. "/FS25_Puncture_history.xml"
    end
	return getUserProfileAppPath() .. "savegame" .. g_currentMission.missionInfo.savegameIndex .. "/FS25_Puncture_history.xml"
end

function Puncture.saveSavegame()
    if not g_currentMission:getIsServer() then return end
	
    local xmlFilePath = getXMLPath()
    local xmlFile = createXMLFile("PunctureHistory", xmlFilePath, "PunctureHistory")
    if xmlFile == nil then log("saveSavegame: failed to create XML") ; return end
    setXMLString(xmlFile, "PunctureHistory.version", "1.0")
	
	-- NEW: Save the Global Farm Data
    -- We use a "global" tag to store the month the 12-month cycle started
    setXMLInt(xmlFile, "PunctureHistory.global#startPeriod", Puncture.farmStartPeriod or g_currentMission.environment.currentPeriod)
    -- Optional: also save the total farm count directly for easier manual editing
    setXMLInt(xmlFile, "PunctureHistory.global#totalFarmCount", Puncture.farmTotalCount or 0)
	setXMLInt(xmlFile, "PunctureHistory.global#period1", Puncture.scheduledPeriod1)
    setXMLInt(xmlFile, "PunctureHistory.global#period2", Puncture.scheduledPeriod2)	
	
    -- local i = 0
    -- for id, entry in pairs(Puncture.history) do
        -- local key = "PunctureHistory.vehicles.vehicle(" .. i .. ")"
        -- setXMLString(xmlFile, key .. "#id",       tostring(id))
        -- setXMLInt(   xmlFile, key .. "#year",     entry.year)
        -- setXMLInt(   xmlFile, key .. "#count",    entry.count)
        -- setXMLInt(   xmlFile, key .. "#firstDay", entry.firstDay)
        -- i = i + 1
    -- end
    saveXMLFile(xmlFile)
    delete(xmlFile)
    log("saveSavegame: saved" )
end

function Puncture.loadStoredXML()
	log("Puncture.loadStoredXML()")
    if g_server == nil then return end
	
    local xmlFilePath = getXMLPath()
    if not fileExists(xmlFilePath) then
        log("loadStoredXML: no history file — building periods")
	
        -- Start a fresh cycle if no history exists
		log("PERIOD CHECK: period=%d season=%d dayInSeason=%d dayInPeriod=%d",
			g_currentMission.environment.currentPeriod,
			g_currentMission.environment.currentSeason,
			g_currentMission.environment.currentDayInSeason,
			g_currentMission.environment.currentDayInPeriod)		
        Puncture.farmStartPeriod = g_currentMission.environment.currentPeriod
        Puncture.farmTotalCount = 0
        Puncture.generateYearlySchedule() 
        return
    end

    local xmlFile = loadXMLFile("PunctureHistory", xmlFilePath)
    
    -- Load the Global Farm Stats
    Puncture.farmStartPeriod = getXMLInt(xmlFile, "PunctureHistory.global#startPeriod") or g_currentMission.environment.currentPeriod
    Puncture.farmTotalCount = getXMLInt(xmlFile, "PunctureHistory.global#totalFarmCount") or 0
    
    -- NEW: Load the specific scheduled months so they persist across restarts
    -- Puncture.scheduledPeriod1 = getXMLInt(xmlFile, "PunctureHistory.global#period1")
    -- Puncture.scheduledPeriod2 = getXMLInt(xmlFile, "PunctureHistory.global#period2")

    -- Safety Check: If for some reason months are missing in an old XML, generate them
    if Puncture.scheduledPeriod1 == nil or Puncture.scheduledPeriod2 == nil then
        Puncture.generateYearlySchedule()
    end
    
    delete(xmlFile)
    log("GLOBAL HISTORY: Cycle Start=%d | Count=%d/2 | Danger Periods=%d, %d", 
        Puncture.farmStartPeriod, Puncture.farmTotalCount, Puncture.scheduledPeriod1, Puncture.scheduledPeriod2)
end

-- Called by Giants automatically before vehicles load (via modDesc specialization entry)
function FS25_Puncture.initSpecialization()
	log("function Puncture.initSpecialization()")
    local saveSchema = Vehicle.xmlSchemaSavegame
    if saveSchema ~= nil then
        saveSchema:register(XMLValueType.BOOL,   "vehicles.vehicle(?)#punctureActive",     "Puncture active",     false)
        saveSchema:register(XMLValueType.BOOL,   "vehicles.vehicle(?)#limitReached",  	   "limitReached",     false)
        saveSchema:register(XMLValueType.FLOAT,  "vehicles.vehicle(?)#puncturePressure",   "Puncture pressure",   1.0)
        saveSchema:register(XMLValueType.INT,    "vehicles.vehicle(?)#punctureWheelIndex", "Puncture wheel index",1)
        saveSchema:register(XMLValueType.INT,    "vehicles.vehicle(?)#punctureType",       "Puncture type",       3)
        saveSchema:register(XMLValueType.STRING, "vehicles.vehicle(?)#punctureTyrePos",    "Puncture tyre pos",   "")
        log("initSpecialization: savegame schema registered")
    end
	
end

function Puncture:loadMap()
    FSBaseMission.saveSavegame = Utils.appendedFunction(FSBaseMission.saveSavegame, Puncture.saveSavegame)
	Puncture.loadStoredXML()
	Puncture.lastLoggedPeriod = Puncture.farmStartPeriod
end

function FS25_Puncture.prerequisitesPresent(specializations)
    -- This ensures it only runs on self-propelled machines (Tractors/Trucks)
    -- and excludes Trailers, Wagons, and Trains.
    return SpecializationUtil.hasSpecialization(Motorized, specializations) and 
           SpecializationUtil.hasSpecialization(Drivable, specializations)
end

function Puncture.register()
    if Puncture._didRegister then return end
    Puncture._didRegister = true

    local specName = "FS25_Puncture" 
    local fullName = modName .. "." .. specName
    
    for typeName, typeDef in pairs(g_vehicleTypeManager.types) do
        local specs = typeDef.specializationsByName
        
        -- 1. Must be Motorized + Drivable + Wheels
        -- 2. MUST NOT be a Locomotive (This stops the Train!)
        if specs["motorized"] ~= nil and specs["drivable"] ~= nil and specs["wheels"] ~= nil then
            if specs["locomotive"] == nil then -- <--- THIS EXCLUDES TRAINS
                if specs[fullName] == nil then
                    g_vehicleTypeManager:addSpecialization(typeName, fullName)
                end
            end
        end
    end
end

TypeManager.validateTypes = Utils.prependedFunction(TypeManager.validateTypes, Puncture.register)

---------------------------------------------------------------
-- GLOBAL SPEED LIMIT PATCH
---------------------------------------------------------------
if Vehicle ~= nil and Vehicle.getSpeedLimit ~= nil then
    Vehicle.getSpeedLimit = Utils.overwrittenFunction(
        Vehicle.getSpeedLimit,
        function(self, superFunc, ...)
            local limit, doCheckSpeedLimit = superFunc(self, ...)
            local spec = self.spec_puncture
            if spec ~= nil and spec.active == true then
                -- Use our custom speed if it exists, otherwise default to 8.0
                local targetSpeed = spec.new_speed or 8.0
                limit = math.min(limit, targetSpeed)
            end
            return limit, doCheckSpeedLimit
        end
    )
end

addModEventListener(Puncture)
log("mod now fully loaded")
