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

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

use crate::config::{Config, Peaks};
use crate::wirehose::state::CaptureEligibility;
use crate::wirehose::{
    media_class, CommandSender, Event as PipewireEvent, PeakProcessor,
    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]>);

/// 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,
    /// Does the view need to be updated with the current PipeWire state?
    state_dirty: bool,
    /// 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>,
    /// Object IDs that are currently visible (including any display
    /// dependencies)
    visible_objects: HashSet<ObjectId>,
    /// Callback for peak ballistics.
    peak_processor: Arc<dyn PeakProcessor>,
    /// Objects eligible for capture.
    capturable_objects: HashSet<ObjectId>,
    /// Objects currently being captured.
    capturing_objects: HashSet<ObjectId>,
}

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 = |new_peak, current_peak, samples, rate| {
            // 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();

        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: false,
            view: View::new(wirehose),
            config,
            drag_row: None,
            help_position: None,
            visible_objects: HashSet::new(),
            peak_processor: Arc::new(peak_processor),
            capturable_objects: HashSet::new(),
            capturing_objects: HashSet::new(),
        }
    }

    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
            if self.state_dirty {
                self.view = View::from(
                    self.wirehose,
                    &self.state,
                    &self.config.names,
                    &self.config.filters,
                );
            }
            self.state_dirty = false;

            let frame = terminal.get_frame();
            current_list!(self).update(frame.area(), &self.view);

            let new_visible_objects =
                current_list!(self).visible_objects(&frame.area(), &self.view);
            if new_visible_objects != self.visible_objects {
                needs_render = true;
                self.visible_objects = new_visible_objects;
                self.update_capturing();
            }

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

                self.mouse_areas.clear();

                terminal.draw(|frame| {
                    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;
    }

    fn stop_capture(&mut self, object_id: ObjectId) {
        self.capturing_objects.remove(&object_id);
        self.wirehose.node_capture_stop(object_id);
    }

    fn start_capture(&mut self, object_id: ObjectId) {
        if self.config.lazy_capture
            && !self.visible_objects.contains(&object_id)
        {
            return;
        }

        let Some(node) = self.state.nodes.get(&object_id) else {
            return;
        };

        if self
            .config
            .filters
            .iter()
            .any(|condition| condition.matches(&self.state, node))
        {
            return;
        }

        let Some(object_serial) = node.props.object_serial() else {
            return;
        };

        let capture_sink =
            node.props
                .media_class()
                .as_ref()
                .is_some_and(|media_class| {
                    media_class::is_sink(media_class)
                        || media_class::is_source(media_class)
                });

        self.capturing_objects.insert(object_id);
        self.wirehose.node_capture_start(
            node.object_id,
            *object_serial,
            capture_sink,
            Arc::clone(&node.peaks_dirty),
            Some(Arc::clone(&self.peak_processor)),
        );
    }

    /// If lazy capture is enabled, make sure visible nodes are captured and
    /// non-visible nodes are not.
    fn update_capturing(&mut self) {
        if !self.config.lazy_capture {
            return;
        }

        let need_to_start: Vec<_> = self
            .visible_objects
            .intersection(&self.capturable_objects)
            .copied()
            .collect();
        for object_id in need_to_start {
            if !self.capturing_objects.contains(&object_id) {
                self.start_capture(object_id);
            }
        }

        let need_to_stop: Vec<_> = self
            .capturing_objects
            .difference(&self.visible_objects)
            .copied()
            .collect();
        for object_id in need_to_stop {
            self.stop_capture(object_id);
        }
    }

    fn set_capture_eligibility(
        &mut self,
        capture_eligibility: CaptureEligibility,
    ) {
        if self.config.peaks == Peaks::Off {
            return;
        }

        match capture_eligibility {
            CaptureEligibility::Eligible(object_id) => {
                self.capturable_objects.insert(object_id);
                if !self.capturing_objects.contains(&object_id) {
                    self.start_capture(object_id);
                }
            }
            CaptureEligibility::NeedsRestart(object_id) => {
                self.capturable_objects.insert(object_id);
                self.start_capture(object_id);
            }
            CaptureEligibility::Ineligible(object_id) => {
                self.capturable_objects.remove(&object_id);
                self.stop_capture(object_id);
            }
        }
    }

    /// 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) => message.handle(app),
            PipewireEvent::State(event) => event.handle(app),
        }
    }
}

