//! The Zwift Click client: an async actor that owns a controller peripheral.
//!
//! Structurally a smaller sibling of [`crate::client`]. One task owns the
//! peripheral; callers hold a cheap handle and receive [`ClickEvent`]s on a
//! broadcast channel.
//!
//! ```text
//!   caller ◄──broadcast── ClickEvent ◄── actor ◄─notify── Click pod
//! ```
//!
//! The protocol itself lives in [`crate::zwift`] as pure functions over bytes;
//! this module is only the radio and the reconnect loop. See REQUIREMENTS.md
//! §2.3.1 for what was confirmed against the hardware — notably that a Click v2
//! needs **no encryption**, so there is no key exchange here to get wrong.
//!
//! Unlike the trainer, a controller has no safety story: it never commands
//! load, and the only bytes ever written to it are the `RideOn` handshake.

use std::future::Future;
use std::time::Duration;

use btleplug::api::{CharPropFlags, Characteristic, Peripheral as _, WriteType};
use btleplug::platform::{Adapter, Peripheral};
use futures::{Stream, StreamExt};
use tokio::sync::{broadcast, mpsc, oneshot};

use crate::client::{Backoff, InFlight, DISCONNECT_TIMEOUT};
use crate::error::FtmsError;
use crate::indoor_bike_data::hex;
use crate::scan::{self, ScanKind};
use crate::zwift::{self, Button, ButtonTracker, PodId};

/// How to pick a controller pod out of a scan.
///
/// Deliberately not [`crate::scan::TrainerSelector`]. A pair of Click pods
/// advertise the *same* local name — both of ours are plain `Zwift Click` — so
/// name matching cannot tell them apart and whichever answered first won. That
/// is the whole reason the connection felt arbitrary: ask for "the Click" and
/// you get a coin toss between the pod with the D-pad and the one without.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PodSelector {
    /// A specific pod, by address. What the app uses once a scan has seen the
    /// pair, because an address cannot be confused with its twin.
    Address(String),
    /// The pod whose manufacturer-data type byte says it is this one (§2.3.1).
    /// Used before either pod has been seen by a scan.
    Pod(PodId),
    /// Any Click pod at all. The fallback for hardware whose type byte we do
    /// not recognise — better a controller in the wrong slot than none.
    Any,
}

impl PodSelector {
    /// Does this peripheral match?
    pub fn matches(&self, d: &scan::DiscoveredDevice) -> bool {
        self.matches_parts(&d.address, d.zwift_kind())
    }

    /// The matching rule, factored out so it can be unit-tested without a
    /// `PeripheralId` (which only the platform backend can construct).
    pub(crate) fn matches_parts(&self, address: &str, kind: Option<zwift::DeviceKind>) -> bool {
        match self {
            // An address is matched without insisting on the manufacturer data:
            // a pod that is mid-connection may not be advertising it, and the
            // address alone already identifies exactly one peripheral.
            PodSelector::Address(a) => address.eq_ignore_ascii_case(a),
            PodSelector::Pod(pod) => kind.and_then(|k| k.pod_id()) == Some(*pod),
            PodSelector::Any => kind.is_some_and(|k| k.is_click()),
        }
    }

    /// Human-readable, for error messages the rider will actually read.
    pub fn describe(&self) -> String {
        match self {
            PodSelector::Address(a) => format!("Click pod at {a}"),
            PodSelector::Pod(p) => format!("the {} Click pod", p.symbol()),
            PodSelector::Any => "any Click pod".to_string(),
        }
    }

    /// Which pod this asks for, when it asks for a particular one at all.
    pub fn pod(&self) -> Option<PodId> {
        match self {
            PodSelector::Pod(p) => Some(*p),
            _ => None,
        }
    }
}

/// Scan until a pod matching `selector` appears, or `timeout` elapses.
pub async fn find_pod(
    adapter: &Adapter,
    selector: &PodSelector,
    timeout: Duration,
) -> Result<Peripheral, FtmsError> {
    // Unfiltered: a Click advertises Zwift's own service, never FTMS.
    scan::find_matching(adapter, ScanKind::All, timeout, &selector.describe(), |d| {
        selector.matches(d)
    })
    .await
}

