///|
/// CLI run command: full EtherCAT lifecycle — scan, validate, transition
/// slaves to Operational, run PDO cycles, and return a diagnostic summary.

///|
/// RunPhase describes which phase the run command is in.
pub(all) enum RunPhase {
  Scanning
  Validating
  Configuring
  Operational
  Stopping
  Done
} derive(Eq, Debug)

///|
pub(all) enum RunFault {
  StartupTransitionFailed(
    station~ : UInt,
    target~ : @protocol.EsmState,
    current~ : @protocol.EsmState?,
    error_indication~ : Bool?,
    status_code~ : UInt?,
    raw_status_code~ : UInt?,
    control_wkc~ : UInt?
  )
  TimeoutRecoveryRequired(
    observed_timeouts~ : Int64,
    consecutive_timeouts~ : Int
  )
  RuntimeRecoveryRequired(expected_wkc~ : UInt, observed_wkc_errors~ : Int64)
  DcDrift(delta_ns~ : Int64)
} derive(Eq, Debug)

///|
priv struct TransitionAttemptObservation {
  reached_target : Bool
  observation : @protocol.EsmStateRequestObservation?
}

///|
/// RunLoopSettings controls runtime loop behavior beyond startup/shutdown
/// state sequencing.
pub(all) struct RunLoopSettings {
  cycles : Int
  until_fault : Bool
  cycle_period_ns : Int64?
  output_period_ns : Int64?
  max_consecutive_timeouts : Int
} derive(Eq, Debug)

///|
pub fn RunLoopSettings::fixed_cycles(cycles : Int) -> RunLoopSettings {
  {
    cycles,
    until_fault: false,
    cycle_period_ns: None,
    output_period_ns: None,
    max_consecutive_timeouts: 5,
  }
}

///|
pub fn RunLoopSettings::until_fault() -> RunLoopSettings {
  {
    cycles: 0,
    until_fault: true,
    cycle_period_ns: None,
    output_period_ns: None,
    max_consecutive_timeouts: 5,
  }
}

///|
/// RunReport summarizes the outcome of a full run cycle.
pub(all) struct RunReport {
  scan : ScanReport
  validation : ValidateReport
  cycles_requested : Int
  cycles_ok : Int
  until_fault : Bool
  cycle_period_ns : Int64
  output_period_ns : Int64?
  telemetry : Telemetry
  final_phase : RunPhase
  fault : RunFault?
} derive(Debug)

///|
pub(all) struct SlaveStartupPreparation {
  mapping : @mailbox.SlaveMapping
  dc_cycle_period_ns : Int64?
  startup_outputs : Bytes?
  startup_mailbox_commands : Array[StartupMailboxCommand]
  mailbox_config : @mailbox.MailboxConfig
  sii : @mailbox.SiiFullInfo
  sii_categories : Array[@mailbox.SiiCategoryEntry]
  prefer_live_pdo_state : Bool
}

///|
pub(all) enum StartupCommandTransition {
  InitToPreOp
  InitToBoot
  PreOpToInit
  PreOpToSafeOp
  SafeOpToInit
  SafeOpToPreOp
  SafeOpToOp
  OpToInit
  OpToPreOp
  OpToSafeOp
  BootToInit
} derive(Eq, Debug)

///|
pub(all) struct StartupMailboxCommand {
  transition : StartupCommandTransition
  index : UInt
  subindex : Byte
  data : Bytes
} derive(Eq, Debug)

///|
pub(all) struct StationStartupMailboxPlan {
  station : UInt
  mailbox_config : @mailbox.MailboxConfig
  commands : Array[StartupMailboxCommand]
} derive(Eq, Debug)

///|
pub(all) struct RunStartupPreparation {
  pdo_ctx : @protocol.PdoContext
  expected_wkc : UInt
  dc_cycle_period_ns : Int64?
  startup_mailbox_plans : Array[StationStartupMailboxPlan]
  esm_timeout_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)]
  slave_preparations : Array[(UInt, SlaveStartupPreparation)]?
}

///|
/// Look up the ESM timeout policy for a station from an array of policies.
/// Returns the default (all-None) policy when no entry is found.
fn lookup_esm_timeout_policy(
  policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
  station : UInt,
) -> @protocol.EsmTimeoutPolicy {
  for item in policies {
    let (s, policy) = item
    if s == station {
      return policy
    }
  }
  @protocol.default_esm_timeout_policy()
}

///|
let startup_eeprom_word_count : Int = 128

///|
pub fn required_sii_read_word_count(
  initial_eeprom : Bytes,
  initial_word_count : Int,
) -> Int {
  let metadata : Result[@mailbox.SiiStandardMetadata, Error] = Result::Ok(
    @mailbox.parse_sii_standard_metadata(initial_eeprom),
  ) catch {
    err => Result::Err(err)
  }
  match metadata {
    Ok(value) => {
      let required_word_count = ((value.eeprom_size_bytes() + 3U) / 4U).reinterpret_as_int()
      if required_word_count > initial_word_count {
        required_word_count
      } else {
        initial_word_count
      }
    }
    Err(_) => initial_word_count
  }
}

///|
pub fn[N : @hal.Nic] read_sii_with_metadata_fp(
  nic : N,
  station : UInt,
  initial_word_count : Int,
  timeout : @hal.Duration,
) -> Bytes raise @hal.EcError {
  @protocol.read_sii_bytes(nic, station, initial_word_count, timeout)
}

///|
pub fn[N : @hal.Nic] read_sii_with_metadata_ap(
  nic : N,
  position : UInt,
  initial_word_count : Int,
  timeout : @hal.Duration,
) -> Bytes raise @hal.EcError {
  @protocol.read_sii_bytes_ap(nic, position, initial_word_count, timeout)
}

///|
enum OperationalStartupStep {
  TransitionToSafeOp
  DcPreconfigure
  WarmupProcessData
  ValidateSmWatchdog
  TransitionToOp
} derive(Eq, Debug)

///|
enum StartupRecoveryStrategy {
  RetryTargetPath
  RollbackToInit
  HoldSafeOp
} derive(Eq, Debug)

///|
pub fn master_config_with_optional_dc(
  dc_cycle_period_ns : Int64?,
) -> @hal.MasterConfig {
  match dc_cycle_period_ns {
    Some(cycle_period_ns) =>
      { ..@hal.MasterConfig::default(), dc_enabled: true, cycle_period_ns }
    None => @hal.MasterConfig::default()
  }
}

///|
/// Check if any slave in the SII info set the PreferNotLRW flag.
fn sii_prefers_not_lrw(sii : @mailbox.SiiFullInfo) -> Bool {
  match sii.general {
    Some(general) => general.prefers_not_lrw()
    None => false
  }
}

///|
pub fn[N : @hal.Nic] build_run_startup_preparation(
  nic : N,
  scan_report : ScanReport,
  logical_base : UInt,
  eeprom_word_count : Int,
  timeout : @hal.Duration,
) -> RunStartupPreparation raise @hal.EcError {
  let slave_mappings : Array[(@mailbox.SlaveMapping, UInt)] = []
  let startup_outputs : Array[(UInt, Bytes)] = []
  let startup_mailbox_plans : Array[StationStartupMailboxPlan] = []
  let esm_timeout_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)] = []
  let mut current_logical_base = logical_base
  let mut dc_cycle_period_ns : Int64? = None
  let mut any_not_lrw = false
  for slave in scan_report.slaves {
    let preparation = prepare_slave_startup(
      nic,
      slave.station_address,
      current_logical_base,
      eeprom_word_count,
      timeout,
    )
    slave_mappings.push((preparation.mapping, slave.station_address))
    if sii_prefers_not_lrw(preparation.sii) {
      any_not_lrw = true
    }
    match preparation.startup_outputs {
      Some(outputs) => startup_outputs.push((slave.station_address, outputs))
      None => ()
    }
    if preparation.startup_mailbox_commands.length() > 0 {
      startup_mailbox_plans.push({
        station: slave.station_address,
        mailbox_config: preparation.mailbox_config,
        commands: preparation.startup_mailbox_commands,
      })
    }
    dc_cycle_period_ns = prefer_dc_cycle_period(
      dc_cycle_period_ns,
      preparation.dc_cycle_period_ns,
    )
    esm_timeout_policies.push(
      (
        slave.station_address,
        @protocol.esm_timeout_policy_from_sii_timeouts(preparation.sii.timeouts),
      ),
    )
    current_logical_base = current_logical_base +
      preparation.mapping.output_bytes.reinterpret_as_uint() +
      preparation.mapping.input_bytes.reinterpret_as_uint()
  }
  let pdo_ctx = @protocol.PdoContext::build(slave_mappings, logical_base)
  pdo_ctx.block_lrw = any_not_lrw
  for i = 0; i < startup_outputs.length(); i = i + 1 {
    let (station, outputs) = startup_outputs[i]
    pdo_ctx.set_slave_outputs(station, outputs)
  }
  {
    pdo_ctx,
    expected_wkc: expected_wkc_from_slave_mappings(slave_mappings),
    dc_cycle_period_ns,
    startup_mailbox_plans,
    esm_timeout_policies,
    slave_preparations: None,
  }
}

///|
pub fn[N : @hal.Nic] prepare_slave_startup(
  nic : N,
  station : UInt,
  logical_base : UInt,
  eeprom_word_count : Int,
  timeout : @hal.Duration,
) -> SlaveStartupPreparation raise @hal.EcError {
  let eeprom = read_sii_with_metadata_fp(
    nic, station, eeprom_word_count, timeout,
  )
  let sii = @mailbox.parse_sii_full_info(eeprom)
  let mapping = @mailbox.calculate_slave_mapping_from_sii(sii, logical_base)
  let startup_outputs = derive_startup_outputs(mapping, sii)
  let startup_mailbox_commands = filter_redundant_startup_mailbox_commands(
    derive_startup_mailbox_commands(sii),
    startup_outputs,
    mapping,
    sii,
  )
  {
    mapping,
    dc_cycle_period_ns: preferred_dc_cycle_period(sii),
    startup_outputs,
    startup_mailbox_commands,
    mailbox_config: @mailbox.MailboxConfig::from_sii(sii.header),
    sii,
    sii_categories: sii.categories,
    prefer_live_pdo_state: true,
  }
}

///|
pub fn startup_preparation_from_esi_offline_report(
  report : EsiOfflineSiiReport,
  logical_base : UInt,
) -> SlaveStartupPreparation {
  let mapping = @mailbox.calculate_slave_mapping_from_sii(
    report.sii,
    logical_base,
  )
  let merged_startup_mailbox_commands = merge_startup_mailbox_commands(
    derive_startup_mailbox_commands(report.sii),
    report.startup_mailbox_commands,
  )
  let startup_outputs = derive_startup_outputs_with_commands(
    mapping,
    report.sii,
    merged_startup_mailbox_commands,
  )
  let startup_mailbox_commands = filter_redundant_startup_mailbox_commands(
    merged_startup_mailbox_commands,
    startup_outputs,
    mapping,
    report.sii,
  )
  {
    mapping,
    dc_cycle_period_ns: preferred_dc_cycle_period(report.sii),
    startup_outputs,
    startup_mailbox_commands,
    mailbox_config: @mailbox.MailboxConfig::from_sii(report.header),
    sii: report.sii,
    sii_categories: report.categories,
    prefer_live_pdo_state: true,
  }
}

