// Copyright (c) FIRST and other WPILib contributors.
// Open Source Software; you can modify and/or share it under the terms of
// the WPILib BSD license file in the root directory of this project.

package org.wpilib.command3;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.wpilib.command3.SchedulerEvent.Canceled;
import static org.wpilib.command3.SchedulerEvent.Mounted;
import static org.wpilib.command3.SchedulerEvent.Scheduled;
import static org.wpilib.command3.SchedulerEvent.Yielded;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.wpilib.annotation.PostConstructionInitializer;

@SuppressWarnings("PMD.CompareObjectsWithEquals")
class StateMachineTest extends CommandTestBase {
  @Test
  @SuppressWarnings(PostConstructionInitializer.SUPPRESSION_KEY)
  void errorsWithoutInitialState() {
    Mechanism mech = new DummyMechanism("Mechanism", m_scheduler);
    Command command1 = mech.run(Coroutine::park).named("Command 1");
    Command command2 = mech.run(Coroutine::park).named("Command 2");

    StateMachine stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    // stateMachine.setInitialState(state1); // Oops, someone forgot to set the initial state!
    state1.switchTo(state2).whenComplete();

    m_scheduler.schedule(stateMachine);

    // Don't worry, it'll be caught at runtime.
    // It would actually be caught at compile time, but we disabled the compiler check for this test
    var exception = assertThrows(IllegalStateException.class, () -> m_scheduler.run());
    assertEquals(
        "State Machine does not have an initial state. Use .setInitialState() to provide one.",
        exception.getMessage());
    assertFalse(m_scheduler.isRunning(stateMachine), "State machine should not be running");
  }

  @Test
  void initialStateCanBeOverridden() {
    Mechanism mech = new DummyMechanism("Mechanism", m_scheduler);
    Command command1 = mech.run(Coroutine::park).named("Command 1");
    Command command2 = mech.run(Coroutine::park).named("Command 2");

    StateMachine stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    stateMachine.setInitialState(state2);
    state2.switchTo(state1).whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertTrue(m_scheduler.isRunning(command2), "Command 2 should be running as the initial state");
    assertFalse(m_scheduler.isRunning(command1), "Command 1 should not be running");
  }

  @Test
  void transitions() {
    AtomicBoolean signalA = new AtomicBoolean(false);
    AtomicBoolean signalB = new AtomicBoolean(false);

    Mechanism mech = new DummyMechanism("Mechanism", m_scheduler);
    var command1 = mech.run(Coroutine::park).named("Command 1");
    var command2 = mech.run(Coroutine::park).named("Command 2");
    var command3 = mech.run(Coroutine::park).named("Command 3");

    StateMachine stateMachine = new StateMachine("State Machine");

    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);

    state1.switchTo(state2).when(signalA::get);
    state2.switchTo(state3).when(signalB::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertAll(
        () -> assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running"),
        () -> assertTrue(m_scheduler.isRunning(command1), "Command 1 should be running"),
        () -> assertFalse(m_scheduler.isRunning(command2), "Command 2 should not be running"),
        () -> assertFalse(m_scheduler.isRunning(command3), "Command 3 should not be running"));

    signalA.set(true);
    m_scheduler.run();
    assertAll(
        () -> assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running"),
        () -> assertFalse(m_scheduler.isRunning(command1), "Command 1 should not be running"),
        () -> assertTrue(m_scheduler.isRunning(command2), "Command 2 should be running"),
        () -> assertFalse(m_scheduler.isRunning(command3), "Command 3 should not be running"));

    signalB.set(true);
    m_scheduler.run();
    assertAll(
        () -> assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running"),
        () -> assertFalse(m_scheduler.isRunning(command1), "Command 1 should not be running"),
        () -> assertFalse(m_scheduler.isRunning(command2), "Command 2 should not be running"),
        () -> assertTrue(m_scheduler.isRunning(command3), "Command 3 should be running"));
  }

  @Test
  void transitionsIfConditionIsAlreadyTrueWhenEntered() {
    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    var signal = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    state1.switchTo(state2).when(signal::get);

    signal.set(true);
    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertFalse(m_scheduler.isRunning(command1), "Command 1 should not be running");
    assertTrue(m_scheduler.isRunning(command2), "State 1 should have transitioned to State 2");
  }