impl Handle for StateEvent {
    fn handle(self, app: &mut App) -> Result<bool> {
        // Peaks are shared between state and view, so no update is necessary.
        app.state_dirty = !matches!(&self, StateEvent::NodePeaksDirty { .. });

        // Determine if any on-screen objects are affected by this event.
        // Generally we only need to check the event's object ID, but there are
        // some special cases.
        let visible_affected = self
            .affected_objects(&app.state)
            .iter()
            .any(|object| app.visible_objects.contains(object));

        for capture_eligibility in app.state.update(self) {
            app.set_capture_eligibility(capture_eligibility);
        }

        Ok(visible_affected)
    }
}

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" => {}
            // This occurs sometimes when Bluetooth devices disconnect
            error if 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 std::cell::RefCell;
    use std::collections::VecDeque;
    use std::sync::Arc;
    use strum::IntoEnumIterator;

    fn fixture<'a>(wirehose: &'a mock::WirehoseHandle<'a>) -> App<'a> {
        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(),
            lazy_capture: Default::default(),
            filters: 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::NodePositions {
                object_id,
                positions: vec![0, 1],
            },
            StateEvent::NodeStreamStarted {
                object_id,
                rate: 44100,
                peaks: Arc::new([0.0.into(), 0.0.into()]),
            },
            StateEvent::NodeVolumes {
                object_id,
                volumes: vec![1.0, 1.0],
            },
            StateEvent::NodeMute {
                object_id,
                mute: false,
            },
        ];
        for event in events {
            event.handle(&mut app).unwrap();
        }
        app.view =
            View::from(wirehose, &app.state, &app.config.names, &Vec::new());

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

        app
    }

    fn add_capturable_node(app: &mut App<'_>, object_id: ObjectId) {
        let mut props = PropertyStore::default();
        props.set_node_description(String::from("Test node"));
        props.set_media_class(String::from("Stream/Output/Audio"));
        props.set_object_serial(u32::from(object_id) as u64);

        StateEvent::NodeProperties { object_id, props }
            .handle(app)
            .unwrap();
    }

    #[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(),
            lazy_capture: Default::default(),
            filters: 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());
    }

    #[test]
    fn update_capturing_noop_when_lazy_disabled() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = false");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);

        // Mark as capturable and visible
        app.capturable_objects.insert(id);
        app.visible_objects.insert(id);
        commands.borrow_mut().clear();

        // update_capturing should do nothing when lazy_capture is false
        app.update_capturing();

        assert!(commands.borrow().is_empty());
    }

    #[test]
    fn update_capturing_starts_visible_capturable() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = true");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);

        // Mark as capturable and visible, but not yet capturing
        app.capturable_objects.insert(id);
        app.visible_objects.insert(id);
        commands.borrow_mut().clear();

        app.update_capturing();

        assert_eq!(
            commands.borrow_mut().pop_front(),
            Some(mock::MockCommand::NodeCaptureStart(id))
        );
        assert!(app.capturing_objects.contains(&id));
    }

    #[test]
    fn update_capturing_stops_invisible() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = true");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);

        // Node is capturing but no longer visible
        app.capturable_objects.insert(id);
        app.capturing_objects.insert(id);
        // visible_objects does NOT contain id
        commands.borrow_mut().clear();

        app.update_capturing();

        assert_eq!(
            commands.borrow_mut().pop_front(),
            Some(mock::MockCommand::NodeCaptureStop(id))
        );
        assert!(!app.capturing_objects.contains(&id));
    }

    #[test]
    fn set_capture_eligibility_eligible_starts_capture() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = false");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);
        // Reset state: node exists but isn't capturing yet
        app.capturing_objects.clear();
        app.capturable_objects.clear();
        commands.borrow_mut().clear();

        app.set_capture_eligibility(CaptureEligibility::Eligible(id));

        assert!(app.capturable_objects.contains(&id));
        assert_eq!(
            commands.borrow_mut().pop_front(),
            Some(mock::MockCommand::NodeCaptureStart(id))
        );
    }

    #[test]
    fn set_capture_eligibility_eligible_skips_invisible_when_lazy() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = true");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);
        // id is NOT in visible_objects
        commands.borrow_mut().clear();

        app.set_capture_eligibility(CaptureEligibility::Eligible(id));

        // Should be capturable but not actually capturing
        assert!(app.capturable_objects.contains(&id));
        assert!(commands.borrow().is_empty());
        assert!(!app.capturing_objects.contains(&id));
    }

    #[test]
    fn set_capture_eligibility_ineligible_stops_capture() {
        let commands = RefCell::new(VecDeque::new());
        let wirehose = mock::WirehoseHandle::with_commands(&commands);
        let (_, event_rx) = mpsc::channel();
        let config = Config::from_toml_str("lazy_capture = false");
        let mut app = App::new(&wirehose, event_rx, config);

        let id = ObjectId::from_raw_id(1);
        add_capturable_node(&mut app, id);

        // Start capturing first
        app.capturable_objects.insert(id);
        app.capturing_objects.insert(id);
        commands.borrow_mut().clear();

        app.set_capture_eligibility(CaptureEligibility::Ineligible(id));

        assert!(!app.capturable_objects.contains(&id));
        assert!(!app.capturing_objects.contains(&id));
        assert_eq!(
            commands.borrow_mut().pop_front(),
            Some(mock::MockCommand::NodeCaptureStop(id))
        );
    }
}