///|
fn startup_mailbox_command_matches_target(
  lhs : StartupMailboxCommand,
  rhs : StartupMailboxCommand,
) -> Bool {
  lhs.transition == rhs.transition &&
  lhs.index == rhs.index &&
  lhs.subindex == rhs.subindex
}

///|
fn merge_startup_mailbox_commands(
  derived : Array[StartupMailboxCommand],
  explicit : Array[StartupMailboxCommand],
) -> Array[StartupMailboxCommand] {
  let merged : Array[StartupMailboxCommand] = []
  for command in derived {
    merged.push(command)
  }
  for command in explicit {
    let mut replaced = false
    for i = 0; i < merged.length(); i = i + 1 {
      if startup_mailbox_command_matches_target(merged[i], command) {
        if merged[i].data != command.data {
          merged[i] = command
        }
        replaced = true
        break
      }
    }
    if !replaced {
      merged.push(command)
    }
  }
  merged
}

///|
fn startup_command_reconfigures_pdo_mapping(
  command : StartupMailboxCommand,
) -> Bool {
  if command.transition != PreOpToSafeOp {
    return false
  }
  if command.index == 0x1C12U || command.index == 0x1C13U {
    return true
  }
  (command.index >= 0x1600U && command.index <= 0x17FFU) ||
  (command.index >= 0x1A00U && command.index <= 0x1BFFU)
}

///|
fn startup_mailbox_commands_need_preop_refinement(
  commands : Array[StartupMailboxCommand],
) -> Bool {
  for command in commands {
    if startup_command_reconfigures_pdo_mapping(command) {
      return true
    }
  }
  false
}

///|
fn filter_live_pdo_reconfiguration_commands(
  commands : Array[StartupMailboxCommand],
) -> Array[StartupMailboxCommand] {
  let filtered : Array[StartupMailboxCommand] = []
  for command in commands {
    if !startup_command_reconfigures_pdo_mapping(command) {
      filtered.push(command)
    }
  }
  filtered
}

///|
pub fn startup_preparation_from_esi_json(
  json_content : String,
  device_index : Int,
  logical_base : UInt,
) -> SlaveStartupPreparation raise @hal.EcError {
  startup_preparation_from_esi_offline_report(
    offline_sii_report_from_esi_json(json_content, device_index),
    logical_base,
  )
}

///|
pub fn run_startup_preparation_from_slave_preparations(
  slave_preparations : Array[(UInt, SlaveStartupPreparation)],
  logical_base : UInt,
) -> RunStartupPreparation raise @hal.EcError {
  let slave_mappings : Array[(@mailbox.SlaveMapping, UInt)] = []
  let startup_outputs : Array[(UInt, Bytes)] = []
  let startup_mailbox_plans : Array[StationStartupMailboxPlan] = []
  let esm_timeout_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)] = []
  let mut dc_cycle_period_ns : Int64? = None
  let mut any_not_lrw = false
  for item in slave_preparations {
    let (station, preparation) = item
    slave_mappings.push((preparation.mapping, station))
    if sii_prefers_not_lrw(preparation.sii) {
      any_not_lrw = true
    }
    match preparation.startup_outputs {
      Some(outputs) => startup_outputs.push((station, outputs))
      None => ()
    }
    if preparation.startup_mailbox_commands.length() > 0 {
      startup_mailbox_plans.push({
        station,
        mailbox_config: preparation.mailbox_config,
        commands: preparation.startup_mailbox_commands,
      })
    }
    dc_cycle_period_ns = prefer_dc_cycle_period(
      dc_cycle_period_ns,
      preparation.dc_cycle_period_ns,
    )
    esm_timeout_policies.push(
      (
        station,
        @protocol.esm_timeout_policy_from_sii_timeouts(preparation.sii.timeouts),
      ),
    )
  }
  let pdo_ctx = @protocol.PdoContext::build(slave_mappings, logical_base)
  pdo_ctx.block_lrw = any_not_lrw
  for i = 0; i < startup_outputs.length(); i = i + 1 {
    let (station, outputs) = startup_outputs[i]
    pdo_ctx.set_slave_outputs(station, outputs)
  }
  {
    pdo_ctx,
    expected_wkc: expected_wkc_from_slave_mappings(slave_mappings),
    dc_cycle_period_ns,
    startup_mailbox_plans,
    esm_timeout_policies,
    slave_preparations: Some(slave_preparations),
  }
}

///|
fn[N : @hal.Nic] refine_run_startup_preparation_override_in_preop(
  nic : N,
  slave_preparations : Array[(UInt, SlaveStartupPreparation)],
  logical_base : UInt,
  timeout : @hal.Duration,
) -> RunStartupPreparation raise @hal.EcError {
  let refined_preparations : Array[(UInt, SlaveStartupPreparation)] = []
  let mut current_logical_base = logical_base
  for item in slave_preparations {
    let (station, preparation) = item
    let snapshot_result : Result[@protocol.EsmStatusSnapshot, Error] = Result::Ok(
      @protocol.read_al_status_snapshot(nic, station, timeout),
    ) catch {
      err => Result::Err(err)
    }
    let refined = match snapshot_result {
      Ok(snapshot) if snapshot.state == @protocol.PreOp &&
        !snapshot.error_indication &&
        startup_mailbox_commands_need_preop_refinement(
          preparation.startup_mailbox_commands,
        ) =>
        refine_startup_preparation_in_preop(
          nic, station, current_logical_base, preparation, timeout,
        )
      _ => preparation
    }
    refined_preparations.push((station, refined))
    current_logical_base = current_logical_base +
      refined.mapping.output_bytes.reinterpret_as_uint() +
      refined.mapping.input_bytes.reinterpret_as_uint()
  }
  run_startup_preparation_from_slave_preparations(
    refined_preparations, logical_base,
  )
}

///|
fn decode_le_u32_payload(payload : Bytes) -> UInt? {
  if payload.length() <= 0 {
    return None
  }
  let mut value = 0U
  let limit = if payload.length() < 4 { payload.length() } else { 4 }
  for i = 0; i < limit; i = i + 1 {
    value = value | (payload[i].to_int().reinterpret_as_uint() << (i * 8))
  }
  Some(value)
}

///|
fn[N : @hal.Nic] sdo_upload_payload_retry(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  rmsm : @mailbox.Rmsm,
  index : UInt,
  subindex : Byte,
  timeout : @hal.Duration,
) -> Bytes raise @hal.EcError {
  @protocol.sdo_upload_retry(
    nic, station, mailbox_config, rmsm, index, subindex, timeout,
  )
}

///|
fn[N : @hal.Nic] sdo_upload_u32_retry(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  rmsm : @mailbox.Rmsm,
  index : UInt,
  subindex : Byte,
  timeout : @hal.Duration,
) -> UInt raise @hal.EcError {
  let payload = sdo_upload_payload_retry(
    nic, station, mailbox_config, rmsm, index, subindex, timeout,
  )
  match decode_le_u32_payload(payload) {
    Some(value) => value
    None =>
      raise @hal.EcError::MailboxProtocol(
        "SDO upload returned empty payload at 0x" +
        index.to_string() +
        ":" +
        subindex.to_int().to_string(),
      )
  }
}

///|
fn[N : @hal.Nic] assigned_pdo_indices(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  rmsm : @mailbox.Rmsm,
  assignment_index : UInt,
  timeout : @hal.Duration,
) -> Array[UInt] raise @hal.EcError {
  let count = sdo_upload_u32_retry(
    nic, station, mailbox_config, rmsm, assignment_index, b'\x00', timeout,
  ).reinterpret_as_int()
  let indices : Array[UInt] = []
  for i = 1; i <= count; i = i + 1 {
    let pdo_index = sdo_upload_u32_retry(
      nic,
      station,
      mailbox_config,
      rmsm,
      assignment_index,
      i.to_byte(),
      timeout,
    )
    indices.push(pdo_index & 0xFFFFU)
  }
  indices
}

///|
fn[N : @hal.Nic] mapped_pdo_bit_length(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  rmsm : @mailbox.Rmsm,
  pdo_index : UInt,
  timeout : @hal.Duration,
) -> Int raise @hal.EcError {
  let count = sdo_upload_u32_retry(
    nic, station, mailbox_config, rmsm, pdo_index, b'\x00', timeout,
  ).reinterpret_as_int()
  let mut total_bits = 0
  for i = 1; i <= count; i = i + 1 {
    let entry = sdo_upload_u32_retry(
      nic,
      station,
      mailbox_config,
      rmsm,
      pdo_index,
      i.to_byte(),
      timeout,
    )
    total_bits = total_bits + (entry & 0xFFU).reinterpret_as_int()
  }
  total_bits
}

///|
fn[N : @hal.Nic] active_process_data_lengths_in_preop(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  timeout : @hal.Duration,
) -> (Int?, Int?) raise @hal.EcError {
  let rmsm = @mailbox.Rmsm::new()
  let rx_assignments = assigned_pdo_indices(
    nic, station, mailbox_config, rmsm, 0x1C12U, timeout,
  )
  let tx_assignments = assigned_pdo_indices(
    nic, station, mailbox_config, rmsm, 0x1C13U, timeout,
  )
  let mut output_bits = 0
  for pdo_index in rx_assignments {
    output_bits = output_bits +
      mapped_pdo_bit_length(
        nic, station, mailbox_config, rmsm, pdo_index, timeout,
      )
  }
  let mut input_bits = 0
  for pdo_index in tx_assignments {
    input_bits = input_bits +
      mapped_pdo_bit_length(
        nic, station, mailbox_config, rmsm, pdo_index, timeout,
      )
  }
  (
    if output_bits > 0 {
      Some((output_bits + 7) / 8)
    } else {
      None
    },
    if input_bits > 0 {
      Some((input_bits + 7) / 8)
    } else {
      None
    },
  )
}

///|
fn map_with_process_data_lengths(
  mapping : @mailbox.SlaveMapping,
  logical_base : UInt,
  output_bytes : Int?,
  input_bytes : Int?,
) -> @mailbox.SlaveMapping {
  let patched_sm_configs : Array[@mailbox.SmConfig] = []
  for sm in mapping.sm_configs {
    patched_sm_configs.push(
      match sm.sm_type {
        @mailbox.ProcessDataOut | @mailbox.DynamicProcessDataOut =>
          match output_bytes {
            Some(length) => { ..sm, length: length.reinterpret_as_uint() }
            None => sm
          }
        @mailbox.ProcessDataIn | @mailbox.DynamicProcessDataIn =>
          match input_bytes {
            Some(length) => { ..sm, length: length.reinterpret_as_uint() }
            None => sm
          }
        _ => sm
      },
    )
  }
  let directions : Array[@mailbox.FmmuDirection] = []
  for fmmu in mapping.fmmu_configs {
    directions.push(fmmu.direction)
  }
  let (fmmu_configs, total_output, total_input) = @mailbox.calculate_fmmu_configs(
    directions, patched_sm_configs, logical_base,
  )
  {
    sm_configs: patched_sm_configs,
    fmmu_configs,
    output_bytes: total_output,
    input_bytes: total_input,
  }
}

