local const  = require("scada-common.constants")
local log    = require("scada-common.log")
local rsio   = require("scada-common.rsio")
local types  = require("scada-common.types")
local util   = require("scada-common.util")

local plc    = require("supervisor.session.plc")

local qtypes = require("supervisor.session.rtu.qtypes")

local RPS_TRIP_CAUSE = types.RPS_TRIP_CAUSE
local TRI_FAIL       = types.TRI_FAIL
local CONTAINER_MODE = types.CONTAINER_MODE
local DUMPING_MODE   = types.DUMPING_MODE
local PRIO           = types.ALARM_PRIORITY
local ALARM_STATE    = types.ALARM_STATE

local TBV_RTU_S_DATA = qtypes.TBV_RTU_S_DATA
local DTV_RTU_S_DATA = qtypes.DTV_RTU_S_DATA

local IO = rsio.IO

local PLC_S_CMDS = plc.PLC_S_CMDS

local AISTATE_NAMES = {
    "INACTIVE",
    "TRIPPING",
    "TRIPPED",
    "ACKED",
    "RING_BACK",
    "RING_BACK_TRIPPING"
}

local FLOW_STABILITY_DELAY_MS = const.FLOW_STABILITY_DELAY_MS

local ANNUNC_LIMS = const.ANNUNCIATOR_LIMITS
local ALARM_LIMS = const.ALARM_LIMITS

---@class unit_logic_extension
local logic = {}

