//! Main rendering and event processing for the application.

use std::sync::mpsc;
use std::time::{Duration, Instant};

use crate::config::{Config, Peaks};
use crate::wirehose::{CommandSender, Event as PipewireEvent, StateEvent};

use anyhow::{anyhow, Result};

use ratatui::{
    layout::Flex,
    prelude::{Buffer, Constraint, Direction, Layout, Position, Rect},
    text::{Line, Span},
    widgets::{Clear, StatefulWidget, Widget},
    DefaultTerminal, Frame,
};

use crossterm::event::{
    Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseButton, MouseEvent,
    MouseEventKind,
};

use serde::Deserialize;
use smallvec::{smallvec, SmallVec};

use crate::device_kind::DeviceKind;
use crate::event::Event;
use crate::help::{HelpWidget, HelpWidgetState};
use crate::object_list::{ObjectList, ObjectListWidget};
use crate::view::{self, ListKind, View};
use crate::wirehose::{state::State, ObjectId};

/// A UI action.
///
/// Used internally as the result of input events.
///
/// Also generated by interaction with [`MouseArea`]s.
///
/// The ordering of variants is used in the help screen.
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, PartialOrd)]
pub enum Action {
    Help,
    Exit,
    MoveUp,
    MoveDown,
    ToggleMute,
    SetRelativeVolume(f32),
    SetDefault,
    ActivateDropdown,
    CloseDropdown,
    TabLeft,
    TabRight,
    SelectTab(usize),
    SetAbsoluteVolume(f32),
    #[serde(skip_deserializing)]
    SelectObject(ObjectId),
    #[serde(skip_deserializing)]
    SetTarget(view::Target),
    // This can be used to delete a default keybinding - make it do nothing.
    Nothing,
}

impl std::fmt::Display for Action {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Action::SelectTab(tab) => write!(f, "Select {tab} tab"),
            Action::MoveUp => write!(f, "Move cursor up"),
            Action::MoveDown => write!(f, "Move cursor down"),
            Action::TabLeft => write!(f, "Select previous tab"),
            Action::TabRight => write!(f, "Select next tab"),
            Action::CloseDropdown => write!(f, "Close menu"),
            Action::ActivateDropdown => write!(f, "Open menu"),
            Action::SelectObject(object_id) => {
                write!(f, "Select object {object_id:?}")
            }
            Action::SetTarget(_) => write!(f, "Set target"),
            Action::ToggleMute => write!(f, "Toggle mute"),
            Action::SetAbsoluteVolume(vol) => {
                write!(f, "Set volume to {}%", Self::format_percentage(*vol))
            }
            Action::SetRelativeVolume(vol) => {
                Self::format_relative_volume(f, *vol)
            }
            Action::SetDefault => write!(f, "Set default"),
            Action::Help => write!(f, "Show/hide help"),
            Action::Exit => write!(f, "Exit wiremix"),
            Action::Nothing => write!(f, "Nothing"),
        }
    }
}

impl Action {
    fn format_percentage(vol: f32) -> u16 {
        (vol * 100.0).trunc() as u16
    }

    fn format_relative_volume(
        f: &mut std::fmt::Formatter<'_>,
        vol: f32,
    ) -> std::fmt::Result {
        match vol {
            0.01 => write!(f, "Increment volume"),
            -0.01 => write!(f, "Decrement volume"),
            v if v >= 0.0 => {
                write!(f, "Increase volume by {}%", Self::format_percentage(v))
            }
            v => {
                write!(f, "Decrease volume by {}%", Self::format_percentage(-v))
            }
        }
    }
}

struct Tab {
    title: String,
    list: ObjectList,
}

impl Tab {
    fn new(title: String, list: ObjectList) -> Self {
        Self { title, list }
    }
}

#[derive(
    Deserialize, Default, Debug, Clone, Copy, PartialEq, clap::ValueEnum,
)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(test, derive(strum::EnumIter))]
pub enum TabKind {
    #[default]
    Playback,
    Recording,
    Output,
    Input,
    Configuration,
}