///|
fn has_process_data_length_override(
  output_bytes : Int?,
  input_bytes : Int?,
) -> Bool {
  output_bytes is Some(_) || input_bytes is Some(_)
}

///|
fn should_retry_process_data_lengths_after_mailbox_refresh(
  result : Result[(Int?, Int?), Error],
) -> Bool {
  match result {
    Ok((output_bytes, input_bytes)) =>
      !has_process_data_length_override(output_bytes, input_bytes)
    Err(@hal.EcError::MailboxTimeout) => true
    Err(@hal.EcError::MailboxProtocol(message)) =>
      message.contains("deactivated/reset")
    Err(_) => false
  }
}

///|
fn[N : @hal.Nic] active_process_data_lengths_after_mailbox_refresh(
  nic : N,
  station : UInt,
  preparation : SlaveStartupPreparation,
  timeout : @hal.Duration,
) -> (Int?, Int?) raise @hal.EcError {
  write_mailbox_mapping(nic, station, preparation.mapping, timeout)
  let snapshot = @protocol.read_al_status_snapshot(nic, station, timeout)
  if snapshot.error_indication {
    ignore(@protocol.recover_al_error_for_transition(nic, station, timeout))
  }
  if snapshot.state != @protocol.PreOp {
    let policy = @protocol.esm_timeout_policy_from_sii_timeouts(
      preparation.sii.timeouts,
    )
    ignore(
      @protocol.transition_to_target_strict_with_timeout_policy(
        nic,
        station,
        @protocol.PreOp,
        policy,
        timeout,
      ),
    )
  }
  return active_process_data_lengths_in_preop(
    nic,
    station,
    preparation.mailbox_config,
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] refine_startup_preparation_in_preop(
  nic : N,
  station : UInt,
  logical_base : UInt,
  preparation : SlaveStartupPreparation,
  timeout : @hal.Duration,
) -> SlaveStartupPreparation {
  if !preparation.prefer_live_pdo_state {
    return preparation
  }
  let direct_lengths_result : Result[(Int?, Int?), Error] = Result::Ok(
    active_process_data_lengths_in_preop(
      nic,
      station,
      preparation.mailbox_config,
      timeout,
    ),
  ) catch {
    err => Result::Err(err)
  }
  let lengths_result : Result[(Int?, Int?), Error] = if should_retry_process_data_lengths_after_mailbox_refresh(
      direct_lengths_result,
    ) {
    Result::Ok(
      active_process_data_lengths_after_mailbox_refresh(
        nic, station, preparation, timeout,
      ),
    ) catch {
      err => Result::Err(err)
    }
  } else {
    direct_lengths_result
  }
  match lengths_result {
    Ok((output_bytes, input_bytes)) => {
      let saw_live_pdo_assignments = output_bytes is Some(_) ||
        input_bytes is Some(_)
      let startup_mailbox_commands = if saw_live_pdo_assignments {
        filter_live_pdo_reconfiguration_commands(
          preparation.startup_mailbox_commands,
        )
      } else {
        preparation.startup_mailbox_commands
      }
      let has_override = has_process_data_length_override(
        output_bytes, input_bytes,
      )
      if !has_override {
        if startup_mailbox_commands.length() ==
          preparation.startup_mailbox_commands.length() {
          preparation
        } else {
          { ..preparation, startup_mailbox_commands, }
        }
      } else {
        let mapping = map_with_process_data_lengths(
          preparation.mapping,
          logical_base,
          output_bytes,
          input_bytes,
        )
        let startup_outputs = derive_startup_outputs_with_commands(
          mapping,
          preparation.sii,
          startup_mailbox_commands,
        )
        {
          ..preparation,
          mapping,
          startup_outputs,
          startup_mailbox_commands: filter_redundant_startup_mailbox_commands(
            startup_mailbox_commands,
            startup_outputs,
            mapping,
            preparation.sii,
          ),
        }
      }
    }
    Err(_) => preparation
  }
}

///|
fn[N : @hal.Nic] build_run_startup_preparation_from_preop(
  nic : N,
  stations : Array[UInt],
  logical_base : UInt,
  timeout : @hal.Duration,
) -> RunStartupPreparation raise @hal.EcError {
  let slave_mappings : Array[(@mailbox.SlaveMapping, UInt)] = []
  let startup_outputs : Array[(UInt, Bytes)] = []
  let startup_mailbox_plans : Array[StationStartupMailboxPlan] = []
  let esm_timeout_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)] = []
  let mut current_logical_base = logical_base
  let mut dc_cycle_period_ns : Int64? = None
  let mut any_not_lrw = false
  for station in stations {
    let initial = prepare_slave_startup(
      nic, station, current_logical_base, startup_eeprom_word_count, timeout,
    )
    let snapshot_result : Result[@protocol.EsmStatusSnapshot, Error] = Result::Ok(
      @protocol.read_al_status_snapshot(nic, station, timeout),
    ) catch {
      err => Result::Err(err)
    }
    let refined = match snapshot_result {
      Ok(snapshot) if snapshot.state == @protocol.PreOp &&
        !snapshot.error_indication &&
        startup_mailbox_commands_need_preop_refinement(
          initial.startup_mailbox_commands,
        ) =>
        refine_startup_preparation_in_preop(
          nic, station, current_logical_base, initial, timeout,
        )
      _ => initial
    }
    slave_mappings.push((refined.mapping, station))
    if sii_prefers_not_lrw(refined.sii) {
      any_not_lrw = true
    }
    match refined.startup_outputs {
      Some(outputs) => startup_outputs.push((station, outputs))
      None => ()
    }
    if refined.startup_mailbox_commands.length() > 0 {
      startup_mailbox_plans.push({
        station,
        mailbox_config: refined.mailbox_config,
        commands: refined.startup_mailbox_commands,
      })
    }
    dc_cycle_period_ns = prefer_dc_cycle_period(
      dc_cycle_period_ns,
      refined.dc_cycle_period_ns,
    )
    esm_timeout_policies.push(
      (
        station,
        @protocol.esm_timeout_policy_from_sii_timeouts(refined.sii.timeouts),
      ),
    )
    current_logical_base = current_logical_base +
      refined.mapping.output_bytes.reinterpret_as_uint() +
      refined.mapping.input_bytes.reinterpret_as_uint()
  }
  let pdo_ctx = @protocol.PdoContext::build(slave_mappings, logical_base)
  pdo_ctx.block_lrw = any_not_lrw
  for i = 0; i < startup_outputs.length(); i = i + 1 {
    let (station, outputs) = startup_outputs[i]
    pdo_ctx.set_slave_outputs(station, outputs)
  }
  {
    pdo_ctx,
    expected_wkc: expected_wkc_from_slave_mappings(slave_mappings),
    dc_cycle_period_ns,
    startup_mailbox_plans,
    esm_timeout_policies,
    slave_preparations: None,
  }
}

///|
fn ds402_mode_command_seed_data(
  commands : Array[StartupMailboxCommand],
  rx_pdos : Array[@mailbox.SiiPdo],
) -> Bytes? {
  let mut explicit_seed : Bytes? = None
  for command in commands {
    if command.transition == SafeOpToOp &&
      command.index == 0x6060U &&
      command.subindex == b'\x00' &&
      command.data.length() > 0 {
      explicit_seed = Some(command.data)
    }
  }
  match explicit_seed {
    Some(_) => explicit_seed
    None =>
      match preferred_ds402_mode_seed(rx_pdos) {
        Some(mode_value) if mode_value != b'\x00' =>
          Some(Bytes::from_array([mode_value]))
        _ => None
      }
  }
}

///|
fn ds402_mode_seed_bytes_for_length(
  mode_data : Bytes,
  mapped_bit_length : Int,
) -> Bytes? {
  if mode_data.length() <= 0 {
    return None
  }
  if mapped_bit_length == 8 {
    return Some(Bytes::from_array([mode_data[0]]))
  }
  if mapped_bit_length == 16 {
    return Some(
      Bytes::from_array([
        mode_data[0],
        if mode_data.length() > 1 {
          mode_data[1]
        } else {
          b'\x00'
        },
      ]),
    )
  }
  None
}

///|
fn bytes_match_at_offset(
  actual : Bytes,
  offset : Int,
  expected : Bytes,
) -> Bool {
  if offset < 0 || offset + expected.length() > actual.length() {
    return false
  }
  for i = 0; i < expected.length(); i = i + 1 {
    if actual[offset + i] != expected[i] {
      return false
    }
  }
  true
}

///|
fn derive_startup_outputs_with_commands(
  mapping : @mailbox.SlaveMapping,
  sii : @mailbox.SiiFullInfo,
  startup_mailbox_commands : Array[StartupMailboxCommand],
) -> Bytes? {
  if mapping.output_bytes <= 0 {
    return None
  }
  guard sii.general is Some(general) else { return None }
  if general.ds402_channels == b'\x00' {
    return None
  }
  let rx_pdos = sii.rx_pdo
  let seed = Array::make(mapping.output_bytes, b'\x00')
  let mut wrote = false
  match find_pdo_entry_byte_offset(rx_pdos, 0x6040U, b'\x00') {
    Some((controlword_offset, controlword_length)) =>
      if controlword_length == 16 &&
        controlword_offset + 1 < mapping.output_bytes {
        seed[controlword_offset] = b'\x06'
        seed[controlword_offset + 1] = b'\x00'
        wrote = true
      }
    _ => ()
  }
  match
    (
      find_pdo_entry_byte_offset(rx_pdos, 0x6060U, b'\x00'),
      ds402_mode_command_seed_data(startup_mailbox_commands, rx_pdos),
    ) {
    (Some((mode_offset, mode_length)), Some(mode_data)) =>
      match ds402_mode_seed_bytes_for_length(mode_data, mode_length) {
        Some(mode_seed) if mode_offset + mode_seed.length() <=
          mapping.output_bytes => {
          for i = 0; i < mode_seed.length(); i = i + 1 {
            seed[mode_offset + i] = mode_seed[i]
          }
          wrote = true
        }
        _ => ()
      }
    _ => ()
  }
  if !wrote {
    return None
  }
  Some(Bytes::from_array(seed))
}

///|
fn derive_startup_outputs(
  mapping : @mailbox.SlaveMapping,
  sii : @mailbox.SiiFullInfo,
) -> Bytes? {
  derive_startup_outputs_with_commands(
    mapping,
    sii,
    derive_startup_mailbox_commands(sii),
  )
}

///|
fn redundant_ds402_mode_command_seeded_in_outputs(
  command : StartupMailboxCommand,
  startup_outputs : Bytes?,
  sii : @mailbox.SiiFullInfo,
) -> Bool {
  if command.transition != SafeOpToOp ||
    command.index != 0x6060U ||
    command.subindex != b'\x00' ||
    command.data.length() <= 0 {
    return false
  }
  guard startup_outputs is Some(outputs) else { return false }
  match find_pdo_entry_byte_offset(sii.rx_pdo, 0x6060U, b'\x00') {
    Some((offset, length)) =>
      match ds402_mode_seed_bytes_for_length(command.data, length) {
        Some(expected_seed) =>
          bytes_match_at_offset(outputs, offset, expected_seed)
        None => false
      }
    None => false
  }
}

