-- =============================================================
-- Real Fuel Consumption — RFCEngineModes.lua
-- Adds Eco / Normal / Power engine modes to motorized vehicles.
-- Calibrated from real DLG PowerMix statistics.
--
--   ECO    : -20% fuel, -27% power, RPM limited (green)
--   NORMAL : baseline (amber)
--   POWER  : +15% fuel, +12% power, full RPM (red)
--
-- Cycled with a single keybind. Only real work vehicles
-- (tractors / harvesters with a motor) get the feature.

RFCEngineModes = {}

-- =============================================================
-- SAVEGAME SCHEMA — v1.2.0.1 attempt reverted after a load-freeze report.
-- Re-implemented in v1.2.0.3 following the EXACT pattern confirmed working
-- in three real, published FS25 mods (useYourTyres, Oil & Grease Service,
-- Advanced Damage System — all reviewed directly):
--   * Register via a Vehicle.init hook, NOT from inside our own
--     TypeManager.finalizeTypes callback. Vehicle.init is the base
--     class's own one-time initializer and is the point both useYourTyres
--     and Oil & Grease Service use for this exact purpose.
--   * Defensive nil-guard before touching Vehicle.xmlSchemaSavegame —
--     copied verbatim in spirit from Oil & Grease Service's
--     VehicleDetect.injVehicleInit, which checks for this exact case.
--   * A "registered once" flag so this is safe even if Vehicle.init fires
--     the hook more than once.
-- Separately, in RFCRegister.lua, our TypeManager.finalizeTypes hook was
-- switched from Utils.prependedFunction to Utils.appendedFunction — ADS
-- (Advanced Damage System) does the type-attachment step with
-- appendedFunction, running AFTER the base game's own finalizeTypes body,
-- not before it. Running before is the most likely reason the previous
-- attempt crashed during type finalization and froze the loading screen.
-- =============================================================
RFCEngineModes.schemaRegistered = false

function RFCEngineModes.injVehicleInit()
    if RFCEngineModes.schemaRegistered then return end
    if Vehicle == nil or Vehicle.xmlSchemaSavegame == nil then return end

    Vehicle.xmlSchemaSavegame:register(XMLValueType.INT,
        "vehicles.vehicle(?)." .. RealFuelConsumption.MOD_NAME .. ".rfcEngineModes#mode",
        "Real Fuel Consumption — saved engine mode (1=Eco, 2=Normal, 3=Power)",
        RealFuelConsumption.MODE_NORMAL)

    RFCEngineModes.schemaRegistered = true
end
Vehicle.init = Utils.appendedFunction(Vehicle.init, RFCEngineModes.injVehicleInit)

-- Only vehicles with the Motorized spec are eligible
function RFCEngineModes.prerequisitesPresent(specializations)
    return SpecializationUtil.hasSpecialization(Motorized, specializations)
end

function RFCEngineModes.registerEventListeners(vehicleType)
    SpecializationUtil.registerEventListener(vehicleType, "onLoad", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "onPostLoad", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "saveToXMLFile", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "onRegisterActionEvents", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "onUpdate", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "onReadStream", RFCEngineModes)
    SpecializationUtil.registerEventListener(vehicleType, "onWriteStream", RFCEngineModes)
end

-- =============================================================
-- LOAD — init per-vehicle state
-- =============================================================
function RFCEngineModes:onLoad(savegame)
    self.spec_rfcEngineModes = self.spec_rfcEngineModes or {}
    local spec = self.spec_rfcEngineModes

    spec.currentMode = RealFuelConsumption.MODE_NORMAL
    spec.capturedMotor = nil
    spec.originalTorque = nil  -- captured lazily on first mode change

    -- CRITICAL: the action-events table MUST exist before
    -- onRegisterActionEvents runs. A nil table here corrupts the whole
    -- input system and breaks the ESC menu (v1.1.0.0 bug).
    spec.actionEvents = {}
end

