-- Copyright ©2019-2026 by Max Radermacher (hirschenpower) 
-- All Rights Reserved



-- - Axis controls steering RATE (speed), not target angle
-- - Center is latched (press once -> return to 0 and stay there)
-- - After centering: inputs are blocked until axis goes neutral once and is then "re-armed"
-- - MP sync
-- - Force AUTO when AI helper is active

ManualTrailerSteering = {}
ManualTrailerSteering.DEBUG = false
ManualTrailerSteering.SHOW_NOTIFY = true

-- ------------------------------------------------------------------
-- l10n keys (define these in modDesc.xml <l10n> section)
-- ------------------------------------------------------------------
ManualTrailerSteering.L10N = {
    HELP_TOGGLE_ON          = "MTS_HELP_TOGGLE_ON",
    HELP_TOGGLE_OFF         = "MTS_HELP_TOGGLE_OFF",
    HELP_TOGGLE_OFF_HELPER  = "MTS_HELP_TOGGLE_OFF_HELPER",
    HELP_AXIS_ACTIVE        = "MTS_HELP_AXIS_ACTIVE",
    HELP_AXIS_INACTIVE      = "MTS_HELP_AXIS_INACTIVE",
    HELP_CENTER             = "MTS_HELP_CENTER",

    NOTIFY_MANUAL_ON        = "MTS_NOTIFY_MANUAL_ON",
    NOTIFY_MANUAL_OFF       = "MTS_NOTIFY_MANUAL_OFF",
    NOTIFY_HELPER_FORCED    = "MTS_NOTIFY_HELPER_FORCED_AUTO",
    NOTIFY_HELPER_LOCKED    = "MTS_NOTIFY_HELPER_LOCKED"
}

-- ---------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------
local function mtsClamp(v, minV, maxV)
    if v < minV then return minV end
    if v > maxV then return maxV end
    return v
end

local function mtsAbs(v)
    return v < 0 and -v or v
end

local function mtsNowMs()
    if g_time ~= nil then
        return g_time
    end
    if g_currentMission ~= nil and g_currentMission.time ~= nil then
        return g_currentMission.time
    end
    return 0
end

-- Get translated text from modDesc l10n.
-- Uses vehicle.customEnvironment when available.
local function mtsGetText(vehicle, key, fallback)
    if g_i18n == nil or key == nil then
        return fallback or ""
    end

    local env = vehicle ~= nil and vehicle.customEnvironment or nil
    local txt = nil

    if env ~= nil then
        local ok, res = pcall(g_i18n.getText, g_i18n, key, env)
        if ok then txt = res end
    end

    if txt == nil or txt == "" then
        local ok, res = pcall(g_i18n.getText, g_i18n, key)
        if ok then txt = res end
    end

    if txt == nil or txt == "" then
        return fallback or key
    end

    return txt
end

local function mtsNotify(text)
    if ManualTrailerSteering.SHOW_NOTIFY and g_currentMission ~= nil and text ~= nil and text ~= "" then
        g_currentMission:addIngameNotification(FSBaseMission.INGAME_NOTIFICATION_INFO, text)
    end
end

-- =========================================================
-- MP EVENT
-- =========================================================
ManualTrailerSteeringEvent = {}
local ManualTrailerSteeringEvent_mt = Class(ManualTrailerSteeringEvent, Event)
InitEventClass(ManualTrailerSteeringEvent, "ManualTrailerSteeringEvent")

function ManualTrailerSteeringEvent.emptyNew()
    return Event.new(ManualTrailerSteeringEvent_mt)
end

function ManualTrailerSteeringEvent.new(vehicle, manualActive, angle)
    local self = ManualTrailerSteeringEvent.emptyNew()
    self.vehicle = vehicle
    self.manualActive = manualActive
    self.angle = angle
    return self
end

function ManualTrailerSteeringEvent:writeStream(streamId)
    NetworkUtil.writeNodeObject(streamId, self.vehicle)
    streamWriteBool(streamId, self.manualActive)
    streamWriteFloat32(streamId, self.angle)
end

function ManualTrailerSteeringEvent:readStream(streamId, connection)
    self.vehicle = NetworkUtil.readNodeObject(streamId)
    self.manualActive = streamReadBool(streamId)
    self.angle = streamReadFloat32(streamId)
    self:run(connection)
end