/// Tunables for [`ClickClient`].
#[derive(Debug, Clone)]
pub struct ClickConfig {
    /// How long to look for the pod before giving up. A Click sleeps quickly
    /// and only advertises after a button press (A-4), so this is generous.
    pub scan_timeout: Duration,
    pub backoff: Backoff,
    pub channel_capacity: usize,
}

impl Default for ClickConfig {
    fn default() -> Self {
        Self {
            scan_timeout: Duration::from_secs(20),
            backoff: Backoff::default(),
            channel_capacity: 64,
        }
    }
}

/// Everything the app learns from a controller.
#[derive(Debug, Clone, PartialEq)]
pub enum ClickEvent {
    Connected {
        address: String,
        name: Option<String>,
        /// Which pod this turned out to be, from its own advertisement — not
        /// from what was asked for. Connecting by address says nothing about
        /// which paddle the pod carries, and the app would otherwise have to
        /// assume it got what it requested.
        pod: Option<PodId>,
    },
    /// The link dropped. Any held button has already been reported as released.
    /// The actor is retrying — this is not terminal.
    Disconnected,
    /// Reconnecting ran out of attempts and the actor has stopped (FR-1.11).
    ///
    /// Terminal, and distinct from [`ClickEvent::Disconnected`] for exactly that
    /// reason: one means "hold on", the other means "go and look at the pod".
    /// Nothing further arrives on this stream.
    GaveUp { attempts: u32 },
    /// A press or release edge. Repeats while held are filtered out here, not
    /// by the consumer (see [`ButtonTracker`]).
    Button { button: Button, pressed: bool },
    Battery { percent: u8 },
    /// A frame we could not interpret. Surfaced rather than dropped: the
    /// protocol is only partly documented, and silence would hide the parts we
    /// have not met yet.
    Unknown { kind: u8, raw: Vec<u8> },
}

enum Cmd {
    Shutdown { reply: oneshot::Sender<()> },
}

/// Cheap, cloneable handle to a controller session.
#[derive(Clone)]
pub struct ClickClient {
    cmd_tx: mpsc::Sender<Cmd>,
    events_tx: broadcast::Sender<ClickEvent>,
    address: String,
    name: Option<String>,
    pod: Option<PodId>,
}

impl ClickClient {
    /// The pod's address. Carried on the handle rather than left to the event
    /// stream because [`ClickEvent::Connected`] for the *first* session is sent
    /// before the caller has had a chance to subscribe — a caller that learned
    /// its identity only from events would sit there believing it had connected
    /// to nothing.
    pub fn address(&self) -> &str {
        &self.address
    }

    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// Which pod of a pair this turned out to be, where it said so (§2.3.1).
    pub fn pod(&self) -> Option<PodId> {
        self.pod
    }

    /// Connect to a Click and start streaming events.
    ///
    /// Returns once the pod has answered the handshake, so a caller that gets
    /// an `Ok` knows the controller is genuinely talking — not merely that a
    /// BLE link exists.
    pub async fn connect(selector: PodSelector, config: ClickConfig) -> Result<Self, FtmsError> {
        let adapter = scan::default_adapter().await?;
        Self::connect_with_adapter(adapter, selector, config).await
    }

    /// As [`ClickClient::connect`], but abandoned as soon as `cancel` resolves.
    ///
    /// Returns `Ok(None)` when it was cancelled. A Click only advertises after a
    /// button press (A-4), so a connect routinely runs the full `scan_timeout` —
    /// twenty seconds, which is long enough that a quit waiting for it reads as
    /// a hang (FR-1.10). Any link the abandoned attempt had opened is closed
    /// before this returns (SAF-9).
    pub async fn connect_cancellable(
        selector: PodSelector,
        config: ClickConfig,
        cancel: impl Future<Output = ()>,
    ) -> Result<Option<Self>, FtmsError> {
        let in_flight = InFlight::default();
        let outcome = {
            let attempt = async {
                let adapter = scan::default_adapter().await?;
                Self::connect_on(adapter, selector, config, &in_flight).await
            };
            tokio::pin!(attempt, cancel);
            tokio::select! {
                result = &mut attempt => Some(result),
                () = &mut cancel => None,
            }
        };
        match outcome {
            Some(result) => result.map(Some),
            None => {
                tracing::info!("click: connect attempt cancelled");
                in_flight.abandon().await;
                Ok(None)
            }
        }
    }

