
ChainsawFuel = {}
local ChainsawFuel_mt = Class(ChainsawFuel)

-- config
ChainsawFuel.DEFAULT_FUEL  = 1.0
ChainsawFuel.DRAIN_PER_SEC = 0.006 -- fuel drain rate

-- pricing
ChainsawFuel.TANK_GALLONS = 5.0

-- hud
ChainsawFuel.HUD_W = 0.14
ChainsawFuel.HUD_H = 0.018

local GAL_TO_L = 3.785411784
local SOURCE_SCAN_MS = 250
local SOURCE_RANGE_SQ = 196.0
local OBJECT_SCAN_RANGE_SQ = 625.0


local CF_ENABLE_DEBUG = false

local function CF_DBG(msg)
    if CF_ENABLE_DEBUG then
        print(string.format("[ChainsawFuel] %s", tostring(msg)))
    end
end

function ChainsawFuel.new()
    local self = setmetatable({}, ChainsawFuel_mt)

    self.fuelByPlayer = {}

    self._playerHooksInstalled = false
    self._chainsawHooked = false
    self._cutBlockHooksInstalled = false

    self._lastSawKey  = nil
    self._lastSawTime = nil

    self._refuelHeld = false
    self._refuelHeldPrev = false
    self._refuelActionPulse = false

    self._emptyMsgTimer = 0
    self._refillMsgTimer = 0
    self.inFuelTrigger = false
    self._fuelTriggerNodes = {}
    self._fuelTriggerActiveCount = 0
    self.nearDieselSource = false
    self._nearDieselSourceDistance = nil
    self._nearDieselHintTimer = 0
    self.activeFuelSource = nil
    self._lastFuelSourceScan = 0
    self.savedCameraRotatableInfo = {}
    self.hudX = 0.84
    self.hudY = 0.20
    self.hudMoveStep = 0.0025
    self._hudMoveHeld = false
    self._emptyChainsawPendingRefill = false
    self._emptyChainsawPlayerKey = nil
    self._emptyChainsawRefillUntil = 0
    self.debugFuelEnabled = false
    self._debugToggleHeld = false
    self._debugDumpHeld = false

    return self
end

function ChainsawFuel:loadMap()
    if g_currentMission ~= nil then
        g_currentMission.chainsawFuelMod = self
    end

    self:installPlayerHooks()
    self:registerFuelTriggers()
    self:installChainsawHook()
    self:installCutBlockHooks()
    if g_dedicatedServer == nil then
        self:registerInput()
    end

    self:loadFromSavegame()
end

function ChainsawFuel:deleteMap()
    self:removeInput()
end

-- player hook
function ChainsawFuel:installPlayerHooks()
    if self._playerHooksInstalled then return end

    if Player == nil or Utils == nil or Utils.overwrittenFunction == nil then
        return
    end

    if type(Player.update) ~= "function" then
        return
    end

    self._playerHooksInstalled = true

    Player.update = Utils.overwrittenFunction(Player.update, function(playerSelf, superFunc, dt, ...)
        local r
        if type(superFunc) == "function" then
            r = superFunc(playerSelf, dt, ...)
        end

        local mod = g_currentMission and g_currentMission.chainsawFuelMod or nil
        if mod ~= nil then
            mod:_playerChainsawTick(playerSelf, dt)
        end

        return r
    end)

    CF_DBG("Installed Player.update hook")
end

function ChainsawFuel:installChainsawHook()
    self:installCutBlockHooks()
end

function ChainsawFuel:_getFuelForTool(tool)
    local player = nil
    if tool ~= nil then
        player = tool.carryingPlayer or tool.player or g_localPlayer
    else
        player = g_localPlayer
    end

    local key = self:getPlayerKey(player)
    local fuel = self.fuelByPlayer[key]
    if fuel == nil then
        fuel = ChainsawFuel.DEFAULT_FUEL
    end

    return fuel, key, player
end

function ChainsawFuel:_blockCutIfEmpty(tool)
    local fuel, _, player = self:_getFuelForTool(tool)
    if fuel > 0 then
        return false
    end

    if player == g_localPlayer then
        self:_notifyOutOfFuel()
    end

    return true
end

function ChainsawFuel:installCutBlockHooks()
    if self._cutBlockHooksInstalled then return end
    if HandToolChainsaw == nil or Utils == nil or Utils.overwrittenFunction == nil then
        return
    end

    local installedAny = false

    local function installMethod(name)
        if type(HandToolChainsaw[name]) ~= "function" then
            return false
        end

        HandToolChainsaw[name] = Utils.overwrittenFunction(HandToolChainsaw[name], function(tool, superFunc, ...)
            local mod = g_currentMission and g_currentMission.chainsawFuelMod or nil
            if mod ~= nil and mod:_blockCutIfEmpty(tool) then
                if name == "testIfCutAllowed" then
                    return false
                end
                return nil
            end

            if type(superFunc) == "function" then
                return superFunc(tool, ...)
            end
        end)

        CF_DBG("Installed HandToolChainsaw." .. tostring(name) .. " hook")
        return true
    end

    installedAny = installMethod("testIfCutAllowed") or installedAny
    installedAny = installMethod("splitShape") or installedAny
    installedAny = installMethod("cutSplitShape") or installedAny
    installedAny = installMethod("cutTree") or installedAny

    self._cutBlockHooksInstalled = installedAny
end

-- handtool
function ChainsawFuel:_getPlayerHandTool(playerSelf)
    local tool = nil
    if playerSelf ~= nil and playerSelf.getCurrentHandTool ~= nil then
        tool = playerSelf:getCurrentHandTool()
    end
    if tool == nil and playerSelf ~= nil then
        tool = playerSelf.currentHandtool or playerSelf.currentHandTool or playerSelf.handTool
    end
    return tool
end

function ChainsawFuel:_isChainsawTool(tool)
    if tool == nil then return false end
    local s = table.concat({
        tostring(tool.typeName),
        tostring(tool.className),
        tostring(tool.configFileName),
        tostring(tool.filename),
        tostring(tool.i3dFilename)
    }, " "):lower()

    return (s:find("chainsaw") ~= nil) or ((s:find("handtool") ~= nil) and (s:find("saw") ~= nil))
end

-- update

function ChainsawFuel:_playerChainsawTick(playerSelf, dt)
    local tool = self:_getPlayerHandTool(playerSelf)
    if not self:_isChainsawTool(tool) then
        return
    end

    self._lastSawTime = g_time or 0
    self._lastSawKey = self:getPlayerKey(playerSelf)

    local key = self._lastSawKey or "local"
    if self.fuelByPlayer[key] == nil then
        self.fuelByPlayer[key] = ChainsawFuel.DEFAULT_FUEL
    end

    local fuel = self.fuelByPlayer[key]

    local tryingToUse = false
    local hasAnyFlag = false

    if tool.getIsRunning ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool:getIsRunning() == true) end
    if tool.isRunning ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool.isRunning == true) end
    if tool.isWorking ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool.isWorking == true) end
    if tool.getIsWorking ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool:getIsWorking() == true) end
    if tool.isCutting ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool.isCutting == true) end
    if tool.getIsCutting ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool:getIsCutting() == true) end
    if tool.isActive ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool.isActive == true) end
    if tool.getIsActive ~= nil then hasAnyFlag = true; tryingToUse = tryingToUse or (tool:getIsActive() == true) end

    local localInputUse = false
    if playerSelf == g_localPlayer and Input ~= nil and Input.isMouseButtonPressed ~= nil then
        if Input.MOUSE_BUTTON_LEFT ~= nil and Input.isMouseButtonPressed(Input.MOUSE_BUTTON_LEFT) == true then
            localInputUse = true
            tryingToUse = true
        end
    end

    if fuel <= 0 then
        self.fuelByPlayer[key] = 0
        self:forceChainsawCutBlocked(tool)
        self:forceUnequipChainsaw(playerSelf)
        if tryingToUse then
            self:_notifyOutOfFuel()
        end
        return
    end

    if not tryingToUse then
        return
    end

    if not localInputUse and not hasAnyFlag then
        return
    end

    fuel = math.max(0, fuel - (ChainsawFuel.DRAIN_PER_SEC * (dt / 1000)))
    self.fuelByPlayer[key] = fuel

    if fuel <= 0 then
        self.fuelByPlayer[key] = 0
        self:forceChainsawCutBlocked(tool)
        self:forceUnequipChainsaw(playerSelf)
        self:_notifyOutOfFuel()
    end
end

-- input
function ChainsawFuel:registerInput()
    if g_inputBinding == nil then return end

    self.actionName = "REFUEL_CHAINSAW_FUEL"
    g_inputBinding:registerActionEvent(self.actionName, self, self.onRefuelAction, true, true, false, true)
end

function ChainsawFuel:removeInput()
    if g_inputBinding == nil then return end
    g_inputBinding:removeActionEventsByTarget(self)
end