function ManualTrailerSteeringEvent:run(connection)
    if self.vehicle ~= nil and self.vehicle.spec_manualTrailerSteering ~= nil then
        self.vehicle:setManualTrailerState(self.manualActive, self.angle, true)
    end

    if not connection:getIsServer() then
        g_server:broadcastEvent(self, false, connection, self.vehicle)
    end
end

function ManualTrailerSteering.sendEvent(vehicle, manualActive, angle, noEventSend)
    if noEventSend then return end

    if g_server ~= nil then
        g_server:broadcastEvent(ManualTrailerSteeringEvent.new(vehicle, manualActive, angle), nil, nil, vehicle)
    else
        g_client:getServerConnection():sendEvent(ManualTrailerSteeringEvent.new(vehicle, manualActive, angle))
    end
end

-- =========================================================
-- SPECIALIZATION
-- =========================================================
function ManualTrailerSteering.prerequisitesPresent(specializations)
    return SpecializationUtil.hasSpecialization(Wheels, specializations)
end

function ManualTrailerSteering.initSpecialization()
    local schema = Vehicle.xmlSchema

    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#maxAngleDeg",            "Max steering angle (deg)", 20)

    -- Rate control
    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#axisRateDegPerSec",     "Axis rate at full deflection (deg/s)", 40)
    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#axisDeadzone",          "Axis deadzone (0..1)", 0.05)
    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#axisRearmThreshold",    "Axis re-arm threshold (0..1)", 0.15)
    schema:register(XMLValueType.INT,    "vehicle.manualTrailerSteering#axisInputTimeoutMs",    "Axis input timeout (ms)", 200)

    -- Key rate (deg/s)
    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#keyRateDegPerSec",      "Key steering rate (deg/s)", 30)

    -- Center return speed (deg/s)
    schema:register(XMLValueType.FLOAT,  "vehicle.manualTrailerSteering#centerReturnDegPerSec", "Center return speed (deg/s)", 70)

    -- Helper forces AUTO
    schema:register(XMLValueType.BOOL,   "vehicle.manualTrailerSteering#forceAutoWithHelper",   "Force AUTO when AI helper is active", true)

    -- Optional: GIANTS icon name for help line (NOT a filename)
    schema:register(XMLValueType.STRING, "vehicle.manualTrailerSteering#helpIconName",          "Help icon name (GIANTS icon-set), e.g. MOUSE_X", "")

    schema:register(XMLValueType.BOOL,
        "vehicle.wheels.wheelConfigurations.wheelConfiguration(?).wheels.wheel(?)#isManualTrailerSteeringWheel",
        "Wheel uses manual trailer steering",
        false
    )
    schema:register(XMLValueType.BOOL, "vehicle.wheels.wheel(?)#isManualTrailerSteeringWheel", "", false)
end

function ManualTrailerSteering.registerFunctions(vehicleType)
    SpecializationUtil.registerFunction(vehicleType, "setManualTrailerState", ManualTrailerSteering.setManualTrailerState)
    SpecializationUtil.registerFunction(vehicleType, "toggleManualTrailerSteering", ManualTrailerSteering.toggleManualTrailerSteering)
    SpecializationUtil.registerFunction(vehicleType, "startCentering", ManualTrailerSteering.startCentering)
    SpecializationUtil.registerFunction(vehicleType, "applyAxisRaw", ManualTrailerSteering.applyAxisRaw)
end

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

