-- FS25_ParkItUnderARoof
-- Slowly dirties/damages parked equipment outdoors and cleans/repairs it under a roof.

ParkItUnderARoof = {}
local ParkItUnderARoof_mt = Class(ParkItUnderARoof)

ParkItUnderARoof.CHECK_INTERVAL_GAME_MS = 60000
ParkItUnderARoof.PARKED_SPEED_LIMIT_KMH = 0.5

-- Amounts applied per in-game hour. Values are normalized where 0 = clean/repaired
-- and 1 = fully dirty/damaged.
ParkItUnderARoof.OUTDOOR_DIRT_MAX = 1.0
ParkItUnderARoof.OUTDOOR_WHEEL_DIRT_MAX = 0.65
ParkItUnderARoof.OUTDOOR_DAMAGE_MAX = 0.4
ParkItUnderARoof.OUTDOOR_DIRT_PER_HOUR = 0.05
ParkItUnderARoof.OUTDOOR_DAMAGE_PER_HOUR = 0.0015
ParkItUnderARoof.INDOOR_CLEAN_PER_HOUR = 1.0
ParkItUnderARoof.INDOOR_REPAIR_PER_HOUR = 0.02
ParkItUnderARoof.CONDITION_CHANGE_EPSILON = 0.000001
ParkItUnderARoof.AUTO_REPAIR_COST_RATIO = 1.25
ParkItUnderARoof.RAIN_FALL_THRESHOLD = 0.05
ParkItUnderARoof.OUTDOOR_RAIN_DIRT_MULTIPLIER = 3
ParkItUnderARoof.OUTDOOR_RAIN_DAMAGE_MULTIPLIER = 2
ParkItUnderARoof.SNOW_FALL_THRESHOLD = 0.05
ParkItUnderARoof.OUTDOOR_SNOW_DIRT_MULTIPLIER = 3
ParkItUnderARoof.OUTDOOR_SNOW_DAMAGE_MULTIPLIER = 2
ParkItUnderARoof.ROOF_RAYCAST_START_OFFSET = 1
ParkItUnderARoof.ROOF_RAYCAST_DISTANCE = 40
ParkItUnderARoof.DEBUG = false
ParkItUnderARoof.DEBUG_MAX_SKIP_LOGS_PER_TICK = 8

ParkItUnderARoofSettings = {}
ParkItUnderARoofSettings.xmlKey = "parkItUnderARoofSettings"
ParkItUnderARoofSettings.xmlFilename = "ParkItUnderARoof.xml"
ParkItUnderARoofSettings.DEFAULT_DAMAGE_SPEED = 2
ParkItUnderARoofSettings.DEFAULT_DIRT_SPEED = 2
ParkItUnderARoofSettings.DEFAULT_AUTO_REPAIR = false
ParkItUnderARoofSettings.DEFAULT_AUTO_REPAIR_COST_RATIO = 1.25
ParkItUnderARoofSettings.DEFAULT_AUTO_WASH = false
ParkItUnderARoofSettings.DEFAULT_SHOW_ROOF_STATUS = true
ParkItUnderARoofSettings.DEFAULT_OUTDOOR_DAMAGE = true
ParkItUnderARoofSettings.DEFAULT_OUTDOOR_DIRT = true
ParkItUnderARoofSettings.DEFAULT_OUTDOOR_DAMAGE_MAX = 0.4
ParkItUnderARoofSettings.damageSpeed = 2
ParkItUnderARoofSettings.dirtSpeed = 2
ParkItUnderARoofSettings.autoRepair = false
ParkItUnderARoofSettings.autoRepairCostRatio = 1.25
ParkItUnderARoofSettings.autoWash = false
ParkItUnderARoofSettings.showRoofStatus = false
ParkItUnderARoofSettings.outdoorDamage = true
ParkItUnderARoofSettings.outdoorDirt = true
ParkItUnderARoofSettings.outdoorDamageMax = 0.4
ParkItUnderARoofSettings.controls = {}

local parkItUnderARoof = nil

local function piuaDebug(message)
    if ParkItUnderARoof.DEBUG then
        print("[ParkItUnderARoof] " .. tostring(message))
    end
end

function ParkItUnderARoofSettings:getSavegameSettingsPath()
    if g_currentMission == nil or g_currentMission.missionInfo == nil then
        return nil
    end

    local savegameDirectory = g_currentMission.missionInfo.savegameDirectory
    if savegameDirectory == nil or savegameDirectory == "" then
        return nil
    end

    return savegameDirectory .. "/" .. self.xmlFilename
end

function ParkItUnderARoofSettings:getTextSafe(key, fallback)
    local text = g_i18n ~= nil and g_i18n:getText(key) or nil
    if text == nil or text == "" or text == key then
        return fallback
    end

    return text
end

function ParkItUnderARoofSettings:resetToDefaults()
    self.damageSpeed = self.DEFAULT_DAMAGE_SPEED
    self.dirtSpeed = self.DEFAULT_DIRT_SPEED
    self.autoRepair = self.DEFAULT_AUTO_REPAIR
    self.autoRepairCostRatio = self.DEFAULT_AUTO_REPAIR_COST_RATIO
    self.autoWash = self.DEFAULT_AUTO_WASH
    self.showRoofStatus = self.DEFAULT_SHOW_ROOF_STATUS
    self.outdoorDamage = self.DEFAULT_OUTDOOR_DAMAGE
    self.outdoorDirt = self.DEFAULT_OUTDOOR_DIRT
    self.outdoorDamageMax = self.DEFAULT_OUTDOOR_DAMAGE_MAX
end

function ParkItUnderARoofSettings:normalizeSpeed(value)
    value = math.floor(tonumber(value) or 2)
    return math.clamp(value, 0, 4)
end

function ParkItUnderARoofSettings:getSpeedState(value)
    return self:normalizeSpeed(value) + 1
end

function ParkItUnderARoofSettings:getSpeedFromState(state)
    return self:normalizeSpeed((tonumber(state) or 3) - 1)
end