    /// As [`ClickClient::connect`], but on a caller-supplied adapter.
    pub async fn connect_with_adapter(
        adapter: Adapter,
        selector: PodSelector,
        config: ClickConfig,
    ) -> Result<Self, FtmsError> {
        Self::connect_on(adapter, selector, config, &InFlight::default()).await
    }

    async fn connect_on(
        adapter: Adapter,
        selector: PodSelector,
        config: ClickConfig,
        in_flight: &InFlight,
    ) -> Result<Self, FtmsError> {
        let (events_tx, _) = broadcast::channel(config.channel_capacity);
        let (cmd_tx, cmd_rx) = mpsc::channel(8);

        let (session, notifications) = open_session(&adapter, &selector, &config, in_flight).await?;
        let (address, name, pod) = (session.address.clone(), session.name.clone(), session.pod);
        // Sent for symmetry with a reconnect. Nobody is subscribed yet, which is
        // why the same three facts also ride out on the handle below.
        let _ = events_tx.send(ClickEvent::Connected {
            address: address.clone(),
            name: name.clone(),
            pod,
        });

        let actor = Actor {
            adapter,
            // Reconnect to *this* pod, not to whatever now answers the original
            // description. Chasing `the − pod` after a drop could land on the
            // twin if the type bytes are not what we think they are, and then
            // both slots would be the same peripheral. Only an address we
            // actually read is an improvement on what we were asked for.
            selector: if address.is_empty() {
                selector
            } else {
                PodSelector::Address(address.clone())
            },
            config,
            events_tx: events_tx.clone(),
            tracker: ButtonTracker::new(),
        };
        tokio::spawn(actor.run(cmd_rx, session, Box::pin(notifications)));

        Ok(Self { cmd_tx, events_tx, address, name, pod })
    }

    /// Subscribe to controller events. Late subscribers see only what arrives
    /// after they subscribe.
    pub fn events(&self) -> broadcast::Receiver<ClickEvent> {
        self.events_tx.subscribe()
    }

    /// Close the link, and wait for it to actually be closed.
    ///
    /// SAF-9: a pod whose link the process merely abandons can stay held by
    /// BlueZ and be unreachable on the next launch. Fire-and-forget would return
    /// before the disconnect had even been issued, which at app exit means it
    /// never is. Idempotent — shutting down a stopped client is a no-op.
    pub async fn shutdown(&self) {
        let (reply, done) = oneshot::channel();
        if self.cmd_tx.send(Cmd::Shutdown { reply }).await.is_err() {
            // The actor is already gone; it tore the link down on its way out.
            return;
        }
        let _ = done.await;
    }
}

/// Boxed so that the initial connection and every reconnect share one type —
/// `impl Stream` would mint a fresh opaque type per call site and the two could
/// not be assigned to the same variable.
type Notifications = std::pin::Pin<
    Box<dyn Stream<Item = btleplug::api::ValueNotification> + Send>,
>;

/// A live connection: the peripheral plus the characteristics we care about.
struct Session {
    peripheral: Peripheral,
    address: String,
    name: Option<String>,
    /// Which pod this is, as it advertised itself.
    pod: Option<PodId>,
    subscribed: Vec<Characteristic>,
}

/// Find the pod, connect, subscribe, and complete the `RideOn` handshake.
async fn open_session(
    adapter: &Adapter,
    selector: &PodSelector,
    config: &ClickConfig,
    in_flight: &InFlight,
) -> Result<(Session, Notifications), FtmsError> {
    let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
    // Cancelling past this point would otherwise strand the link (FR-1.10).
    in_flight.hold(peripheral.clone());

    match setup_session(peripheral.clone(), selector.pod()).await {
        Ok(session) => {
            in_flight.released();
            Ok(session)
        }
        Err(e) => {
            // Every failure after `connect()` leaves a live GATT link behind,
            // and a pod that is still held will not advertise for the next
            // attempt — so the retry loop would never succeed.
            tracing::debug!(error = %e, "click: session setup failed; disconnecting");
            let _ = peripheral.disconnect().await;
            in_flight.released();
            Err(e)
        }
    }
}