///|
fn ds402_mode_command_outside_process_image(
  command : StartupMailboxCommand,
  mapping : @mailbox.SlaveMapping,
  sii : @mailbox.SiiFullInfo,
) -> Bool {
  if command.transition != SafeOpToOp ||
    command.index != 0x6060U ||
    command.subindex != b'\x00' ||
    command.data.length() <= 0 {
    return false
  }
  match find_pdo_entry_byte_offset(sii.rx_pdo, 0x6060U, b'\x00') {
    Some((offset, length)) => offset + (length + 7) / 8 > mapping.output_bytes
    None => false
  }
}

///|
fn filter_redundant_startup_mailbox_commands(
  commands : Array[StartupMailboxCommand],
  startup_outputs : Bytes?,
  mapping : @mailbox.SlaveMapping,
  sii : @mailbox.SiiFullInfo,
) -> Array[StartupMailboxCommand] {
  let filtered : Array[StartupMailboxCommand] = []
  for command in commands {
    if !redundant_ds402_mode_command_seeded_in_outputs(
        command, startup_outputs, sii,
      ) &&
      !ds402_mode_command_outside_process_image(command, mapping, sii) {
      filtered.push(command)
    }
  }
  filtered
}

///|
fn derive_startup_mailbox_commands(
  sii : @mailbox.SiiFullInfo,
) -> Array[StartupMailboxCommand] {
  let commands : Array[StartupMailboxCommand] = []
  if sii.general is Some(general) {
    if general.supports_coe_pdo_configuration() {
      for pdo in sii.rx_pdo {
        commands.push({
          transition: PreOpToSafeOp,
          index: pdo.pdo_index,
          subindex: b'\x00',
          data: pdo_complete_access_data(pdo),
        })
      }
      for pdo in sii.tx_pdo {
        commands.push({
          transition: PreOpToSafeOp,
          index: pdo.pdo_index,
          subindex: b'\x00',
          data: pdo_complete_access_data(pdo),
        })
      }
    }
    if general.supports_coe_pdo_assign() {
      commands.push({
        transition: PreOpToSafeOp,
        index: 0x1C12U,
        subindex: b'\x00',
        data: sm_assignment_complete_access_data(sii.rx_pdo, b'\x02'),
      })
      commands.push({
        transition: PreOpToSafeOp,
        index: 0x1C13U,
        subindex: b'\x00',
        data: sm_assignment_complete_access_data(sii.tx_pdo, b'\x03'),
      })
    }
    if general.ds402_channels != b'\x00' {
      let rx_pdos = sii.rx_pdo
      match preferred_ds402_mode_seed(rx_pdos) {
        Some(mode_value) if mode_value != b'\x00' =>
          commands.push({
            transition: SafeOpToOp,
            index: 0x6060U,
            subindex: b'\x00',
            data: Bytes::from_array([mode_value]),
          })
        _ => ()
      }
    }
  }
  commands
}

///|
fn pdo_mapping_entry_u32(entry : @mailbox.SiiPdoEntry) -> UInt {
  (entry.entry_index << 16) |
  (entry.subindex.to_uint() << 8) |
  entry.bit_length.to_uint()
}

///|
fn pdo_complete_access_data(pdo : @mailbox.SiiPdo) -> Bytes {
  let buf = Buffer(size_hint=1 + pdo.entries.length() * 4)
  buf.write_byte(pdo.entry_count)
  for entry in pdo.entries {
    let mapping = pdo_mapping_entry_u32(entry)
    buf.write_byte((mapping & 0xFFU).to_byte())
    buf.write_byte(((mapping >> 8) & 0xFFU).to_byte())
    buf.write_byte(((mapping >> 16) & 0xFFU).to_byte())
    buf.write_byte(((mapping >> 24) & 0xFFU).to_byte())
  }
  buf.to_bytes()
}

///|
fn sm_assignment_complete_access_data(
  pdos : Array[@mailbox.SiiPdo],
  sync_manager : Byte,
) -> Bytes {
  let selected : Array[UInt] = []
  for pdo in pdos {
    if pdo.sync_manager == sync_manager {
      selected.push(pdo.pdo_index)
    }
  }
  let buf = Buffer(size_hint=1 + selected.length() * 2)
  buf.write_byte(selected.length().to_byte())
  for index in selected {
    buf.write_byte((index & 0xFFU).to_byte())
    buf.write_byte(((index >> 8) & 0xFFU).to_byte())
  }
  buf.to_bytes()
}

///|
fn startup_command_uses_complete_access(
  command : StartupMailboxCommand,
) -> Bool {
  command.subindex == b'\x00' && command.data.length() > 1
}

///|
fn startup_command_subindex_sequence_entry_width(
  command : StartupMailboxCommand,
) -> Int? {
  if command.subindex != b'\x00' || command.data.length() <= 1 {
    return None
  }
  if command.index == 0x1C12U || command.index == 0x1C13U {
    return Some(2)
  }
  if (command.index >= 0x1600U && command.index <= 0x17FFU) ||
    (command.index >= 0x1A00U && command.index <= 0x1BFFU) {
    return Some(4)
  }
  None
}

///|
fn startup_command_subindex_sequence_count(
  command : StartupMailboxCommand,
  entry_width : Int,
) -> Int raise @hal.EcError {
  let count = command.data[0].to_int()
  let expected_length = 1 + count * entry_width
  if command.data.length() < expected_length {
    raise @hal.EcError::MailboxProtocol(
      "startup mailbox command payload too short for 0x" +
      command.index.to_string(radix=16) +
      ": expected at least " +
      expected_length.to_string() +
      " bytes, got " +
      command.data.length().to_string(),
    )
  }
  count
}

///|
fn startup_command_subindex_sequence_entry_data(
  command : StartupMailboxCommand,
  entry_width : Int,
  index : Int,
) -> Bytes {
  let start = 1 + index * entry_width
  Bytes::makei(entry_width, fn(offset) { command.data[start + offset] })
}

///|
fn[N : @hal.Nic] apply_startup_mailbox_subindex_sequence(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  rmsm : @mailbox.Rmsm,
  command : StartupMailboxCommand,
  entry_width : Int,
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  let count = startup_command_subindex_sequence_count(command, entry_width)
  @protocol.sdo_download_retry(
    nic,
    station,
    mailbox_config,
    rmsm,
    command.index,
    b'\x00',
    Bytes::from_array([b'\x00']),
    timeout,
  )
  for i = 0; i < count; i = i + 1 {
    @protocol.sdo_download_retry(
      nic,
      station,
      mailbox_config,
      rmsm,
      command.index,
      (i + 1).to_byte(),
      startup_command_subindex_sequence_entry_data(command, entry_width, i),
      timeout,
    )
  }
  @protocol.sdo_download_retry(
    nic,
    station,
    mailbox_config,
    rmsm,
    command.index,
    b'\x00',
    Bytes::from_array([count.to_byte()]),
    timeout,
  )
}

///|
fn preferred_ds402_mode_seed(rx_pdos : Array[@mailbox.SiiPdo]) -> Byte? {
  if find_pdo_entry_byte_offset(rx_pdos, 0x607AU, b'\x00') is Some(_) {
    return Some(b'\x08')
  }
  if find_pdo_entry_byte_offset(rx_pdos, 0x60FFU, b'\x00') is Some(_) {
    return Some(b'\x09')
  }
  None
}

///|
let startup_mailbox_drain_limit : Int = 4

///|
fn[N : @hal.Nic] drain_startup_mailbox(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
) -> Unit {
  let zero_timeout = @hal.Duration(0L)
  for _drain = 0; _drain < startup_mailbox_drain_limit; _drain = _drain + 1 {
    let poll_result : Result[Bool, @hal.EcError] = Result::Ok(
      @protocol.mailbox_poll(nic, station, mailbox_config, zero_timeout),
    ) catch {
      err => Result::Err(err)
    }
    match poll_result {
      Ok(true) =>
        ignore(
          Result::Ok(
            @protocol.mailbox_recv(
              nic,
              station,
              mailbox_config,
              zero_timeout,
              max_polls=1,
            ),
          ) catch {
            err => Result::Err(err)
          },
        )
      _ => return
    }
  }
}

///|
pub fn[N : @hal.Nic] apply_startup_mailbox_commands(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  commands : Array[StartupMailboxCommand],
  transition : StartupCommandTransition,
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  if !mailbox_config.is_valid() || commands.length() <= 0 {
    return
  }
  let rmsm = @mailbox.Rmsm::new(max_retries=0)
  for command in commands {
    if command.transition != transition {
      continue
    }
    drain_startup_mailbox(nic, station, mailbox_config)
    match startup_command_subindex_sequence_entry_width(command) {
      Some(entry_width) => {
        apply_startup_mailbox_subindex_sequence(
          nic, station, mailbox_config, rmsm, command, entry_width, timeout,
        )
        continue
      }
      None => ()
    }
    if startup_command_uses_complete_access(command) {
      @protocol.sdo_download_complete_access(
        nic,
        station,
        mailbox_config,
        rmsm,
        command.index,
        command.subindex,
        command.data,
        timeout,
      )
    } else {
      @protocol.sdo_download_retry(
        nic,
        station,
        mailbox_config,
        rmsm,
        command.index,
        command.subindex,
        command.data,
        timeout,
      )
    }
  }
}