function ChainsawFuel:onRefuelAction(actionName, inputValue, callbackState, isAnalog)
    if inputValue ~= nil and inputValue > 0.5 then
        self._refuelActionPulse = true
    end
end

function ChainsawFuel:_pollRefuelHeld()
    local held = false

    if Input ~= nil and Input.isKeyPressed ~= nil then
        if Input.KEY_r ~= nil and Input.isKeyPressed(Input.KEY_r) == true then
            held = true
        elseif Input.KEY_R ~= nil and Input.isKeyPressed(Input.KEY_R) == true then
            held = true
        end
    end

    if not held and self._refuelActionPulse == true then
        self._refuelActionPulse = false
        held = true
    end

    return held
end

-- refill
function ChainsawFuel:_getDieselPricePerLiter()
    local ft = (FillType ~= nil and FillType.DIESEL) or nil

    if g_currentMission ~= nil and g_currentMission.economyManager ~= nil then
        local em = g_currentMission.economyManager
        if em.getPricePerLiter ~= nil and ft ~= nil then
            local ok, v = pcall(em.getPricePerLiter, em, ft)
            if ok and type(v) == "number" and v > 0 then
                return v
            end
        end
    end

    if g_economyManager ~= nil and g_economyManager.getPricePerLiter ~= nil and ft ~= nil then
        local ok, v = pcall(g_economyManager.getPricePerLiter, g_economyManager, ft)
        if ok and type(v) == "number" and v > 0 then
            return v
        end
    end

    return 1.50
end

function ChainsawFuel:_getDieselPricePerGallon()
    return self:_getDieselPricePerLiter() * GAL_TO_L
end


function ChainsawFuel:_getDieselFillType()
    if FillType ~= nil then
        return FillType.DIESEL
    end
    return nil
end

function ChainsawFuel:_objectSupportsDiesel(object)
    local diesel = self:_getDieselFillType()
    if object == nil or diesel == nil then
        return false
    end

    if object.fillType ~= nil and object.fillType == diesel then
        return true
    end

    local supportedTables = {
        object.supportedFillTypes,
        object.fillTypes,
        object.spec_buyingStation and object.spec_buyingStation.supportedFillTypes or nil,
        object.spec_loadingStation and object.spec_loadingStation.supportedFillTypes or nil,
        object.spec_sellingStation and object.spec_sellingStation.supportedFillTypes or nil,
        object.spec_fillUnit and object.spec_fillUnit.supportedFillTypes or nil,
        object.spec_storage and object.spec_storage.supportedFillTypes or nil,
        object.spec_silo and object.spec_silo.supportedFillTypes or nil
    }

    for _, supported in ipairs(supportedTables) do
        if supported ~= nil then
            for fillType, value in pairs(supported) do
                if fillType == diesel and value ~= false and value ~= 0 then
                    return true
                end
            end
        end
    end

    return false
end

function ChainsawFuel:_distanceSq(x1, y1, z1, x2, y2, z2)
    local dx = x1 - x2
    local dy = y1 - y2
    local dz = z1 - z2
    return dx * dx + dy * dy + dz * dz
end


function ChainsawFuel:_getObjectLabel(object)
    if object == nil then
        return "nil"
    end

    return tostring(object.configFileName or object.xmlFilename or object.filename or object.typeName or object.className or object.customEnvironment or object)
end

function ChainsawFuel:_getFillTypeDebugName(fillType)
    if fillType == nil then
        return "nil"
    end

    if g_fillTypeManager ~= nil then
        if g_fillTypeManager.getFillTypeNameByIndex ~= nil then
            local ok, name = pcall(g_fillTypeManager.getFillTypeNameByIndex, g_fillTypeManager, fillType)
            if ok and name ~= nil then
                return string.format("%s[%s]", tostring(name), tostring(fillType))
            end
        end

        if g_fillTypeManager.indexToName ~= nil and g_fillTypeManager.indexToName[fillType] ~= nil then
            return string.format("%s[%s]", tostring(g_fillTypeManager.indexToName[fillType]), tostring(fillType))
        end
    end

    return tostring(fillType)
end

function ChainsawFuel:_collectNamedCandidateNodesFromValue(value, keyName, entries, seen, depth)
    if value == nil or depth <= 0 then
        return
    end

    local valueType = type(value)
    local loweredKey = tostring(keyName or ""):lower()

    if valueType == "number" then
        if loweredKey:find("node", 1, true) ~= nil or loweredKey:find("trigger", 1, true) ~= nil or loweredKey:find("marker", 1, true) ~= nil
            or loweredKey:find("discharge", 1, true) ~= nil or loweredKey:find("fillroot", 1, true) ~= nil then
            if entityExists(value) and not seen[value] then
                seen[value] = true
                table.insert(entries, { name = tostring(keyName), node = value })
            end
        end
        return
    end

    if valueType ~= "table" then
        return
    end

    for childKey, childValue in pairs(value) do
        local childName = (keyName ~= nil and tostring(keyName) ~= "") and (tostring(keyName) .. "." .. tostring(childKey)) or tostring(childKey)
        self:_collectNamedCandidateNodesFromValue(childValue, childName, entries, seen, depth - 1)
    end
end

function ChainsawFuel:_getNamedCandidateNodesForObject(object)
    local entries = {}
    local seen = {}

    local rootNode = self:_getObjectRootNode(object)
    if rootNode ~= nil and entityExists(rootNode) then
        seen[rootNode] = true
        table.insert(entries, { name = "rootNode", node = rootNode })
    end

    if object ~= nil then
        self:_collectNamedCandidateNodesFromValue(object.spec_buyingStation, "spec_buyingStation", entries, seen, 3)
        self:_collectNamedCandidateNodesFromValue(object.buyingStation, "buyingStation", entries, seen, 3)
        self:_collectNamedCandidateNodesFromValue(object.spec_loadingStation, "spec_loadingStation", entries, seen, 3)
        self:_collectNamedCandidateNodesFromValue(object.spec_unloadingStation, "spec_unloadingStation", entries, seen, 3)
        self:_collectNamedCandidateNodesFromValue(object.spec_silo, "spec_silo", entries, seen, 4)
        self:_collectNamedCandidateNodesFromValue(object.spec_fillUnit, "spec_fillUnit", entries, seen, 3)
        self:_collectNamedCandidateNodesFromValue(object.spec_storage, "spec_storage", entries, seen, 3)
    end

    return entries
end

function ChainsawFuel:_dumpFuelObjectDebug(object, px, py, pz, prefix)
    if object == nil then
        CF_DBG((prefix or "fuel") .. " | object=nil")
        return
    end

    local source = self:_inspectFuelObject(object)
    local label = self:_getObjectLabel(object)
    local isStation = self:_isStationObject(object)
    local rootNode = self:_getObjectRootNode(object)
    local rootDist = nil
    if rootNode ~= nil and px ~= nil and py ~= nil and pz ~= nil and entityExists(rootNode) then
        local ox, oy, oz = getWorldTranslation(rootNode)
        if ox ~= nil then
            rootDist = math.sqrt(self:_distanceSq(px, py, pz, ox, oy, oz))
        end
    end

    CF_DBG(string.format("%s | obj=%s | isStation=%s | rootDist=%s | inspectedType=%s | fillUnitIndex=%s | liters=%s | capacity=%s | readable=%s | empty=%s",
        tostring(prefix or "fuel"),
        tostring(label),
        tostring(isStation),
        rootDist ~= nil and string.format("%.2f", rootDist) or "n/a",
        tostring(source and source.type or "nil"),
        tostring(source and source.fillUnitIndex or "nil"),
        tostring(source and source.liters or "nil"),
        tostring(source and source.capacity or "nil"),
        tostring(source and source.hasReadableFillUnit or false),
        tostring(source and source.isEmpty or false)
    ))

    local indices = self:_getObjectFillUnitIndices(object)
    if #indices == 0 then
        CF_DBG("  no fill units found")
    else
        for _, fillUnitIndex in ipairs(indices) do
            local fillType = self:_getFillUnitValue(object, "getFillUnitFillType", fillUnitIndex, nil)
            local liters = self:_getFillUnitValue(object, "getFillUnitFillLevel", fillUnitIndex, nil)
            local capacity = self:_getFillUnitValue(object, "getFillUnitCapacity", fillUnitIndex, nil)
            local supportsDiesel = self:_fillUnitSupportsDiesel(object, fillUnitIndex)
            CF_DBG(string.format("  fillUnit[%s] | fillType=%s | liters=%s | capacity=%s | supportsDiesel=%s",
                tostring(fillUnitIndex),
                self:_getFillTypeDebugName(fillType),
                tostring(liters),
                tostring(capacity),
                tostring(supportsDiesel)
            ))
        end
    end

    local storageEntries = self:_getStorageEntriesForObject(object)
    if #storageEntries == 0 then
        CF_DBG("  no storages found")
    else
        local diesel = self:_getDieselFillType()
        for _, entry in ipairs(storageEntries) do
            local storage = entry.storage
            local liters = self:_getStorageValue(storage, "getFillLevel", diesel)
            if type(liters) ~= "number" and type(storage.fillLevels) == "table" then
                liters = storage.fillLevels[diesel]
            end
            local capacity = self:_getStorageCapacity(storage, diesel)
            local supportsDiesel = self:_getStorageValue(storage, "getIsFillTypeSupported", diesel)
            if supportsDiesel == nil and type(storage.fillTypes) == "table" then
                supportsDiesel = storage.fillTypes[diesel] == true
            end
            CF_DBG(string.format("  storage[%s] | owner=%s | liters=%s | capacity=%s | supportsDiesel=%s",
                tostring(entry.index),
                tostring(entry.ownerName),
                tostring(liters),
                tostring(capacity),
                tostring(supportsDiesel)
            ))
        end
    end

    local namedNodes = self:_getNamedCandidateNodesForObject(object)
    if #namedNodes == 0 then
        CF_DBG("  no candidate nodes found")
    else
        for _, entry in ipairs(namedNodes) do
            local distText = "n/a"
            if px ~= nil and py ~= nil and pz ~= nil and entry.node ~= nil and entityExists(entry.node) then
                local nx, ny, nz = getWorldTranslation(entry.node)
                if nx ~= nil then
                    distText = string.format("%.2f", math.sqrt(self:_distanceSq(px, py, pz, nx, ny, nz)))
                end
            end
            CF_DBG(string.format("  node | %s | id=%s | dist=%s", tostring(entry.name), tostring(entry.node), tostring(distText)))
        end
    end