async fn setup_session(
    peripheral: Peripheral,
    asked_for: Option<PodId>,
) -> Result<(Session, Notifications), FtmsError> {
    // Always ride a link we opened ourselves. Finding the peripheral already
    // connected is not a shortcut worth taking — it means someone else left it
    // that way, and after a shutdown that ran out of budget that someone is
    // usually our own previous run (§7.1, SAF-9). An inherited half-closed
    // session answers the handshake and streams battery every five seconds
    // while never delivering a single button, which reads as broken hardware
    // and is the single most expensive failure this project has had.
    //
    // Dropping it first costs one round trip on a path that already takes
    // seconds, and buys the guarantee that every connection starts identical.
    if peripheral.is_connected().await.unwrap_or(false) {
        tracing::debug!("click: dropping a pre-existing link before connecting");
        let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, peripheral.disconnect()).await;
    }
    peripheral.connect().await?;
    peripheral.discover_services().await?;

    // A Click v2 carries 0xFC82; the trainer carries 00000001-19CA-…. Both hold
    // the same characteristics, so take whichever is present.
    let service = zwift::SERVICES
        .iter()
        .find_map(|want| peripheral.services().into_iter().find(|s| s.uuid == *want))
        .ok_or(FtmsError::MissingCharacteristic("Zwift custom service"))?;

    let notifications = peripheral.notifications().await?;

    // Subscribe before writing, so the handshake reply cannot outrun us.
    let mut subscribed = Vec::new();
    for ch in &service.characteristics {
        if ch
            .properties
            .intersects(CharPropFlags::NOTIFY | CharPropFlags::INDICATE)
        {
            match peripheral.subscribe(ch).await {
                Ok(()) => subscribed.push(ch.clone()),
                Err(e) => tracing::debug!(uuid = %ch.uuid, error = %e, "click: subscribe failed"),
            }
        }
    }
    if subscribed.is_empty() {
        return Err(FtmsError::MissingCharacteristic(
            "a notifying Zwift characteristic",
        ));
    }

    let sync_rx = service
        .characteristics
        .iter()
        .find(|c| c.uuid == zwift::SYNC_RX)
        .ok_or(FtmsError::MissingCharacteristic("Zwift sync RX"))?;
    let write_type = if sync_rx
        .properties
        .contains(CharPropFlags::WRITE_WITHOUT_RESPONSE)
    {
        WriteType::WithoutResponse
    } else {
        WriteType::WithResponse
    };
    peripheral
        .write(sync_rx, &zwift::handshake(&zwift::REQUEST_START), write_type)
        .await?;

    let described = scan::describe(&peripheral).await;
    Ok((
        Session {
            address: described
                .as_ref()
                .map(|d| d.address.clone())
                .unwrap_or_default(),
            // A peripheral that is already connected may no longer carry its
            // advertisement, so fall back to the pod we went looking for.
            pod: described
                .as_ref()
                .and_then(|d| d.pod_id())
                .or(asked_for),
            name: described.and_then(|d| d.name),
            peripheral,
            subscribed,
        },
        Box::pin(notifications),
    ))
}

struct Actor {
    adapter: Adapter,
    selector: PodSelector,
    config: ClickConfig,
    events_tx: broadcast::Sender<ClickEvent>,
    tracker: ButtonTracker,
}