///|
pub fn[N : @hal.Nic] apply_presafeop_startup_mailbox_commands(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  commands : Array[StartupMailboxCommand],
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  apply_startup_mailbox_commands(
    nic,
    station,
    mailbox_config,
    commands,
    PreOpToSafeOp,
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] apply_safeop_startup_mailbox_commands(
  nic : N,
  station : UInt,
  mailbox_config : @mailbox.MailboxConfig,
  commands : Array[StartupMailboxCommand],
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  apply_startup_mailbox_commands(
    nic,
    station,
    mailbox_config,
    commands,
    SafeOpToOp,
    timeout,
  )
}

///|
fn find_pdo_entry_byte_offset(
  pdos : Array[@mailbox.SiiPdo],
  entry_index : UInt,
  subindex : Byte,
) -> (Int, Int)? {
  let mut bit_offset = 0
  for pdo in pdos {
    for entry in pdo.entries {
      let bit_length = entry.bit_length.to_int()
      if entry.entry_index == entry_index && entry.subindex == subindex {
        if bit_length <= 0 || bit_offset % 8 != 0 || bit_length % 8 != 0 {
          return None
        }
        return Some((bit_offset / 8, bit_length))
      }
      bit_offset = bit_offset + bit_length
    }
  }
  None
}

///|
fn preferred_dc_cycle_period(sii : @mailbox.SiiFullInfo) -> Int64? {
  match preferred_sii_dc_clock(sii.dc_clocks) {
    Some(clock) => dc_cycle_period_ns(clock)
    None => None
  }
}

///|
pub fn preferred_sii_dc_clock(
  dc_clocks : Array[@mailbox.SiiDcClock],
) -> @mailbox.SiiDcClock? {
  for clock in dc_clocks {
    if clock.assign_activate != 0U {
      return Some(clock)
    }
  }
  if dc_clocks.is_empty() {
    None
  } else {
    Some(dc_clocks[0])
  }
}

///|
pub fn dc_cycle_period_ns(clock : @mailbox.SiiDcClock) -> Int64? {
  if clock.cycle_time_0 == 0U {
    return None
  }
  Some(clock.cycle_time_0.reinterpret_as_int().to_int64())
}

///|
fn mailbox_sm_field_matches(expected : Bytes, actual : Bytes) -> Bool {
  if expected.length() < @protocol.sm_entry_size ||
    actual.length() < @protocol.sm_entry_size {
    return false
  }
  expected[0] == actual[0] &&
  expected[1] == actual[1] &&
  expected[2] == actual[2] &&
  expected[3] == actual[3] &&
  expected[4] == actual[4] &&
  expected[6] == actual[6]
}

///|
fn mailbox_sm_hex_byte(value : Byte) -> String {
  let digits = "0123456789ABCDEF"
  digits[(value.to_int() >> 4) & 0xF].to_string() +
  digits[value.to_int() & 0xF].to_string()
}

///|
fn mailbox_sm_hex(bytes : Bytes) -> String {
  let mut rendered = "0x"
  for i = 0; i < bytes.length(); i = i + 1 {
    rendered = rendered + mailbox_sm_hex_byte(bytes[i])
  }
  rendered
}

///|
fn[N : @hal.Nic] verify_mailbox_sm_readback(
  nic : N,
  station : UInt,
  sm_index : Int,
  expected : Bytes,
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  let sm_addr = @protocol.reg_sm_base +
    sm_index.reinterpret_as_uint() *
    @protocol.sm_entry_size.reinterpret_as_uint()
  let actual = @protocol.read_register_fp(
      nic, station, sm_addr, @protocol.sm_entry_size, timeout,
    ).data
  if !mailbox_sm_field_matches(expected, actual) {
    raise @hal.EcError::MailboxProtocol(
      "mailbox SyncManager readback mismatch at SM" +
      sm_index.to_string() +
      ": expected=" +
      mailbox_sm_hex(expected) +
      " actual=" +
      mailbox_sm_hex(actual),
    )
  }
}

///|
/// Write only mailbox SyncManager configuration derived from SII.
pub fn[N : @hal.Nic] write_mailbox_mapping(
  nic : N,
  station : UInt,
  mapping : @mailbox.SlaveMapping,
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  for i = 0; i < mapping.sm_configs.length(); i = i + 1 {
    let sm = mapping.sm_configs[i]
    match sm.sm_type {
      @mailbox.MailboxOut | @mailbox.MailboxIn => {
        let sm_addr = @protocol.reg_sm_base +
          i.reinterpret_as_uint() *
          @protocol.sm_entry_size.reinterpret_as_uint()
        let expected = sm.to_bytes()
        let _ = @protocol.write_register_fp(
          nic, station, sm_addr, expected, timeout,
        )
        verify_mailbox_sm_readback(nic, station, i, expected, timeout)
      }
      _ => ()
    }
  }
}

///|
/// Write process-data SyncManager and FMMU configuration derived from SII.
/// Call this only after mailbox configuration is accepted and the station is
/// already in PreOp.
pub fn[N : @hal.Nic] write_process_data_mapping(
  nic : N,
  station : UInt,
  mapping : @mailbox.SlaveMapping,
  timeout : @hal.Duration,
) -> Unit raise @hal.EcError {
  for i = 0; i < mapping.sm_configs.length(); i = i + 1 {
    let sm = mapping.sm_configs[i]
    match sm.sm_type {
      @mailbox.ProcessDataOut
      | @mailbox.ProcessDataIn
      | @mailbox.DynamicProcessDataOut
      | @mailbox.DynamicProcessDataIn => {
        let sm_addr = @protocol.reg_sm_base +
          i.reinterpret_as_uint() *
          @protocol.sm_entry_size.reinterpret_as_uint()
        let deactivated = { ..sm, activate: sm.activate & b'\xFE' }
        if deactivated.activate != sm.activate {
          let _ = @protocol.write_register_fp(
            nic,
            station,
            sm_addr,
            deactivated.to_bytes(),
            timeout,
          )
        }
        let _ = @protocol.write_register_fp(
          nic,
          station,
          sm_addr,
          sm.to_bytes(),
          timeout,
        )
      }
      _ => ()
    }
  }
  for i = 0; i < mapping.fmmu_configs.length(); i = i + 1 {
    let fmmu_addr = @protocol.reg_fmmu_base +
      i.reinterpret_as_uint() * @protocol.fmmu_entry_size.reinterpret_as_uint()
    let _ = @protocol.write_register_fp(
      nic,
      station,
      fmmu_addr,
      mapping.fmmu_configs[i].to_bytes(),
      timeout,
    )
  }
}

///|
fn prefer_dc_cycle_period(current : Int64?, candidate : Int64?) -> Int64? {
  match current {
    Some(_) => current
    None => candidate
  }
}

///|
fn expected_wkc_from_slave_mappings(
  slave_mappings : Array[(@mailbox.SlaveMapping, UInt)],
) -> UInt {
  let mut total = 0U
  for i = 0; i < slave_mappings.length(); i = i + 1 {
    let (mapping, _) = slave_mappings[i]
    if mapping.output_bytes > 0 {
      total = total + 1U
    }
    if mapping.input_bytes > 0 {
      total = total + 1U
    }
  }
  total
}

///|
/// Execute a full EtherCAT run workflow:
/// 1. Scan the bus to discover slaves.
/// 2. Validate discovered slaves against expected configuration.
/// 3. Transition all slaves to Operational.
/// 4. Run PDO cycles.
/// 5. Transition slaves back to Init.
/// 6. Return a summary report.
pub fn[N : @hal.Nic] run(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  cycles : Int,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    RunLoopSettings::fixed_cycles(cycles),
    @protocol.Op,
    Some(@protocol.Init),
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] run_with_settings(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    @protocol.Op,
    Some(@protocol.Init),
    timeout,
  )
}

///|
pub fn[N : @hal.Nic, S : RunProgressSink] run_with_settings_reporting_to(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  timeout : @hal.Duration,
  sink : S,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings_impl(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    @protocol.Op,
    Some(@protocol.Init),
    None,
    None,
    timeout,
    sink,
  )
}

///|
pub fn[N : @hal.Nic] run_until_fault(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    RunLoopSettings::until_fault(),
    @protocol.Op,
    Some(@protocol.Init),
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] run_with_states(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  cycles : Int,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    RunLoopSettings::fixed_cycles(cycles),
    startup_state,
    shutdown_state,
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] run_with_states_until_fault(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    RunLoopSettings::until_fault(),
    startup_state,
    shutdown_state,
    timeout,
  )
}

///|
pub fn[N : @hal.Nic] run_with_states_and_settings(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  timeout : @hal.Duration,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings_prepared_reporting_to(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    startup_state,
    shutdown_state,
    None,
    timeout,
    NullRunProgressSink::new(),
  )
}

///|
pub fn[N : @hal.Nic, S : RunProgressSink] run_with_states_and_settings_prepared_reporting_to(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  startup_preparation_override : RunStartupPreparation?,
  timeout : @hal.Duration,
  sink : S,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings_impl(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    startup_state,
    shutdown_state,
    startup_preparation_override,
    None,
    timeout,
    sink,
  )
}

///|
pub fn[N : @hal.Nic, S : RunProgressSink] run_with_scan_and_states_and_settings_prepared_reporting_to(
  nic : N,
  scan_report : ScanReport,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  startup_preparation_override : RunStartupPreparation?,
  timeout : @hal.Duration,
  sink : S,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings_impl(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    startup_state,
    shutdown_state,
    startup_preparation_override,
    Some(scan_report),
    timeout,
    sink,
  )
}

///|
pub fn[N : @hal.Nic, S : RunProgressSink] run_with_states_and_settings_reporting_to(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  timeout : @hal.Duration,
  sink : S,
) -> RunReport raise @hal.EcError {
  run_with_states_and_settings_prepared_reporting_to(
    nic,
    config,
    expected,
    pdo_ctx,
    expected_wkc,
    settings,
    startup_state,
    shutdown_state,
    None,
    timeout,
    sink,
  )
}