-- =========================================================
-- LOAD
-- =========================================================
function ManualTrailerSteering:onLoad(savegame)
    local spec = {}
    self.spec_manualTrailerSteering = spec

    spec.manualActive = false

    spec.maxAngle = math.rad(self.xmlFile:getValue("vehicle.manualTrailerSteering#maxAngleDeg", 20))

    spec.axisRate = math.rad(self.xmlFile:getValue("vehicle.manualTrailerSteering#axisRateDegPerSec", 40)) -- rad/s
    spec.axisDeadzone = self.xmlFile:getValue("vehicle.manualTrailerSteering#axisDeadzone", 0.05)
    spec.axisRearmThreshold = self.xmlFile:getValue("vehicle.manualTrailerSteering#axisRearmThreshold", 0.15)
    spec.axisTimeoutMs = self.xmlFile:getValue("vehicle.manualTrailerSteering#axisInputTimeoutMs", 200)

    if spec.axisRearmThreshold < spec.axisDeadzone * 1.5 then
        spec.axisRearmThreshold = spec.axisDeadzone * 1.5
    end

    spec.keyRate = math.rad(self.xmlFile:getValue("vehicle.manualTrailerSteering#keyRateDegPerSec", 30)) -- rad/s
    spec.centerReturn = math.rad(self.xmlFile:getValue("vehicle.manualTrailerSteering#centerReturnDegPerSec", 70)) -- rad/s

    spec.forceAutoWithHelper = self.xmlFile:getValue("vehicle.manualTrailerSteering#forceAutoWithHelper", true)

    -- GIANTS icon NAME (not a file)
    spec.helpIconName = self.xmlFile:getValue("vehicle.manualTrailerSteering#helpIconName", "")

    spec.currentAngle = 0.0

    -- input state
    spec.axisRaw = 0.0
    spec.axisValue = 0.0
    spec.axisLastUpdateMs = 0

    spec.keyLeft = 0.0
    spec.keyRight = 0.0

    -- center mode / blocking
    spec.centering = false
    spec.inputBlocked = false
    spec.rearmNeedsNeutral = false

    -- helper state
    spec.aiIsActive = false
    spec.aiForcedOff = false

    spec.steeringWheels = {}
    spec.actionEvents = {}

    -- MP throttle
    spec.lastSentAngle = 999999
    spec.sendThreshold = math.rad(0.25)
end

function ManualTrailerSteering:onPostLoad(savegame)
    local spec = self.spec_manualTrailerSteering
    if self.spec_wheels == nil or self.spec_wheels.wheels == nil then return end

    for _, wheel in ipairs(self.spec_wheels.wheels) do
        local flagged = false
        if wheel.xmlIndex ~= nil then
            flagged = self.xmlFile:getValue(wheel.xmlIndex .. "#isManualTrailerSteeringWheel", false)
        end
        if flagged then
            table.insert(spec.steeringWheels, wheel)
        end
    end
end

-- =========================================================
-- HUD / Help texts (localized)
-- =========================================================
local function mtsUpdateActionTexts(vehicle)
    local spec = vehicle.spec_manualTrailerSteering
    if spec == nil then return end

    if spec.eventIdToggle ~= nil then
        local text
        if spec.manualActive then
            text = mtsGetText(vehicle, ManualTrailerSteering.L10N.HELP_TOGGLE_ON, "MTS Ausschalten")
        else
            if spec.aiIsActive and spec.forceAutoWithHelper then
                text = mtsGetText(vehicle, ManualTrailerSteering.L10N.HELP_TOGGLE_OFF_HELPER, "MTS Einschalten (Helfer aktiv)")
            else
                text = mtsGetText(vehicle, ManualTrailerSteering.L10N.HELP_TOGGLE_OFF, "MTS Einschalten")
            end
        end

        g_inputBinding:setActionEventText(spec.eventIdToggle, text)
        g_inputBinding:setActionEventActive(spec.eventIdToggle, true)
        g_inputBinding:setActionEventTextVisibility(spec.eventIdToggle, true)
        g_inputBinding:setActionEventTextPriority(spec.eventIdToggle, GS_PRIO_HIGH)
    end

    if spec.eventIdAxis ~= nil then
        -- Beim Lenken nur das Icon zeigen, keinen Text
        g_inputBinding:setActionEventText(spec.eventIdAxis, "")
        g_inputBinding:setActionEventTextVisibility(spec.eventIdAxis, true)
        g_inputBinding:setActionEventTextPriority(spec.eventIdAxis, GS_PRIO_VERY_HIGH)

        if spec.helpIconName ~= nil and spec.helpIconName ~= "" and g_inputBinding.setActionEventIcon ~= nil then
            g_inputBinding:setActionEventIcon(spec.eventIdAxis, spec.helpIconName)
        end

        g_inputBinding:setActionEventActive(spec.eventIdAxis, spec.manualActive)
    end

    if spec.eventIdCenter ~= nil then
        local text = mtsGetText(vehicle, ManualTrailerSteering.L10N.HELP_CENTER, "Achse zentrieren")
        g_inputBinding:setActionEventText(spec.eventIdCenter, text)
        g_inputBinding:setActionEventActive(spec.eventIdCenter, spec.manualActive)
        g_inputBinding:setActionEventTextVisibility(spec.eventIdCenter, spec.manualActive)
        g_inputBinding:setActionEventTextPriority(spec.eventIdCenter, GS_PRIO_VERY_HIGH)
    end