impl Actor {
    async fn run(
        mut self,
        mut cmd_rx: mpsc::Receiver<Cmd>,
        mut current: Session,
        mut notifications: Notifications,
    ) {
        let mut attempt = 0u32;

        loop {
            let dropped = tokio::select! {
                cmd = cmd_rx.recv() => {
                    match cmd {
                        Some(Cmd::Shutdown { reply }) => {
                            self.teardown(&current).await;
                            // Answered only once the link is genuinely closed,
                            // so a caller blocking the app's exit on this knows
                            // what it waited for (SAF-9).
                            let _ = reply.send(());
                            return;
                        }
                        None => {
                            self.teardown(&current).await;
                            return;
                        }
                    }
                }
                frame = notifications.next() => match frame {
                    Some(n) => { self.handle(&n.value); false }
                    // The stream ending is how btleplug reports a dropped link.
                    None => true,
                },
            };

            if !dropped {
                continue;
            }

            // Said out loud: a rider reporting that a pod "keeps dropping" needs
            // the log to show whether the radio link actually ended, and this is
            // the moment it did. The stream ending is BlueZ's only notice.
            tracing::warn!(address = %self.selector.describe(), "click: notification stream ended — link dropped");

            // Release anything still held, so a paddle held through a dropout
            // cannot latch (see ButtonTracker::reset).
            self.release_held();
            let _ = self.events_tx.send(ClickEvent::Disconnected);

            // FR-1.6: reconnect with backoff. A Click sleeps aggressively, so
            // "not found" is the normal case, not an error worth giving up on.
            loop {
                if self.config.backoff.exhausted(attempt) {
                    tracing::warn!("click: giving up after {attempt} reconnect attempts");
                    // Say so before going away. Leaving silently strands the
                    // supervisor holding a handle whose actor is gone, with
                    // nothing to tell the rider why the buttons stopped working
                    // (FR-1.11).
                    let _ = self.events_tx.send(ClickEvent::GaveUp { attempts: attempt });
                    return;
                }
                let delay = self.config.backoff.delay(attempt);
                attempt += 1;

                // Both the backoff *and* the attempt stay answerable to
                // shutdown. `open_session` runs for as long as `scan_timeout`
                // — twenty seconds against a pod that only advertises after a
                // button press — and a quit that waits for that is a quit that
                // leaves the link open (FR-1.10, SAF-9).
                let in_flight = InFlight::default();
                let outcome = {
                    let attempting = async {
                        tokio::time::sleep(delay).await;
                        open_session(&self.adapter, &self.selector, &self.config, &in_flight).await
                    };
                    tokio::pin!(attempting);
                    tokio::select! {
                        biased;
                        cmd = cmd_rx.recv() => Err(cmd),
                        result = &mut attempting => Ok(result),
                    }
                };

                let result = match outcome {
                    Ok(result) => result,
                    Err(cmd) => {
                        // There is no session to tear down — the attempt owns
                        // whatever link exists, so closing that is the whole job.
                        in_flight.abandon().await;
                        if let Some(Cmd::Shutdown { reply }) = cmd {
                            let _ = reply.send(());
                        }
                        return;
                    }
                };

                match result {
                    Ok((session, stream)) => {
                        let _ = self.events_tx.send(ClickEvent::Connected {
                            address: session.address.clone(),
                            name: session.name.clone(),
                            pod: session.pod,
                        });
                        current = session;
                        notifications = stream;
                        attempt = 0;
                        break;
                    }
                    Err(e) => tracing::debug!(error = %e, "click: reconnect failed"),
                }
            }
        }
    }

    /// Decode one notification into events.
    fn handle(&mut self, raw: &[u8]) {
        // Every frame, before anything decides what it means (NFR-8). The
        // decoders below all fail by dropping, so without this a pod that is
        // talking and a pod that is silent produce identical logs — which is
        // exactly the ambiguity that made a working paddle look like dead
        // hardware.
        tracing::debug!(len = raw.len(), raw = %hex(raw), "click: frame");
        if zwift::is_ride_on_reply(raw) {
            tracing::debug!("click: RideOn acknowledged");
            return;
        }
        let Some(frame) = zwift::parse_frame(raw) else {
            return;
        };

        match frame.kind {
            zwift::MessageType::ButtonBitmask => {
                match zwift::decode_button_bitmask(frame.payload) {
                    Ok(mask) => {
                        for edge in self.tracker.update(mask) {
                            let _ = self.events_tx.send(ClickEvent::Button {
                                button: edge.button,
                                pressed: edge.pressed,
                            });
                        }
                    }
                    Err(e) => tracing::debug!(error = %e, "click: bad button frame"),
                }
            }
            zwift::MessageType::Battery => {
                if let Ok(Some(percent)) = zwift::decode_battery(frame.payload) {
                    let _ = self.events_tx.send(ClickEvent::Battery { percent });
                }
            }
            zwift::MessageType::KeepAlive => {}
            zwift::MessageType::Unknown(kind) => {
                let _ = self.events_tx.send(ClickEvent::Unknown {
                    kind,
                    raw: raw.to_vec(),
                });
            }
            _ => {}
        }
    }

    /// Emit a release for every button the tracker still believes is held.
    fn release_held(&mut self) {
        for button in self.tracker.held() {
            let _ = self.events_tx.send(ClickEvent::Button {
                button,
                pressed: false,
            });
        }
        self.tracker.reset();
    }