///|
fn[N : @hal.Nic, S : RunProgressSink] run_with_states_and_settings_impl(
  nic : N,
  config : @hal.MasterConfig,
  expected : Array[@hal.SlaveConfig],
  pdo_ctx : @protocol.PdoContext,
  expected_wkc : UInt,
  settings : RunLoopSettings,
  startup_state : @protocol.EsmState,
  shutdown_state : @protocol.EsmState?,
  startup_preparation_override : RunStartupPreparation?,
  scan_report_override : ScanReport?,
  timeout : @hal.Duration,
  sink : S,
) -> RunReport raise @hal.EcError {
  let run_config = resolve_run_loop_config(config, settings)
  let progress_every_cycles = resolve_progress_every_cycles(
    run_config, settings,
  )
  let mut active_pdo_ctx = pdo_ctx
  let mut active_expected_wkc = expected_wkc
  let mut active_startup_mailbox_plans : Array[StationStartupMailboxPlan] = []
  let mut active_startup_slave_preparations : Array[
    (UInt, SlaveStartupPreparation),
  ]? = None
  let mut active_esm_timeout_policies : Array[
    (UInt, @protocol.EsmTimeoutPolicy),
  ] = []
  let mut startup_preop_ready = false
  let scan_report = match scan_report_override {
    Some(report) => report
    None => scan(nic, timeout)
  }
  let has_discovered_slaves = scan_report.slave_count > 0
  let stations = scan_report.slaves.map(fn(slave) { slave.station_address })

  let validate_report = validate(scan_report, expected)
  if !validate_report.all_ok {
    return {
      scan: scan_report,
      validation: validate_report,
      cycles_requested: settings.cycles,
      cycles_ok: 0,
      until_fault: settings.until_fault,
      cycle_period_ns: run_config.cycle_period_ns,
      output_period_ns: settings.output_period_ns,
      telemetry: Telemetry::new(),
      final_phase: Validating,
      fault: None,
    }
  }

  if has_discovered_slaves {
    if startup_state == @protocol.SafeOp || startup_state == @protocol.Op {
      let preop_fault = match startup_preparation_override {
        Some(prepared) =>
          match prepared.slave_preparations {
            Some(slave_preparations) =>
              transition_stations_to_preop_with_preparations(
                nic,
                slave_preparations,
                timeout,
                prepared.esm_timeout_policies,
              )
            None =>
              transition_stations_to_preop_with_mailbox(
                nic, stations, timeout, active_esm_timeout_policies,
              )
          }
        None =>
          transition_stations_to_preop_with_mailbox(
            nic, stations, timeout, active_esm_timeout_policies,
          )
      }
      match preop_fault {
        Some(fault) =>
          return {
            scan: scan_report,
            validation: validate_report,
            cycles_requested: settings.cycles,
            cycles_ok: 0,
            until_fault: settings.until_fault,
            cycle_period_ns: run_config.cycle_period_ns,
            output_period_ns: settings.output_period_ns,
            telemetry: Telemetry::new(),
            final_phase: Configuring,
            fault: Some(fault),
          }
        None => ()
      }
      let resolved_preparation = match startup_preparation_override {
        Some(prepared) =>
          match prepared.slave_preparations {
            Some(slave_preparations) =>
              refine_run_startup_preparation_override_in_preop(
                nic,
                slave_preparations,
                pdo_ctx.logical_base,
                timeout,
              )
            None => prepared
          }
        None =>
          build_run_startup_preparation_from_preop(
            nic,
            stations,
            pdo_ctx.logical_base,
            timeout,
          )
      }
      if resolved_preparation.pdo_ctx.logical_size > 0 ||
        resolved_preparation.expected_wkc > 0U ||
        (active_pdo_ctx.logical_size <= 0 && active_expected_wkc == 0U) {
        active_pdo_ctx = resolved_preparation.pdo_ctx
        active_expected_wkc = resolved_preparation.expected_wkc
      }
      active_startup_mailbox_plans = resolved_preparation.startup_mailbox_plans
      active_startup_slave_preparations = resolved_preparation.slave_preparations
      active_esm_timeout_policies = resolved_preparation.esm_timeout_policies
      startup_preop_ready = true
    }
    let startup_fault = transition_stations_for_startup(
      nic, stations, startup_state, active_pdo_ctx, active_startup_mailbox_plans,
      active_startup_slave_preparations, run_config, timeout, startup_preop_ready,
      active_esm_timeout_policies,
    )
    match startup_fault {
      Some(fault) =>
        return {
          scan: scan_report,
          validation: validate_report,
          cycles_requested: settings.cycles,
          cycles_ok: 0,
          until_fault: settings.until_fault,
          cycle_period_ns: run_config.cycle_period_ns,
          output_period_ns: settings.output_period_ns,
          telemetry: Telemetry::new(),
          final_phase: Configuring,
          fault: Some(fault),
        }
      None => ()
    }
  }

  let rt = Runtime::new(run_config)
  rt.configure_pdo(active_pdo_ctx, active_expected_wkc)
  // Configure emergency polling for all stations with mailbox support.
  let emg_stations : Array[(UInt, @mailbox.MailboxConfig)] = []
  for plan in active_startup_mailbox_plans {
    emg_stations.push((plan.station, plan.mailbox_config))
  }
  rt.configure_emergency_stations(emg_stations)

  let dc_ok = rt.init_dc(nic, stations, timeout) catch { _ => false }
  rt.set_timeout_threshold(settings.max_consecutive_timeouts)
  rt.set_state(Running(if dc_ok { DcSync } else { FreeRun }))
  let ok_count = if settings.until_fault {
    rt.run_until_fault_with_reporting_to(
      nic,
      timeout,
      progress_every_cycles,
      settings.output_period_ns.map(fn(period_ns) { period_ns.to_int() }),
      sink,
    )
  } else {
    rt.run_cycles_with_reporting_to(
      nic,
      settings.cycles,
      timeout,
      progress_every_cycles,
      settings.output_period_ns.map(fn(period_ns) { period_ns.to_int() }),
      sink,
    )
  }
  let telemetry = rt.get_telemetry()
  let runtime_fault = runtime_fault_from_runtime(rt)

  if has_discovered_slaves {
    match shutdown_state {
      Some(target) =>
        ignore(
          transition_stations_to_target(
            nic, stations, target, timeout, active_esm_timeout_policies,
          ),
        )
      None => ()
    }
  }
  rt.set_state(Idle)

  {
    scan: scan_report,
    validation: validate_report,
    cycles_requested: if settings.until_fault {
      telemetry.cycles.to_int()
    } else {
      settings.cycles
    },
    cycles_ok: ok_count,
    until_fault: settings.until_fault,
    cycle_period_ns: run_config.cycle_period_ns,
    output_period_ns: settings.output_period_ns,
    telemetry,
    final_phase: match runtime_fault {
      Some(_) => Operational
      None => Done
    },
    fault: runtime_fault,
  }
}

///|
fn build_operational_startup_steps(
  dc_enabled : Bool,
  has_process_data : Bool,
) -> Array[OperationalStartupStep] {
  let steps : Array[OperationalStartupStep] = [TransitionToSafeOp]
  if dc_enabled {
    steps.push(DcPreconfigure)
  }
  if has_process_data {
    steps.push(WarmupProcessData)
  }
  steps.push(ValidateSmWatchdog)
  steps.push(TransitionToOp)
  steps
}

///|
/// Recovery policy is driven by the AL Status Code observed before the caller
/// acknowledges the pending error. Low-stage configuration failures should be
/// reinitialized from Init, while SafeOp->Op readiness failures should remain
/// in SafeOp so the caller can continue after fixing the missing condition.
fn classify_startup_recovery_strategy(
  current : @protocol.EsmState?,
  target : @protocol.EsmState,
  status_code : UInt?,
) -> StartupRecoveryStrategy {
  match status_code {
    Some(0x0016U) | Some(0x001DU) | Some(0x001EU) | Some(0x0070U) =>
      RollbackToInit
    Some(0x001BU) | Some(0x002DU) | Some(0x002EU) | Some(0x0030U) =>
      if target == @protocol.Op || current == Some(@protocol.SafeOp) {
        HoldSafeOp
      } else {
        RetryTargetPath
      }
    _ =>
      if target == @protocol.Op && current == Some(@protocol.SafeOp) {
        HoldSafeOp
      } else {
        RetryTargetPath
      }
  }
}

///|
fn[N : @hal.Nic] stabilize_failed_startup_station(
  nic : N,
  station : UInt,
  strategy : StartupRecoveryStrategy,
  timeout : @hal.Duration,
) -> Unit {
  let stabilize_target = match strategy {
    RollbackToInit => Some(@protocol.Init)
    HoldSafeOp => Some(@protocol.SafeOp)
    RetryTargetPath => None
  }
  match stabilize_target {
    Some(target) => {
      let snapshot = Some(
        @protocol.read_al_status_snapshot(nic, station, timeout),
      ) catch {
        _ => None
      }
      match snapshot {
        Some(status) if status.error_indication =>
          ignore(
            Result::Ok(
              @protocol.recover_al_error_for_transition(nic, station, timeout),
            ) catch {
              err => Result::Err(err)
            },
          )
        _ => ()
      }
      ignore(
        Result::Ok(
          @protocol.transition_to_target_strict(nic, station, target, timeout),
        ) catch {
          err => Result::Err(err)
        },
      )
    }
    None => ()
  }
}

///|
fn[N : @hal.Nic] read_startup_fault_details(
  nic : N,
  station : UInt,
  timeout : @hal.Duration,
) -> @protocol.EsmStatusSnapshot? {
  Some(@protocol.read_al_status_snapshot(nic, station, timeout)) catch {
    _ => None
  }
}

///|
fn startup_snapshot_from_request_observation(
  observation : @protocol.EsmStateRequestObservation,
) -> @protocol.EsmStatusSnapshot {
  {
    state: observation.state,
    error_indication: observation.error_indication,
    status_code: observation.status_code,
  }
}

///|
fn startup_transition_failed_fault(
  station : UInt,
  target : @protocol.EsmState,
  snapshot : @protocol.EsmStatusSnapshot?,
  observation : @protocol.EsmStateRequestObservation?,
) -> RunFault {
  let current = match snapshot {
    Some(value) => Some(value.state)
    None => observation.map(fn(value) { value.state })
  }
  let error_indication = match snapshot {
    Some(value) => Some(value.error_indication)
    None => observation.map(fn(value) { value.error_indication })
  }
  let status_code = match snapshot {
    Some(value) =>
      match value.status_code {
        Some(_) => value.status_code
        None => observation.bind(fn(item) { item.status_code })
      }
    None => observation.bind(fn(value) { value.status_code })
  }
  RunFault::StartupTransitionFailed(
    station~,
    target~,
    current~,
    error_indication~,
    status_code~,
    raw_status_code=observation.bind(fn(value) { value.raw_status_code }),
    control_wkc=observation.map(fn(value) { value.control_wkc }),
  )
}

///|
fn startup_mailbox_transition_target(
  transition : StartupCommandTransition,
) -> @protocol.EsmState {
  match transition {
    InitToPreOp => @protocol.PreOp
    InitToBoot => @protocol.Boot
    PreOpToInit => @protocol.Init
    PreOpToSafeOp => @protocol.SafeOp
    SafeOpToInit => @protocol.Init
    SafeOpToPreOp => @protocol.PreOp
    SafeOpToOp => @protocol.Op
    OpToInit => @protocol.Init
    OpToPreOp => @protocol.PreOp
    OpToSafeOp => @protocol.SafeOp
    BootToInit => @protocol.Init
  }
}

///|
fn startup_mailbox_transition_current(
  transition : StartupCommandTransition,
) -> @protocol.EsmState? {
  match transition {
    InitToPreOp => Some(@protocol.Init)
    InitToBoot => Some(@protocol.Init)
    PreOpToInit => Some(@protocol.PreOp)
    PreOpToSafeOp => Some(@protocol.PreOp)
    SafeOpToInit => Some(@protocol.SafeOp)
    SafeOpToPreOp => Some(@protocol.SafeOp)
    SafeOpToOp => Some(@protocol.SafeOp)
    OpToInit => Some(@protocol.Op)
    OpToPreOp => Some(@protocol.Op)
    OpToSafeOp => Some(@protocol.Op)
    BootToInit => Some(@protocol.Boot)
  }
}

///|
fn[N : @hal.Nic] startup_mailbox_plan_failed_fault(
  nic : N,
  station : UInt,
  transition : StartupCommandTransition,
  timeout : @hal.Duration,
) -> RunFault {
  let target = startup_mailbox_transition_target(transition)
  let snapshot = read_startup_fault_details(nic, station, timeout)
  match snapshot {
    Some(_) => startup_transition_failed_fault(station, target, snapshot, None)
    None =>
      RunFault::StartupTransitionFailed(
        station~,
        target~,
        current=startup_mailbox_transition_current(transition),
        error_indication=None,
        status_code=None,
        raw_status_code=None,
        control_wkc=None,
      )
  }
}