impl TabKind {
    pub fn index(&self) -> usize {
        *self as usize
    }
}

impl std::fmt::Display for TabKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TabKind::Playback => write!(f, "Playback"),
            TabKind::Recording => write!(f, "Recording"),
            TabKind::Output => write!(f, "Output Devices"),
            TabKind::Input => write!(f, "Input Devices"),
            TabKind::Configuration => write!(f, "Configuration"),
        }
    }
}

// Mouse events matching one of the MouseEventKinds within the Rect will
// perform the Actions.
pub type MouseArea =
    (Rect, SmallVec<[MouseEventKind; 4]>, SmallVec<[Action; 4]>);

#[derive(Default, Debug, Clone, Copy)]
pub enum StateDirty {
    #[default]
    Clean,
    PeaksOnly,
    Everything,
}

/// Handles the main UI for the application.
///
/// This runs the main loop to process PipeWire events and terminal input and
/// to render the main tabs of the application.
pub struct App<'a> {
    /// If set, tells the main loop it's time to exit
    exit: bool,
    /// wirehose handle, for sending commands
    wirehose: &'a dyn CommandSender,
    /// [`Event`](`crate::event::Event`) channel
    rx: mpsc::Receiver<Event>,
    /// An error message to return on exit
    error_message: Option<String>,
    /// The main tabs
    tabs: Vec<Tab>,
    /// The index of the currently-visible tab
    current_tab_index: usize,
    /// Areas populated during rendering which define actions corresponding to
    /// mouse activity
    mouse_areas: Vec<MouseArea>,
    /// wirehose has received all initial information
    is_ready: bool,
    /// The current PipeWire state
    state: State,
    /// How dirty is the PipeWire state?
    state_dirty: StateDirty,
    /// A rendering view based on the current PipeWire state
    view: View<'a>,
    /// The application configuration
    config: Config,
    /// The row on which the mouse is being dragged. While the left mouse
    /// button is held down, this is used in place of the real row to allow the
    /// mouse to move on the vertical axis during horizontal dragging.
    drag_row: Option<u16>,
    /// Position in help text (None if not showing help)
    help_position: Option<u16>,
}

macro_rules! current_list {
    ($self:expr) => {
        $self.tabs[$self.current_tab_index].list
    };
}

impl<'a> App<'a> {
    pub fn new(
        wirehose: &'a dyn CommandSender,
        rx: mpsc::Receiver<Event>,
        config: Config,
    ) -> Self {
        let tabs = vec![
            Tab::new(
                TabKind::Playback.to_string(),
                ObjectList::new(ListKind::Node(view::NodeKind::Playback), None),
            ),
            Tab::new(
                TabKind::Recording.to_string(),
                ObjectList::new(
                    ListKind::Node(view::NodeKind::Recording),
                    None,
                ),
            ),
            Tab::new(
                TabKind::Output.to_string(),
                ObjectList::new(
                    ListKind::Node(view::NodeKind::Output),
                    Some(DeviceKind::Sink),
                ),
            ),
            Tab::new(
                TabKind::Input.to_string(),
                ObjectList::new(
                    ListKind::Node(view::NodeKind::Input),
                    Some(DeviceKind::Source),
                ),
            ),
            Tab::new(
                TabKind::Configuration.to_string(),
                ObjectList::new(ListKind::Device, None),
            ),
        ];

        // Update peaks with VU-meter-style ballistics
        let peak_processor = |current_peak, new_peak, rate, samples| {
            // Attack/release time of 300 ms
            let time_constant = 0.3;
            let coef =
                1.0 - (-(samples as f32) / (time_constant * rate as f32)).exp();

            current_peak + (new_peak - current_peak) * coef
        };

        let state = State::default()
            .with_peak_processor(Box::new(peak_processor))
            .with_capture(config.peaks != Peaks::Off);

        App {
            exit: false,
            wirehose,
            rx,
            error_message: None,
            tabs,
            current_tab_index: config.tab.index(),
            mouse_areas: Vec::new(),
            is_ready: false,
            state,
            state_dirty: StateDirty::default(),
            view: View::new(wirehose),
            config,
            drag_row: None,
            help_position: None,
        }
    }

    pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> {
        // Wait until we've received all initial data from PipeWire
        let _ = terminal.draw(|frame| {
            frame.render_widget(Line::from("Initializing..."), frame.area());
        });
        while !self.exit && !self.is_ready {
            let _ = self.handle_events(None);
        }

        let mut pacer = RenderPacer::new(self.config.fps);

        // Did we handle any events and thus need to re-render?
        let mut needs_render = true;

        while !self.exit {
            // Update view if needed
            match self.state_dirty {
                StateDirty::Everything => {
                    self.view = View::from(
                        self.wirehose,
                        &self.state,
                        &self.config.names,
                    );
                }
                StateDirty::PeaksOnly => {
                    self.view.update_peaks(&self.state);
                }
                _ => {}
            }
            self.state_dirty = StateDirty::Clean;

            if needs_render && pacer.is_time_to_render() {
                needs_render = false;

                self.mouse_areas.clear();

                terminal.draw(|frame| {
                    current_list!(self).update(frame.area(), &self.view);

                    self.draw(frame);
                })?;
            }

            needs_render |= self.handle_events(
                // If there's no fps limit, we definitely rendered in this
                // iteration, so needs_render is false, and there is no timeout.
                needs_render.then_some(pacer.duration_until_next_frame()),
            )?;
        }

        self.error_message.map_or(Ok(()), |s| Err(anyhow!(s)))
    }

    fn draw(&mut self, frame: &mut Frame) {
        let widget = AppWidget {
            current_tab_index: self.current_tab_index,
            view: &self.view,
            config: &self.config,
        };
        let mut widget_state = AppWidgetState {
            mouse_areas: &mut self.mouse_areas,
            tabs: &mut self.tabs,
            help_position: &mut self.help_position,
        };

        frame.render_stateful_widget(widget, frame.area(), &mut widget_state);
    }

    fn exit(&mut self, error_message: Option<String>) {
        self.exit = true;
        self.error_message = error_message;
    }

    /// Handle events with optional timeout.
    /// Returns true if events were handled.
    fn handle_events(&mut self, timeout: Option<Duration>) -> Result<bool> {
        let mut were_events_handled = match timeout {
            Some(timeout) => match self.rx.recv_timeout(timeout) {
                Ok(event) => event.handle(self)?,
                Err(mpsc::RecvTimeoutError::Timeout) => return Ok(false),
                Err(e) => return Err(e.into()),
            },
            // Block on the next event.
            None => self.rx.recv()?.handle(self)?,
        };
        // Then handle the rest that are available.
        while let Ok(event) = self.rx.try_recv() {
            were_events_handled |= event.handle(self)?;
        }

        Ok(were_events_handled)
    }
}

struct RenderPacer {
    frame_duration: Duration,
    next_frame_time: Instant,
}

impl RenderPacer {
    fn new(fps: Option<f32>) -> Self {
        let frame_duration = fps.map_or(Default::default(), |fps| {
            Duration::from_secs_f32(1.0 / fps)
        });

        Self {
            frame_duration,
            next_frame_time: Instant::now(),
        }
    }

    fn is_time_to_render(&mut self) -> bool {
        let now = Instant::now();

        if now >= self.next_frame_time {
            if now > self.next_frame_time + self.frame_duration {
                // We're running behind, so reset the frame timing.
                self.next_frame_time = now + self.frame_duration;
            } else {
                self.next_frame_time += self.frame_duration;
            }

            return true;
        }

        false
    }

    fn duration_until_next_frame(&self) -> Duration {
        self.next_frame_time
            .saturating_duration_since(Instant::now())
    }
}

trait Handle {
    /// Handle some kind of event. Returns true if the event was handled which
    /// indicates that the UI needs to be redrawn.
    fn handle(self, app: &mut App) -> Result<bool>;
}

impl Handle for Event {
    fn handle(self, app: &mut App) -> Result<bool> {
        match self {
            Event::Input(event) => event.handle(app),
            Event::Pipewire(event) => event.handle(app),
        }
    }
}