function ParkItUnderARoofSettings:normalizeOutdoorDamageMax(value)
    value = tonumber(value) or self.DEFAULT_OUTDOOR_DAMAGE_MAX
    value = math.floor(value * 10 + 0.5) / 10
    return math.clamp(value, 0.1, 1)
end

function ParkItUnderARoofSettings:normalizeAutoRepairCostRatio(value)
    value = tonumber(value) or self.DEFAULT_AUTO_REPAIR_COST_RATIO
    value = math.floor(value * 4 + 0.5) / 4
    return math.clamp(value, 0.5, 2)
end

function ParkItUnderARoofSettings:getAutoRepairCostRatioState(value)
    return math.floor(self:normalizeAutoRepairCostRatio(value) * 4 + 0.5) - 1
end

function ParkItUnderARoofSettings:getAutoRepairCostRatioFromState(state)
    state = math.clamp(math.floor(tonumber(state) or self:getAutoRepairCostRatioState(self.DEFAULT_AUTO_REPAIR_COST_RATIO)), 1, 7)
    return self:normalizeAutoRepairCostRatio((state + 1) / 4)
end

function ParkItUnderARoofSettings:getAutoRepairCostRatio()
    return self:normalizeAutoRepairCostRatio(self.autoRepairCostRatio)
end

function ParkItUnderARoofSettings:getOutdoorDamageMaxState(value)
    return math.floor(self:normalizeOutdoorDamageMax(value) * 10 + 0.5)
end

function ParkItUnderARoofSettings:getOutdoorDamageMaxFromState(state)
    state = math.clamp(math.floor(tonumber(state) or self:getOutdoorDamageMaxState(self.DEFAULT_OUTDOOR_DAMAGE_MAX)), 1, 10)
    return self:normalizeOutdoorDamageMax(state / 10)
end

function ParkItUnderARoofSettings:getOutdoorDamageMax()
    return self:normalizeOutdoorDamageMax(self.outdoorDamageMax)
end

function ParkItUnderARoofSettings:getSpeedMultiplier(value)
    value = self:normalizeSpeed(value)
    if value == 0 then
        return 0.25
    elseif value == 1 then
        return 0.5
    elseif value == 3 then
        return 2
    elseif value == 4 then
        return 4
    end

    return 1
end

function ParkItUnderARoofSettings:collectSnapshot()
    return {
        damageSpeed = self:normalizeSpeed(self.damageSpeed),
        dirtSpeed = self:normalizeSpeed(self.dirtSpeed),
        autoRepair = self.autoRepair == true,
        autoRepairCostRatio = self:getAutoRepairCostRatio(),
        autoWash = self.autoWash == true,
        showRoofStatus = self.showRoofStatus == true,
        outdoorDamage = self.outdoorDamage ~= false,
        outdoorDirt = self.outdoorDirt ~= false,
        outdoorDamageMax = self:getOutdoorDamageMax()
    }
end

function ParkItUnderARoofSettings:applySnapshot(snapshot, doWrite)
    if snapshot == nil then
        return
    end

    self.damageSpeed = self:normalizeSpeed(snapshot.damageSpeed)
    self.dirtSpeed = self:normalizeSpeed(snapshot.dirtSpeed)
    self.autoRepair = snapshot.autoRepair == true
    self.autoRepairCostRatio = self:normalizeAutoRepairCostRatio(snapshot.autoRepairCostRatio)
    self.autoWash = snapshot.autoWash == true
    self.showRoofStatus = snapshot.showRoofStatus == true
    self.outdoorDamage = snapshot.outdoorDamage ~= false
    self.outdoorDirt = snapshot.outdoorDirt ~= false
    self.outdoorDamageMax = self:normalizeOutdoorDamageMax(snapshot.outdoorDamageMax)

    if doWrite == true then
        self:writeSettings()
    end

    self:refreshMenuStates()
end

function ParkItUnderARoofSettings:writeSettings()
    local path = self:getSavegameSettingsPath()
    if path == nil then
        piuaDebug("settings: no savegame path available")
        return
    end

    local xmlFile = createXMLFile("settings", path, self.xmlKey)
    if xmlFile == nil or xmlFile == 0 then
        piuaDebug("settings: failed to create XML at " .. tostring(path))
        return
    end

    setXMLInt(xmlFile, self.xmlKey .. ".damageSpeed#value", self:normalizeSpeed(self.damageSpeed))
    setXMLInt(xmlFile, self.xmlKey .. ".dirtSpeed#value", self:normalizeSpeed(self.dirtSpeed))
    setXMLBool(xmlFile, self.xmlKey .. ".autoRepair#value", self.autoRepair == true)
    setXMLFloat(xmlFile, self.xmlKey .. ".autoRepairCostRatio#value", self:getAutoRepairCostRatio())
    setXMLBool(xmlFile, self.xmlKey .. ".autoWash#value", self.autoWash == true)
    setXMLBool(xmlFile, self.xmlKey .. ".showRoofStatus#value", self.showRoofStatus == true)
    setXMLBool(xmlFile, self.xmlKey .. ".outdoorDamage#value", self.outdoorDamage ~= false)
    setXMLBool(xmlFile, self.xmlKey .. ".outdoorDirt#value", self.outdoorDirt ~= false)
    setXMLFloat(xmlFile, self.xmlKey .. ".outdoorDamageMax#value", self:getOutdoorDamageMax())
    saveXMLFile(xmlFile)
    delete(xmlFile)
end