  @Test
  void commandExits() {
    AtomicBoolean signal = new AtomicBoolean(false);

    var command1 = Command.noRequirements(co -> co.waitUntil(signal::get)).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);

    stateMachine.setInitialState(state1);

    state1.switchTo(state2).whenComplete();
    state2.exitStateMachine().whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertTrue(m_scheduler.isRunning(command1), "Command 1 should be running");

    signal.set(true);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertFalse(m_scheduler.isRunning(command1), "Command 1 should have ended");
    assertTrue(m_scheduler.isRunning(command2), "Command 2 should have started");
  }

  @Test
  void stateTransitionsToSelf() {
    AtomicBoolean signal = new AtomicBoolean(false);
    AtomicInteger initCount = new AtomicInteger(0);

    var command =
        Command.noRequirements(
                co -> {
                  initCount.incrementAndGet();
                  co.park();
                })
            .named("Command");
    var stateMachine = new StateMachine("State Machine");
    var state = stateMachine.addState(command);
    stateMachine.setInitialState(state);
    state.switchTo(state).when(signal::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertEquals(1, initCount.get(), "Command should be initialized once");

    signal.set(true);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should still be running");
    assertEquals(2, initCount.get(), "Command should have reinitialized");

    assertEquals(14, m_events.size());
    assertAll(
        // First run
        () -> assertTrue(m_events.get(0) instanceof Scheduled s && s.command() == stateMachine),
        () -> assertTrue(m_events.get(1) instanceof Mounted m && m.command() == stateMachine),
        () -> assertTrue(m_events.get(2) instanceof Scheduled s && s.command() == command),
        () -> assertTrue(m_events.get(3) instanceof Mounted m && m.command() == command),
        () -> assertTrue(m_events.get(4) instanceof Yielded y && y.command() == command),
        () -> assertTrue(m_events.get(5) instanceof Yielded y && y.command() == stateMachine),
        () -> assertTrue(m_events.get(6) instanceof Mounted m && m.command() == command),
        () -> assertTrue(m_events.get(7) instanceof Yielded y && y.command() == command),
        // Second run
        () -> assertTrue(m_events.get(8) instanceof Mounted m && m.command() == stateMachine),
        () -> assertTrue(m_events.get(9) instanceof Canceled c && c.command() == command),
        () -> assertTrue(m_events.get(10) instanceof Scheduled s && s.command() == command),
        () -> assertTrue(m_events.get(11) instanceof Mounted m && m.command() == command),
        () -> assertTrue(m_events.get(12) instanceof Yielded y && y.command() == command),
        () -> assertTrue(m_events.get(13) instanceof Yielded y && y.command() == stateMachine));
  }

  @Test
  void oneshotCommandTransitionsToSelfOnComplete() {
    AtomicInteger count = new AtomicInteger(0);
    var command = Command.noRequirements(c -> count.incrementAndGet()).named("Command");
    var stateMachine = new StateMachine("State Machine");
    var state = stateMachine.addState(command);
    stateMachine.setInitialState(state);
    state.switchTo(state).whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertEquals(1, count.get(), "Command should have run once");
  }

  @Test
  void onlyFirstExplicitTransitionFires() {
    var signal = new AtomicBoolean(false);

    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::park).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);
    state1.switchTo(state2).when(signal::get);
    state1.switchTo(state3).when(signal::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    signal.set(true);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command2), m_scheduler.getRunningCommands());
  }

  @Test
  void onlyLastWhenCompleteTransitionFires() {
    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::yield).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::yield).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);
    state1.switchTo(state2).whenComplete();
    state1.switchTo(state3).whenComplete(); // overrides the previous transition

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    m_scheduler.run();
    assertEquals(List.of(stateMachine, command3), m_scheduler.getRunningCommands());
  }

  @Test
  void whenCompleteAndTakesPriorityOverWhenCompleteIfCalledLast() {
    var signal = new AtomicBoolean(false);

    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::yield).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::yield).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);
    state1.switchTo(state2).whenComplete();
    state1.switchTo(state3).whenCompleteAnd(signal::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    signal.set(true);
    m_scheduler.run();
    assertEquals(
        List.of(stateMachine, command3), // would be command2 if `whenComplete` took precedence
        m_scheduler.getRunningCommands());
  }

  @Test
  void whenCompleteAndTakesPriorityOverWhenCompleteIfCalleFirst() {
    var signal = new AtomicBoolean(false);

    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::yield).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::yield).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);
    state1.switchTo(state3).whenCompleteAnd(signal::get);
    state1.switchTo(state2).whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    signal.set(true);
    m_scheduler.run();
    assertEquals(
        List.of(stateMachine, command3), // would be command3 if `whenCompleteAnd` took precedence
        m_scheduler.getRunningCommands());
  }

  @Test
  void composingComplete() {
    AtomicBoolean signal = new AtomicBoolean(false);
    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);

    stateMachine.setInitialState(state1);
    state1.exitStateMachine().whenComplete();
    state1.switchTo(state2).whenCompleteAnd(signal::get);

    // First run, signal is low - state machine exits on state completion
    {
      m_scheduler.schedule(stateMachine);
      m_scheduler.run();
      assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
      assertTrue(m_scheduler.isRunning(command1), "Command should be running");

      m_scheduler.run();
      assertFalse(m_scheduler.isRunning(stateMachine), "State machine should have exited");
    }

    // Second run, signal goes high - state machine switches to state2 instead of exiting
    {
      m_scheduler.schedule(stateMachine);
      m_scheduler.run();
      assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
      assertTrue(m_scheduler.isRunning(command1), "Command should be running");

      signal.set(true);
      m_scheduler.run();
      assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
      assertFalse(m_scheduler.isRunning(command1), "Command should have ended");
      assertTrue(m_scheduler.isRunning(command2), "Command 2 should have started");
    }
  }

  @Test
  void switchFromAny() {
    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::park).named("Command 3");

    AtomicBoolean signal = new AtomicBoolean(false);

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);

    stateMachine.setInitialState(state1);
    stateMachine.switchFromAny(state1, state2).to(state3).when(signal::get);
    state1.switchTo(state2).whenComplete();

    // transition from 1 -> 3
    {
      m_scheduler.schedule(stateMachine);
      m_scheduler.run();
      assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
      assertTrue(m_scheduler.isRunning(command1), "Command 1 should be running");

      signal.set(true);
      m_scheduler.run();
      assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
      assertFalse(m_scheduler.isRunning(command1), "Command 1 should have ended");
      assertTrue(m_scheduler.isRunning(command3), "Command 3 should have started");
    }

    m_scheduler.cancel(stateMachine);
    signal.set(false);

    // transition from 2 -> 3
    {
      m_scheduler.schedule(stateMachine);
      m_scheduler.run(); // yield 1
      assertEquals(
          List.of("State Machine", "Command 1"),
          m_scheduler.getRunningCommands().stream().map(Command::name).toList());

      m_scheduler.run(); // transition 1 -> 2
      assertEquals(
          List.of("State Machine", "Command 2"),
          m_scheduler.getRunningCommands().stream().map(Command::name).toList());

      signal.set(true);
      m_scheduler.run(); // transition 2 -> 3
      assertEquals(
          List.of("State Machine", "Command 3"),
          m_scheduler.getRunningCommands().stream().map(Command::name).toList());
    }
  }

  @Test
  void switchToSupplierWhenComplete() {
    AtomicInteger count = new AtomicInteger(0);

    var command1 =
        Command.noRequirements(
                co -> {
                  count.incrementAndGet();
                  co.yield();
                })
            .named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::park).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);
    stateMachine.setInitialState(state1);
    state1
        .switchTo(
            () -> {
              if (count.get() == 1) {
                return state2;
              } else {
                return state3;
              }
            })
        .whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run(); // command 1 increments the count and then yields
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    // command 1 completes, state machine moves to the next state
    // if the supplier is checked at configuration time, the count would be 0 and return state3
    // if the supplier is checked at runtime, the count would be 1 and return state2
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command2), m_scheduler.getRunningCommands());
  }

  @Test
  void switchToSupplierWithCondition() {
    AtomicInteger count = new AtomicInteger(0);
    var command1 =
        Command.noRequirements(
                co -> {
                  while (true) {
                    // Increment after yielding. Otherwise, the condition is checked and the state
                    // machine immediately switches to the next state all within the first cycle;
                    // the running command1 is never observed.
                    co.yield();
                    count.incrementAndGet();
                  }
                })
            .named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");
    var command3 = Command.noRequirements(Coroutine::park).named("Command 3");

    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    var state3 = stateMachine.addState(command3);
    stateMachine.setInitialState(state1);
    state1
        .switchTo(
            () -> {
              if (count.get() == 1) {
                return state2;
              } else {
                return state3;
              }
            })
        .when(() -> count.get() == 1);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(List.of(stateMachine, command1), m_scheduler.getRunningCommands());

    m_scheduler.run();
    assertEquals(List.of(stateMachine, command2), m_scheduler.getRunningCommands());
  }

  @Test
  void runsOnEnterForInitialState() {
    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    AtomicInteger enterCount = new AtomicInteger(0);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    state1.onEnter(enterCount::incrementAndGet);
    state1.switchTo(state2).whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(1, enterCount.get(), "onEnter should have been called once");
  }

  @Test
  void runsOnExitOnTransition() {
    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    AtomicInteger exitCount = new AtomicInteger(0);
    AtomicBoolean signal = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    state1.onExit(exitCount::incrementAndGet);
    state1.switchTo(state2).when(signal::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(0, exitCount.get(), "onExit should not have been called");

    signal.set(true);
    m_scheduler.run();
    assertEquals(1, exitCount.get(), "onExit should have been called");
  }

  @Test
  void runsOnExitWhenComplete() {
    var command1 = Command.noRequirements(co -> {}).named("Command 1");

    AtomicInteger exitCount = new AtomicInteger(0);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    stateMachine.setInitialState(state1);
    state1.onExit(exitCount::incrementAndGet);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(1, exitCount.get(), "onExit should have been called");
    assertFalse(m_scheduler.isRunning(command1), "State should have exited");
  }

  @Test
  void onExitCanSchedule() {
    var mech = new DummyMechanism("Mechanism", m_scheduler);
    var mainMechCommand = mech.run(Coroutine::park).named("Main Mech Command");
    var backgroundMechCommand = mech.run(Coroutine::park).named("Background Mech Command");
    var nextStateCommand = Command.noRequirements(Coroutine::park).named("Next");

    AtomicBoolean signal = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(mainMechCommand);
    var state2 = stateMachine.addState(nextStateCommand);
    stateMachine.setInitialState(state1);
    state1.switchTo(state2).when(signal::get);
    state1.onExit(() -> m_scheduler.schedule(backgroundMechCommand));

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertTrue(m_scheduler.isRunning(mainMechCommand), "Main Mechanism should be running");

    signal.set(true);
    m_scheduler.run();
    assertTrue(m_scheduler.isRunning(stateMachine), "State machine should be running");
    assertFalse(m_scheduler.isRunning(mainMechCommand), "Main Mechanism should have ended");
    assertTrue(
        m_scheduler.isRunning(backgroundMechCommand), "Background Mechanism should have started");
    assertTrue(m_scheduler.isRunning(nextStateCommand), "Next State should have started");
  }

  @Test
  void runsOnEnterCallbacksInInsertionOrder() {
    var command1 = Command.noRequirements(co -> {}).named("Command 1");

    List<String> callbackInfo = new ArrayList<>();
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    stateMachine.setInitialState(state1);
    state1.onEnter(() -> callbackInfo.add("onEnter 1"));
    state1.onEnter(() -> callbackInfo.add("onEnter 2"));

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(
        List.of("onEnter 1", "onEnter 2"), callbackInfo, "onEnter callbacks did not run correctly");
  }

  @Test
  void runsOnExitCallbacksInInsertionOrder() {
    // Make the command immediately exit
    var command1 = Command.noRequirements(co -> {}).named("Command 1");

    List<String> callbackInfo = new ArrayList<>();
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    stateMachine.setInitialState(state1);
    state1.onExit(() -> callbackInfo.add("onExit 1"));
    state1.onExit(() -> callbackInfo.add("onExit 2"));

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertEquals(
        List.of("onExit 1", "onExit 2"), callbackInfo, "onExit callbacks did not run correctly");
  }

  @Test
  void onEnterSeesNewCommand() {
    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");

    AtomicBoolean sawCommand1OnEnter = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    stateMachine.setInitialState(state1);
    state1.onEnter(() -> sawCommand1OnEnter.set(m_scheduler.isRunning(command1)));

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();
    assertTrue(sawCommand1OnEnter.get(), "onEnter should have seen the command running");
  }

  @Test
  void onExitWithTransitionSeesExitedCommand() {
    var command1 = Command.noRequirements(Coroutine::park).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    AtomicBoolean sawCommand1OnExit = new AtomicBoolean(false);
    AtomicBoolean signal = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    state1.onExit(() -> sawCommand1OnExit.set(m_scheduler.isRunning(command1)));
    state1.switchTo(state2).when(signal::get);

    m_scheduler.schedule(stateMachine);
    m_scheduler.run();

    signal.set(true);
    m_scheduler.run();
    assertTrue(sawCommand1OnExit.get(), "onExit should have seen the exiting command");
  }

  // Because completion is defined as the command finishing on its own, callbacks will never
  // be able to see the command running in the scheduler because they're invoked _after_ the
  // command has finished.
  @Test
  void onExitWithCompleteCannotSeeExitedCommand() {
    var command1 = Command.noRequirements(Coroutine::yield).named("Command 1");
    var command2 = Command.noRequirements(Coroutine::park).named("Command 2");

    AtomicBoolean onExitCalled = new AtomicBoolean(false);
    AtomicBoolean sawCommand1OnExit = new AtomicBoolean(false);
    var stateMachine = new StateMachine("State Machine");
    var state1 = stateMachine.addState(command1);
    var state2 = stateMachine.addState(command2);
    stateMachine.setInitialState(state1);
    state1.onExit(
        () -> {
          onExitCalled.set(true);
          sawCommand1OnExit.set(m_scheduler.isRunning(command1));
        });
    state1.switchTo(state2).whenComplete();

    m_scheduler.schedule(stateMachine);
    m_scheduler.run(); // command yields...
    assertFalse(onExitCalled.get(), "onExit should not have been called yet");

    m_scheduler.run(); // ...then exits here
    assertTrue(onExitCalled.get(), "onExit should have been called");
    assertFalse(sawCommand1OnExit.get(), "exiting command should be invisible");
  }

  @Test
  void ledStateMachine() {
    var leds =
        new DummyMechanism("LEDs", m_scheduler) {
          Command idleAnimation() {
            return run(Coroutine::park).withPriority(-1).named("Default Animation");
          }

          Command infoAnimation() {
            return run(Coroutine::yield).withPriority(0).named("Info");
          }

          Command warningAnimation() {
            return run(Coroutine::yield).withPriority(1).named("Warning");
          }
        };

    Trigger normalPriorityEvent = new Trigger(() -> true);
    Trigger highPriorityEvent = new Trigger(() -> true);

    StateMachine stateMachine = new StateMachine("State Machine");

    var idleState = stateMachine.addState(leds.idleAnimation());
    var infoState = stateMachine.addState(leds.infoAnimation());
    var warningState = stateMachine.addState(leds.warningAnimation());

    stateMachine.setInitialState(idleState);

    idleState.switchTo(infoState).when(normalPriorityEvent.and(highPriorityEvent.negate()));
    idleState.switchTo(warningState).when(highPriorityEvent);

    warningState.switchTo(infoState).whenCompleteAnd(normalPriorityEvent);
    infoState.switchTo(warningState).whenCompleteAnd(highPriorityEvent);

    stateMachine.switchFromAny().to(warningState).when(highPriorityEvent);
    stateMachine.switchFromAny().to(idleState).whenComplete();
  }
}