impl Handle for crossterm::event::Event {
    fn handle(self, app: &mut App) -> Result<bool> {
        match self {
            CrosstermEvent::Key(event) => event.handle(app),
            CrosstermEvent::Mouse(event) => event.handle(app),
            CrosstermEvent::Resize(..) => Ok(true),
            _ => Ok(false),
        }
    }
}

impl Handle for KeyEvent {
    fn handle(self, app: &mut App) -> Result<bool> {
        if self.kind != KeyEventKind::Press {
            return Ok(false);
        }

        if let Some(&action) = app.config.keybindings.get(&self) {
            return action.handle(app);
        }

        Ok(false)
    }
}

impl Handle for Action {
    fn handle(self, app: &mut App) -> Result<bool> {
        if let Some(ref mut help_position) = app.help_position {
            match self {
                Action::MoveDown => {
                    *help_position = help_position.saturating_add(1);
                    return Ok(true);
                }
                Action::MoveUp => {
                    *help_position = help_position.saturating_sub(1);
                    return Ok(true);
                }
                Action::ActivateDropdown
                | Action::CloseDropdown
                | Action::Help => {
                    // Close the help menu
                    app.help_position = None;
                    return Ok(true);
                }
                Action::Exit => {
                    app.exit(None);
                    return Ok(true);
                }
                _ => {
                    return Ok(false);
                }
            }
        }

        match self {
            Action::SelectTab(index) => {
                if index < app.tabs.len() {
                    app.current_tab_index = index;
                }
            }
            Action::MoveDown => {
                current_list!(app).down(&app.view);
            }
            Action::MoveUp => {
                current_list!(app).up(&app.view);
            }
            Action::TabLeft => {
                app.current_tab_index = app
                    .current_tab_index
                    .checked_sub(1)
                    .unwrap_or(app.tabs.len() - 1)
            }
            Action::TabRight => {
                app.current_tab_index =
                    (app.current_tab_index + 1) % app.tabs.len()
            }
            Action::CloseDropdown => {
                current_list!(app).dropdown_close();
            }
            Action::ActivateDropdown => {
                current_list!(app).dropdown_activate(&app.view);
            }
            Action::SetTarget(target) => {
                current_list!(app).set_target(&app.view, target);
            }
            Action::SelectObject(object_id) => {
                app.tabs[app.current_tab_index].list.selected = Some(object_id)
            }
            Action::ToggleMute => {
                current_list!(app).toggle_mute(&app.view);
            }
            Action::SetAbsoluteVolume(volume) => {
                let max = app
                    .config
                    .enforce_max_volume
                    .then_some(app.config.max_volume_percent);
                current_list!(app).set_absolute_volume(&app.view, volume, max);
                return Ok(current_list!(app)
                    .set_absolute_volume(&app.view, volume, max));
            }
            Action::SetRelativeVolume(volume) => {
                // Relative decreases have no maximum.
                let max = (volume > 0.0 && app.config.enforce_max_volume)
                    .then_some(app.config.max_volume_percent);
                return Ok(current_list!(app)
                    .set_relative_volume(&app.view, volume, max));
            }
            Action::SetDefault => {
                current_list!(app).set_default(&app.view);
            }
            Action::Exit => {
                app.exit(None);
            }
            Action::Nothing => {
                // Did nothing
                return Ok(false);
            }
            Action::Help => {
                // Activate the help menu
                app.help_position = Some(0);
            }
        }

        Ok(true)
    }
}

impl Handle for MouseEvent {
    fn handle(self, app: &mut App) -> Result<bool> {
        match self.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                app.drag_row = Some(self.row)
            }
            MouseEventKind::Up(MouseButton::Left) => app.drag_row = None,
            _ => {}
        }

        let actions = app
            .mouse_areas
            .iter()
            .rev()
            .find(|(rect, kinds, _)| {
                rect.contains(Position {
                    x: self.column,
                    y: app.drag_row.unwrap_or(self.row),
                }) && kinds.contains(&self.kind)
            })
            .map(|(_, _, action)| action.clone())
            .into_iter()
            .flatten();

        let mut handled_action = false;
        for action in actions {
            handled_action = true;
            let _ = action.handle(app);
        }

        Ok(handled_action)
    }
}