function ParkItUnderARoofSettings:readSettings()
    local path = self:getSavegameSettingsPath()
    if path == nil then
        piuaDebug("settings: no savegame path available")
        return
    end

    if not fileExists(path) then
        if g_server ~= nil then
            self:writeSettings()
        end
        return
    end

    local xmlFile = loadXMLFile(self.xmlKey, path)
    if xmlFile == nil or xmlFile == 0 then
        piuaDebug("settings: failed to load XML at " .. tostring(path))
        return
    end

    local damageSpeed = getXMLInt(xmlFile, self.xmlKey .. ".damageSpeed#value")
    local dirtSpeed = getXMLInt(xmlFile, self.xmlKey .. ".dirtSpeed#value")
    local autoRepair = getXMLBool(xmlFile, self.xmlKey .. ".autoRepair#value")
    local autoRepairCostRatio = getXMLFloat(xmlFile, self.xmlKey .. ".autoRepairCostRatio#value")
    local autoWash = getXMLBool(xmlFile, self.xmlKey .. ".autoWash#value")
    local showRoofStatus = getXMLBool(xmlFile, self.xmlKey .. ".showRoofStatus#value")
    local outdoorDamage = getXMLBool(xmlFile, self.xmlKey .. ".outdoorDamage#value")
    local outdoorDirt = getXMLBool(xmlFile, self.xmlKey .. ".outdoorDirt#value")
    local outdoorDamageMax = getXMLFloat(xmlFile, self.xmlKey .. ".outdoorDamageMax#value")

    if damageSpeed ~= nil then
        self.damageSpeed = self:normalizeSpeed(damageSpeed)
    end
    if dirtSpeed ~= nil then
        self.dirtSpeed = self:normalizeSpeed(dirtSpeed)
    end
    if autoRepair ~= nil then
        self.autoRepair = autoRepair == true
    end
    if autoRepairCostRatio ~= nil then
        self.autoRepairCostRatio = self:normalizeAutoRepairCostRatio(autoRepairCostRatio)
    end
    if autoWash ~= nil then
        self.autoWash = autoWash == true
    end
    if showRoofStatus ~= nil then
        self.showRoofStatus = showRoofStatus == true
    end
    if outdoorDamage ~= nil then
        self.outdoorDamage = outdoorDamage == true
    end
    if outdoorDirt ~= nil then
        self.outdoorDirt = outdoorDirt == true
    end
    if outdoorDamageMax ~= nil then
        self.outdoorDamageMax = self:normalizeOutdoorDamageMax(outdoorDamageMax)
    end

    delete(xmlFile)
end

function ParkItUnderARoofSettings:getIsAdmin()
    return g_currentMission ~= nil and (g_currentMission:getIsServer() or g_currentMission.isMasterUser == true)
end

function ParkItUnderARoofSettings:getSettingDefinition(id)
    if id == "damageSpeed" or id == "dirtSpeed" then
        return {
            values = { 1, 2, 3, 4, 5 },
            strings = {
                self:getTextSafe("piua_setting_verySlow", "Very Slow"),
                self:getTextSafe("piua_setting_slow", "Slow"),
                self:getTextSafe("piua_setting_normal", "Normal"),
                self:getTextSafe("piua_setting_fast", "Fast"),
                self:getTextSafe("piua_setting_veryFast", "Very Fast")
            }
        }
    end

    if id == "outdoorDamageMax" then
        return {
            values = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 },
            strings = { "10%", "20%", "30%", "40%", "50%", "60%", "70%", "80%", "90%", "100%" }
        }
    end

    if id == "autoRepairCostRatio" then
        return {
            values = { 1, 2, 3, 4, 5, 6, 7 },
            strings = { "50%", "75%", "100%", "125%", "150%", "175%", "200%" }
        }
    end

    return {
        values = { false, true },
        strings = {
            self:getTextSafe("ui_no", "No"),
            self:getTextSafe("ui_yes", "Yes")
        }
    }
end

function ParkItUnderARoofSettings:addMenuSection(settingsLayout, settingsPage)
    local header
    for _, element in ipairs(settingsLayout.elements) do
        if element.name == "sectionHeader" then
            header = element:clone(settingsLayout)
            break
        end
    end

    if header == nil then
        header = TextElement.new()
        header:applyProfile("fs25_settingsSectionHeader", true)
        header.name = "sectionHeader"
        settingsLayout:addElement(header)
    end

    header:setText(self:getTextSafe("piua_settings_title", "Park It Under A Roof"))
    header.focusId = FocusManager:serveAutoFocusId()
    table.insert(settingsPage.controlsList, header)
end