end

-- =========================================================
-- ACTION EVENTS
-- =========================================================
function ManualTrailerSteering:onRegisterActionEvents(isActiveForInput, isActiveForInputIgnoreSelection)
    local spec = self.spec_manualTrailerSteering
    self:clearActionEventsTable(spec.actionEvents)

    if not self.isClient then return end

    if isActiveForInputIgnoreSelection then
        local _, eventId

        _, eventId = self:addActionEvent(spec.actionEvents, "MTS_STEER_AXIS", self, ManualTrailerSteering.actionSteerAxis, false, true, true, true)
        spec.eventIdAxis = eventId

        _, eventId = self:addActionEvent(spec.actionEvents, "MTS_CENTER", self, ManualTrailerSteering.actionCenter, false, true, false, true)
        spec.eventIdCenter = eventId

        _, eventId = self:addActionEvent(spec.actionEvents, "MTS_TOGGLE", self, ManualTrailerSteering.actionToggle, false, true, false, true)
        spec.eventIdToggle = eventId

        mtsUpdateActionTexts(self)
    end
end

function ManualTrailerSteering:actionSteerAxis(actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_manualTrailerSteering
    if not spec.manualActive then return end
    self:applyAxisRaw(inputValue)
end

function ManualTrailerSteering:applyAxisRaw(inputValue)
    local spec = self.spec_manualTrailerSteering
    local v = mtsClamp(inputValue or 0, -1, 1)

    spec.axisRaw = v
    spec.axisLastUpdateMs = mtsNowMs()

    if mtsAbs(v) < spec.axisDeadzone then
        spec.axisValue = 0
    else
        spec.axisValue = v
    end
end

function ManualTrailerSteering:actionSteerLeft(actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_manualTrailerSteering
    if not spec.manualActive then return end

    if inputValue ~= nil then
        spec.keyLeft = (inputValue > 0 and 1 or 0)
        return
    end

    if callbackState == InputAction.CALLBACK_STATE_PRESSED or callbackState == InputAction.CALLBACK_STATE_REPEAT then
        spec.keyLeft = 1
    elseif callbackState == InputAction.CALLBACK_STATE_RELEASED then
        spec.keyLeft = 0
    end
end

function ManualTrailerSteering:actionSteerRight(actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_manualTrailerSteering
    if not spec.manualActive then return end

    if inputValue ~= nil then
        spec.keyRight = (inputValue > 0 and 1 or 0)
        return
    end

    if callbackState == InputAction.CALLBACK_STATE_PRESSED or callbackState == InputAction.CALLBACK_STATE_REPEAT then
        spec.keyRight = 1
    elseif callbackState == InputAction.CALLBACK_STATE_RELEASED then
        spec.keyRight = 0
    end
end

function ManualTrailerSteering:actionCenter(actionName, inputValue, callbackState, isAnalog)
    local spec = self.spec_manualTrailerSteering
    if not spec.manualActive then return end

    if callbackState == nil or callbackState == InputAction.CALLBACK_STATE_PRESSED then
        self:startCentering()
    end
end

function ManualTrailerSteering:actionToggle()
    self:toggleManualTrailerSteering()
end

-- =========================================================
-- STREAM SYNC
-- =========================================================
function ManualTrailerSteering:onWriteStream(streamId, connection)
    local spec = self.spec_manualTrailerSteering
    streamWriteBool(streamId, spec.manualActive)
    streamWriteFloat32(streamId, spec.currentAngle)
end

function ManualTrailerSteering:onReadStream(streamId, connection)
    local spec = self.spec_manualTrailerSteering
    spec.manualActive = streamReadBool(streamId)
    spec.currentAngle = streamReadFloat32(streamId)
end

-- =========================================================
-- AI / Helper detection (best-effort)
-- =========================================================
local function mtsTryIsAIActiveOn(vehicle)
    if vehicle == nil then return false end

    if vehicle.getIsAIActive ~= nil then
        local ok, res = pcall(vehicle.getIsAIActive, vehicle)
        if ok and res ~= nil then
            return res == true
        end
    end

    local s = vehicle.spec_aiVehicle
    if s ~= nil then
        if s.isActive ~= nil then return s.isActive == true end
        if s.aiDriveStrategy ~= nil then return true end
        if s.driveStrategy ~= nil then return true end
        if s.isStarted ~= nil then return s.isStarted == true end
        if s.started ~= nil then return s.started == true end
    end

    local fw = vehicle.spec_aiFieldWorker
    if fw ~= nil and fw.isActive ~= nil then
        return fw.isActive == true
    end

    return false
end

local function mtsIsHelperActiveForThis(self)
    if self.getRootVehicle ~= nil then
        local ok, root = pcall(self.getRootVehicle, self)
        if ok and root ~= nil then
            if mtsTryIsAIActiveOn(root) then
                return true
            end
        end
    end
    return mtsTryIsAIActiveOn(self)
end

-- =========================================================
-- Auto angle read (best-effort)
-- =========================================================
local function mtsGetCurrentAutoAngle(vehicle)
    if vehicle.spec_attachable ~= nil and vehicle.spec_attachable.steeringAxleAngle ~= nil then
        return vehicle.spec_attachable.steeringAxleAngle
    end
    if vehicle.spec_trailer ~= nil and vehicle.spec_trailer.steeringAxleAngle ~= nil then
        return vehicle.spec_trailer.steeringAxleAngle
    end
    if vehicle.spec_manualTrailerSteering ~= nil then
        return vehicle.spec_manualTrailerSteering.currentAngle or 0
    end
    return 0
end

-- =========================================================
-- STATE
-- =========================================================
function ManualTrailerSteering:setManualTrailerState(active, angle, noEventSend)
    local spec = self.spec_manualTrailerSteering
    spec.manualActive = active
    spec.currentAngle = mtsClamp(angle, -spec.maxAngle, spec.maxAngle)

    if self.isClient then
        mtsUpdateActionTexts(self)
    end

    if noEventSend or mtsAbs(spec.currentAngle - spec.lastSentAngle) > spec.sendThreshold or not spec.manualActive then
        spec.lastSentAngle = spec.currentAngle
        ManualTrailerSteering.sendEvent(self, spec.manualActive, spec.currentAngle, noEventSend)
    end
end

function ManualTrailerSteering:toggleManualTrailerSteering(noEventSend)
    local spec = self.spec_manualTrailerSteering

    if spec.forceAutoWithHelper then
        local helperActive = mtsIsHelperActiveForThis(self)
        spec.aiIsActive = helperActive

        if helperActive and not spec.manualActive then
            if self.isClient then
                mtsUpdateActionTexts(self)
                mtsNotify(mtsGetText(self, ManualTrailerSteering.L10N.NOTIFY_HELPER_LOCKED, "Helfer aktiv: Manuelle Achslenkung gesperrt"))
            end
            return
        end
    end

    local newActive = not spec.manualActive

    if newActive then
        local a = mtsGetCurrentAutoAngle(self)
        spec.centering = false
        spec.inputBlocked = false
        spec.rearmNeedsNeutral = false

        spec.keyLeft, spec.keyRight = 0, 0
        spec.axisRaw, spec.axisValue = 0, 0
        spec.axisLastUpdateMs = mtsNowMs()

        self:setManualTrailerState(true, a, noEventSend)
    else
        spec.centering = false
        spec.inputBlocked = false
        spec.rearmNeedsNeutral = false

        spec.keyLeft, spec.keyRight = 0, 0
        spec.axisRaw, spec.axisValue = 0, 0
        spec.axisLastUpdateMs = mtsNowMs()

        self:setManualTrailerState(false, spec.currentAngle, noEventSend)
    end

    if self.isClient then
        mtsNotify(mtsGetText(self,
            newActive and ManualTrailerSteering.L10N.NOTIFY_MANUAL_ON or ManualTrailerSteering.L10N.NOTIFY_MANUAL_OFF,
            newActive and "Manuelle Achslenkung: AN" or "Manuelle Achslenkung: AUS"))
    end
end

function ManualTrailerSteering:startCentering()
    local spec = self.spec_manualTrailerSteering

    spec.centering = true
    spec.inputBlocked = true
    spec.rearmNeedsNeutral = true

    -- clear current inputs immediately
    spec.axisRaw = 0
    spec.axisValue = 0
    spec.keyLeft = 0
    spec.keyRight = 0
    spec.axisLastUpdateMs = mtsNowMs()
end

-- =========================================================
-- UPDATE (integrate rate)
-- =========================================================
function ManualTrailerSteering:onUpdate(dt)
    local spec = self.spec_manualTrailerSteering

    -- Helper forces AUTO
    if spec.forceAutoWithHelper then
        local helperActive = mtsIsHelperActiveForThis(self)
        spec.aiIsActive = helperActive

        if helperActive and spec.manualActive then
            spec.centering = false
            spec.inputBlocked = false
            spec.rearmNeedsNeutral = false
            spec.keyLeft, spec.keyRight = 0, 0
            spec.axisRaw, spec.axisValue = 0, 0
            spec.axisLastUpdateMs = mtsNowMs()

            spec.aiForcedOff = true
            self:setManualTrailerState(false, spec.currentAngle)

            if self.isClient then
                mtsUpdateActionTexts(self)
                mtsNotify(mtsGetText(self, ManualTrailerSteering.L10N.NOTIFY_HELPER_FORCED, "Helfer aktiv: Automatikmodus gewählt"))
            end
            return
        end

        if not helperActive and spec.aiForcedOff then
            spec.aiForcedOff = false
            if self.isClient then
                mtsUpdateActionTexts(self)
            end
        end
    end

    if not spec.manualActive then return end

    local now = mtsNowMs()

    -- If no axis update arrived for a while, assume axis=0 (prevents stuck input)
    if spec.axisTimeoutMs > 0 and (now - spec.axisLastUpdateMs) > spec.axisTimeoutMs then
        spec.axisRaw = 0
        spec.axisValue = 0
    end

    local dtSec = dt * 0.001

    -- Input block / re-arm logic
    if spec.inputBlocked then
        if spec.rearmNeedsNeutral then
            if mtsAbs(spec.axisRaw) <= spec.axisDeadzone and spec.keyLeft <= 0 and spec.keyRight <= 0 then
                spec.rearmNeedsNeutral = false
            end

            spec.axisValue = 0
            spec.keyLeft = 0
            spec.keyRight = 0
        else
            if mtsAbs(spec.axisRaw) >= spec.axisRearmThreshold or spec.keyLeft > 0 or spec.keyRight > 0 then
                spec.inputBlocked = false
            else
                spec.axisValue = 0
                spec.keyLeft = 0
                spec.keyRight = 0
            end
        end
    end

    -- Centering (latched)
    if spec.centering then
        local step = spec.centerReturn * dtSec
        if mtsAbs(spec.currentAngle) <= step then
            spec.currentAngle = 0
            spec.centering = false
            self:setManualTrailerState(true, spec.currentAngle)
            return
        else
            spec.currentAngle = spec.currentAngle - step * (spec.currentAngle > 0 and 1 or -1)
            self:setManualTrailerState(true, spec.currentAngle)
            return
        end
    end

    -- rate from axis + keys (NO target angle)
    local rate = 0.0
    rate = rate + (spec.axisValue * spec.axisRate)

    if spec.keyLeft > 0 and spec.keyRight <= 0 then
        rate = rate - spec.keyRate
    elseif spec.keyRight > 0 and spec.keyLeft <= 0 then
        rate = rate + spec.keyRate
    end

    if rate ~= 0 then
        spec.currentAngle = mtsClamp(spec.currentAngle + rate * dtSec, -spec.maxAngle, spec.maxAngle)
        self:setManualTrailerState(true, spec.currentAngle)
    end
end

-- =========================================================
-- POST UPDATE apply
-- =========================================================
local function mtsApplyAngle(vehicle, angle)
    if vehicle.spec_attachable ~= nil and vehicle.spec_attachable.steeringAxleAngle ~= nil then
        vehicle.spec_attachable.steeringAxleAngle = angle
    end
    if vehicle.spec_trailer ~= nil and vehicle.spec_trailer.steeringAxleAngle ~= nil then
        vehicle.spec_trailer.steeringAxleAngle = angle
    end

    local specMts = vehicle.spec_manualTrailerSteering
    for _, wheel in ipairs(specMts.steeringWheels) do
        wheel.steeringAngle = angle
        if wheel.steeringAngleRaw ~= nil then wheel.steeringAngleRaw = angle end
        if wheel.steeringRot ~= nil then wheel.steeringRot = angle end
        if wheel.steeringAngleOffset ~= nil then wheel.steeringAngleOffset = angle end
    end
end

function ManualTrailerSteering:onPostUpdate(dt)
    local spec = self.spec_manualTrailerSteering
    if not spec.manualActive then return end
    mtsApplyAngle(self, spec.currentAngle)
end