-- update the annunciator
---@param self _unit_self
function logic.update_annunciator(self)
    local DT_KEYS = self.types.DT_KEYS
    local _get_dt = self._get_dt

    local num_boilers = self.num_boilers
    local num_turbines = self.num_turbines

    self.db.annunciator.RCSFault = false

    -- variables for boiler, or reactor if no boilers used
    local total_boil_rate = 0.0

    -------------
    -- REACTOR --
    -------------

    self.db.annunciator.AutoControl = self.auto_engaged

    -- check PLC status
    self.db.annunciator.PLCOnline = self.plc_i ~= nil

    local plc_ready = self.db.annunciator.PLCOnline

    if self.db.annunciator.PLCOnline then
        local plc_db = self.plc_i.get_db()

        -- update ready state
        --  - can't be tripped
        --  - must have received status at least once
        --  - must have received struct at least once
        plc_ready = plc_db.formed and (not plc_db.no_reactor) and (not plc_db.rps_tripped) and
                    (next(self.plc_i.get_status()) ~= nil) and (next(self.plc_i.get_struct()) ~= nil)

        -- update auto control limit
        if (self.db.control.lim_br100 == 0) or ((self.db.control.lim_br100 / 100) > plc_db.mek_struct.max_burn) then
            self.db.control.lim_br100 = math.floor(plc_db.mek_struct.max_burn * 100)
        end

        -- some alarms wait until the burn rate has stabilized, so keep track of that
        if math.abs(_get_dt(DT_KEYS.ReactorBurnR)) > 0 then
            self.last_rate_change_ms = util.time_ms()
        end

        -- record reactor stats
        self.plc_cache.active = plc_db.mek_status.status
        self.plc_cache.ok = not (plc_db.rps_status.fault or plc_db.rps_status.sys_fail or plc_db.rps_status.force_dis)
        self.plc_cache.rps_trip = plc_db.rps_tripped
        self.plc_cache.rps_status = plc_db.rps_status
        self.plc_cache.damage = plc_db.mek_status.damage
        self.plc_cache.temp = plc_db.mek_status.temp
        self.plc_cache.waste = plc_db.mek_status.waste_fill

        -- track damage
        if plc_db.mek_status.damage > 0 then
            if self.damage_start == 0 then
                self.damage_decreasing = false
                self.damage_start = util.time_s()
                self.damage_initial = plc_db.mek_status.damage
            end
        else
            self.damage_decreasing = false
            self.damage_start = 0
            self.damage_initial = 0
            self.damage_last = 0
            self.damage_est_last = 0
        end

        -- heartbeat blink about every second
        if self.last_heartbeat + 1000 < plc_db.last_status_update then
            self.db.annunciator.PLCHeartbeat = not self.db.annunciator.PLCHeartbeat
            self.last_heartbeat = plc_db.last_status_update
        end

        local flow_low = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, ANNUNC_LIMS.RCSFlowLow_NA, ANNUNC_LIMS.RCSFlowLow_H2O)

        -- update other annunciator fields
        self.db.annunciator.ReactorSCRAM = plc_db.rps_tripped
        self.db.annunciator.ManualReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.MANUAL
        self.db.annunciator.AutoReactorSCRAM = plc_db.rps_trip_cause == types.RPS_TRIP_CAUSE.AUTOMATIC
        self.db.annunciator.RCPTrip = plc_db.rps_tripped and (plc_db.rps_status.ex_hcool or plc_db.rps_status.low_cool)
        self.db.annunciator.RCSFlowLow = _get_dt(DT_KEYS.ReactorCCool) < flow_low
        self.db.annunciator.CoolantLevelLow = plc_db.mek_status.ccool_fill < ANNUNC_LIMS.CoolantLevelLow
        self.db.annunciator.ReactorTempHigh = plc_db.mek_status.temp > ANNUNC_LIMS.ReactorTempHigh
        self.db.annunciator.ReactorHighDeltaT = _get_dt(DT_KEYS.ReactorTemp) > ANNUNC_LIMS.ReactorHighDeltaT
        self.db.annunciator.FuelInputRateLow = _get_dt(DT_KEYS.ReactorFuel) < -1.0 or plc_db.mek_status.fuel_fill <= ANNUNC_LIMS.FuelLevelLow
        self.db.annunciator.WasteLineOcclusion = _get_dt(DT_KEYS.ReactorWaste) > 1.0 or plc_db.mek_status.waste_fill >= ANNUNC_LIMS.WasteLevelHigh

        local heating_rate_conv = util.trinary(plc_db.mek_status.ccool_type == types.FLUID.SODIUM, 200000, 20000)
        local high_rate = plc_db.mek_status.burn_rate >= (plc_db.mek_status.ccool_amnt * 0.27 / heating_rate_conv)
        -- this advisory applies when no coolant is buffered (which we can't easily determine)<br>
        -- it's a rough estimation, see GitHub cc-mek-scada/wiki/High-Rate-Calculation
        self.db.annunciator.HighStartupRate = not plc_db.mek_status.status and high_rate

        -- if no boilers, use reactor heating rate to check for boil rate mismatch
        if num_boilers == 0 then
            total_boil_rate = plc_db.mek_status.heating_rate
        end
    else
        self.plc_cache.ok = false
    end

    ---------------
    -- MISC RTUs --
    ---------------

    self.db.annunciator.RadiationMonitor = 1
    self.db.annunciator.RadiationWarning = false
    for i = 1, #self.envd do
        local envd = self.envd[i]   ---@type unit_session
        self.db.annunciator.RadiationMonitor = util.trinary(envd.is_faulted(), 2, 3)
        self.db.annunciator.RadiationWarning = envd.get_db().radiation_raw >= ANNUNC_LIMS.RadiationWarning
        break
    end

    self.db.annunciator.EmergencyCoolant = 1
    for i = 1, #self.redstone do
        local db = self.redstone[i].get_db()    ---@type redstone_session_db
        local io = db.io[IO.U_EMER_COOL]        ---@type rs_db_dig_io|nil
        if io ~= nil then
            self.db.annunciator.EmergencyCoolant = util.trinary(io.read(), 3, 2)
            break
        end
    end

    -------------
    -- BOILERS --
    -------------

    local boilers_ready = num_boilers == #self.boilers

    -- clear boiler online flags
    for i = 1, num_boilers do self.db.annunciator.BoilerOnline[i] = false end

    -- aggregated statistics
    local boiler_steam_dt_sum = 0.0
    local boiler_water_dt_sum = 0.0

    if num_boilers > 0 then
        -- go through boilers for stats and online
        for i = 1, #self.boilers do
            local session = self.boilers[i] ---@type unit_session
            local boiler = session.get_db() ---@type boilerv_session_db
            local idx = session.get_device_idx()

            self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not boiler.formed) or session.is_faulted()

            -- update ready state
            --  - must be formed
            --  - must have received build, state, and tanks at least once
            boilers_ready = boilers_ready and boiler.formed and
                            (boiler.build.last_update > 0) and
                            (boiler.state.last_update > 0) and
                            (boiler.tanks.last_update > 0)

            total_boil_rate = total_boil_rate + boiler.state.boil_rate
            boiler_steam_dt_sum = _get_dt(DT_KEYS.BoilerSteam .. idx)
            boiler_water_dt_sum = _get_dt(DT_KEYS.BoilerWater .. idx)

            self.db.annunciator.BoilerOnline[idx] = true
            self.db.annunciator.WaterLevelLow[idx] = boiler.tanks.water_fill < ANNUNC_LIMS.WaterLevelLow
        end

        -- check heating rate low
        if self.plc_i ~= nil and #self.boilers > 0 then
            local r_db = self.plc_i.get_db()

            -- check for inactive boilers while reactor is active
            for i = 1, #self.boilers do
                local boiler = self.boilers[i]  ---@type unit_session
                local idx = boiler.get_device_idx()
                local db = boiler.get_db()      ---@type boilerv_session_db

                if r_db.mek_status.status then
                    self.db.annunciator.HeatingRateLow[idx] = db.state.boil_rate == 0
                else
                    self.db.annunciator.HeatingRateLow[idx] = false
                end
            end
        end
    else
        boiler_steam_dt_sum = _get_dt(DT_KEYS.ReactorHCool)
        boiler_water_dt_sum = _get_dt(DT_KEYS.ReactorCCool)
    end

    ---------------------------
    -- COOLANT FEED MISMATCH --
    ---------------------------

    -- check coolant feed mismatch if using boilers, otherwise calculate with reactor
    local cfmismatch = false

    if num_boilers > 0 then
        for i = 1, #self.boilers do
            local boiler = self.boilers[i]      ---@type unit_session
            local idx = boiler.get_device_idx()
            local db = boiler.get_db()          ---@type boilerv_session_db

            local gaining_hc = _get_dt(DT_KEYS.BoilerHCool .. idx) > 10.0 or db.tanks.hcool_fill == 1

            -- gaining heated coolant
            cfmismatch = cfmismatch or gaining_hc
            -- losing cooled coolant
            cfmismatch = cfmismatch or _get_dt(DT_KEYS.BoilerCCool .. idx) < -10.0 or (gaining_hc and db.tanks.ccool_fill == 0)
        end
    elseif self.plc_i ~= nil then
        local r_db = self.plc_i.get_db()

        local gaining_hc = _get_dt(DT_KEYS.ReactorHCool) > 10.0 or r_db.mek_status.hcool_fill == 1

        -- gaining heated coolant (steam)
        cfmismatch = cfmismatch or gaining_hc
        -- losing cooled coolant (water)
        cfmismatch = cfmismatch or _get_dt(DT_KEYS.ReactorCCool) < -10.0 or (gaining_hc and r_db.mek_status.ccool_fill == 0)
    end

    self.db.annunciator.CoolantFeedMismatch = cfmismatch

    --------------
    -- TURBINES --
    --------------

    local turbines_ready = num_turbines == #self.turbines

    -- clear turbine online flags
    for i = 1, num_turbines do self.db.annunciator.TurbineOnline[i] = false end

    -- aggregated statistics
    local total_flow_rate = 0
    local total_input_rate = 0
    local max_water_return_rate = 0

    -- recompute blade count on the chance that it may have changed
    self.db.control.blade_count = 0

    -- go through turbines for stats and online
    for i = 1, #self.turbines do
        local session = self.turbines[i]    ---@type unit_session
        local turbine = session.get_db()    ---@type turbinev_session_db

        self.db.annunciator.RCSFault = self.db.annunciator.RCSFault or (not turbine.formed) or session.is_faulted()

        -- update ready state
        --  - must be formed
        --  - must have received build, state, and tanks at least once
        turbines_ready = turbines_ready and turbine.formed and
                        (turbine.build.last_update > 0) and
                        (turbine.state.last_update > 0) and
                        (turbine.tanks.last_update > 0)

        total_flow_rate = total_flow_rate + turbine.state.flow_rate
        total_input_rate = total_input_rate + turbine.state.steam_input_rate
        max_water_return_rate = max_water_return_rate + turbine.build.max_water_output
        self.db.control.blade_count = self.db.control.blade_count + turbine.build.blades

        self.db.annunciator.TurbineOnline[session.get_device_idx()] = true
    end

    -- check for boil rate mismatch (> 4% error) either between reactor and turbine or boiler and turbine
    self.db.annunciator.BoilRateMismatch = math.abs(total_boil_rate - total_input_rate) > (0.04 * total_boil_rate)

    -- check for steam feed mismatch and max return rate
    local steam_dt_max = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MaxSteamDT_H20, ANNUNC_LIMS.SFM_MaxSteamDT_NA)
    local water_dt_min = util.trinary(num_boilers == 0, ANNUNC_LIMS.SFM_MinWaterDT_H20, ANNUNC_LIMS.SFM_MinWaterDT_NA)
    local sfmismatch = math.abs(total_flow_rate - total_input_rate) > ANNUNC_LIMS.SteamFeedMismatch
    sfmismatch = sfmismatch or boiler_steam_dt_sum > steam_dt_max or boiler_water_dt_sum < water_dt_min
    self.db.annunciator.SteamFeedMismatch = sfmismatch
    self.db.annunciator.MaxWaterReturnFeed = max_water_return_rate == total_flow_rate and total_flow_rate ~= 0

    -- turbine safety checks
    for i = 1, #self.turbines do
        local turbine = self.turbines[i]    ---@type unit_session
        local db = turbine.get_db()         ---@type turbinev_session_db
        local idx = turbine.get_device_idx()

        -- check if steam dumps are open
        if db.state.dumping_mode == DUMPING_MODE.IDLE then
            self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.OK
        elseif db.state.dumping_mode == DUMPING_MODE.DUMPING_EXCESS then
            self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.PARTIAL
        else
            self.db.annunciator.SteamDumpOpen[idx] = TRI_FAIL.FULL
        end

        -- check if turbines are at max speed but not keeping up
        self.db.annunciator.TurbineOverSpeed[idx] = (db.state.flow_rate == db.build.max_flow_rate) and (_get_dt(DT_KEYS.TurbineSteam .. idx) > 0.0)

        --[[
            Generator Trip
            a generator trip is when a generator suddenly and unexpectedly loses it's external load, which occurs when a power plant
            is disconnected from the grid. in our case, this is when the turbine is disconnected, or what it's connected to becomes
            fully charged. this is identified by detecting if:
                - the internal power storage of the turbine is increasing AND
                - there is at least 5% energy fill (preventing false trips with periodic power extraction from other mods)
            this would then mean there is no external load and there will be a turbine trip soon if this is not resolved
        ]]--
        self.db.annunciator.GeneratorTrip[idx] = (_get_dt(DT_KEYS.TurbinePower .. idx) > 0.0) and (db.tanks.energy_fill > 0.05)

        --[[
            Turbine Trip
            a turbine trip is when the turbine stops, which means we are no longer receiving water and lose the ability to cool.
            this can be identified by these conditions:
            - the current flow rate is 0 mB/t and it should not be
                - can initially catch this by detecting a 0 flow rate with a non-zero input rate, but eventually the steam will fill up
                - can later identified by presence of steam in tank with a 0 flow rate
        ]]--
        local has_steam = db.state.steam_input_rate > 0 or db.tanks.steam_fill > 0.01
        self.db.annunciator.TurbineTrip[idx] = has_steam and db.state.flow_rate == 0
    end

    -- update auto control ready state for this unit
    self.db.control.ready = plc_ready and boilers_ready and turbines_ready