    /// Close the link. Every step is bounded: this runs on the app's exit path,
    /// where a radio operation that never returns is a window that never shuts
    /// (NFR-9).
    async fn teardown(&mut self, session: &Session) {
        self.release_held();
        // Straight to the disconnect, and deliberately no unsubscribe loop.
        //
        // A pod carries five notifying characteristics, and unsubscribing each
        // under its own DISCONNECT_TIMEOUT could spend 10 s before this even
        // reached the disconnect — against a supervisor budget of 3 s. The
        // caller gave up first, the process exited, and the link was left open:
        // BlueZ then held the pod with no app running, and the next run
        // inherited a half-dead session that streamed battery and no buttons.
        // Observed exactly that, and it cost an evening.
        //
        // Nothing is lost by skipping it. Dropping the link clears the pod's
        // CCCDs on its own, so the unsubscribes were only ever politeness
        // toward a link we are about to destroy (NFR-9).
        tracing::debug!(
            subscribed = session.subscribed.len(),
            "click: closing the link"
        );
        match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await {
            Ok(Ok(())) => tracing::info!("click: disconnected"),
            Ok(Err(e)) => tracing::debug!(error = %e, "click: disconnect failed"),
            // Loud, because the cost lands on the *next* run rather than this
            // one: a link we failed to close stays up in BlueZ after we exit.
            Err(_) => tracing::warn!(
                "click: disconnect timed out — the link may be left open, and a stale link \
                 makes the pod look connected while sending nothing"
            ),
        }
        let _ = self.events_tx.send(ClickEvent::Disconnected);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_defaults_are_patient_enough_for_a_sleeping_pod() {
        let c = ClickConfig::default();
        assert!(c.scan_timeout >= Duration::from_secs(10));
        // Retry forever: a Click that has gone to sleep is the normal case.
        assert_eq!(c.backoff.max_attempts, None);
    }

    /// The bug this whole selector exists to kill: both pods of a pair
    /// advertise the identical local name, so "the Click" is not a thing you
    /// can ask a scan for. Which one you got was a race.
    #[test]
    fn a_pod_is_picked_by_its_type_byte_not_by_a_name_they_both_share() {
        let minus = Some(zwift::DeviceKind::from_type_byte(0x0B));
        let plus = Some(zwift::DeviceKind::from_type_byte(0x0A));

        let want_minus = PodSelector::Pod(PodId::Minus);
        assert!(want_minus.matches_parts("f4:c4:59:03:a1:8e", minus));
        assert!(!want_minus.matches_parts("c0:4a:0e:f9:a8:78", plus));

        let want_plus = PodSelector::Pod(PodId::Plus);
        assert!(want_plus.matches_parts("c0:4a:0e:f9:a8:78", plus));
        assert!(!want_plus.matches_parts("f4:c4:59:03:a1:8e", minus));
    }

    #[test]
    fn an_address_identifies_a_pod_that_has_stopped_advertising() {
        // Reconnect runs against a pod that may no longer be broadcasting its
        // manufacturer data. Insisting on the type byte here would mean never
        // finding it again.
        let s = PodSelector::Address("F4:C4:59:03:A1:8E".into());
        assert!(s.matches_parts("f4:c4:59:03:a1:8e", None));
        assert!(!s.matches_parts("c0:4a:0e:f9:a8:78", None));
    }

    #[test]
    fn any_takes_a_click_but_not_a_trainer_speaking_the_zwift_protocol() {
        // The D100 advertises Zwift's custom service with no manufacturer data.
        // Connecting to it as a controller would wedge the trainer link.
        let s = PodSelector::Any;
        assert!(s.matches_parts("aa", Some(zwift::DeviceKind::from_type_byte(0x0A))));
        assert!(!s.matches_parts("aa", None));
        assert!(!s.matches_parts("aa", Some(zwift::DeviceKind::Unknown(0x77))));
    }

    #[test]
    fn events_compare_by_value() {
        assert_eq!(
            ClickEvent::Button { button: Button::Plus, pressed: true },
            ClickEvent::Button { button: Button::Plus, pressed: true }
        );
        assert_ne!(
            ClickEvent::Button { button: Button::Plus, pressed: true },
            ClickEvent::Button { button: Button::Plus, pressed: false }
        );
    }
}