impl Handle for PipewireEvent {
    fn handle(self, app: &mut App) -> Result<bool> {
        match self {
            PipewireEvent::Ready => {
                app.is_ready = true;
                Ok(true)
            }
            PipewireEvent::Error(message) => {
                match message {
                    // These happen when objects are removed while wirehose is
                    // still in the process of setting up listeners
                    error if error.starts_with("no global ") => {}
                    error if error.starts_with("unknown resource ") => {}
                    // I see this one when disconnecting a Bluetooth sink
                    error if error == "Received error event" => {}
                    // Not sure where this originates
                    error if error == "Error: Buffer allocation failed" => {}
                    _ => app.exit(Some(message)),
                }
                Ok(false) // This makes sense for now
            }
            PipewireEvent::State(event) => event.handle(app),
        }
    }
}

impl Handle for StateEvent {
    fn handle(self, app: &mut App) -> Result<bool> {
        // Peaks updates are very frequent and easy to merge, so track if those
        // are the only updates done since the state was last Clean.
        match (app.state_dirty, &self) {
            (
                StateDirty::Clean | StateDirty::PeaksOnly,
                StateEvent::NodePeaks { .. },
            ) => {
                app.state_dirty = StateDirty::PeaksOnly;
            }
            _ => {
                app.state_dirty = StateDirty::Everything;
            }
        }

        app.state.update(app.wirehose, self);

        Ok(true)
    }
}

impl Handle for String {
    fn handle(self, app: &mut App) -> Result<bool> {
        // Handle errors
        match self {
            // These happen when objects are removed while wirehose is still in
            // the process of setting up listeners
            error if error.starts_with("no global ") => {}
            error if error.starts_with("unknown resource ") => {}
            // I see this one when disconnecting a Bluetooth sink
            error if error == "Received error event" => {}
            // Not sure where this originates
            error if error == "Error: Buffer allocation failed" => {}
            _ => app.exit(Some(self)),
        }
        Ok(false) // This makes sense for now
    }
}

pub struct AppWidget<'a, 'b> {
    current_tab_index: usize,
    view: &'a View<'b>,
    config: &'a Config,
}

pub struct AppWidgetState<'a> {
    mouse_areas: &'a mut Vec<MouseArea>,
    tabs: &'a mut Vec<Tab>,
    help_position: &'a mut Option<u16>,
}