-- =============================================================
-- POST-LOAD — restore the saved engine mode, once the savegame XML is
-- available. Mirrors the base game's own pattern: onLoad sets defaults
-- for a brand-new vehicle, onPostLoad overrides them from the savegame
-- when one actually exists. savegame is nil for a freshly bought vehicle.
-- Wrapped in pcall throughout (matching Oil & Grease Service's own
-- defensive style) — if anything about this read fails, we simply keep
-- the Normal default rather than propagate an error into vehicle loading.
-- =============================================================
function RFCEngineModes:onPostLoad(savegame)
    local spec = self.spec_rfcEngineModes
    if spec == nil then return end
    if savegame == nil or savegame.resetVehicles then return end
    if savegame.xmlFile == nil or savegame.key == nil then return end

    local ok, mode = pcall(function()
        return savegame.xmlFile:getValue(savegame.key .. "." .. RealFuelConsumption.MOD_NAME .. ".rfcEngineModes#mode", RealFuelConsumption.MODE_NORMAL)
    end)
    if ok and mode ~= nil and RealFuelConsumption.ENGINE_MODES[mode] ~= nil then
        spec.currentMode = mode
        pcall(RFCEngineModes.applyPower, self)
    end
end

-- =============================================================
-- SAVE — persist the current engine mode into the vehicle's savegame
-- block. `key` here already points at this specialization's own XML
-- namespace for this vehicle instance (the framework resolves the
-- vehicle index and spec name before calling us).
-- =============================================================
function RFCEngineModes:saveToXMLFile(xmlFile, key, usedModNames)
    local spec = self.spec_rfcEngineModes
    if spec == nil or xmlFile == nil then return end
    pcall(function()
        xmlFile:setValue(key .. "#mode", spec.currentMode or RealFuelConsumption.MODE_NORMAL)
    end)
end

-- =============================================================
-- UPDATE — re-assert the motor limits every frame.
-- The base game recomputes maxForwardSpeed / clutch limits during
-- driving, so a one-shot write on mode change would be overwritten.
-- This is cheap: three field writes on the controlled vehicle only.
-- =============================================================
function RFCEngineModes:onUpdate(dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected)
    local spec = self.spec_rfcEngineModes
    if spec == nil then return end
    if not RealFuelConsumption.settings.engineModesEnabled then return end
    -- Only bother while the engine is actually running
    if self.getIsMotorStarted ~= nil and not self:getIsMotorStarted() then return end
    -- Defense in depth: never let an error inside applyPower propagate out
    -- of onUpdate. This function runs every frame for every vehicle with
    -- this spec — an unguarded error here can disrupt whatever else that
    -- vehicle's update chain does that frame (other specializations,
    -- including things like boom/arm control on telehandlers).
    pcall(RFCEngineModes.applyPower, self)
end

-- =============================================================
-- INPUT — register the cycle keybind
-- =============================================================
function RFCEngineModes:onRegisterActionEvents(isActiveForInput, isActiveForInputIgnoreSelection)
    if self.isClient then
        local spec = self.spec_rfcEngineModes
        self:clearActionEventsTable(spec.actionEvents)

        if isActiveForInputIgnoreSelection then
            -- Only enable if the feature is on and this is a work vehicle
            if RealFuelConsumption.settings.engineModesEnabled
               and RFCEngineModes.isWorkVehicle(self)
               and InputAction.RFC_CYCLE_ENGINE_MODE ~= nil then
                local _, actionEventId = self:addActionEvent(
                    spec.actionEvents,
                    InputAction.RFC_CYCLE_ENGINE_MODE,
                    self,
                    RFCEngineModes.actionCycleMode,
                    false, true, false, true, nil
                )
                if actionEventId ~= nil then
                    g_inputBinding:setActionEventText(actionEventId, g_i18n:getText("rfc_mode_cycle"))
                    g_inputBinding:setActionEventTextPriority(actionEventId, GS_PRIO_NORMAL)
                    g_inputBinding:setActionEventTextVisibility(actionEventId, true)
                end
            end
        end
    end
end

-- Is this a real work vehicle (has a motor and is drivable)?
function RFCEngineModes.isWorkVehicle(vehicle)
    if vehicle.spec_motorized == nil then return false end
    -- Exclude non-drivable / trailer-like motorized edge cases
    if vehicle.getIsMotorStarted == nil then return false end
    return true
end