///|
fn[N : @hal.Nic] transition_stations_for_startup(
  nic : N,
  stations : Array[UInt],
  startup_state : @protocol.EsmState,
  pdo_ctx : @protocol.PdoContext,
  startup_mailbox_plans : Array[StationStartupMailboxPlan],
  startup_slave_preparations : Array[(UInt, SlaveStartupPreparation)]?,
  run_config : @hal.MasterConfig,
  timeout : @hal.Duration,
  preop_ready : Bool,
  esm_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  if startup_state == @protocol.PreOp {
    if preop_ready {
      return None
    }
    return transition_stations_to_preop_with_mailbox(
      nic, stations, timeout, esm_policies,
    )
  }
  if startup_state == @protocol.SafeOp {
    match startup_slave_preparations {
      Some(slave_preparations) =>
        return transition_stations_to_safeop_with_preparations(
          nic, slave_preparations, timeout, preop_ready, esm_policies,
        )
      None => ()
    }
    return transition_stations_to_safeop_with_process_mapping(
      nic, stations, pdo_ctx, startup_mailbox_plans, timeout, preop_ready, esm_policies,
    )
  }
  if startup_state != @protocol.Op {
    return transition_stations_to_target(
      nic, stations, startup_state, timeout, esm_policies,
    )
  }

  let safeop_fault = match startup_slave_preparations {
    Some(slave_preparations) =>
      transition_stations_to_safeop_with_preparations(
        nic, slave_preparations, timeout, preop_ready, esm_policies,
      )
    None =>
      transition_stations_to_safeop_with_process_mapping(
        nic, stations, pdo_ctx, startup_mailbox_plans, timeout, preop_ready, esm_policies,
      )
  }
  match safeop_fault {
    Some(_) => return safeop_fault
    None => ()
  }

  let startup_mailbox_fault = apply_station_startup_mailbox_plans(
    nic,
    startup_mailbox_plans,
    SafeOpToOp,
    timeout,
  )
  match startup_mailbox_fault {
    Some(_) => return startup_mailbox_fault
    None => ()
  }

  let steps = build_operational_startup_steps(
    run_config.dc_enabled,
    pdo_ctx.logical_size > 0,
  )
  for step in steps {
    match step {
      TransitionToSafeOp => ()
      DcPreconfigure =>
        ignore(
          Result::Ok(
            @protocol.dc_configure_linear(
              nic,
              stations,
              run_config.cycle_period_ns.to_int().reinterpret_as_uint(),
              timeout,
              static_sync_iterations=32,
            ),
          ) catch {
            err => Result::Err(err)
          },
        )
      WarmupProcessData => warmup_process_data_for_op(nic, pdo_ctx, timeout)
      ValidateSmWatchdog =>
        validate_sm_watchdog_before_op(nic, stations, timeout)
      TransitionToOp => {
        let fault = transition_stations_to_op_with_process_data(
          nic, stations, pdo_ctx, timeout,
        )
        match fault {
          Some(_) => return fault
          None => ()
        }
      }
    }
  }
  None
}

///|
fn[N : @hal.Nic] transition_stations_to_preop_with_mailbox(
  nic : N,
  stations : Array[UInt],
  timeout : @hal.Duration,
  esm_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  for station in stations {
    let policy = lookup_esm_timeout_policy(esm_policies, station)
    let fault = transition_slave_to_preop_with_mailbox(
      nic, station, timeout, policy,
    )
    match fault {
      Some(_) => return fault
      None => ()
    }
  }
  None
}

///|
fn[N : @hal.Nic] transition_stations_to_preop_with_preparations(
  nic : N,
  slave_preparations : Array[(UInt, SlaveStartupPreparation)],
  timeout : @hal.Duration,
  esm_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  for item in slave_preparations {
    let (station, preparation) = item
    let policy = lookup_esm_timeout_policy(esm_policies, station)
    let fault = transition_slave_to_preop_with_preparation(
      nic, station, preparation, timeout, policy,
    )
    match fault {
      Some(_) => return fault
      None => ()
    }
  }
  None
}

///|
fn[N : @hal.Nic] transition_slave_to_target_strict_after_recovery(
  nic : N,
  station : UInt,
  target : @protocol.EsmState,
  timeout : @hal.Duration,
) -> RunFault? {
  let snapshot = @protocol.read_al_status_snapshot(nic, station, timeout) catch {
    _ =>
      return Some(startup_transition_failed_fault(station, target, None, None))
  }
  if snapshot.error_indication {
    ignore(@protocol.recover_al_error_for_transition(nic, station, timeout)) catch {
      _ =>
        return Some(
          startup_transition_failed_fault(station, target, Some(snapshot), None),
        )
    }
  }
  let observation = @protocol.transition_to_target_strict_observe(
    nic, station, target, timeout,
  ) catch {
    _ => None
  }
  match observation {
    Some(value) if value.state == target => None
    Some(value) =>
      Some(
        startup_transition_failed_fault(
          station,
          target,
          Some(startup_snapshot_from_request_observation(value)),
          Some(value),
        ),
      )
    None => {
      let final_snapshot = read_startup_fault_details(nic, station, timeout)
      match final_snapshot {
        Some(value) if value.state == target => None
        _ =>
          Some(
            startup_transition_failed_fault(
              station,
              target,
              final_snapshot,
              None,
            ),
          )
      }
    }
  }
}

///|
fn[N : @hal.Nic] transition_stations_to_safeop_with_preparations(
  nic : N,
  slave_preparations : Array[(UInt, SlaveStartupPreparation)],
  timeout : @hal.Duration,
  preop_ready : Bool,
  esm_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  if !preop_ready {
    let preop_fault = transition_stations_to_preop_with_preparations(
      nic, slave_preparations, timeout, esm_policies,
    )
    match preop_fault {
      Some(_) => return preop_fault
      None => ()
    }
  }
  let stations : Array[UInt] = []
  for item in slave_preparations {
    let (station, preparation) = item
    stations.push(station)
    let current_snapshot = read_startup_fault_details(nic, station, timeout)
    match current_snapshot {
      Some(status) if !status.error_indication &&
        (status.state == @protocol.SafeOp || status.state == @protocol.Op) =>
        continue
      Some(status) if status.error_indication =>
        ignore(
          Result::Ok(
            @protocol.recover_al_error_for_transition(nic, station, timeout),
          ) catch {
            err => Result::Err(err)
          },
        )
      _ => ()
    }
    let mailbox_result : Result[Unit, Error] = Result::Ok(
      apply_startup_mailbox_commands(
        nic,
        station,
        preparation.mailbox_config,
        preparation.startup_mailbox_commands,
        PreOpToSafeOp,
        timeout,
      ),
    ) catch {
      err => Result::Err(err)
    }
    match mailbox_result {
      Ok(_) => ()
      Err(_) =>
        return Some(
          startup_mailbox_plan_failed_fault(
            nic,
            station,
            PreOpToSafeOp,
            timeout,
          ),
        )
    }
    ignore(
      write_process_data_mapping(nic, station, preparation.mapping, timeout),
    ) catch {
      _ =>
        return Some(
          startup_transition_failed_fault(
            station,
            @protocol.SafeOp,
            read_startup_fault_details(nic, station, timeout),
            None,
          ),
        )
    }
    let safeop_fault = transition_slave_to_target_strict_after_recovery(
      nic,
      station,
      @protocol.SafeOp,
      timeout,
    )
    match safeop_fault {
      Some(_) => return safeop_fault
      None => ()
    }
  }
  None
}

///|
fn[N : @hal.Nic] transition_stations_to_safeop_with_process_mapping(
  nic : N,
  stations : Array[UInt],
  pdo_ctx : @protocol.PdoContext,
  startup_mailbox_plans : Array[StationStartupMailboxPlan],
  timeout : @hal.Duration,
  preop_ready : Bool,
  esm_policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  if !preop_ready {
    let preop_fault = transition_stations_to_preop_with_mailbox(
      nic, stations, timeout, esm_policies,
    )
    match preop_fault {
      Some(_) => return preop_fault
      None => ()
    }
  }
  let presafeop_mailbox_fault = apply_station_startup_mailbox_plans(
    nic,
    startup_mailbox_plans,
    PreOpToSafeOp,
    timeout,
  )
  match presafeop_mailbox_fault {
    Some(_) => return presafeop_mailbox_fault
    None => ()
  }
  for station in stations {
    let process_fault = configure_slave_process_mapping_from_pdo_ctx(
      nic, station, pdo_ctx, timeout,
    )
    match process_fault {
      Some(_) => return process_fault
      None => ()
    }
  }
  transition_stations_to_target(
    nic,
    stations,
    @protocol.SafeOp,
    timeout,
    esm_policies,
  )
}

///|
fn[N : @hal.Nic] transition_stations_to_target(
  nic : N,
  stations : Array[UInt],
  target : @protocol.EsmState,
  timeout : @hal.Duration,
  policies : Array[(UInt, @protocol.EsmTimeoutPolicy)],
) -> RunFault? {
  for station in stations {
    let policy = lookup_esm_timeout_policy(policies, station)
    let fault = transition_slave_to_target(
      nic, station, target, timeout, policy,
    )
    match fault {
      Some(_) => return fault
      None => ()
    }
  }
  None
}

///|
fn[N : @hal.Nic] transition_stations_to_op_with_process_data(
  nic : N,
  stations : Array[UInt],
  pdo_ctx : @protocol.PdoContext,
  timeout : @hal.Duration,
) -> RunFault? {
  for station in stations {
    let fault = transition_slave_to_op_with_process_data(
      nic, station, pdo_ctx, timeout,
    )
    match fault {
      Some(_) => return fault
      None => ()
    }
  }
  None
}

///|
fn[N : @hal.Nic] apply_station_startup_mailbox_plans(
  nic : N,
  plans : Array[StationStartupMailboxPlan],
  transition : StartupCommandTransition,
  timeout : @hal.Duration,
) -> RunFault? {
  for plan in plans {
    let result : Result[Unit, Error] = Result::Ok(
      apply_startup_mailbox_commands(
        nic,
        plan.station,
        plan.mailbox_config,
        plan.commands,
        transition,
        timeout,
      ),
    ) catch {
      err => Result::Err(err)
    }
    guard result is Err(_) else { continue }
    return Some(
      startup_mailbox_plan_failed_fault(nic, plan.station, transition, timeout),
    )
  }
  None
}

///|
fn[N : @hal.Nic] transition_slave_to_target(
  nic : N,
  station : UInt,
  target : @protocol.EsmState,
  timeout : @hal.Duration,
  policy : @protocol.EsmTimeoutPolicy,
) -> RunFault? {
  let attempt = attempt_transition_slave_to_target(
    nic, station, target, timeout, policy,
  )
  if attempt.reached_target {
    None
  } else {
    let snapshot = read_startup_fault_details(nic, station, timeout)
    let strategy = classify_startup_recovery_strategy(
      snapshot.map(fn(value) { value.state }),
      target,
      match snapshot {
        Some(value) =>
          match value.status_code {
            Some(_) => value.status_code
            None => attempt.observation.bind(fn(item) { item.status_code })
          }
        None => attempt.observation.bind(fn(item) { item.status_code })
      },
    )
    stabilize_failed_startup_station(nic, station, strategy, timeout)
    if strategy == RetryTargetPath {
      let retry_attempt = attempt_transition_slave_to_target(
        nic, station, target, timeout, policy,
      )
      if retry_attempt.reached_target {
        None
      } else {
        Some(
          startup_transition_failed_fault(
            station,
            target,
            read_startup_fault_details(nic, station, timeout),
            retry_attempt.observation,
          ),
        )
      }
    } else {
      Some(
        startup_transition_failed_fault(
          station,
          target,
          snapshot,
          attempt.observation,
        ),
      )
    }
  }
}

///|
fn[N : @hal.Nic] transition_slave_to_preop_with_mailbox(
  nic : N,
  station : UInt,
  timeout : @hal.Duration,
  policy : @protocol.EsmTimeoutPolicy,
) -> RunFault? {
  let preparation = prepare_slave_startup(
    nic, station, 0U, startup_eeprom_word_count, timeout,
  ) catch {
    _ =>
      return Some(
        startup_transition_failed_fault(
          station,
          @protocol.PreOp,
          read_startup_fault_details(nic, station, timeout),
          None,
        ),
      )
  }
  transition_slave_to_preop_with_preparation(
    nic, station, preparation, timeout, policy,
  )
}