impl<'a> StatefulWidget for AppWidget<'a, '_> {
    type State = AppWidgetState<'a>;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let layout = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(0),    // list_area
                Constraint::Length(1), // menu_area
            ])
            .split(area);
        let list_area = layout[0];
        let menu_area = layout[1];

        let constraints: Vec<_> = state
            .tabs
            .iter()
            .map(|tab| Constraint::Length(tab.title.len() as u16 + 2))
            .collect();

        let menu_areas = Layout::default()
            .direction(Direction::Horizontal)
            .constraints(constraints)
            .split(menu_area);

        for (i, tab) in state.tabs.iter().enumerate() {
            let title_line = if i == self.current_tab_index {
                Line::from(vec![
                    Span::styled(
                        &self.config.char_set.tab_marker_left,
                        self.config.theme.tab_marker,
                    ),
                    Span::styled(&tab.title, self.config.theme.tab_selected),
                    Span::styled(
                        &self.config.char_set.tab_marker_right,
                        self.config.theme.tab_marker,
                    ),
                ])
            } else {
                Line::from(Span::styled(
                    format!(" {} ", tab.title),
                    self.config.theme.tab,
                ))
            };
            title_line.render(menu_areas[i], buf);

            state.mouse_areas.push((
                menu_areas[i],
                smallvec![MouseEventKind::Down(MouseButton::Left)],
                smallvec![Action::SelectTab(i)],
            ));
        }

        let mut widget = ObjectListWidget {
            object_list: &mut state.tabs[self.current_tab_index].list,
            view: self.view,
            config: self.config,
        };
        widget.render(list_area, buf, state.mouse_areas);

        // Render the help menu if it's open
        if let Some(ref mut help_position) = state.help_position {
            // Ignore any mouse actions on the lower area
            state.mouse_areas.clear();
            // Close the help menu if clicked anywhere outside
            state.mouse_areas.push((
                area,
                smallvec![MouseEventKind::Down(MouseButton::Left)],
                smallvec![Action::Help],
            ));

            let width: u16 = self
                .config
                .help
                .widths
                .iter()
                .fold(HelpWidget::base_width(), |acc, &x| acc.saturating_add(x))
                .try_into()
                .unwrap_or(u16::MAX);
            let [help_area] = Layout::horizontal([Constraint::Max(width)])
                .flex(Flex::Center)
                .areas(list_area);
            // Fit to the number of help text rows, or 80% of the area if they
            // don't all fit
            let height: u16 = self
                .config
                .help
                .rows
                .len()
                .saturating_add(2)
                .try_into()
                .unwrap_or(u16::MAX)
                .min(((help_area.height as f32) * 0.90) as u16);
            let [help_area] = Layout::vertical([Constraint::Length(height)])
                .flex(Flex::Center)
                .areas(help_area);

            Clear.render(help_area, buf);

            HelpWidget {
                config: self.config,
            }
            .render(
                help_area,
                buf,
                &mut HelpWidgetState {
                    mouse_areas: state.mouse_areas,
                    help_position,
                },
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mock;
    use crate::wirehose::PropertyStore;
    use strum::IntoEnumIterator;

    fn fixture(wirehose: &mock::WirehoseHandle) -> App<'_> {
        let (_, event_rx) = mpsc::channel();

        let config = Config {
            remote: None,
            fps: None,
            mouse: false,
            peaks: Default::default(),
            char_set: Default::default(),
            theme: Default::default(),
            max_volume_percent: Default::default(),
            enforce_max_volume: Default::default(),
            keybindings: Default::default(),
            help: Default::default(),
            names: Default::default(),
            tab: Default::default(),
        };

        let mut app = App::new(wirehose, event_rx, config);

        // Create a node for testing
        let object_id = ObjectId::from_raw_id(0);
        let mut props = PropertyStore::default();
        props.set_node_description(String::from("Test node"));
        props.set_media_class(String::from("Stream/Output/Audio"));
        props.set_media_name(String::from("Media name"));
        props.set_node_name(String::from("Node name"));
        props.set_object_serial(0);
        let props = props;
        let events = vec![
            StateEvent::NodeProperties { object_id, props },
            StateEvent::NodePeaks {
                object_id,
                peaks: vec![0.0, 0.0],
                samples: 512,
            },
            StateEvent::NodePositions {
                object_id,
                positions: vec![0, 1],
            },
            StateEvent::NodeRate {
                object_id,
                rate: 44100,
            },
            StateEvent::NodeVolumes {
                object_id,
                volumes: vec![1.0, 1.0],
            },
            StateEvent::NodeMute {
                object_id,
                mute: false,
            },
        ];
        for event in events {
            assert!(event.handle(&mut app).unwrap());
        }
        app.view = View::from(wirehose, &app.state, &app.config.names);

        // Select the node
        assert!(Action::SelectObject(object_id).handle(&mut app).unwrap());

        app
    }

    #[test]
    fn select_tab_bounds() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);

        let _ = Action::SelectTab(app.tabs.len()).handle(&mut app);
        assert!(app.current_tab_index < app.tabs.len());
    }

    #[test]
    fn key_modifiers() {
        use crossterm::event::{KeyCode, KeyModifiers};
        use std::collections::HashMap;
        let wirehose = mock::WirehoseHandle::default();
        let (_, event_rx) = mpsc::channel();

        let x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
        let ctrl_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);

        let keybindings = HashMap::from([
            (x, Action::SelectTab(2)),
            (ctrl_x, Action::SelectTab(4)),
        ]);
        let config = Config {
            remote: None,
            fps: None,
            mouse: false,
            peaks: Default::default(),
            char_set: Default::default(),
            theme: Default::default(),
            max_volume_percent: Default::default(),
            enforce_max_volume: Default::default(),
            keybindings,
            help: Default::default(),
            names: Default::default(),
            tab: Default::default(),
        };
        let mut app = App::new(&wirehose, event_rx, config);

        let _ = x.handle(&mut app);
        assert_eq!(app.current_tab_index, 2);
        let _ = ctrl_x.handle(&mut app);
        assert_eq!(app.current_tab_index, 4);
        let _ = x.handle(&mut app);
        assert_eq!(app.current_tab_index, 2);
    }

    /// Ensure that the tabs enum variants are in the same order as the app's
    /// tab Vec. Making the initial tab configurable depends on this property
    /// because it uses the position of the enum variants to derivce an index
    /// into the tab Vec.
    #[test]
    fn tab_enum_order_matches_tab_vec() {
        let wirehose = mock::WirehoseHandle::default();
        let app = fixture(&wirehose);

        assert_eq!(TabKind::iter().count(), app.tabs.len());

        for (tab, Tab { title, .. }) in TabKind::iter().zip(app.tabs.iter()) {
            match tab {
                TabKind::Playback => assert_eq!(title, "Playback"),
                TabKind::Recording => assert_eq!(title, "Recording"),
                TabKind::Output => assert_eq!(title, "Output Devices"),
                TabKind::Input => assert_eq!(title, "Input Devices"),
                TabKind::Configuration => assert_eq!(title, "Configuration"),
            }
        }
    }

    #[test]
    fn help_underflow() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);

        assert!(Action::Help.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));

        assert!(Action::MoveUp.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));
    }

    #[test]
    fn help_up_down() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);

        assert!(Action::Help.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));

        assert!(Action::MoveDown.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(1));

        assert!(Action::MoveUp.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));
    }

    #[test]
    fn help_toggle() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);

        assert!(Action::Help.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));

        assert!(Action::Help.handle(&mut app).unwrap());
        assert!(app.help_position.is_none());
    }

    #[test]
    fn help_ignore_other_actions() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);

        assert!(Action::SetDefault.handle(&mut app).unwrap());

        assert!(Action::Help.handle(&mut app).unwrap());
        assert_eq!(app.help_position, Some(0));

        assert!(!Action::SetDefault.handle(&mut app).unwrap());
    }

    #[test]
    fn volume_limit_not_enforcing() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);
        app.config.max_volume_percent = 100.0;
        app.config.enforce_max_volume = false;

        // The current volume is 100%

        // 110% is allowed
        assert!(Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());

        // 90% is allowed
        assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
    }

    #[test]
    fn volume_limit_at_max() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);
        app.config.max_volume_percent = 100.0;
        app.config.enforce_max_volume = true;

        // The current volume is 100%

        // 110% is not allowed
        assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
        assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());

        // 90% is allowed
        assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());

        // 100% is allowed
        assert!(Action::SetAbsoluteVolume(1.00).handle(&mut app).unwrap());
    }

    #[test]
    fn volume_limit_above_max() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);
        app.config.max_volume_percent = 95.0;
        app.config.enforce_max_volume = true;

        // The current volume is 100.0

        // 110% is not allowed
        assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
        assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());

        // 90% is allowed
        assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());

        // 95% is allowed
        assert!(Action::SetAbsoluteVolume(0.95).handle(&mut app).unwrap());
    }

    #[test]
    fn volume_limit_below_max() {
        let wirehose = mock::WirehoseHandle::default();
        let mut app = fixture(&wirehose);
        app.config.max_volume_percent = 105.0;
        app.config.enforce_max_volume = true;

        // The current volume is 100.0

        // 110% is not allowed
        assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap());
        assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap());

        // 105% is allowed
        assert!(Action::SetRelativeVolume(0.05).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(1.05).handle(&mut app).unwrap());

        // 90% is allowed
        assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap());
        assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap());
    }
}