-- =============================================================
-- ACTION — cycle Eco -> Normal -> Power -> Eco
-- =============================================================
function RFCEngineModes.actionCycleMode(self, actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_rfcEngineModes
    if spec == nil then return end

    local newMode = spec.currentMode + 1
    if newMode > RealFuelConsumption.MODE_POWER then
        newMode = RealFuelConsumption.MODE_ECO
    end

    RFCEngineModes.setMode(self, newMode, true)
end

-- =============================================================
-- SET MODE — apply power scaling and notify
-- =============================================================
function RFCEngineModes.setMode(self, mode, showNotification, noEventSend)
    local spec = self.spec_rfcEngineModes
    if spec == nil then return end
    mode = tonumber(mode)
    if mode == nil or RealFuelConsumption.ENGINE_MODES[mode] == nil then
        return
    end

    spec.currentMode = mode
    RFCEngineModes.applyPower(self)

    -- Sync across the network (standard Giants vehicle-event pattern)
    RFCModeEvent.sendEvent(self, mode, noEventSend)

    if showNotification and g_currentMission ~= nil and g_dedicatedServer == nil then
        local modeData = RealFuelConsumption.ENGINE_MODES[mode]
        local modeName = g_i18n:getText("rfc_mode_" .. modeData.name)
        local msg = string.format(g_i18n:getText("rfc_mode_changed"), modeName)
        if g_currentMission.addIngameNotification ~= nil then
            g_currentMission:addIngameNotification(FSBaseMission.INGAME_NOTIFICATION_OK, msg)
        end
    end
end

-- =============================================================
-- APPLY POWER — make each mode physically felt.
--
-- NOTE (v1.1.0.4): scaling only the torque curve was cosmetic
-- because FS25's automatic/CVT gearbox compensates — it just picks
-- a different ratio to keep power constant, so RPM never dropped
-- (confirmed via the transmission debug: our -24% torque scale was
-- applied but had no effect on wheel output).
--
-- The gearbox CANNOT compensate for a lowered RPM ceiling or a
-- speed cap, so those are what the player actually feels. We now
-- combine three levers per mode, mirroring how WorkMode/CVT_Addon
-- manipulate the live motor object:
--   * motor.maxRpm        → ECO caps revs (engine "runs out of breath")
--   * motor.maxForwardSpeed → ECO limits top speed under load
--   * motor.peakMotorTorque → clutch/pull limit
-- Originals captured once per motor; every mode applied relative to
-- them so switching never compounds. Re-applied each frame in
-- onUpdate because the base game recomputes some of these.
-- =============================================================
function RFCEngineModes.applyPower(self)
    local spec = self.spec_rfcEngineModes
    local mSpec = self.spec_motorized
    if spec == nil or mSpec == nil then return end

    local motor = mSpec.motor
    if motor == nil then return end

    -- Capture stock values once per motor object
    if spec.motorOrig == nil or spec.motorOrigFor ~= motor then
        spec.motorOrigFor = motor
        spec.motorOrig = {
            maxRpm          = motor.maxRpm,
            minRpm          = motor.minRpm,
            peakMotorTorque = motor.peakMotorTorque,
            -- Confirmed always non-nil: VehicleMotor.new() sets a computed
            -- default (see GDN LuaDoc source), so this is safe to capture.
            accelLimit      = motor.motorRotationAccelerationLimit,
            -- NOT guaranteed to exist: only vehicles whose loadMotor calls
            -- motor:setLowBrakeForce(...) have these set. Many don't, so
            -- both may be nil — handled below.
            lowBrakeScale   = motor.lowBrakeForceScale,
            lowBrakeSpeed   = motor.lowBrakeForceSpeedLimit,
        }
    end
    local orig = spec.motorOrig

    local md = RealFuelConsumption.ENGINE_MODES[spec.currentMode]
    if md == nil then return end

    local on = RealFuelConsumption.settings.engineModesEnabled
    local powerScale = on and md.powerScale or 1.0
    local rpmScale   = on and (md.rpmLimit or 1.0) or 1.0

    -- CRITICAL FIX: previously this scaled motor.maxRpm / motor.minRpm
    -- directly. Those exact two fields are ALSO read by the base game's
    -- own fuel consumption formula (confirmed against the real
    -- Motorized:updateConsumers source):
    --   rpmPercentage = (lastMotorRpm - minRpm) / (maxRpm - minRpm)
    -- Scaling them for our own "RPM ceiling" feature silently corrupted
    -- that shared denominator too — and since every vehicle has a
    -- different RPM range and gear table, the side effect came out
    -- differently per vehicle: some showed consumption cut in half,
    -- others far more than expected, and gear/speed calculations that
    -- also key off maxRpm could be thrown off too (reported as some
    -- vehicles' top speed being halved, others exceeding their real max).
    --
    -- Fix: use motor:setRpmLimit(), a SEPARATE field (confirmed via GDN
    -- LuaDoc: VehicleMotor.new sets self.rpmLimit = math.huge by default,
    -- with its own setRpmLimit()/getRequiredRpmAtSpeedLimit() pair) meant
    -- exactly for capping usable RPM without touching maxRpm/minRpm at
    -- all. It can only cap DOWNWARD, never extend beyond the engine's
    -- real maxRpm — which is why POWER's old "+12% rev headroom" had to
    -- be dropped entirely rather than ported to this safer field: there
    -- is no safe way to let an engine rev past its own physical ceiling,
    -- and POWER already differentiates plenty via torque (+20%) and
    -- throttle response (+45% faster) without it.
    if motor.setRpmLimit ~= nil and orig.maxRpm ~= nil then
        if on and rpmScale < 1.0 then
            motor:setRpmLimit(orig.maxRpm * rpmScale)
        else
            motor:setRpmLimit(math.huge) -- NORMAL / POWER: no artificial cap
        end
    end

    -- Peak torque (clutch / pull limit) — untouched by this fix, does
    -- not interact with the consumption formula above.
    if orig.peakMotorTorque ~= nil then
        motor.peakMotorTorque = orig.peakMotorTorque * powerScale
    end

    -- Throttle response — how fast RPM can climb (rad/s²). This is what
    -- makes ECO feel genuinely "heavy": the engine is slow to rev up, just
    -- like a de-rated real engine. POWER is the opposite: eager to rev.
    -- Verified against the official GDN LuaDoc source for VehicleMotor.
    if on and orig.accelLimit ~= nil and motor.setMotorRotationAccelerationLimit ~= nil then
        local accelScale = md.accelLimitScale or 1.0
        motor:setMotorRotationAccelerationLimit(orig.accelLimit * accelScale)
    elseif orig.accelLimit ~= nil and motor.setMotorRotationAccelerationLimit ~= nil then
        -- Feature off or NORMAL mode: always restore stock responsiveness
        motor:setMotorRotationAccelerationLimit(orig.accelLimit)
    end

    -- Low-speed engine drag (creep resistance). Only touched if the
    -- vehicle actually defines this (many don't — nil stock means we
    -- leave it alone entirely rather than inventing a baseline).
    if orig.lowBrakeScale ~= nil and motor.setLowBrakeForce ~= nil then
        local boost = (on and md.lowBrakeBoost) or 0.0
        motor:setLowBrakeForce(orig.lowBrakeScale + boost, orig.lowBrakeSpeed)
    end

    -- ECO's load-based speed sag. The harder the engine is working, the
    -- lower the speed ceiling — mimicking an engine that has been
    -- de-rated and simply runs out of grunt under load. At light/no load
    -- it barely limits you, so road driving stays usable.
    if on and md.loadSpeedFloor ~= nil and motor.setSpeedLimit ~= nil then
        local load = motor.smoothedLoadPercentage or 0.0
        -- CRITICAL FIX: this used to call math.clamp(...), a function we
        -- never actually confirmed exists as a real FS25 global (the
        -- official GDN source we found for VehicleMotor uses MathUtil.clamp
        -- instead). If math.clamp was nil, this line would throw an
        -- unguarded error every single frame a vehicle was in ECO mode,
        -- right here inside onUpdate — a very plausible cause of the
        -- "telehandler boom won't raise/lower" report, since an unguarded
        -- error mid-specialization-update can disrupt whatever runs after
        -- it for that vehicle that frame. Replaced with a manual clamp that
        -- has zero external dependencies and cannot ever be nil.
        if load < 0.0 then load = 0.0
        elseif load > 1.0 then load = 1.0 end
        -- interpolate ceiling: high when unloaded, low (floor) at full load
        local cap = md.loadSpeedCeil + (md.loadSpeedFloor - md.loadSpeedCeil) * load
        motor:setSpeedLimit(cap)
    end
end

-- Current fuel multiplier from the active mode (read by RFCVehicleSpec)
function RFCEngineModes.getModeFuelMult(vehicle)
    local spec = vehicle.spec_rfcEngineModes
    if spec == nil then return 1.0 end
    if not RealFuelConsumption.settings.engineModesEnabled then return 1.0 end
    local modeData = RealFuelConsumption.ENGINE_MODES[spec.currentMode]
    return modeData ~= nil and modeData.fuelMult or 1.0
end

-- True when the active engine mode is ECO. Lets RFCVehicleSpec apply the
-- load-scaled "guaranteed saving" that cancels FS25's base-consumption
-- inflation so ECO always ends up cheaper than NORMAL.
function RFCEngineModes.isEcoMode(vehicle)
    local spec = vehicle.spec_rfcEngineModes
    if spec == nil then return false end
    if not RealFuelConsumption.settings.engineModesEnabled then return false end
    return spec.currentMode == RealFuelConsumption.MODE_ECO
end

-- =============================================================
-- NETWORK SYNC (mode is part of vehicle stream)
-- =============================================================
function RFCEngineModes:onReadStream(streamId, connection)
    local spec = self.spec_rfcEngineModes
    if spec ~= nil then
        spec.currentMode = streamReadInt8(streamId)
        RFCEngineModes.applyPower(self)
    end
end

function RFCEngineModes:onWriteStream(streamId, connection)
    local spec = self.spec_rfcEngineModes
    if spec ~= nil then
        streamWriteInt8(streamId, spec.currentMode or RealFuelConsumption.MODE_NORMAL)
    end
end

-- =============================================================
-- RFCModeEvent — network sync of a single vehicle's engine mode.
-- Client -> server -> broadcast, standard Giants pattern.
-- =============================================================
RFCModeEvent = {}
local RFCModeEvent_mt = Class(RFCModeEvent, Event)
InitEventClass(RFCModeEvent, "RFCModeEvent")

function RFCModeEvent.emptyNew()
    return Event.new(RFCModeEvent_mt)
end

function RFCModeEvent.new(vehicle, mode)
    local self = RFCModeEvent.emptyNew()
    self.vehicle = vehicle
    self.mode = mode
    return self
end

function RFCModeEvent:writeStream(streamId, connection)
    NetworkUtil.writeNodeObject(streamId, self.vehicle)
    streamWriteInt8(streamId, self.mode)
end

function RFCModeEvent:readStream(streamId, connection)
    self.vehicle = NetworkUtil.readNodeObject(streamId)
    self.mode = streamReadInt8(streamId)
    self:run(connection)
end

function RFCModeEvent:run(connection)
    local modeIsValid = RealFuelConsumption ~= nil
        and RealFuelConsumption.ENGINE_MODES ~= nil
        and RealFuelConsumption.ENGINE_MODES[self.mode] ~= nil

    if modeIsValid and self.vehicle ~= nil and self.vehicle.spec_rfcEngineModes ~= nil then
        -- Apply without re-sending (noEventSend = true)
        RFCEngineModes.setMode(self.vehicle, self.mode, false, true)
    end

    -- If we are the server and this came from a client, relay to the rest
    if modeIsValid and not connection:getIsServer() and g_server ~= nil and self.vehicle ~= nil then
        g_server:broadcastEvent(RFCModeEvent.new(self.vehicle, self.mode), nil, connection, self.vehicle)
    end
end

-- Helper: decide direction automatically
function RFCModeEvent.sendEvent(vehicle, mode, noEventSend)
    if noEventSend == true then return end

    if g_server ~= nil then
        -- We are the server: broadcast to all clients
        g_server:broadcastEvent(RFCModeEvent.new(vehicle, mode), nil, nil, vehicle)
    elseif g_client ~= nil then
        -- We are a client: send to the server (it will relay)
        g_client:getServerConnection():sendEvent(RFCModeEvent.new(vehicle, mode))
    end
end