end

function ChainsawFuel:_dumpNearbyFuelDebug()
    if g_localPlayer == nil or g_localPlayer.rootNode == nil then
        CF_DBG("Debug dump aborted | no local player")
        return
    end

    local px, py, pz = getWorldTranslation(g_localPlayer.rootNode)
    local nearbyObjects = {}
    self:_scanMissionObjectsForFuel(px, py, pz, nearbyObjects)

    local list = {}
    for object, data in pairs(nearbyObjects) do
        local source = self:_inspectFuelObject(object)
        local accurateDistanceSq, accurateNode = self:_getObjectDistanceSq(object, px, py, pz, OBJECT_SCAN_RANGE_SQ)
        if accurateDistanceSq ~= nil or source ~= nil then
            table.insert(list, {
                object = object,
                data = data,
                source = source,
                distanceSq = accurateDistanceSq or (data and data.distanceSq) or math.huge,
                node = accurateNode or (data and data.node) or nil
            })
        end
    end

    table.sort(list, function(a, b)
        return (a.distanceSq or math.huge) < (b.distanceSq or math.huge)
    end)

    CF_DBG("========== Fuel Debug Dump ==========")
    if self.activeFuelSource ~= nil then
        CF_DBG(string.format("Current active source | type=%s | dist=%.2f | obj=%s | fillUnit=%s | liters=%s | capacity=%s | readable=%s | empty=%s",
            tostring(self.activeFuelSource.type),
            tonumber(self.activeFuelSource.distance or -1) or -1,
            tostring(self:_getObjectLabel(self.activeFuelSource.object)),
            tostring(self.activeFuelSource.fillUnitIndex or "nil"),
            tostring(self.activeFuelSource.liters or "nil"),
            tostring(self.activeFuelSource.capacity or "nil"),
            tostring(self.activeFuelSource.hasReadableFillUnit or false),
            tostring(self.activeFuelSource.isEmpty or false)
        ))
    else
        CF_DBG("Current active source | nil")
    end

    if #list == 0 then
        CF_DBG("No nearby fuel candidates")
    else
        local count = math.min(#list, 10)
        CF_DBG(string.format("Nearby candidates: %d (showing %d)", #list, count))
        for i = 1, count do
            local entry = list[i]
            local label = self:_getObjectLabel(entry.object)
            local dist = math.sqrt(entry.distanceSq or math.huge)
            CF_DBG(string.format("Candidate %d | dist=%.2f | obj=%s | inspectedType=%s", i, dist, tostring(label), tostring(entry.source and entry.source.type or "nil")))
            self:_dumpFuelObjectDebug(entry.object, px, py, pz, "candidate[" .. tostring(i) .. "]")
        end
    end
    CF_DBG("====================================")
end

function ChainsawFuel:_updateDebugHotkeys()
    return
end

function ChainsawFuel:drawFuelDebugText()
    return
end

function ChainsawFuel:_getObjectFillUnitIndices(object)
    local indices = {}
    local seen = {}

    if object ~= nil and object.getFillUnits ~= nil then
        local ok, fillUnits = pcall(object.getFillUnits, object)
        if ok and type(fillUnits) == "table" then
            for index, _ in pairs(fillUnits) do
                if type(index) == "number" and not seen[index] then
                    seen[index] = true
                    table.insert(indices, index)
                end
            end
        end
    end

    if #indices == 0 and object ~= nil and object.getFillUnitExists ~= nil then
        for index = 1, 16 do
            local ok, exists = pcall(object.getFillUnitExists, object, index)
            if ok and exists and not seen[index] then
                seen[index] = true
                table.insert(indices, index)
            end
        end
    end

    table.sort(indices)
    return indices
end

function ChainsawFuel:_getFillUnitValue(object, methodName, fillUnitIndex, fallback)
    if object == nil or methodName == nil then
        return fallback
    end
    local fn = object[methodName]
    if type(fn) ~= "function" then
        return fallback
    end

    local ok, value = pcall(fn, object, fillUnitIndex)
    if ok then
        return value
    end

    return fallback
end

function ChainsawFuel:_fillUnitSupportsDiesel(object, fillUnitIndex)
    local diesel = self:_getDieselFillType()
    if object == nil or fillUnitIndex == nil or diesel == nil then
        return false
    end

    if object.getFillUnitSupportsFillType ~= nil then
        local ok, value = pcall(object.getFillUnitSupportsFillType, object, fillUnitIndex, diesel)
        if ok and value == true then
            return true
        end
    end

    local supported = self:_getFillUnitValue(object, "getFillUnitSupportedFillTypes", fillUnitIndex, nil)
    if type(supported) == "table" then
        return supported[diesel] ~= nil and supported[diesel] ~= false and supported[diesel] ~= 0
    end

    local currentFillType = self:_getFillUnitValue(object, "getFillUnitFillType", fillUnitIndex, nil)
    if currentFillType == diesel then
        return true
    end

    return false
end

function ChainsawFuel:_getObjectRootNode(object)
    if object == nil then
        return nil
    end

    if object.rootNode ~= nil and entityExists(object.rootNode) then
        return object.rootNode
    end
    if object.node ~= nil and entityExists(object.node) then
        return object.node
    end
    if object.components ~= nil and object.components[1] ~= nil then
        local componentNode = object.components[1].node or object.components[1].rootNode
        if componentNode ~= nil and entityExists(componentNode) then
            return componentNode
        end
    end

    return nil
end

function ChainsawFuel:_addCandidateNode(nodes, seen, node)
    if node == nil then
        return
    end
    if type(node) ~= "number" then
        return
    end
    if not entityExists(node) then
        return
    end
    if seen[node] then
        return
    end

    seen[node] = true
    table.insert(nodes, node)
end

function ChainsawFuel:_collectCandidateNodesFromValue(value, keyName, nodes, seen, depth)
    if value == nil or depth <= 0 then
        return
    end

    local valueType = type(value)
    if valueType == "number" then
        local key = tostring(keyName or ""):lower()
        if key:find("node", 1, true) ~= nil or key:find("trigger", 1, true) ~= nil or key:find("marker", 1, true) ~= nil
            or key:find("discharge", 1, true) ~= nil or key:find("fillroot", 1, true) ~= nil then
            self:_addCandidateNode(nodes, seen, value)
        end
        return
    end

    if valueType ~= "table" then
        return
    end

    for childKey, childValue in pairs(value) do
        local childKeyStr = tostring(childKey)
        if type(childValue) == "number" then
            local lowered = childKeyStr:lower()
            if lowered:find("node", 1, true) ~= nil or lowered:find("trigger", 1, true) ~= nil or lowered:find("marker", 1, true) ~= nil
                or lowered:find("discharge", 1, true) ~= nil or lowered:find("fillroot", 1, true) ~= nil then
                self:_addCandidateNode(nodes, seen, childValue)
            end
        elseif type(childValue) == "table" then
            self:_collectCandidateNodesFromValue(childValue, childKeyStr, nodes, seen, depth - 1)
        end
    end
end

function ChainsawFuel:_getCandidateNodesForObject(object)
    local nodes = {}
    local seen = {}

    self:_addCandidateNode(nodes, seen, self:_getObjectRootNode(object))

    if object ~= nil then
        self:_collectCandidateNodesFromValue(object.spec_buyingStation, "spec_buyingStation", nodes, seen, 3)
        self:_collectCandidateNodesFromValue(object.buyingStation, "buyingStation", nodes, seen, 3)
        self:_collectCandidateNodesFromValue(object.spec_loadingStation, "spec_loadingStation", nodes, seen, 3)
        self:_collectCandidateNodesFromValue(object.spec_unloadingStation, "spec_unloadingStation", nodes, seen, 3)
        self:_collectCandidateNodesFromValue(object.spec_silo, "spec_silo", nodes, seen, 4)
        self:_collectCandidateNodesFromValue(object.spec_fillUnit, "spec_fillUnit", nodes, seen, 3)
        self:_collectCandidateNodesFromValue(object.spec_storage, "spec_storage", nodes, seen, 3)
    end

    return nodes
end

function ChainsawFuel:_getObjectDistanceSq(object, px, py, pz, maxDistanceSq)
    if object == nil then
        return nil, nil
    end

    local bestDistSq = nil
    local bestNode = nil
    local nodes = self:_getCandidateNodesForObject(object)

    for _, node in ipairs(nodes) do
        if entityExists(node) then
            local ox, oy, oz = getWorldTranslation(node)
            if ox ~= nil then
                local distanceSq = self:_distanceSq(px, py, pz, ox, oy, oz)
                if distanceSq <= (maxDistanceSq or OBJECT_SCAN_RANGE_SQ) and (bestDistSq == nil or distanceSq < bestDistSq) then
                    bestDistSq = distanceSq
                    bestNode = node
                end
            end
        end
    end

    return bestDistSq, bestNode
end

function ChainsawFuel:_isStationObject(object)
    if object == nil then
        return false
    end

    if object.spec_buyingStation ~= nil or object.buyingStation ~= nil or object.isBuyingStation == true then
        return true
    end

    if object.spec_silo ~= nil or object.spec_storage ~= nil then
        return false
    end

    local label = table.concat({
        tostring(object.typeName),
        tostring(object.className),
        tostring(object.customEnvironment),
        tostring(object.configFileName),
        tostring(object.xmlFilename),
        tostring(object.filename)
    }, " "):lower()

    if label:find("buyingstation", 1, true) ~= nil or label:find("fuelstation", 1, true) ~= nil
        or label:find("gasstation", 1, true) ~= nil or label:find("dieselstation", 1, true) ~= nil
        or label:find("fuel pump", 1, true) ~= nil or label:find("gas pump", 1, true) ~= nil then
        return true
    end

    return false
end

function ChainsawFuel:_isVehicleObject(object)
    if object == nil then
        return false
    end

    if object.spec_motorized ~= nil or object.spec_drivable ~= nil or object.spec_enterable ~= nil then
        return true
    end

    if object.getIsVehicle ~= nil then
        local ok, value = pcall(object.getIsVehicle, object)
        if ok and value == true then
            return true
        end
    end

    local label = table.concat({
        tostring(object.typeName),
        tostring(object.className),
        tostring(object.configFileName),
        tostring(object.xmlFilename),
        tostring(object.filename)
    }, " "):lower()

    return label:find("data/vehicles/", 1, true) ~= nil or label:find(" vehicle", 1, true) ~= nil
end

function ChainsawFuel:_getStorageEntriesForObject(object)
    local entries = {}

    local function addStorageList(storageList, ownerName)
        if type(storageList) ~= "table" then
            return
        end

        for index, storage in pairs(storageList) do
            if type(storage) == "table" then
                table.insert(entries, {
                    storage = storage,
                    index = index,
                    ownerName = ownerName
                })
            end
        end
    end

    if object ~= nil then
        addStorageList(object.spec_silo and object.spec_silo.storages or nil, "spec_silo.storages")
        addStorageList(object.spec_storage and object.spec_storage.storages or nil, "spec_storage.storages")
        addStorageList(object.storages, "storages")
    end

    table.sort(entries, function(a, b)
        return (tonumber(a.index) or 0) < (tonumber(b.index) or 0)
    end)

    return entries
end

function ChainsawFuel:_getStorageValue(storage, methodName, ...)
    if storage == nil or methodName == nil then
        return nil
    end

    local fn = storage[methodName]
    if type(fn) ~= "function" then
        return nil
    end

    local ok, value = pcall(fn, storage, ...)
    if ok then
        return value
    end

    return nil
end

function ChainsawFuel:_getStorageCapacity(storage, fillType)
    local capacity = self:_getStorageValue(storage, "getCapacity", fillType)
    if type(capacity) == "number" then
        return capacity
    end

    if type(storage.capacities) == "table" and storage.capacities[fillType] ~= nil then
        return storage.capacities[fillType]
    end

    if type(storage.capacity) == "number" then
        return storage.capacity
    end

    return 0
end

function ChainsawFuel:_inspectStorageBackedTank(object)
    local diesel = self:_getDieselFillType()
    if object == nil or diesel == nil then
        return nil
    end

    local best = nil
    for _, entry in ipairs(self:_getStorageEntriesForObject(object)) do
        local storage = entry.storage
        local supportsDiesel = false

        local supportValue = self:_getStorageValue(storage, "getIsFillTypeSupported", diesel)
        if supportValue == true then
            supportsDiesel = true
        elseif type(storage.fillTypes) == "table" and storage.fillTypes[diesel] == true then
            supportsDiesel = true
        end

        local liters = self:_getStorageValue(storage, "getFillLevel", diesel)
        if type(liters) ~= "number" and type(storage.fillLevels) == "table" then
            liters = storage.fillLevels[diesel]
        end
        liters = liters or 0

        if supportsDiesel or liters > 0.001 then
            local capacity = self:_getStorageCapacity(storage, diesel)
            local candidate = {
                type = "tank",
                object = object,
                storage = storage,
                storageIndex = entry.index,
                storageOwnerName = entry.ownerName,
                fillUnitIndex = nil,
                liters = liters,
                capacity = capacity,
                fillType = diesel,
                hasReadableFillUnit = true,
                hasReadableStorage = true,
                isEmpty = liters <= 0.001,
                isPlaceableStorage = true,
                isExplicitRefuelPoint = object.spec_silo ~= nil or object.spec_loadingStation ~= nil,
                sourcePriority = (object.spec_silo ~= nil or object.spec_loadingStation ~= nil) and 450 or 420
            }

            if candidate.isEmpty then
                candidate.sourcePriority = candidate.sourcePriority - 10
            end

            if best == nil or candidate.liters > (best.liters or -1) then
                best = candidate
            end
        end
    end

    return best
end

function ChainsawFuel:_inspectFuelObject(object)
    local diesel = self:_getDieselFillType()
    if object == nil or diesel == nil then
        return nil
    end

    local isStation = self:_isStationObject(object)
    local isVehicle = self:_isVehicleObject(object)
    local storageTank = nil
    if not isStation then
        storageTank = self:_inspectStorageBackedTank(object)
    end

    -- Release behavior: only explicit placeable/storage tanks and gas stations are valid
    -- player refuel sources. Regular vehicle diesel tanks should never be selected.
    if isVehicle and storageTank == nil and not isStation then
        return nil
    end

    local bestIndex = nil
    local bestLiters = nil
    local bestCapacity = 0
    local bestFillType = nil
    local exactDieselIndex = nil
    local exactDieselLiters = nil
    local exactDieselCapacity = 0
    local supportsAnyDiesel = false

    for _, fillUnitIndex in ipairs(self:_getObjectFillUnitIndices(object)) do
        local supportsDiesel = self:_fillUnitSupportsDiesel(object, fillUnitIndex)
        local fillType = self:_getFillUnitValue(object, "getFillUnitFillType", fillUnitIndex, nil)
        local liters = self:_getFillUnitValue(object, "getFillUnitFillLevel", fillUnitIndex, 0) or 0
        local capacity = self:_getFillUnitValue(object, "getFillUnitCapacity", fillUnitIndex, 0) or 0

        if supportsDiesel or (fillType == diesel) then
            supportsAnyDiesel = true
        end

        if fillType == diesel then
            if exactDieselIndex == nil or liters > (exactDieselLiters or -1) then
                exactDieselIndex = fillUnitIndex
                exactDieselLiters = liters
                exactDieselCapacity = capacity
            end
        end

        if isStation and (supportsDiesel or (fillType == diesel)) then
            if bestIndex == nil or liters > (bestLiters or -1) then
                bestIndex = fillUnitIndex
                bestLiters = liters
                bestCapacity = capacity
                bestFillType = fillType
            end
        end
    end

    if isStation and bestIndex ~= nil then
        local liters = bestLiters or math.huge
        local capacity = bestCapacity or 0
        if liters <= 0 then
            liters = math.huge
        end
        return {
            type = "station",
            object = object,
            fillUnitIndex = bestIndex,
            liters = liters,
            capacity = capacity,
            fillType = bestFillType or diesel,
            hasReadableFillUnit = true,
            isEmpty = false,
            sourcePriority = 500
        }
    end

    if storageTank ~= nil then
        return storageTank
    end

    if not isStation and exactDieselIndex ~= nil then
        local liters = exactDieselLiters or 0
        local capacity = exactDieselCapacity or 0
        return {
            type = "tank",
            object = object,
            fillUnitIndex = exactDieselIndex,
            liters = liters,
            capacity = capacity,
            fillType = diesel,
            hasReadableFillUnit = true,
            isEmpty = liters <= 0.001,
            sourcePriority = isVehicle and 300 or 380,
            isVehicleTank = isVehicle
        }
    end

    if isStation then
        return {
            type = "station",
            object = object,
            fillType = diesel,
            liters = math.huge,
            isEmpty = false,
            sourcePriority = 480
        }
    end

    if supportsAnyDiesel or self:_objectSupportsDiesel(object) then
        return {
            type = "tank",
            object = object,
            fillType = diesel,
            liters = 0,
            capacity = 0,
            fillUnitIndex = nil,
            hasReadableFillUnit = false,
            isEmpty = true,
            sourcePriority = isVehicle and 250 or 340,
            isVehicleTank = isVehicle
        }
    end

    return nil
end

function ChainsawFuel:_chooseBetterFuelSource(currentBest, candidate)
    if candidate == nil then
        return currentBest
    end
    if currentBest == nil then
        return candidate
    end

    local currentPriority = currentBest.sourcePriority or 0
    local candidatePriority = candidate.sourcePriority or 0
    if candidatePriority > currentPriority then
        return candidate
    end
    if candidatePriority < currentPriority then
        return currentBest
    end

    local function score(source)
        if source == nil then
            return -1
        end
        if source.type == "tank" and not source.isEmpty then
            return 4
        end
        if source.type == "station" then
            return 3
        end
        if source.type == "tank" then
            return 2
        end
        return 0
    end

    local bestScore = score(currentBest)
    local candidateScore = score(candidate)

    if candidateScore > bestScore then
        return candidate
    end
    if candidateScore < bestScore then
        return currentBest
    end

    local currentDist = currentBest.distanceSq or math.huge
    local candidateDist = candidate.distanceSq or math.huge
    if candidateDist < currentDist then
        return candidate
    end

    return currentBest
end

function ChainsawFuel:_scanMissionObjectsForFuel(px, py, pz, nearbyObjects)
    local mission = g_currentMission
    if mission == nil then
        return
    end

    if mission.nodeToObject ~= nil then
        for node, object in pairs(mission.nodeToObject) do
            if node ~= nil and object ~= nil and entityExists(node) then
                local ox, oy, oz = getWorldTranslation(node)
                if ox ~= nil then
                    local distanceSq = self:_distanceSq(px, py, pz, ox, oy, oz)
                    if distanceSq <= OBJECT_SCAN_RANGE_SQ then
                        local existing = nearbyObjects[object]
                        if existing == nil or distanceSq < existing.distanceSq then
                            nearbyObjects[object] = {
                                node = node,
                                distanceSq = distanceSq
                            }
                        end
                    end
                end
            end
        end
    end

    if mission.placeableSystem ~= nil and mission.placeableSystem.placeables ~= nil then
        for _, object in pairs(mission.placeableSystem.placeables) do
            if object ~= nil then
                local distanceSq, node = self:_getObjectDistanceSq(object, px, py, pz, OBJECT_SCAN_RANGE_SQ)
                if distanceSq ~= nil then
                    local existing = nearbyObjects[object]
                    if existing == nil or distanceSq < existing.distanceSq then
                        nearbyObjects[object] = {
                            node = node or self:_getObjectRootNode(object),
                            distanceSq = distanceSq
                        }
                    end
                end
            end
        end
    end
end

function ChainsawFuel:_scanActiveFuelSource(force)
    local now = g_time or 0
    if not force and self.activeFuelSource ~= nil and now < ((self._lastFuelSourceScan or 0) + SOURCE_SCAN_MS) then
        return self.activeFuelSource
    end

    self._lastFuelSourceScan = now
    local previous = self.activeFuelSource
    self.activeFuelSource = nil
    self.nearDieselSource = false
    self._nearDieselSourceDistance = nil

    if g_localPlayer == nil or g_localPlayer.rootNode == nil or g_currentMission == nil then
        return nil
    end

    local px, py, pz = getWorldTranslation(g_localPlayer.rootNode)
    local nearbyObjects = {}
    self:_scanMissionObjectsForFuel(px, py, pz, nearbyObjects)

    local best = nil
    for object, data in pairs(nearbyObjects) do
        local source = self:_inspectFuelObject(object)
        if source ~= nil then
            local accurateDistanceSq, accurateNode = self:_getObjectDistanceSq(object, px, py, pz, OBJECT_SCAN_RANGE_SQ)
            if accurateDistanceSq ~= nil then
                source.node = accurateNode or data.node
                source.distanceSq = accurateDistanceSq
                source.distance = math.sqrt(accurateDistanceSq)
                if accurateDistanceSq <= SOURCE_RANGE_SQ then
                    best = self:_chooseBetterFuelSource(best, source)
                end
            elseif data.distanceSq ~= nil and data.distanceSq <= SOURCE_RANGE_SQ then
                source.node = data.node
                source.distanceSq = data.distanceSq
                source.distance = math.sqrt(data.distanceSq)
                best = self:_chooseBetterFuelSource(best, source)
            end
        end
    end

    self.activeFuelSource = best
    if best ~= nil then
        self.nearDieselSource = true
        self._nearDieselSourceDistance = best.distance
    end

    if (previous == nil and best ~= nil) or (previous ~= nil and best == nil)
        or (previous ~= nil and best ~= nil and (previous.object ~= best.object or previous.type ~= best.type)) then
        if best ~= nil then
            CF_DBG(string.format("Active fuel source: %s | dist=%.2f | obj=%s", tostring(best.type), best.distance or -1, tostring(best.object.configFileName or best.object.typeName or best.object.className)))
            if self.debugFuelEnabled then
                self:_dumpFuelObjectDebug(best.object, px, py, pz, "activeSource")
            end
        else
            CF_DBG("Active fuel source cleared")
        end
    end

    return best
end

function ChainsawFuel:_updateDieselProximity()
    self:_scanActiveFuelSource(false)
end

function ChainsawFuel:_showInfo(msg)
    local mission = g_currentMission
    if mission == nil then return end

    local ok = false
    if mission.addIngameNotification ~= nil and FSBaseMission ~= nil and FSBaseMission.INGAME_NOTIFICATION_INFO ~= nil then
        ok = pcall(mission.addIngameNotification, mission, FSBaseMission.INGAME_NOTIFICATION_INFO, msg)
    end

    if not ok and mission.showBlinkingWarning ~= nil then
        pcall(mission.showBlinkingWarning, mission, msg, 2000)
    end
end

function ChainsawFuel:_resolveMoneyType()
    if MoneyType == nil then return nil end

    local candidates = {
        "SHOP_PROPERTY_MAINTENANCE",
        "PROPERTY_MAINTENANCE",
        "SHOP_VEHICLE_BUY",
        "VEHICLE_BUY",
        "OTHER",
        "MISC",
        "UNKNOWN"
    }

    for _, name in ipairs(candidates) do
        local v = MoneyType[name]
        if v ~= nil then
            return v
        end
    end

    for _, v in pairs(MoneyType) do
        if v ~= nil then
            return v
        end
    end

    return nil
end

function ChainsawFuel:_getChainsawCapacityLiters()
    return ChainsawFuel.TANK_GALLONS * GAL_TO_L
end

function ChainsawFuel:_getMissingFuelLiters(fuel)
    return math.max(0, (1.0 - (fuel or 0)) * self:_getChainsawCapacityLiters())
end

function ChainsawFuel:_consumeTankFuel(source, litersToUse, playerSelf)
    if source == nil or source.object == nil then
        return 0
    end

    local object = source.object
    local diesel = self:_getDieselFillType()

    if source.storage ~= nil then
        local storage = source.storage
        local before = self:_getStorageValue(storage, "getFillLevel", diesel)
        if type(before) ~= "number" and type(storage.fillLevels) == "table" then
            before = storage.fillLevels[diesel]
        end
        before = before or 0

        if before <= 0.001 then
            CF_DBG(string.format("Tank draw blocked | storage %s is empty | obj=%s", tostring(source.storageIndex or "nil"), tostring(object.configFileName or object.typeName or object.className)))
            return 0
        end

        local toUse = math.min(before, math.max(0, litersToUse or 0))
        if toUse <= 0.001 then
            return 0
        end

        local ok = false
        if storage.setFillLevel ~= nil then
            ok = pcall(storage.setFillLevel, storage, before - toUse, diesel, nil)
        end
        if not ok and type(storage.fillLevels) == "table" then
            storage.fillLevels[diesel] = math.max(0, before - toUse)
            ok = true
        end

        local after = self:_getStorageValue(storage, "getFillLevel", diesel)
        if type(after) ~= "number" and type(storage.fillLevels) == "table" then
            after = storage.fillLevels[diesel]
        end
        after = after or before

        local used = math.max(0, before - after)
        if used > 0.0001 then
            CF_DBG(string.format("Tank draw success | storage=%s | requested=%.2fL | used=%.2fL | before=%.2fL | after=%.2fL", tostring(source.storageIndex or "nil"), toUse, used, before, after))
            return used
        end

        CF_DBG(string.format("Tank draw failed | storage=%s | requested=%.2fL | before=%.2fL | after=%.2fL | obj=%s", tostring(source.storageIndex or "nil"), toUse, before, after, tostring(object.configFileName or object.typeName or object.className)))
        return 0
    end

    if source.fillUnitIndex == nil then
        CF_DBG(string.format("Tank draw blocked | no readable diesel source | obj=%s", tostring(object.configFileName or object.typeName or object.className)))
        return 0
    end

    local fillUnitIndex = source.fillUnitIndex
    local fillType = self:_getFillUnitValue(object, "getFillUnitFillType", fillUnitIndex, nil)
    local before = self:_getFillUnitValue(object, "getFillUnitFillLevel", fillUnitIndex, 0) or 0
    if fillType ~= diesel then
        CF_DBG(string.format("Tank draw blocked | fill unit %s is not diesel (fillType=%s) | obj=%s", tostring(fillUnitIndex), tostring(fillType), tostring(object.configFileName or object.typeName or object.className)))
        return 0
    end
    if before <= 0.001 then
        CF_DBG(string.format("Tank draw blocked | diesel fill unit %s is empty | obj=%s", tostring(fillUnitIndex), tostring(object.configFileName or object.typeName or object.className)))
        return 0
    end

    local toUse = math.min(before, math.max(0, litersToUse or 0))
    if toUse <= 0.001 then
        return 0
    end

    local farmId = (playerSelf and playerSelf.farmId) or 1
    local toolType = (ToolType ~= nil and ToolType.UNDEFINED) or 0

    if object.addFillUnitFillLevel ~= nil then
        local ok = pcall(object.addFillUnitFillLevel, object, farmId, fillUnitIndex, -toUse, diesel, toolType)
        if not ok then
            pcall(object.addFillUnitFillLevel, object, farmId, fillUnitIndex, -toUse, diesel)
        end
    end

    local after = self:_getFillUnitValue(object, "getFillUnitFillLevel", fillUnitIndex, 0) or before
    local used = math.max(0, before - after)

    if used > 0.0001 then
        CF_DBG(string.format("Tank draw success | requested=%.2fL | used=%.2fL | before=%.2fL | after=%.2fL", toUse, used, before, after))
        return used
    end

    CF_DBG(string.format("Tank draw failed | requested=%.2fL | before=%.2fL | after=%.2fL | obj=%s", toUse, before, after, tostring(object.configFileName or object.typeName or object.className)))
    return 0
end

function ChainsawFuel:_formatLitersText(liters)
    return string.format("%.1fL", math.max(0, liters or 0))
end

function ChainsawFuel:_tryPayRefill(playerSelf)
    if not self:_canRefillForPlayer(playerSelf) then
        return
    end

    local key = self:getPlayerKey(playerSelf)
    local fuel = self.fuelByPlayer[key] or ChainsawFuel.DEFAULT_FUEL
    if fuel >= 0.999 then
        return
    end

    local source = self:_scanActiveFuelSource(true)
    if source == nil then
        local now = g_time or 0
        if now > (self._refillMsgTimer or 0) then
            self._refillMsgTimer = now + 2000
            self:_showInfo("Go to a diesel tank or gas station to refuel")
        end
        return
    end

    local missingLiters = self:_getMissingFuelLiters(fuel)
    if missingLiters <= 0.001 then
        return
    end

    if self.debugFuelEnabled then
        CF_DBG(string.format("Refill attempt | source=%s | obj=%s | fillUnit=%s | liters=%s | capacity=%s | readable=%s | empty=%s | missing=%.2fL",
            tostring(source.type),
            tostring(self:_getObjectLabel(source.object)),
            tostring(source.fillUnitIndex or "nil"),
            tostring(source.liters or "nil"),
            tostring(source.capacity or "nil"),
            tostring(source.hasReadableFillUnit or false),
            tostring(source.isEmpty or false),
            missingLiters
        ))
        self:_dumpFuelObjectDebug(source.object, nil, nil, nil, "refillSource")
    end

    if source.type == "tank" then
        local availableLiters = math.max(0, source.liters or 0)
        if availableLiters <= 0.001 then
            local now = g_time or 0
            if now > (self._refillMsgTimer or 0) then
                self._refillMsgTimer = now + 2000
                self:_showInfo("Fuel tank is empty")
            end
            return
        end

        local usedLiters = self:_consumeTankFuel(source, missingLiters, playerSelf)
        if usedLiters <= 0.001 then
            local now = g_time or 0
            if now > (self._refillMsgTimer or 0) then
                self._refillMsgTimer = now + 2000
                self:_showInfo("Unable to draw fuel from this tank")
            end
            return
        end

        local addedFuel = usedLiters / self:_getChainsawCapacityLiters()
        self.fuelByPlayer[key] = math.min(1.0, fuel + addedFuel)
        self:saveToSavegame()
        self:_clearEmptyRefillWindow()
        self:_scanActiveFuelSource(true)

        local now = g_time or 0
        if now > (self._refillMsgTimer or 0) then
            self._refillMsgTimer = now + 1000
            if self.fuelByPlayer[key] >= 0.999 then
                self:_showInfo("Chainsaw refueled from tank")
            else
                self:_showInfo("Chainsaw partially refueled from tank (" .. self:_formatLitersText(usedLiters) .. ")")
            end
        end
        return
    end

    if source.type == "station" then
        local pricePerL = self:_getDieselPricePerLiter()
        local farmId = (playerSelf and playerSelf.farmId) or 1
        local mission = g_currentMission
        local availableMoney = nil

        if mission ~= nil and mission.getMoney ~= nil then
            availableMoney = mission:getMoney(farmId) or 0
        end

        local litersToBuy = missingLiters
        if availableMoney ~= nil and pricePerL > 0 then
            litersToBuy = math.min(litersToBuy, math.max(0, availableMoney / pricePerL))
        end

        if litersToBuy <= 0.001 then
            local now = g_time or 0
            if now > (self._refillMsgTimer or 0) then
                self._refillMsgTimer = now + 2000
                self:_showInfo("Not enough money to buy diesel")
            end
            return
        end

        local rawCost = litersToBuy * pricePerL
        local cost = math.max(0, math.floor(rawCost + 0.5))
        if litersToBuy > 0 and cost <= 0 then
            cost = 1
        end

        local charged = false
        local moneyType = self:_resolveMoneyType()
        if cost > 0 and mission ~= nil and mission.addMoney ~= nil and moneyType ~= nil then
            local ok, err = pcall(mission.addMoney, mission, -cost, farmId, moneyType, true)
            if ok then
                charged = true
            else
                CF_DBG("addMoney failed, refilling without charge: " .. tostring(err))
            end
        elseif cost > 0 then
            CF_DBG("No safe MoneyType found, refilling without charge")
        end

        local addedFuel = litersToBuy / self:_getChainsawCapacityLiters()
        self.fuelByPlayer[key] = math.min(1.0, fuel + addedFuel)
        self:saveToSavegame()
        self:_clearEmptyRefillWindow()

        local now = g_time or 0
        if now > (self._refillMsgTimer or 0) then
            self._refillMsgTimer = now + 1000
            if charged then
                if self.fuelByPlayer[key] >= 0.999 then
                    self:_showInfo(string.format("Chainsaw refueled (-$%d)", cost))
                else
                    self:_showInfo(string.format("Chainsaw partially refueled (-$%d)", cost))
                end
            else
                if self.fuelByPlayer[key] >= 0.999 then
                    self:_showInfo("Chainsaw refueled")
                else
                    self:_showInfo("Chainsaw partially refueled")
                end
            end
        end
    end
end

-- draw
function ChainsawFuel:update(dt)
    if g_currentMission == nil then return end

    if not self._playerHooksInstalled then
        self:installPlayerHooks()
    end
    if not self._cutBlockHooksInstalled then
        self:installCutBlockHooks()
    end

    self._refuelHeldPrev = self._refuelHeld
    self._refuelHeld = self:_pollRefuelHeld()
    self:_updateDieselProximity()
    self:_updateDebugHotkeys()
    self:updateHudFreeze()
    self:_updateHudMoveInput()

    if Input ~= nil and Input.isKeyPressed ~= nil then
        local alt = (Input.KEY_lalt and Input.isKeyPressed(Input.KEY_lalt)) or (Input.KEY_ralt and Input.isKeyPressed(Input.KEY_ralt))
        local h = (Input.KEY_h and Input.isKeyPressed(Input.KEY_h))
        if alt and h and not self._altHToggle then
            self:toggleHudMove()
            self._altHToggle = true
        elseif not (alt and h) then
            self._altHToggle = false
        end
    end

    if g_localPlayer ~= nil then
        local tool = self:_getPlayerHandTool(g_localPlayer)
        if self:_isChainsawTool(tool) then

            self._lastSawTime = g_time or 0
            self._lastSawKey = self:getPlayerKey(g_localPlayer)
        end

        if self._refuelHeld and not self._refuelHeldPrev then
            self:_tryPayRefill(g_localPlayer)
        end
    end
end


function ChainsawFuel:_updateHudMoveInput()
    if Input == nil or Input.isKeyPressed == nil then
        return
    end

    local ctrl = false
    if Input.KEY_lctrl ~= nil and Input.isKeyPressed(Input.KEY_lctrl) == true then ctrl = true end
    if Input.KEY_rctrl ~= nil and Input.isKeyPressed(Input.KEY_rctrl) == true then ctrl = true end

    local alt = false
    if Input.KEY_lalt ~= nil and Input.isKeyPressed(Input.KEY_lalt) == true then alt = true end
    if Input.KEY_ralt ~= nil and Input.isKeyPressed(Input.KEY_ralt) == true then alt = true end

    local active = ctrl and alt
    if not active then
        self._hudMoveHeld = false
        return
    end

    local moved = false
    local step = self.hudMoveStep or 0.0025

    if Input.KEY_left ~= nil and Input.isKeyPressed(Input.KEY_left) == true then
        self.hudX = math.max(0.01, (self.hudX or 0.84) - step)
        moved = true
    end
    if Input.KEY_right ~= nil and Input.isKeyPressed(Input.KEY_right) == true then
        self.hudX = math.min(0.95, (self.hudX or 0.84) + step)
        moved = true
    end
    if Input.KEY_up ~= nil and Input.isKeyPressed(Input.KEY_up) == true then
        self.hudY = math.min(0.95, (self.hudY or 0.20) + step)
        moved = true
    end
    if Input.KEY_down ~= nil and Input.isKeyPressed(Input.KEY_down) == true then
        self.hudY = math.max(0.01, (self.hudY or 0.20) - step)
        moved = true
    end

    if moved and not self._hudMoveHeld then
        self._hudMoveHeld = true
        self:saveToSavegame()
    elseif not moved then
        self._hudMoveHeld = false
    end
end

function ChainsawFuel:draw()
    local now = g_time or 0

    if Input ~= nil and Input.isKeyPressed ~= nil then
        local alt = (Input.KEY_lalt and Input.isKeyPressed(Input.KEY_lalt)) or (Input.KEY_ralt and Input.isKeyPressed(Input.KEY_ralt))
        local h = (Input.KEY_h and Input.isKeyPressed(Input.KEY_h))
        if alt and h and not self._altHToggle then
            self:toggleHudMove()
            self._altHToggle = true
        elseif not (alt and h) then
            self._altHToggle = false
        end
    end

    if g_localPlayer ~= nil then
        local tool = self:_getPlayerHandTool(g_localPlayer)
        if self:_isChainsawTool(tool) then
            self._lastSawTime = now
            self._lastSawKey = self:getPlayerKey(g_localPlayer)
        end
    end

    if self._lastSawTime == nil or (now - self._lastSawTime) > 1500 then
        return
    end

    local key = self._lastSawKey or "local"
    local fuel = self.fuelByPlayer[key] or ChainsawFuel.DEFAULT_FUEL
    self:drawFuelText(fuel)
    self:drawFuelDebugText()
end

function ChainsawFuel:drawFuelText(fuel)
    local pct = math.max(0, math.min(1, fuel or 0))
    local pctText = string.format("%d%%", math.floor(pct * 100 + 0.5))

    local x = self.hudX or 0.84
    local y = self.hudY or 0.20
    local boxW = 0.112
    local boxH = 0.024

    local padX = 0.004
    local padY = 0.003
    local barH = 0.007
    local barY = y + 0.004
    local textY = y + 0.014

    local alpha = 0.26
    if self.hudMoveEnabled then
        alpha = 0.42
    end

    drawFilledRect(x, y, boxW, boxH, 0, 0, 0, alpha)
    drawFilledRect(x, y + boxH - 0.001, boxW, 0.001, 0.65, 0.65, 0.65, 0.35)

    drawFilledRect(x + padX, barY, boxW - (padX * 2), barH, 0.18, 0.18, 0.18, 0.80)

    local fillW = (boxW - (padX * 2)) * pct
    local r, g, b = 0.22, 0.82, 0.28
    if pct < 0.35 then
        r, g, b = 0.90, 0.65, 0.18
    end
    if pct < 0.15 then
        r, g, b = 0.88, 0.22, 0.22
    end

    drawFilledRect(x + padX, barY, fillW, barH, r, g, b, 0.92)

    setTextBold(false)
    setTextAlignment(RenderText.ALIGN_LEFT)
    setTextColor(1, 1, 1, 0.82)
    renderText(x + padX, textY, 0.0085, "Chainsaw Fuel")

    setTextAlignment(RenderText.ALIGN_RIGHT)
    setTextColor(1, 1, 1, 0.82)
    renderText(x + boxW - padX, textY, 0.0085, pctText)

    if self.hudMoveEnabled then
        setTextAlignment(RenderText.ALIGN_LEFT)
        setTextColor(1, 1, 1, 0.50)
        renderText(x, y - 0.009, 0.008, "Alt+H drag")
    end
end

-- helpers
function ChainsawFuel:_notifyOutOfFuel()
    local now = g_time or 0
    if now < (self._emptyMsgTimer or 0) then return end
    self._emptyMsgTimer = now + 2000

    self:_showInfo("Chainsaw is out of fuel.")
end

function ChainsawFuel:forceChainsawCutBlocked(tool)
    if tool == nil then return end

    if tool.setIsRunning ~= nil then pcall(tool.setIsRunning, tool, false) end
    if tool.setIsActive ~= nil then pcall(tool.setIsActive, tool, false) end
    if tool.setIsWorking ~= nil then pcall(tool.setIsWorking, tool, false) end
    if tool.setIsFiring ~= nil then pcall(tool.setIsFiring, tool, false) end
    if tool.setIsCutting ~= nil then pcall(tool.setIsCutting, tool, false) end
    if tool.setCuttingActive ~= nil then pcall(tool.setCuttingActive, tool, false) end
    if tool.setTurnedOn ~= nil then pcall(tool.setTurnedOn, tool, false) end
    if tool.stop ~= nil then pcall(tool.stop, tool) end

    if tool.isRunning ~= nil then tool.isRunning = false end
    if tool.isActive ~= nil then tool.isActive = false end
    if tool.isWorking ~= nil then tool.isWorking = false end
    if tool.isFiring ~= nil then tool.isFiring = false end
    if tool.isCutting ~= nil then tool.isCutting = false end
    if tool.cutIsActive ~= nil then tool.cutIsActive = false end
    if tool.activatePressed ~= nil then tool.activatePressed = false end
    if tool.activated ~= nil then tool.activated = false end
end

function ChainsawFuel:getPlayerKey(player)
    if player == nil then
        return "local"
    end

    if player.getUserId ~= nil then
        local uid = player:getUserId()
        if uid ~= nil then return "user_" .. tostring(uid) end
    end

    if player.userId ~= nil then return "user_" .. tostring(player.userId) end
    if player.playerId ~= nil then return "player_" .. tostring(player.playerId) end

    return "local"
end

function ChainsawFuel:setFuelForPlayerKey(playerKey, fuel, noEventSend)
    if playerKey == nil then
        return
    end

    local clampedFuel = math.max(0, math.min(1.0, fuel or 0))
    self.fuelByPlayer[playerKey] = clampedFuel
    self:saveToSavegame()

    if noEventSend then
        return
    end

    if g_server ~= nil then
        g_server:broadcastEvent(ChainsawFuelEvent.new(playerKey, clampedFuel), nil, nil, g_currentMission)
    elseif g_client ~= nil and g_client.getServerConnection ~= nil then
        local connection = g_client:getServerConnection()
        if connection ~= nil then
            connection:sendEvent(ChainsawFuelEvent.new(playerKey, clampedFuel))
        end
    end
end


function ChainsawFuel:_markEmptyRefillWindow(player)
    self._emptyChainsawPendingRefill = true
    self._emptyChainsawPlayerKey = self:getPlayerKey(player)
    self._emptyChainsawRefillUntil = (g_time or 0) + 10000
end

function ChainsawFuel:_clearEmptyRefillWindow()
    self._emptyChainsawPendingRefill = false
    self._emptyChainsawPlayerKey = nil
    self._emptyChainsawRefillUntil = 0
end

function ChainsawFuel:_canRefillForPlayer(player)
    if player == nil then
        return false
    end

    local tool = self:_getPlayerHandTool(player)
    if self:_isChainsawTool(tool) then
        return true
    end

    if not self._emptyChainsawPendingRefill then
        return false
    end

    local now = g_time or 0
    if now > (self._emptyChainsawRefillUntil or 0) then
        self:_clearEmptyRefillWindow()
        return false
    end

    return self._emptyChainsawPlayerKey == self:getPlayerKey(player)
end

function ChainsawFuel:forceUnequipChainsaw(player)
    if player == nil then
        return
    end

    local tool = self:_getPlayerHandTool(player)
    if tool ~= nil then
        self:forceChainsawCutBlocked(tool)
    end

    self:_markEmptyRefillWindow(player)

    if player.unequipHandTool ~= nil then
        pcall(player.unequipHandTool, player)
    end

    if player.setCurrentHandToolIndex ~= nil then
        pcall(player.setCurrentHandToolIndex, player, 0)
    elseif player.setCurrentHandTool ~= nil then
        pcall(player.setCurrentHandTool, player, nil)
    elseif player.setHandTool ~= nil then
        pcall(player.setHandTool, player, nil)
    end
end



function ChainsawFuel:onFuelTriggerCallback(triggerId, otherId, onEnter, onLeave)
    if g_localPlayer == nil or g_localPlayer.rootNode == nil then
        return
    end

    if otherId ~= g_localPlayer.rootNode then
        return
    end

    if onEnter then
        self.inFuelTrigger = true
    elseif onLeave then
        self.inFuelTrigger = false
    end
end

function ChainsawFuel:registerFuelTriggers()
    self.inFuelTrigger = false
    self._fuelTriggerNodes = {}
    self._fuelTriggerActiveCount = 0
end

-- save
function ChainsawFuel:getSavePath()
    local dir = g_currentMission and g_currentMission.missionInfo and g_currentMission.missionInfo.savegameDirectory
    if dir == nil then
        dir = getUserProfileAppPath() .. "savegame1"
    end
    return dir .. "/modSettings/FS25_ChainsawFuel/"
end

function ChainsawFuel:ensureSaveDirs()
    local dir = g_currentMission and g_currentMission.missionInfo and g_currentMission.missionInfo.savegameDirectory
    if dir == nil then
        dir = getUserProfileAppPath() .. "savegame1"
    end

    self:ensureDir(dir .. "/modSettings/")
    self:ensureDir(dir .. "/modSettings/FS25_ChainsawFuel/")
end

function ChainsawFuel:ensureDir(path)
    if createFolder ~= nil then
        createFolder(path)
    end
end

function ChainsawFuel:loadFromSavegame()
    local basePath = self:getSavePath()
    self:ensureSaveDirs()

    local filePath = basePath .. "chainsawFuel.xml"
    if fileExists == nil or not fileExists(filePath) then
        return
    end

    local xml = XMLFile.load("chainsawFuelXML", filePath)
    if xml == nil then return end

    self.hudX = xml:getFloat("chainsawFuel.hud#x", self.hudX or 0.84)
    self.hudY = xml:getFloat("chainsawFuel.hud#y", self.hudY or 0.20)

    local i = 0
    while true do
        local key = string.format("chainsawFuel.players.player(%d)", i)
        if not xml:hasProperty(key) then break end

        local playerKey = xml:getString(key .. "#id")
        local fuel = xml:getFloat(key .. "#fuel")
        if playerKey ~= nil and fuel ~= nil then
            self.fuelByPlayer[playerKey] = math.max(0, math.min(1.0, fuel))
        end
        i = i + 1
    end

    xml:delete()
end

function ChainsawFuel:saveToSavegame()
    local basePath = self:getSavePath()
    self:ensureSaveDirs()

    local filePath = basePath .. "chainsawFuel.xml"
    local xml = XMLFile.create("chainsawFuelXML", filePath, "chainsawFuel")
    if xml == nil then return end

    xml:setFloat("chainsawFuel.hud#x", self.hudX or 0.84)
    xml:setFloat("chainsawFuel.hud#y", self.hudY or 0.20)

    local i = 0
    for playerKey, fuel in pairs(self.fuelByPlayer) do
        local key = string.format("chainsawFuel.players.player(%d)", i)
        xml:setString(key .. "#id", tostring(playerKey))
        xml:setFloat(key .. "#fuel", fuel)
        i = i + 1
    end

    xml:save()
    xml:delete()
end


function ChainsawFuel:refreshCurrentHandTool(player)
    if player == nil then
        return
    end

    local tool = self:_getPlayerHandTool(player)
    if tool == nil then
        return
    end

    if tool.setIsActive ~= nil then tool:setIsActive(false) end
    if tool.setIsWorking ~= nil then tool:setIsWorking(false) end
    if tool.setIsRunning ~= nil then tool:setIsRunning(false) end

    if tool.isActive ~= nil then tool.isActive = false end
    if tool.isWorking ~= nil then tool.isWorking = false end
    if tool.isRunning ~= nil then tool.isRunning = false end
    if tool.isCutting ~= nil then tool.isCutting = false end

    if player.setCurrentHandTool ~= nil and player.getCurrentHandTool ~= nil then
        local currentTool = player:getCurrentHandTool()
        if currentTool ~= nil then
            pcall(player.setCurrentHandTool, player, nil, true)
            pcall(player.setCurrentHandTool, player, currentTool, true)
        end
    end
end

function ChainsawFuel:tryRefreshEmptyChainsaw(player)
    local now = g_time or 0
    if now < (self._refreshTimer or 0) then
        return
    end

    self._refreshTimer = now + 500
    self:refreshCurrentHandTool(player)
end



function ChainsawFuel:toggleHudMove()
    self.hudMoveEnabled = not (self.hudMoveEnabled == true)
    self.draggingHud = false

    if self.hudMoveEnabled then
        self.savedCameraRotatableInfo = {}
        self:setCameraRotatable(false)

        if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then
            pcall(g_inputBinding.setShowMouseCursor, g_inputBinding, true)
        end

        self:_showInfo("HUD Move ON")
    else
        self:setCameraRotatable(true)

        if g_currentMission ~= nil then
            g_currentMission.isPlayerFrozen = false
            g_currentMission.isPlayerInputDisabled = false
            g_currentMission.isInputDisabled = false
        end

        if g_localPlayer ~= nil and g_localPlayer.setIsPlayerFrozen ~= nil then
            pcall(g_localPlayer.setIsPlayerFrozen, g_localPlayer, false)
        end

        if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then
            pcall(g_inputBinding.setShowMouseCursor, g_inputBinding, false)
        end

        self:_showInfo("HUD Move OFF")
        self:saveToSavegame()
    end
end

function ChainsawFuel:updateHudFreeze()
    if not self.hudMoveEnabled then
        return
    end

    if g_currentMission ~= nil then
        g_currentMission.isPlayerFrozen = true
        g_currentMission.isPlayerInputDisabled = true
        g_currentMission.isInputDisabled = true
        if g_currentMission.disableInputForDuration ~= nil then
            pcall(g_currentMission.disableInputForDuration, g_currentMission, 250)
        end
    end

    if g_localPlayer ~= nil and g_localPlayer.setIsPlayerFrozen ~= nil then
        pcall(g_localPlayer.setIsPlayerFrozen, g_localPlayer, true)
    end

    if g_inputBinding ~= nil and g_inputBinding.setShowMouseCursor ~= nil then
        pcall(g_inputBinding.setShowMouseCursor, g_inputBinding, true)
    end

    self:setCameraRotatable(false)
end

function ChainsawFuel:isPointInHud(mx, my)
    local x = self.hudX or 0.84
    local y = self.hudY or 0.20
    local w = 0.062
    local h = 0.017
    return mx >= x and mx <= x + w and my >= y and my <= y + h
end


function ChainsawFuel:mouseEvent(posX, posY, isDown, isUp, button)
    if not self.hudMoveEnabled then
        return false
    end

    self:updateHudFreeze()

    if button == Input.MOUSE_BUTTON_LEFT then
        if isDown and self:isPointInHud(posX, posY) then
            self.draggingHud = true
            self.dragOffX = posX - (self.hudX or 0.84)
            self.dragOffY = posY - (self.hudY or 0.20)
            return true
        end

        if isUp and self.draggingHud then
            self.draggingHud = false
            self:saveToSavegame()
            return true
        end
    end

    if self.draggingHud then
        local newX = posX - (self.dragOffX or 0)
        local newY = posY - (self.dragOffY or 0)

        self.hudX = math.max(0.01, math.min(0.96 - 0.062, newX))
        self.hudY = math.max(0.01, math.min(0.96 - 0.017, newY))
        return true
    end

    return true
end


function ChainsawFuel:setCameraRotatable(enabled)
    local vehicle = g_currentMission and g_currentMission.controlledVehicle or nil
    if vehicle == nil or vehicle.getAttachedCameras == nil then
        return
    end

    local cameras = vehicle:getAttachedCameras() or {}
    self.savedCameraRotatableInfo = self.savedCameraRotatableInfo or {}

    for i, camera in ipairs(cameras) do
        if camera ~= nil then
            if enabled then
                if self.savedCameraRotatableInfo[i] ~= nil then
                    camera.isRotatable = self.savedCameraRotatableInfo[i]
                else
                    camera.isRotatable = true
                end
            else
                self.savedCameraRotatableInfo[i] = camera.isRotatable
                camera.isRotatable = false
            end
        end
    end
end