function ParkItUnderARoofSettings:addMenuOption(settingsLayout, settingsPage, id, labelKey, fallbackLabel)
    local definition = self:getSettingDefinition(id)
    local template = (#definition.values == 2) and settingsPage.checkWoodHarvesterAutoCutBox or settingsPage.multiVolumeVoiceBox
    if template == nil then
        return
    end

    local optionBox = template:clone(settingsLayout)
    optionBox.id = "piua_" .. id .. "Box"

    local option = optionBox.elements[1]
    option.id = id
    option.target = self
    option:setCallback("onClickCallback", "onMenuOptionChanged")
    option:setTexts({ unpack(definition.strings) })
    option:setDisabled(not self:getIsAdmin())

    local tooltip = option.elements[1]
    if tooltip ~= nil then
        tooltip:setText(self:getTextSafe("tooltip_piua_" .. id, ""))
    end

    local label = optionBox.elements[2]
    if label ~= nil then
        label:setText(self:getTextSafe(labelKey, fallbackLabel))
    end

    self.controls[id] = option
    self:updateMenuOptionState(id)
    self:updateFocusIds(optionBox)
    table.insert(settingsPage.controlsList, optionBox)
end

function ParkItUnderARoofSettings:updateFocusIds(element)
    if element == nil then
        return
    end

    element.focusId = FocusManager:serveAutoFocusId()
    for _, child in pairs(element.elements) do
        self:updateFocusIds(child)
    end
end

function ParkItUnderARoofSettings:updateMenuOptionState(id)
    local option = self.controls[id]
    if option == nil then
        return
    end

    if id == "damageSpeed" then
        option:setState(self:getSpeedState(self.damageSpeed))
    elseif id == "dirtSpeed" then
        option:setState(self:getSpeedState(self.dirtSpeed))
    elseif id == "autoRepair" then
        option:setState((self.autoRepair == true) and 2 or 1)
    elseif id == "autoRepairCostRatio" then
        option:setState(self:getAutoRepairCostRatioState(self.autoRepairCostRatio))
    elseif id == "autoWash" then
        option:setState((self.autoWash == true) and 2 or 1)
    elseif id == "showRoofStatus" then
        option:setState((self.showRoofStatus == true) and 2 or 1)
    elseif id == "outdoorDamage" then
        option:setState((self.outdoorDamage ~= false) and 2 or 1)
    elseif id == "outdoorDirt" then
        option:setState((self.outdoorDirt ~= false) and 2 or 1)
    elseif id == "outdoorDamageMax" then
        option:setState(self:getOutdoorDamageMaxState(self.outdoorDamageMax))
    end

    option:setDisabled(not self:getIsAdmin())
end

function ParkItUnderARoofSettings:refreshMenuStates()
    for id, _ in pairs(self.controls) do
        self:updateMenuOptionState(id)
    end
end

function ParkItUnderARoofSettings:onMenuOptionChanged(state, option)
    if option == nil or not self:getIsAdmin() then
        self:refreshMenuStates()
        return
    end

    local id = option.id
    local s = option.getState ~= nil and option:getState() or state
    if type(s) == "boolean" then
        s = s and 2 or 1
    elseif type(s) ~= "number" then
        s = 1
    elseif s == 0 then
        s = 1
    end

    if id == "damageSpeed" then
        self.damageSpeed = self:getSpeedFromState(s)
    elseif id == "dirtSpeed" then
        self.dirtSpeed = self:getSpeedFromState(s)
    elseif id == "autoRepair" then
        self.autoRepair = s == 2
    elseif id == "autoRepairCostRatio" then
        self.autoRepairCostRatio = self:getAutoRepairCostRatioFromState(s)
    elseif id == "autoWash" then
        self.autoWash = s == 2
    elseif id == "showRoofStatus" then
        self.showRoofStatus = s == 2
    elseif id == "outdoorDamage" then
        self.outdoorDamage = s == 2
    elseif id == "outdoorDirt" then
        self.outdoorDirt = s == 2
    elseif id == "outdoorDamageMax" then
        self.outdoorDamageMax = self:getOutdoorDamageMaxFromState(s)
    else
        return
    end

    if g_server ~= nil then
        self:writeSettings()
    end
    if ParkItUnderARoofSettingsEvent ~= nil then
        ParkItUnderARoofSettingsEvent.sendSnapshot(self:collectSnapshot())
    end
end

function ParkItUnderARoofSettings:buildMenu()
    if AA_DAMAGE_UNIFIED_OPTIONS == true then
        return false
    end

    if g_gui == nil or g_gui.screenControllers == nil then
        return false
    end

    local inGameMenu = g_gui.screenControllers[InGameMenu]
    if inGameMenu == nil or inGameMenu.pageSettings == nil then
        return false
    end

    local settingsPage = inGameMenu.pageSettings
    local settingsLayout = settingsPage.gameSettingsLayout
    if settingsLayout == nil or self.menuBuilt == true then
        return false
    end

    self:addMenuSection(settingsLayout, settingsPage)
    self:addMenuOption(settingsLayout, settingsPage, "damageSpeed", "setting_piua_damageSpeed", "Damage speed")
    self:addMenuOption(settingsLayout, settingsPage, "dirtSpeed", "setting_piua_dirtSpeed", "Dirt speed")
    self:addMenuOption(settingsLayout, settingsPage, "autoRepair", "setting_piua_autoRepair", "Auto repair")
    self:addMenuOption(settingsLayout, settingsPage, "autoRepairCostRatio", "setting_piua_autoRepairCostRatio", "Auto repair cost")
    self:addMenuOption(settingsLayout, settingsPage, "autoWash", "setting_piua_autoWash", "Auto wash")
    self:addMenuOption(settingsLayout, settingsPage, "outdoorDamage", "setting_piua_outdoorDamage", "Outdoor damage")
    self:addMenuOption(settingsLayout, settingsPage, "outdoorDamageMax", "setting_piua_outdoorDamageMax", "Outdoor damage cap")
    self:addMenuOption(settingsLayout, settingsPage, "outdoorDirt", "setting_piua_outdoorDirt", "Outdoor dirt")
    self:addMenuOption(settingsLayout, settingsPage, "showRoofStatus", "setting_piua_showRoofStatus", "Show roof status")

    settingsLayout:invalidateLayout()
    self.menuBuilt = true
    return true
end

function ParkItUnderARoofSettings:requestServerSettingsIfNeeded()
    if self.mpWantsRequest == true and self.mpRequestSent ~= true then
        if g_client ~= nil and g_client.getServerConnection ~= nil and g_client:getServerConnection() ~= nil and ParkItUnderARoofSettingsEvent ~= nil then
            ParkItUnderARoofSettingsEvent.sendRequest()
            self.mpRequestSent = true
            self.mpWantsRequest = false
        end
    end
end

function ParkItUnderARoof.new()
    local self = setmetatable({}, ParkItUnderARoof_mt)

    self.isActive = false
    self.gameTimeAccumulator = 0
    self.roofRaycastHit = false

    return self
end

function ParkItUnderARoof:loadMap()
    self.isActive = true
    self.gameTimeAccumulator = 0
    ParkItUnderARoofSettings:resetToDefaults()
    if self:getIsServer() then
        ParkItUnderARoofSettings:readSettings()
    end
    if g_currentMission ~= nil and not self:getIsServer() then
        ParkItUnderARoofSettings.mpWantsRequest = true
        ParkItUnderARoofSettings.mpRequestSent = false
    end
    if g_currentMission ~= nil and FSBaseMission ~= nil then
        FSBaseMission.registerToLoadOnMapFinished(g_currentMission, self)
    end
    piuaDebug("loadMap: mod active")
end

function ParkItUnderARoof:deleteMap()
    self.isActive = false
    self.gameTimeAccumulator = 0
    ParkItUnderARoofSettings.controls = {}
    ParkItUnderARoofSettings.menuBuilt = false
    piuaDebug("deleteMap: mod inactive")
end

function ParkItUnderARoof:onLoadMapFinished()
    ParkItUnderARoofSettings:buildMenu()
    ParkItUnderARoofSettings:refreshMenuStates()
end

function ParkItUnderARoof:update(dt)
    if not self.isActive or g_currentMission == nil or not self:getIsServer() then
        ParkItUnderARoofSettings:requestServerSettingsIfNeeded()
        return
    end

    ParkItUnderARoofSettings:requestServerSettingsIfNeeded()

    self.gameTimeAccumulator = self.gameTimeAccumulator + (dt * self:getEffectiveTimeScale())
    if self.gameTimeAccumulator < ParkItUnderARoof.CHECK_INTERVAL_GAME_MS then
        return
    end

    local elapsedGameHours = self.gameTimeAccumulator / (1000 * 60 * 60)
    self.gameTimeAccumulator = 0
    piuaDebug(string.format("tick: elapsedGameHours=%.4f timeScale=%.2f", elapsedGameHours, self:getEffectiveTimeScale()))
    self:updateParkedVehicles(elapsedGameHours)
end

function ParkItUnderARoof:getIsServer()
    if g_currentMission ~= nil and g_currentMission.getIsServer ~= nil then
        return g_currentMission:getIsServer()
    end

    return g_server ~= nil
end

function ParkItUnderARoof:getEffectiveTimeScale()
    if g_currentMission ~= nil and g_currentMission.getEffectiveTimeScale ~= nil then
        return g_currentMission:getEffectiveTimeScale()
    end

    if g_currentMission ~= nil and g_currentMission.missionInfo ~= nil and g_currentMission.missionInfo.timeScale ~= nil then
        return g_currentMission.missionInfo.timeScale
    end

    return 1
end

function ParkItUnderARoof:getRainFallScale()
    if g_currentMission == nil or g_currentMission.environment == nil then
        return 0
    end

    local weather = g_currentMission.environment.weather
    if weather ~= nil and weather.getRainFallScale ~= nil then
        return math.clamp(weather:getRainFallScale() or 0, 0, 1)
    end

    return 0
end

function ParkItUnderARoof:getSnowFallScale()
    if g_currentMission == nil or g_currentMission.environment == nil then
        return 0
    end

    local weather = g_currentMission.environment.weather
    if weather ~= nil and weather.getSnowFallScale ~= nil then
        return math.clamp(weather:getSnowFallScale() or 0, 0, 1)
    end

    return 0
end

function ParkItUnderARoof:getIsRaining()
    return self:getRainFallScale() > ParkItUnderARoof.RAIN_FALL_THRESHOLD
end

function ParkItUnderARoof:getIsSnowing()
    return self:getSnowFallScale() > ParkItUnderARoof.SNOW_FALL_THRESHOLD
end

function ParkItUnderARoof:updateParkedVehicles(elapsedGameHours)
    local vehicleSystem = g_currentMission.vehicleSystem
    if vehicleSystem == nil or vehicleSystem.vehicles == nil then
        piuaDebug("updateParkedVehicles: no vehicleSystem.vehicles")
        return
    end

    local totalCount = 0
    local affectedCount = 0
    local skippedCount = 0
    local skipReasons = {}
    local skipLogs = 0

    for _, vehicle in pairs(vehicleSystem.vehicles) do
        totalCount = totalCount + 1

        local canAffect, skipReason = self:getCanAffectVehicle(vehicle)
        if canAffect then
            affectedCount = affectedCount + 1
            self:applyConditionChange(vehicle, self:isVehicleUnderRoof(vehicle), elapsedGameHours)
        else
            skippedCount = skippedCount + 1
            skipReason = skipReason or "unknown"
            skipReasons[skipReason] = (skipReasons[skipReason] or 0) + 1

            if skipLogs < ParkItUnderARoof.DEBUG_MAX_SKIP_LOGS_PER_TICK then
                skipLogs = skipLogs + 1
                piuaDebug(string.format("skip: name=%s reason=%s typeName=%s", self:getVehicleDebugName(vehicle), skipReason, tostring(vehicle ~= nil and vehicle.typeName)))
            end
        end
    end

    local reasonText = ""
    for reason, count in pairs(skipReasons) do
        reasonText = reasonText .. string.format(" %s=%d", reason, count)
    end

    piuaDebug(string.format("summary: total=%d affected=%d skipped=%d%s", totalCount, affectedCount, skippedCount, reasonText))
end

function ParkItUnderARoof:getCanAffectVehicle(vehicle)
    if vehicle == nil or vehicle.rootNode == nil or vehicle.rootNode == 0 then
        return false, "invalid_root"
    end

    if not self:getIsOwnedOrLeasedVehicle(vehicle) then
        return false, "not_owned_or_leased"
    end

    local ownerFarmId = vehicle.ownerFarmId
    if ownerFarmId == nil or ownerFarmId == FarmManager.SPECTATOR_FARM_ID then
        return false, "invalid_owner"
    end

    if not self:getHasConditionStats(vehicle) then
        return false, "no_condition_stats"
    end

    local isInUse, useReason = self:getIsVehicleInUse(vehicle)
    if isInUse then
        return false, useReason or "in_use"
    end

    return true
end

function ParkItUnderARoof:getIsOwnedOrLeasedVehicle(vehicle)
    if vehicle == nil or VehiclePropertyState == nil then
        return false
    end

    return vehicle.propertyState == VehiclePropertyState.OWNED or vehicle.propertyState == VehiclePropertyState.LEASED
end

function ParkItUnderARoof:getHasConditionStats(vehicle)
    if vehicle == nil then
        return false
    end

    local hasDirt = vehicle.getDirtAmount ~= nil and vehicle.setDirtAmount ~= nil
    local hasDamage = vehicle.getDamageAmount ~= nil and vehicle.setDamageAmount ~= nil

    return hasDirt or hasDamage
end

function ParkItUnderARoof:getIsVehicleInUse(vehicle)
    if vehicle.getIsMotorStarted ~= nil and vehicle:getIsMotorStarted() then
        return true, "motor_started"
    end

    if vehicle.getIsTurnedOn ~= nil and vehicle:getIsTurnedOn() then
        return true, "turned_on"
    end

    if vehicle.getIsControlled ~= nil and vehicle:getIsControlled() then
        return true, "controlled"
    end

    if vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() then
        return true, "ai_active"
    end

    if vehicle.getLastSpeed ~= nil and math.abs(vehicle:getLastSpeed(true)) > ParkItUnderARoof.PARKED_SPEED_LIMIT_KMH then
        return true, "moving"
    end

    if vehicle.rootVehicle ~= nil and vehicle.rootVehicle ~= vehicle then
        local rootVehicle = vehicle.rootVehicle

        if rootVehicle.getIsMotorStarted ~= nil and rootVehicle:getIsMotorStarted() then
            return true, "root_motor_started"
        end

        if rootVehicle.getLastSpeed ~= nil and math.abs(rootVehicle:getLastSpeed()) > ParkItUnderARoof.PARKED_SPEED_LIMIT_KMH then
            return true, "root_moving"
        end
    end

    return false
end

function ParkItUnderARoof:onRoofRaycastCallback()
    self.roofRaycastHit = true
end

function ParkItUnderARoof:isVehicleUnderRoof(vehicle)
    if vehicle ~= nil and vehicle.spec_AdvancedDamageSystem ~= nil and vehicle.isUnderRoof ~= nil then
        return vehicle:isUnderRoof()
    end

    local indoorMask = g_currentMission ~= nil and g_currentMission.indoorMask
    local x, y, z = getWorldTranslation(vehicle.rootNode)
    local isIndoorMask = indoorMask ~= nil and indoorMask:getIsIndoorAtWorldPosition(x, z) == true

    self.roofRaycastHit = false
    local roofMask = CollisionFlag.STATIC_OBJECT + CollisionFlag.BUILDING
    raycastClosest(
        x,
        y + ParkItUnderARoof.ROOF_RAYCAST_START_OFFSET,
        z,
        0,
        1,
        0,
        ParkItUnderARoof.ROOF_RAYCAST_DISTANCE,
        "onRoofRaycastCallback",
        self,
        roofMask
    )

    piuaDebug(string.format(
        "roofCheck: name=%s indoorMask=%s raycast=%s distance=%.1f",
        self:getVehicleDebugName(vehicle),
        tostring(isIndoorMask),
        tostring(self.roofRaycastHit),
        ParkItUnderARoof.ROOF_RAYCAST_DISTANCE
    ))

    -- Some map roofs, such as service station canopies, have collision but no
    -- indoor mask area. A physical roof hit is enough for this mod's purposes.
    return self.roofRaycastHit
end

function ParkItUnderARoof:getIsVehicleOnOwnerFarmland(vehicle)
    if vehicle == nil or vehicle.rootNode == nil or vehicle.rootNode == 0 or g_farmlandManager == nil then
        return false
    end

    local ownerFarmId = self:getVehicleOwnerFarmId(vehicle)
    if ownerFarmId == nil or ownerFarmId == FarmManager.SPECTATOR_FARM_ID then
        return false
    end

    local x, _, z = getWorldTranslation(vehicle.rootNode)
    local farmlandId = g_farmlandManager:getFarmlandIdAtWorldPosition(x, z)
    if farmlandId == nil or farmlandId == FarmlandManager.NOT_BUYABLE_FARM_ID then
        piuaDebug(string.format(
            "farmlandCheck: name=%s ownerFarmId=%s farmlandId=%s owned=false",
            self:getVehicleDebugName(vehicle),
            tostring(ownerFarmId),
            tostring(farmlandId)
        ))
        return false
    end

    local farmlandOwner = g_farmlandManager:getFarmlandOwner(farmlandId)
    local isOwned = farmlandOwner == ownerFarmId
    piuaDebug(string.format(
        "farmlandCheck: name=%s ownerFarmId=%s farmlandId=%s farmlandOwner=%s owned=%s",
        self:getVehicleDebugName(vehicle),
        tostring(ownerFarmId),
        tostring(farmlandId),
        tostring(farmlandOwner),
        tostring(isOwned)
    ))

    return isOwned
end

function ParkItUnderARoof:applyConditionChange(vehicle, isUnderRoof, elapsedGameHours)
    local oldDirt = vehicle.getDirtAmount ~= nil and vehicle:getDirtAmount() or nil
    local oldDamage = vehicle.getDamageAmount ~= nil and vehicle:getDamageAmount() or nil
    local rainScale = self:getRainFallScale()
    local snowScale = self:getSnowFallScale()
    local isRainingOutside = not isUnderRoof and rainScale > ParkItUnderARoof.RAIN_FALL_THRESHOLD
    local isSnowingOutside = not isUnderRoof and snowScale > ParkItUnderARoof.SNOW_FALL_THRESHOLD
    local isOnOwnerFarmland = not isUnderRoof or self:getIsVehicleOnOwnerFarmland(vehicle)

    if vehicle.setDirtAmount ~= nil and vehicle.getDirtAmount ~= nil then
        local rate = isUnderRoof and -ParkItUnderARoof.INDOOR_CLEAN_PER_HOUR or ParkItUnderARoof.OUTDOOR_DIRT_PER_HOUR
        rate = rate * ParkItUnderARoofSettings:getSpeedMultiplier(ParkItUnderARoofSettings.dirtSpeed)
        if isUnderRoof and (not ParkItUnderARoofSettings.autoWash or not isOnOwnerFarmland) then
            rate = 0
        elseif not isUnderRoof and ParkItUnderARoofSettings.outdoorDirt == false then
            rate = 0
        end

        if isRainingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_RAIN_DIRT_MULTIPLIER
        end
        if isSnowingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_SNOW_DIRT_MULTIPLIER
        end

        local change = rate * elapsedGameHours
        local currentDirt = vehicle:getDirtAmount()
        local newDirt = currentDirt + change
        if not isUnderRoof and change > 0 then
            if currentDirt >= ParkItUnderARoof.OUTDOOR_DIRT_MAX then
                newDirt = currentDirt
            else
                newDirt = math.min(newDirt, ParkItUnderARoof.OUTDOOR_DIRT_MAX)
            end
        end
        if math.abs(change) > ParkItUnderARoof.CONDITION_CHANGE_EPSILON then
            newDirt = math.clamp(newDirt, 0, 1)
            if isUnderRoof then
                self:setIndoorDirtAmount(vehicle, newDirt, -change)
            else
                self:setOutdoorDirtAmount(vehicle, newDirt, change)
            end
        end
    end

    local adsSpec = vehicle.spec_AdvancedDamageSystem
    if adsSpec ~= nil and adsSpec.isExcludedVehicle ~= true then
        -- AA Damage is authoritative. Park It Under A Roof may add outdoor
        -- chassis wear, but it never writes the vanilla damage value or
        -- performs free automatic ADS repairs under a roof.
        local rate = ParkItUnderARoof.OUTDOOR_DAMAGE_PER_HOUR
            * ParkItUnderARoofSettings:getSpeedMultiplier(ParkItUnderARoofSettings.damageSpeed)
        if isUnderRoof or ParkItUnderARoofSettings.outdoorDamage == false then
            rate = 0
        end
        if isRainingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_RAIN_DAMAGE_MULTIPLIER
        end
        if isSnowingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_SNOW_DAMAGE_MULTIPLIER
        end

        local change = math.max(rate * elapsedGameHours, 0)
        local chassis = adsSpec.systems ~= nil and adsSpec.systems.chassis or nil
        if chassis ~= nil and change > ParkItUnderARoof.CONDITION_CHANGE_EPSILON then
            local minimumCondition = 1 - ParkItUnderARoofSettings:getOutdoorDamageMax()
            if (chassis.condition or 1) > minimumCondition and vehicle.applyInstantDamageToSystem ~= nil then
                vehicle:applyInstantDamageToSystem(AdvancedDamageSystem.SYSTEMS.CHASSIS, change)
                chassis.condition = math.max(chassis.condition or minimumCondition, minimumCondition)
                if vehicle.updateConditionLevel ~= nil then
                    vehicle:updateConditionLevel()
                end
            end
        end
    elseif vehicle.setDamageAmount ~= nil and vehicle.getDamageAmount ~= nil then
        local repairPrice = isUnderRoof and vehicle.getRepairPrice ~= nil and vehicle:getRepairPrice() or nil
        local rate = isUnderRoof and -ParkItUnderARoof.INDOOR_REPAIR_PER_HOUR or ParkItUnderARoof.OUTDOOR_DAMAGE_PER_HOUR
        rate = rate * ParkItUnderARoofSettings:getSpeedMultiplier(ParkItUnderARoofSettings.damageSpeed)
        if isUnderRoof and (not ParkItUnderARoofSettings.autoRepair or not isOnOwnerFarmland) then
            rate = 0
        elseif not isUnderRoof and ParkItUnderARoofSettings.outdoorDamage == false then
            rate = 0
        end

        if isRainingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_RAIN_DAMAGE_MULTIPLIER
        end
        if isSnowingOutside then
            rate = rate * ParkItUnderARoof.OUTDOOR_SNOW_DAMAGE_MULTIPLIER
        end

        local change = rate * elapsedGameHours
        local currentDamage = vehicle:getDamageAmount()
        local newDamage = currentDamage + change
        if not isUnderRoof and change > 0 then
            local outdoorDamageMax = ParkItUnderARoofSettings:getOutdoorDamageMax()
            if currentDamage >= outdoorDamageMax then
                newDamage = currentDamage
            else
                newDamage = math.min(newDamage, outdoorDamageMax)
            end
        end
        if math.abs(change) > ParkItUnderARoof.CONDITION_CHANGE_EPSILON then
            vehicle:setDamageAmount(math.clamp(newDamage, 0, 1), true)
        end
        if isUnderRoof and rate < 0 then
            self:chargeAutoRepairCost(vehicle, oldDamage, vehicle:getDamageAmount(), repairPrice)
        end
    end

    local newDirt = vehicle.getDirtAmount ~= nil and vehicle:getDirtAmount() or nil
    local newDamage = vehicle.getDamageAmount ~= nil and vehicle:getDamageAmount() or nil
    piuaDebug(string.format(
        "apply: name=%s roof=%s ownerFarmland=%s rain=%.2f snow=%.2f rainOutside=%s snowOutside=%s hours=%.4f dirt=%s->%s damage=%s->%s",
        self:getVehicleDebugName(vehicle),
        tostring(isUnderRoof),
        tostring(isOnOwnerFarmland),
        rainScale,
        snowScale,
        tostring(isRainingOutside),
        tostring(isSnowingOutside),
        elapsedGameHours,
        oldDirt ~= nil and string.format("%.4f", oldDirt) or "nil",
        newDirt ~= nil and string.format("%.4f", newDirt) or "nil",
        oldDamage ~= nil and string.format("%.4f", oldDamage) or "nil",
        newDamage ~= nil and string.format("%.4f", newDamage) or "nil"
    ))
end

function ParkItUnderARoof:setIndoorDirtAmount(vehicle, dirtAmount, cleanAmount)
    local spec = vehicle ~= nil and vehicle.spec_washable
    if spec == nil or spec.washableNodes == nil or vehicle.setNodeDirtAmount == nil then
        vehicle:setDirtAmount(dirtAmount)
        return
    end

    local changedNodes = 0
    cleanAmount = math.max(cleanAmount or 0, 0)
    for i = 1, #spec.washableNodes do
        local nodeData = spec.washableNodes[i]
        local currentDirt = nodeData.dirtAmount or 0
        local hasMudNodes = nodeData.mudNodes ~= nil and next(nodeData.mudNodes) ~= nil
        if hasMudNodes or nodeData.wheel ~= nil then
            vehicle:setNodeDirtAmount(nodeData, math.max(currentDirt - cleanAmount, 0), true)
        else
            vehicle:setNodeDirtAmount(nodeData, dirtAmount, true)
        end
        changedNodes = changedNodes + 1
    end

    if changedNodes == 0 then
        vehicle:setDirtAmount(dirtAmount)
    end
end

function ParkItUnderARoof:setOutdoorDirtAmount(vehicle, dirtAmount, change)
    local spec = vehicle ~= nil and vehicle.spec_washable
    if spec == nil or spec.washableNodes == nil or vehicle.setNodeDirtAmount == nil then
        vehicle:setDirtAmount(dirtAmount)
        return
    end

    local changedBodyNodes = 0
    local changedWheelNodes = 0
    for i = 1, #spec.washableNodes do
        local nodeData = spec.washableNodes[i]
        local hasMudNodes = nodeData.mudNodes ~= nil and next(nodeData.mudNodes) ~= nil
        if not hasMudNodes and nodeData.wheel ~= nil and change > 0 then
            local wheelDirt = nodeData.dirtAmount or 0
            if wheelDirt < ParkItUnderARoof.OUTDOOR_WHEEL_DIRT_MAX then
                vehicle:setNodeDirtAmount(nodeData, math.min(wheelDirt + change, ParkItUnderARoof.OUTDOOR_WHEEL_DIRT_MAX), true)
            end
            changedWheelNodes = changedWheelNodes + 1
        elseif not hasMudNodes and nodeData.wheel == nil then
            vehicle:setNodeDirtAmount(nodeData, dirtAmount, true)
            changedBodyNodes = changedBodyNodes + 1
        end
    end

    if changedBodyNodes == 0 and changedWheelNodes == 0 then
        vehicle:setDirtAmount(dirtAmount)
    end
end

function ParkItUnderARoof:getVehicleOwnerFarmId(vehicle)
    if vehicle == nil then
        return nil
    end

    if vehicle.getOwnerFarmId ~= nil then
        return vehicle:getOwnerFarmId()
    end

    return vehicle.ownerFarmId
end

function ParkItUnderARoof:chargeAutoRepairCost(vehicle, oldDamage, newDamage, repairPrice)
    if vehicle == nil or oldDamage == nil or newDamage == nil or repairPrice == nil or repairPrice <= 0 then
        return 0
    end

    local repairedDamage = oldDamage - newDamage
    if repairedDamage <= 0 or oldDamage <= 0 then
        return 0
    end

    local farmId = self:getVehicleOwnerFarmId(vehicle)
    if farmId == nil or farmId == FarmManager.SPECTATOR_FARM_ID then
        return 0
    end

    local costRatio = ParkItUnderARoofSettings ~= nil and ParkItUnderARoofSettings:getAutoRepairCostRatio() or ParkItUnderARoof.AUTO_REPAIR_COST_RATIO
    local cost = repairPrice * (repairedDamage / oldDamage) * costRatio
    if cost > 0 and g_currentMission ~= nil and g_currentMission.addMoney ~= nil then
        g_currentMission:addMoney(-cost, farmId, MoneyType.VEHICLE_REPAIR, false, true)
        piuaDebug(string.format(
            "autoRepairCost: name=%s farmId=%s repaired=%.4f repairPrice=%.2f ratio=%.2f cost=%.2f",
            self:getVehicleDebugName(vehicle),
            tostring(farmId),
            repairedDamage,
            repairPrice,
            costRatio,
            cost
        ))
    end

    return cost
end

function ParkItUnderARoof:getVehicleDebugName(vehicle)
    if vehicle == nil then
        return "nil"
    end

    if vehicle.getName ~= nil then
        local ok, name = pcall(function()
            return vehicle:getName()
        end)

        if ok and name ~= nil and name ~= "" then
            return tostring(name)
        end
    end

    return tostring(vehicle.configFileName or vehicle.typeName or vehicle)
end

function ParkItUnderARoof.appendVehicleInfo(vehicle, infoBox)
    if parkItUnderARoof == nil or infoBox == nil or vehicle == nil or vehicle.rootNode == nil or vehicle.rootNode == 0 then
        return
    end

    if ParkItUnderARoofSettings == nil or ParkItUnderARoofSettings.showRoofStatus ~= true then
        return
    end

    if not parkItUnderARoof:getIsOwnedOrLeasedVehicle(vehicle) then
        return
    end

    if not parkItUnderARoof:getHasConditionStats(vehicle) then
        return
    end

    local title = g_i18n:getText("infohud_piuaRoofStatus")
    local value = parkItUnderARoof:isVehicleUnderRoof(vehicle) and g_i18n:getText("ui_yes") or g_i18n:getText("ui_no")
    infoBox:addLine(title, value)
end

Vehicle.showInfo = Utils.appendedFunction(Vehicle.showInfo, ParkItUnderARoof.appendVehicleInfo)

if InGameMenuSettingsFrame ~= nil and Utils ~= nil and Utils.appendedFunction ~= nil then
    InGameMenuSettingsFrame.onFrameOpen = Utils.appendedFunction(InGameMenuSettingsFrame.onFrameOpen, function()
        if ParkItUnderARoofSettings ~= nil then
            ParkItUnderARoofSettings:refreshMenuStates()
        end
    end)
end

if FocusManager ~= nil and Utils ~= nil and Utils.appendedFunction ~= nil then
    FocusManager.setGui = Utils.appendedFunction(FocusManager.setGui, function(_, gui)
        if gui == "ingameMenuSettings" and ParkItUnderARoofSettings ~= nil then
            for _, control in pairs(ParkItUnderARoofSettings.controls or {}) do
                if control ~= nil and (not control.focusId or not FocusManager.currentFocusData.idToElementMapping[control.focusId]) then
                    FocusManager:loadElementFromCustomValues(control, nil, nil, false, false)
                end
            end

            local inGameMenu = g_gui ~= nil and g_gui.screenControllers ~= nil and g_gui.screenControllers[InGameMenu]
            if inGameMenu ~= nil and inGameMenu.pageSettings ~= nil and inGameMenu.pageSettings.gameSettingsLayout ~= nil then
                inGameMenu.pageSettings.gameSettingsLayout:invalidateLayout()
            end
        end
    end)
end

if FSCareerMissionInfo ~= nil and Utils ~= nil and Utils.appendedFunction ~= nil then
    FSCareerMissionInfo.saveToXMLFile = Utils.appendedFunction(FSCareerMissionInfo.saveToXMLFile, function()
        if ParkItUnderARoofSettings ~= nil and g_server ~= nil then
            ParkItUnderARoofSettings:writeSettings()
        end
    end)
end

parkItUnderARoof = ParkItUnderARoof.new()
addModEventListener(parkItUnderARoof)