///|
fn[N : @hal.Nic] transition_slave_to_preop_with_preparation(
  nic : N,
  station : UInt,
  preparation : SlaveStartupPreparation,
  timeout : @hal.Duration,
  policy : @protocol.EsmTimeoutPolicy,
) -> RunFault? {
  let current_snapshot = read_startup_fault_details(nic, station, timeout)
  match current_snapshot {
    Some(status) if !status.error_indication &&
      (
        status.state == @protocol.PreOp ||
        status.state == @protocol.SafeOp ||
        status.state == @protocol.Op
      ) => return None
    Some(status) if status.error_indication =>
      ignore(
        Result::Ok(
          @protocol.recover_al_error_for_transition(nic, station, timeout),
        ) catch {
          err => Result::Err(err)
        },
      )
    _ => ()
  }
  ignore(write_mailbox_mapping(nic, station, preparation.mapping, timeout)) catch {
    _ =>
      return Some(
        startup_transition_failed_fault(
          station,
          @protocol.PreOp,
          read_startup_fault_details(nic, station, timeout),
          None,
        ),
      )
  }
  let snapshot = read_startup_fault_details(nic, station, timeout)
  match snapshot {
    Some(status) if status.error_indication =>
      ignore(
        Result::Ok(
          @protocol.recover_al_error_for_transition(nic, station, timeout),
        ) catch {
          err => Result::Err(err)
        },
      )
    _ => ()
  }
  transition_slave_to_target(nic, station, @protocol.PreOp, timeout, policy)
}

///|
fn pdo_logical_base_for_station(
  pdo_ctx : @protocol.PdoContext,
  station : UInt,
) -> UInt? {
  for mapping in pdo_ctx.mappings {
    if mapping.station == station {
      return Some(mapping.output_logical)
    }
  }
  None
}

///|
fn[N : @hal.Nic] configure_slave_process_mapping_from_pdo_ctx(
  nic : N,
  station : UInt,
  pdo_ctx : @protocol.PdoContext,
  timeout : @hal.Duration,
) -> RunFault? {
  let logical_base = match pdo_logical_base_for_station(pdo_ctx, station) {
    Some(value) => value
    None => 0U
  }
  let initial = prepare_slave_startup(
    nic, station, logical_base, startup_eeprom_word_count, timeout,
  ) catch {
    _ =>
      return Some(
        RunFault::StartupTransitionFailed(
          station~,
          target=@protocol.SafeOp,
          current=None,
          error_indication=None,
          status_code=None,
          raw_status_code=None,
          control_wkc=None,
        ),
      )
  }
  let preparation = refine_startup_preparation_in_preop(
    nic, station, logical_base, initial, timeout,
  )
  ignore(write_process_data_mapping(nic, station, preparation.mapping, timeout)) catch {
    _ =>
      return Some(
        RunFault::StartupTransitionFailed(
          station~,
          target=@protocol.SafeOp,
          current=None,
          error_indication=None,
          status_code=None,
          raw_status_code=None,
          control_wkc=None,
        ),
      )
  }
  None
}

///|
fn[N : @hal.Nic] transition_slave_to_op_with_process_data(
  nic : N,
  station : UInt,
  pdo_ctx : @protocol.PdoContext,
  timeout : @hal.Duration,
) -> RunFault? {
  let observation = Some(
    @protocol.request_state_with_process_data_observe(
      nic,
      station,
      @protocol.Op,
      pdo_ctx,
      timeout,
    ),
  ) catch {
    _ => None
  }
  if observation is Some(observed) && observed.state == @protocol.Op {
    None
  } else {
    let fault_snapshot = match observation {
      Some(observed) =>
        Some(startup_snapshot_from_request_observation(observed))
      None => read_startup_fault_details(nic, station, timeout)
    }
    let strategy = classify_startup_recovery_strategy(
      fault_snapshot.map(fn(value) { value.state }),
      @protocol.Op,
      fault_snapshot.bind(fn(value) { value.status_code }),
    )
    stabilize_failed_startup_station(nic, station, strategy, timeout)
    Some(
      startup_transition_failed_fault(
        station,
        @protocol.Op,
        fault_snapshot,
        observation,
      ),
    )
  }
}

///|
fn[N : @hal.Nic] attempt_transition_slave_to_target(
  nic : N,
  station : UInt,
  target : @protocol.EsmState,
  timeout : @hal.Duration,
  policy : @protocol.EsmTimeoutPolicy,
) -> TransitionAttemptObservation {
  let snapshot = @protocol.read_al_status_snapshot(nic, station, timeout) catch {
    _ => return { reached_target: false, observation: None }
  }
  let active_snapshot = if snapshot.error_indication {
    @protocol.recover_al_error_for_transition(nic, station, timeout) catch {
      _ => return { reached_target: false, observation: None }
    }
  } else {
    snapshot
  }
  let observation = @protocol.transition_to_target_strict_with_timeout_policy_observe(
    nic, station, target, policy, timeout,
  ) catch {
    _ => return { reached_target: false, observation: None }
  }
  match observation {
    Some(value) =>
      { reached_target: value.state == target, observation: Some(value) }
    None =>
      { reached_target: active_snapshot.state == target, observation: None }
  }
}

///|
fn[N : @hal.Nic] warmup_process_data_for_op(
  nic : N,
  pdo_ctx : @protocol.PdoContext,
  timeout : @hal.Duration,
) -> Unit {
  if pdo_ctx.logical_size > 0 {
    ignore(
      Result::Ok(@protocol.pdo_exchange_result(nic, pdo_ctx, timeout)) catch {
        err => Result::Err(err)
      },
    )
  }
}

///|
/// SM Watchdog validation result for a single station.
pub(all) struct SmWatchdogValidation {
  station : UInt
  config : @protocol.SmWatchdogConfig
  watchdog_active : Bool
} derive(Eq, Debug)

///|
/// Read and validate SM Watchdog configuration for all stations before SafeOp→Op.
/// This is a diagnostic-only step: logs watchdog state per station but does not
/// block the transition. Devices without a configured SM watchdog will still
/// be promoted to Op (the ESC itself enforces watchdog-related faults via
/// AL Status Code 0x001B during runtime).
fn[N : @hal.Nic] validate_sm_watchdog_before_op(
  nic : N,
  stations : Array[UInt],
  timeout : @hal.Duration,
) -> Unit {
  for station in stations {
    let _config = @protocol.read_sm_watchdog_config(nic, station, timeout) catch {
      _ => continue
    }
  }
}

///|
fn runtime_fault_from_runtime(rt : Runtime) -> RunFault? {
  match rt.dc_state {
    Drifting(delta_ns) => Some(DcDrift(delta_ns~))
    _ =>
      if rt.consecutive_timeouts >= rt.max_consecutive_timeouts {
        Some(
          TimeoutRecoveryRequired(
            observed_timeouts=rt.telemetry.timeouts,
            consecutive_timeouts=rt.consecutive_timeouts,
          ),
        )
      } else if rt.needs_recovery() || rt.state is Error(_) {
        Some(
          RuntimeRecoveryRequired(
            expected_wkc=rt.expected_wkc,
            observed_wkc_errors=rt.telemetry.wkc_errors,
          ),
        )
      } else {
        None
      }
  }
}

///|
fn resolve_run_loop_config(
  config : @hal.MasterConfig,
  settings : RunLoopSettings,
) -> @hal.MasterConfig {
  match settings.cycle_period_ns {
    Some(cycle_period_ns) => { ..config, cycle_period_ns, }
    None => config
  }
}

///|
fn resolve_progress_every_cycles(
  config : @hal.MasterConfig,
  settings : RunLoopSettings,
) -> Int? {
  match settings.output_period_ns {
    Some(output_period_ns) =>
      if output_period_ns > 0L {
        Some(
          ((output_period_ns + config.cycle_period_ns - 1L) /
          config.cycle_period_ns).to_int(),
        )
      } else {
        None
      }
    _ => None
  }
}

///|
fn run_fault_text(fault : RunFault) -> String {
  match fault {
    StartupTransitionFailed(
      station~,
      target~,
      current~,
      error_indication~,
      status_code~,
      raw_status_code~,
      control_wkc~
    ) => {
      let current_text = match current {
        Some(state) => @debug.to_string(state)
        None => "unknown"
      }
      let error_text = match error_indication {
        Some(true) => "set"
        Some(false) => "clear"
        None => "unknown"
      }
      let status_text = match status_code {
        Some(code) => code.to_string()
        None => "unknown"
      }
      let raw_status_text = match raw_status_code {
        Some(code) => code.to_string()
        None => "unknown"
      }
      let control_wkc_text = match control_wkc {
        Some(wkc) => wkc.to_string()
        None => "unknown"
      }
      "startup transition failed: station=" +
      station.to_string() +
      " target=" +
      @debug.to_string(target) +
      " current=" +
      current_text +
      " error_flag=" +
      error_text +
      " status_code=" +
      status_text +
      " raw_status_code=" +
      raw_status_text +
      " control_wkc=" +
      control_wkc_text
    }
    TimeoutRecoveryRequired(observed_timeouts~, consecutive_timeouts~) =>
      "timeout recovery required: timeouts=" +
      observed_timeouts.to_string() +
      " consecutive_timeouts=" +
      consecutive_timeouts.to_string()
    RuntimeRecoveryRequired(expected_wkc~, observed_wkc_errors~) =>
      "runtime recovery required: expected_wkc=" +
      expected_wkc.to_string() +
      " wkc_errors=" +
      observed_wkc_errors.to_string()
    DcDrift(delta_ns~) => "dc drift detected: delta_ns=" + delta_ns.to_string()
  }
}

///|
/// Format a RunReport as a human-readable summary.
pub fn RunReport::format(self : RunReport) -> String {
  let buf = StringBuilder(size_hint=512)
  let fault_text = match self.fault {
    Some(fault) => run_fault_text(fault)
    None => "none"
  }
  let output_period_text = match self.output_period_ns {
    Some(output_period_ns) => output_period_ns.to_string()
    None => "none"
  }
  buf.write_string("MoonECAT Run Report\n")
  buf.write_string("===================\n")
  buf.write_string("Slaves found: " + self.scan.slave_count.to_string() + "\n")
  buf.write_string(
    "Validation: " +
    (if self.validation.all_ok { "PASS" } else { "FAIL" }) +
    "\n",
  )
  buf.write_string(
    "Mode: " +
    (if self.until_fault { "UntilFault" } else { "FixedCycles" }) +
    "\n",
  )
  buf.write_string("CyclePeriodNs: " + self.cycle_period_ns.to_string() + "\n")
  buf.write_string("OutputPeriodNs: " + output_period_text + "\n")
  buf.write_string(
    "Cycles: " +
    self.cycles_ok.to_string() +
    "/" +
    self.cycles_requested.to_string() +
    " OK\n",
  )
  buf.write_string("Phase: " + @debug.to_string(self.final_phase) + "\n")
  let tel = self.telemetry
  buf.write_string(
    "Telemetry: tx=" +
    tel.tx_frames.to_string() +
    " rx=" +
    tel.rx_frames.to_string() +
    " timeouts=" +
    tel.timeouts.to_string() +
    " irq_events=" +
    tel.irq_events.to_string() +
    " dc_delay_updates=" +
    tel.dc_delay_updates.to_string() +
    " wkc_errors=" +
    tel.wkc_errors.to_string() +
    "\n",
  )
  buf.write_string("Fault: " + fault_text + "\n")
  buf.to_string()
}