end

-- update an alarm state given conditions
---@param self _unit_self unit instance
---@param tripped boolean if the alarm condition is still active
---@param alarm alarm_def alarm table
---@return boolean new_trip if the alarm just changed to being tripped
local function _update_alarm_state(self, tripped, alarm)
    local AISTATE = self.types.AISTATE
    local int_state = alarm.state
    local ext_state = self.db.alarm_states[alarm.id]

    -- alarm inactive
    if int_state == AISTATE.INACTIVE then
        if tripped then
            alarm.trip_time = util.time_ms()
            if alarm.hold_time > 0 then
                alarm.state = AISTATE.TRIPPING
                self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE
            else
                alarm.state = AISTATE.TRIPPED
                self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED
                log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): TRIPPED [PRIORITY ",
                    types.ALARM_PRIORITY_NAMES[alarm.tier],"]"))
            end
        else
            alarm.trip_time = util.time_ms()
            self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE
        end
    -- alarm condition met, but not yet for required hold time
    elseif (int_state == AISTATE.TRIPPING) or (int_state == AISTATE.RING_BACK_TRIPPING) then
        if tripped then
            local elapsed = util.time_ms() - alarm.trip_time
            if elapsed > (alarm.hold_time * 1000) then
                alarm.state = AISTATE.TRIPPED
                self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED
                log.info(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): TRIPPED [PRIORITY ",
                    types.ALARM_PRIORITY_NAMES[alarm.tier],"]"))
            end
        elseif int_state == AISTATE.RING_BACK_TRIPPING then
            alarm.trip_time = 0
            alarm.state = AISTATE.RING_BACK
            self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK
        else
            alarm.trip_time = 0
            alarm.state = AISTATE.INACTIVE
            self.db.alarm_states[alarm.id] = ALARM_STATE.INACTIVE
        end
    -- alarm tripped and alarming
    elseif int_state == AISTATE.TRIPPED then
        if tripped then
            if ext_state == ALARM_STATE.ACKED then
                -- was acked by coordinator
                alarm.state = AISTATE.ACKED
            end
        else
            alarm.state = AISTATE.RING_BACK
            self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK
        end
    -- alarm acknowledged but still tripped
    elseif int_state == AISTATE.ACKED then
        if not tripped then
            alarm.state = AISTATE.RING_BACK
            self.db.alarm_states[alarm.id] = ALARM_STATE.RING_BACK
        end
    -- alarm no longer tripped, operator must reset to clear
    elseif int_state == AISTATE.RING_BACK then
        if tripped then
            alarm.trip_time = util.time_ms()
            if alarm.hold_time > 0 then
                alarm.state = AISTATE.RING_BACK_TRIPPING
            else
                alarm.state = AISTATE.TRIPPED
                self.db.alarm_states[alarm.id] = ALARM_STATE.TRIPPED
            end
        elseif ext_state == ALARM_STATE.INACTIVE then
            -- was reset by coordinator
            alarm.state = AISTATE.INACTIVE
            alarm.trip_time = 0
        end
    else
        log.error(util.c("invalid alarm state for unit ", self.r_id, " alarm ", alarm.id), true)
    end

    -- check for state change
    if alarm.state ~= int_state then
        local change_str = util.c(AISTATE_NAMES[int_state], " -> ", AISTATE_NAMES[alarm.state])
        log.debug(util.c("UNIT ", self.r_id, " ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], "): ", change_str))
        return alarm.state == AISTATE.TRIPPED
    else return false end
end

-- evaluate alarm conditions
---@param self _unit_self unit instance
function logic.update_alarms(self)
    local annunc = self.db.annunciator
    local plc_cache = self.plc_cache

    -- Containment Breach
    -- lost plc with critical damage (rip plc, you will be missed)
    _update_alarm_state(self, (not plc_cache.ok) and (plc_cache.damage > 99), self.alarms.ContainmentBreach)

    -- Containment Radiation
    local rad_alarm = false
    for i = 1, #self.envd do
        self.last_radiation = self.envd[i].get_db().radiation_raw
        rad_alarm = self.last_radiation >= ALARM_LIMS.HIGH_RADIATION
        break
    end
    _update_alarm_state(self, rad_alarm, self.alarms.ContainmentRadiation)

    -- Reactor Lost
    _update_alarm_state(self, self.had_reactor and self.plc_i == nil, self.alarms.ReactorLost)

    -- Critical Damage
    _update_alarm_state(self, plc_cache.damage >= 100, self.alarms.CriticalDamage)

    -- Reactor Damage
    local rps_dmg_90 = plc_cache.rps_status.high_dmg and not self.last_rps_trips.high_dmg
    if _update_alarm_state(self, (plc_cache.damage > 0) or rps_dmg_90, self.alarms.ReactorDamage) then
        log.debug(util.c(">> Trip Detail Report for ", types.ALARM_NAMES[self.alarms.ReactorDamage.id]," <<"))
        log.debug(util.c("| plc_cache.damage[", plc_cache.damage, "] rps_dmg_90[", rps_dmg_90, "]"))
    end

    -- Over-Temperature
    local rps_high_temp = plc_cache.rps_status.high_temp and not self.last_rps_trips.high_temp
    if _update_alarm_state(self, (plc_cache.temp >= 1200) or rps_high_temp, self.alarms.ReactorOverTemp) then
        log.debug(util.c(">> Trip Detail Report for ", types.ALARM_NAMES[self.alarms.ReactorOverTemp.id]," <<"))
        log.debug(util.c("| plc_cache.temp[", plc_cache.temp, "] rps_high_temp[", rps_high_temp, "]"))
    end

    -- High Temperature
    _update_alarm_state(self, plc_cache.temp >= ALARM_LIMS.HIGH_TEMP, self.alarms.ReactorHighTemp)

    -- Waste Leak
    _update_alarm_state(self, plc_cache.waste >= 1.0, self.alarms.ReactorWasteLeak)

    -- High Waste
    local rps_high_waste = plc_cache.rps_status.ex_waste and not self.last_rps_trips.ex_waste
    if _update_alarm_state(self, (plc_cache.waste > ALARM_LIMS.HIGH_WASTE) or rps_high_waste, self.alarms.ReactorHighWaste) then
        log.debug(util.c(">> Trip Detail Report for ", types.ALARM_NAMES[self.alarms.ReactorHighWaste.id]," <<"))
        log.debug(util.c("| plc_cache.waste[", plc_cache.waste, "] rps_high_waste[", rps_high_waste, "]"))
    end

    -- RPS Transient (excludes timeouts and manual trips)
    local rps_alarm = false
    if plc_cache.rps_status.manual ~= nil then
        if plc_cache.rps_trip then
            for key, val in pairs(plc_cache.rps_status) do
                if key ~= "manual" and key ~= "timeout" then rps_alarm = rps_alarm or val end
            end
        end
    end

    _update_alarm_state(self, rps_alarm, self.alarms.RPSTransient)

    -- RCS Transient
    local any_low = annunc.CoolantLevelLow
    local any_over = false
    local gen_trip = false
    for i = 1, #annunc.WaterLevelLow do any_low = any_low or annunc.WaterLevelLow[i] end
    for i = 1, #annunc.TurbineOverSpeed do any_over = any_over or annunc.TurbineOverSpeed[i] end
    for i = 1, #annunc.GeneratorTrip do gen_trip = gen_trip or annunc.GeneratorTrip[i] end

    local rcs_trans = any_low or any_over or gen_trip or annunc.RCPTrip or annunc.MaxWaterReturnFeed

    -- annunciator indicators for these states may not indicate a real issue when:
    --  > flow is ramping up right after reactor start
    --  > flow is ramping down after reactor shutdown
    if ((util.time_ms() - self.last_rate_change_ms) > FLOW_STABILITY_DELAY_MS) and plc_cache.active then
        rcs_trans = rcs_trans or annunc.RCSFlowLow or annunc.BoilRateMismatch or annunc.CoolantFeedMismatch or annunc.SteamFeedMismatch
    end

    if _update_alarm_state(self, rcs_trans, self.alarms.RCSTransient) then
        log.debug(util.c(">> Trip Detail Report for ", types.ALARM_NAMES[self.alarms.RCSTransient.id]," <<"))
        log.debug(util.c("| any_low[", any_low, "] any_over[", any_over, "] gen_trip[", gen_trip, "]"))
        log.debug(util.c("| RCPTrip[", annunc.RCPTrip, "] MaxWaterReturnFeed[", annunc.MaxWaterReturnFeed, "]"))
        log.debug(util.c("| RCSFlowLow[", annunc.RCSFlowLow, "] BoilRateMismatch[", annunc.BoilRateMismatch,
                    "] CoolantFeedMismatch[", annunc.CoolantFeedMismatch, "] SteamFeedMismatch[", annunc.SteamFeedMismatch, "]"))
    end

    -- Turbine Trip
    local any_trip = false
    for i = 1, #annunc.TurbineTrip do any_trip = any_trip or annunc.TurbineTrip[i] end
    _update_alarm_state(self, any_trip, self.alarms.TurbineTrip)

    -- update last trips table
    for key, val in pairs(plc_cache.rps_status) do self.last_rps_trips[key] = val end
end

-- update the internal automatic safety control performed while in auto control mode
---@param public reactor_unit reactor unit public functions
---@param self _unit_self unit instance
function logic.update_auto_safety(public, self)
    local AISTATE = self.types.AISTATE

    if self.auto_engaged then
        local alarmed = false

        for _, alarm in pairs(self.alarms) do
            if alarm.tier <= PRIO.URGENT and (alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED) then
                if not self.auto_was_alarmed then
                    log.info(util.c("UNIT ", self.r_id, " AUTO SCRAM due to ALARM ", alarm.id, " (", types.ALARM_NAMES[alarm.id], ") [PRIORITY ",
                        types.ALARM_PRIORITY_NAMES[alarm.tier],"]"))
                end

                alarmed = true
                break
            end
        end

        if alarmed and not self.plc_cache.rps_status.automatic then
            public.auto_scram()
        end

        self.auto_was_alarmed = alarmed
    else
        self.auto_was_alarmed = false
    end
end

-- update the two unit status text messages
---@param self _unit_self unit instance
function logic.update_status_text(self)
    local AISTATE = self.types.AISTATE

    -- check if an alarm is active (tripped or ack'd)
    ---@nodiscard
    ---@param alarm table alarm entry
    ---@return boolean active
    local function is_active(alarm)
        return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED
    end

    -- update status text (what the reactor doin?)
    if is_active(self.alarms.ContainmentBreach) then
        -- boom? or was boom disabled
        if self.plc_i ~= nil and self.plc_i.get_rps().force_dis then
            self.status_text = { "REACTOR FORCE DISABLED", "meltdown would have occurred" }
        else
            self.status_text = { "CORE MELTDOWN", "reactor destroyed" }
        end
    elseif is_active(self.alarms.CriticalDamage) then
        -- so much for it being a "routine turbin' trip"...
        self.status_text = { "MELTDOWN IMMINENT", "evacuate facility immediately" }
    elseif is_active(self.alarms.ReactorDamage) then
        -- attempt to determine when a chance of a meltdown will occur
        self.status_text[1] = "CONTAINMENT TAKING DAMAGE"
        if self.plc_cache.damage >= 100 then
            self.status_text[2] = "damage critical"
        elseif (self.plc_cache.damage < self.damage_last) or ((self.plc_cache.damage - self.damage_initial) < 0) then
            self.damage_decreasing = true
            self.status_text = { "CONTAINMENT TOOK DAMAGE", "damage level lowering..." }

            -- reset damage estimation data in case it goes back up again
            self.damage_initial = self.plc_cache.damage
            self.damage_start = util.time_s()
            self.damage_est_last = 0
        elseif (not self.damage_decreasing) or (self.plc_cache.damage > self.damage_last) then
            self.damage_decreasing = false

            if (self.plc_cache.damage - self.damage_initial) > 0 then
                if self.plc_cache.damage > self.damage_last then
                    local rate = (self.plc_cache.damage - self.damage_initial) / (util.time_s() - self.damage_start)
                    self.damage_est_last = (100 - self.plc_cache.damage) / rate
                end

                self.status_text[2] = util.c("damage critical in ", util.sprintf("%.1f", self.damage_est_last), "s")
            else
                self.status_text[2] = "estimating time to critical..."
            end
        else
            self.status_text = { "CONTAINMENT TOOK DAMAGE", "damage level lowering..." }
        end

        self.damage_last = self.plc_cache.damage
    elseif is_active(self.alarms.ContainmentRadiation) then
        self.status_text[1] = "RADIATION DETECTED"

        if self.last_radiation >= const.EXTREME_RADIATION then
            self.status_text[2] = "extremely high radiation level"
        elseif self.last_radiation >= const.SEVERE_RADIATION then
            self.status_text[2] = "severely high radiation level"
        elseif self.last_radiation >= const.VERY_HIGH_RADIATION then
            self.status_text[2] = "very high level of radiation"
        elseif self.last_radiation >= const.HIGH_RADIATION then
            self.status_text[2] = "high level of radiation"
        elseif self.last_radiation >= const.HAZARD_RADIATION then
            self.status_text[2] = "hazardous level of radiation"
        else
            self.status_text[2] = "elevated level of radiation"
        end
    elseif is_active(self.alarms.ReactorOverTemp) then
        self.status_text = { "CORE OVER TEMP", "reactor core temperature >=1200K" }
    elseif is_active(self.alarms.ReactorWasteLeak) then
        self.status_text = { "WASTE LEAK", "radioactive waste leak detected" }
    elseif is_active(self.alarms.ReactorHighTemp) then
        self.status_text = { "CORE TEMP HIGH", "reactor core temperature >1150K" }
    elseif is_active(self.alarms.ReactorHighWaste) then
        self.status_text = { "WASTE LEVEL HIGH", "waste accumulating in reactor" }
    elseif is_active(self.alarms.TurbineTrip) then
        self.status_text = { "TURBINE TRIP", "turbine stall occurred" }
    elseif is_active(self.alarms.RCSTransient) then
        self.status_text = { "RCS TRANSIENT", "check coolant system" }
    -- elseif is_active(self.alarms.RPSTransient) then
        -- RPS status handled when checking reactor status
    elseif self.emcool_opened then
        self.status_text = { "EMERGENCY COOLANT OPENED", "reset RPS to close valve" }
    -- connection dependent states
    elseif self.plc_i ~= nil then
        local plc_db = self.plc_i.get_db()
        if plc_db.mek_status.status then
            self.status_text[1] = "ACTIVE"

            if self.db.annunciator.ReactorHighDeltaT then
                self.status_text[2] = "core temperature rising"
            elseif self.db.annunciator.ReactorTempHigh then
                self.status_text[2] = "core temp high, system nominal"
            elseif self.db.annunciator.FuelInputRateLow then
                self.status_text[2] = "insufficient fuel input rate"
            elseif self.db.annunciator.WasteLineOcclusion then
                self.status_text[2] = "insufficient waste output rate"
            elseif (util.time_ms() - self.last_rate_change_ms) <= FLOW_STABILITY_DELAY_MS then
                self.status_text[2] = "awaiting flow stability"
            else
                self.status_text[2] = "system nominal"
            end
        elseif plc_db.rps_tripped then
            local cause = "unknown"

            if plc_db.rps_trip_cause == RPS_TRIP_CAUSE.OK then
                -- hmm...
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.HIGH_DMG then
                cause = "core damage high"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.HIGH_TEMP then
                cause = "core temperature high"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.LOW_COOLANT then
                cause = "insufficient coolant"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.EX_WASTE then
                cause = "excess waste"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.EX_HCOOLANT then
                cause = "excess heated coolant"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.NO_FUEL then
                cause = "insufficient fuel"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.FAULT then
                cause = "hardware fault"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.TIMEOUT then
                cause = "connection timed out"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.MANUAL then
                cause = "manual operator SCRAM"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.AUTOMATIC then
                cause = "automated system SCRAM"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.SYS_FAIL then
                cause = "PLC system failure"
            elseif plc_db.rps_trip_cause == RPS_TRIP_CAUSE.FORCE_DISABLED then
                cause = "reactor force disabled"
            end

            self.status_text = { "RPS SCRAM", cause }
        elseif self.db.annunciator.RadiationWarning then
            -- elevated, non-hazardous level of radiation is low priority, so display it now if everything else was fine
            self.status_text = { "RADIATION DETECTED", "elevated level of radiation" }
        else
            self.status_text[1] = "IDLE"

            local temp = plc_db.mek_status.temp
            if temp < 350 then
                self.status_text[2] = "core cold"
            elseif temp < 600 then
                self.status_text[2] = "core warm"
            else
                self.status_text[2] = "core hot"
            end
        end
    elseif self.db.annunciator.RadiationWarning then
        -- in case PLC was disconnected but radiation is present
        self.status_text = { "RADIATION DETECTED", "elevated level of radiation" }
    else
        self.status_text = { "REACTOR OFF-LINE", "awaiting connection..." }
    end
end

-- handle unit redstone I/O
---@param self _unit_self unit instance
function logic.handle_redstone(self)
    local AISTATE = self.types.AISTATE

    -- check if an alarm is active (tripped or ack'd)
    ---@nodiscard
    ---@param alarm table alarm entry
    ---@return boolean active
    local function is_active(alarm)
        return alarm.state == AISTATE.TRIPPED or alarm.state == AISTATE.ACKED
    end

    -- reactor controls
    if self.plc_s ~= nil then
        if (not self.plc_cache.rps_status.manual) and self.io_ctl.digital_read(IO.R_SCRAM) then
            -- reactor SCRAM requested but not yet done; perform it
            self.plc_s.in_queue.push_command(PLC_S_CMDS.SCRAM)
        end

        if self.plc_cache.rps_trip and self.io_ctl.digital_read(IO.R_RESET) then
            -- reactor RPS reset requested but not yet done; perform it
            self.plc_s.in_queue.push_command(PLC_S_CMDS.RPS_RESET)
        end

        if (not self.auto_engaged) and (not self.plc_cache.active) and
           (not self.plc_cache.rps_trip) and self.io_ctl.digital_read(IO.R_ENABLE) then
            -- reactor enable requested and allowable, but not yet done; perform it
            self.plc_s.in_queue.push_command(PLC_S_CMDS.ENABLE)
        end
    end

    -- check for request to ack all alarms
    if self.io_ctl.digital_read(IO.U_ACK) then
        for i = 1, #self.db.alarm_states do
            if self.db.alarm_states[i] == ALARM_STATE.TRIPPED then
                self.db.alarm_states[i] = ALARM_STATE.ACKED
            end
        end
    end

    -- write reactor status outputs
    self.io_ctl.digital_write(IO.R_ACTIVE, self.plc_cache.active)
    self.io_ctl.digital_write(IO.R_AUTO_CTRL, self.auto_engaged)
    self.io_ctl.digital_write(IO.R_SCRAMMED, self.plc_cache.rps_trip)
    self.io_ctl.digital_write(IO.R_AUTO_SCRAM, self.plc_cache.rps_status.automatic)
    self.io_ctl.digital_write(IO.R_HIGH_DMG, self.plc_cache.rps_status.high_dmg)
    self.io_ctl.digital_write(IO.R_HIGH_TEMP, self.plc_cache.rps_status.high_temp)
    self.io_ctl.digital_write(IO.R_LOW_COOLANT, self.plc_cache.rps_status.low_cool)
    self.io_ctl.digital_write(IO.R_EXCESS_HC, self.plc_cache.rps_status.ex_hcool)
    self.io_ctl.digital_write(IO.R_EXCESS_WS, self.plc_cache.rps_status.ex_waste)
    self.io_ctl.digital_write(IO.R_INSUFF_FUEL, self.plc_cache.rps_status.no_fuel)
    self.io_ctl.digital_write(IO.R_PLC_FAULT, self.plc_cache.rps_status.fault)
    self.io_ctl.digital_write(IO.R_PLC_TIMEOUT, self.plc_cache.rps_status.timeout)

    -- write unit outputs

    local has_alarm = false
    for i = 1, #self.db.alarm_states do
        if self.db.alarm_states[i] == ALARM_STATE.TRIPPED or self.db.alarm_states[i] == ALARM_STATE.ACKED then
            has_alarm = true
            break
        end
    end

    self.io_ctl.digital_write(IO.U_ALARM, has_alarm)

    -----------------------
    -- Emergency Coolant --
    -----------------------

    local enable_emer_cool = self.plc_cache.rps_status.low_cool or
        (self.auto_engaged and self.db.annunciator.CoolantLevelLow and is_active(self.alarms.ReactorOverTemp))

    -- don't turn off emergency coolant on sufficient coolant level since it might drop again
    -- turn off once system is OK again
    -- if auto control is engaged, alarm check will SCRAM on reactor over temp so that's covered
    if not self.plc_cache.rps_trip then
        -- set turbines to not dump steam
        for i = 1, #self.turbines do
            local session = self.turbines[i]    ---@type unit_session
            local turbine = session.get_db()    ---@type turbinev_session_db

            if turbine.state.dumping_mode ~= DUMPING_MODE.IDLE then
                session.get_cmd_queue().push_data(TBV_RTU_S_DATA.SET_DUMP_MODE, DUMPING_MODE.IDLE)
            end
        end

        if self.db.annunciator.EmergencyCoolant > 1 and self.emcool_opened then
            log.info(util.c("UNIT ", self.r_id, " emergency coolant valve closed"))
            log.info(util.c("UNIT ", self.r_id, " turbines set to not dump steam"))
        end

        self.emcool_opened = false
    elseif enable_emer_cool or self.emcool_opened then
        -- set turbines to dump excess steam
        for i = 1, #self.turbines do
            local session = self.turbines[i]    ---@type unit_session
            local turbine = session.get_db()    ---@type turbinev_session_db

            if turbine.state.dumping_mode ~= DUMPING_MODE.DUMPING_EXCESS then
                session.get_cmd_queue().push_data(TBV_RTU_S_DATA.SET_DUMP_MODE, DUMPING_MODE.DUMPING_EXCESS)
            end
        end

        -- make sure dynamic tanks are allowing outflow
        for i = 1, #self.tanks do
            local session = self.tanks[i]   ---@type unit_session
            local tank = session.get_db()   ---@type dynamicv_session_db

            if tank.state.container_mode == CONTAINER_MODE.FILL then
                session.get_cmd_queue().push_data(DTV_RTU_S_DATA.SET_CONT_MODE, CONTAINER_MODE.BOTH)
            end
        end

        if self.db.annunciator.EmergencyCoolant > 1 and not self.emcool_opened then
            log.info(util.c("UNIT ", self.r_id, " emergency coolant valve opened"))
            log.info(util.c("UNIT ", self.r_id, " turbines set to dump excess steam"))
        end

        self.emcool_opened = true
    end

    -- set valve state always
    if self.emcool_opened then self.valves.emer_cool.open() else self.valves.emer_cool.close() end
end

return logic