mod album_list;
mod album_tracks;
mod analysis;
mod announcement_prompt;
mod artist;
mod artists;
mod common_key_events;
#[cfg(feature = "cover-art")]
mod cover_art_view;
mod create_playlist;
mod dialog;
mod discover;
mod empty;
mod episode_table;
mod error_screen;
mod friends;
mod help_menu;
mod home;
mod input;
mod library;
mod local_browser;
mod lyrics_view;
mod miniplayer;
mod mouse;
mod party;
mod playbar;
mod playlist;
mod plugin_screen;
mod podcasts;
mod queue_menu;
mod recap_prompt;
mod recently_played;
pub mod resize;
mod search_results;
mod select_device;
mod settings;
mod sort_menu;
mod stats;
mod track_table;

use crate::core::app::{
  ActiveBlock, App, ArtistBlock, InputContext, RouteId, SearchResultBlock, SourceFocus,
};
use crate::core::source::Source;
use crate::infra::network::IoEvent;
use crate::tui::event::Key;
use rspotify::model::{context::CurrentPlaybackContext, PlayableItem};
use rspotify::prelude::Id;

pub use input::handler as input_handler;
pub use mouse::handler as mouse_handler;

#[cfg(target_os = "macos")]
fn key_matches_open_settings_binding(key: Key, binding: Key) -> bool {
  key == binding
    || (binding == Key::Alt(',') && key == Key::Char('≤'))
    || (binding == Key::Ctrl(',')
      && (key == Key::Ctrl('l')
        || key == Key::Ctrl('L')
        || key == Key::Ctrl('4')
        || key == Key::Ctrl('<')))
}

#[cfg(not(target_os = "macos"))]
fn key_matches_open_settings_binding(key: Key, binding: Key) -> bool {
  key == binding
}

fn open_settings(app: &mut App) {
  app.load_settings_for_category();
  app.push_navigation_stack(RouteId::Settings, ActiveBlock::Settings);
}

/// The `radio:` URI scheme shared across handlers. The canonical parser in
/// `crate::infra::radio` lives behind the `internet-radio` feature, so the
/// favoriting handlers (which persist config even in slim builds) keep this one
/// ungated copy rather than duplicating it per file.
pub(super) const RADIO_URI_PREFIX: &str = "radio:";

/// Strip the `radio:` prefix off a station URI and return the trimmed,
/// non-empty stream URL.
pub(super) fn radio_stream_url(uri: &str) -> Option<&str> {
  uri
    .strip_prefix(RADIO_URI_PREFIX)
    .map(str::trim)
    .filter(|url| !url.is_empty())
}

fn should_route_friends_before_globals(key: Key, app: &App) -> bool {
  if app.get_current_route().active_block != ActiveBlock::Friends {
    return false;
  }

  app.friend_add_dialog_visible
    || !app.friend_search_input.is_empty()
    || matches!(
      key,
      Key::Char('a') | Key::Char('c') | Key::Char('u') | Key::Tab
    )
}

pub fn handle_app(key: Key, app: &mut App) {
  // Plugin popup is a modal: intercept all keys before anything else.
  if app.plugin_popup.is_some() {
    match key {
      Key::Esc | Key::Char('q') => {
        app.plugin_popup = None;
        app.plugin_popup_scroll = 0;
      }
      k if common_key_events::up_event(k, &app.user_config.keys) => {
        app.plugin_popup_scroll = app.plugin_popup_scroll.saturating_sub(1);
      }
      k if common_key_events::down_event(k, &app.user_config.keys) => {
        let max_scroll = app
          .plugin_popup
          .as_ref()
          .map(|p| p.lines.len().saturating_sub(1) as u16)
          .unwrap_or(0);
        app.plugin_popup_scroll = app.plugin_popup_scroll.saturating_add(1).min(max_scroll);
      }
      _ => {} // swallow all other keys
    }
    return;
  }

  if app.get_current_route().active_block == ActiveBlock::Settings
    && (app.settings_unsaved_prompt_visible || app.settings_edit_mode)
  {
    settings::handler(key, app);
    return;
  }

  // When Party popup is open, all keys go to the party handler first (so 'c' and 'l' aren't stolen by global bindings).
  if app.get_current_route().active_block == ActiveBlock::Party {
    handle_block_events(key, app);
    return;
  }

  // When Create Playlist form is open, all keys go directly to the form handler
  // (so typed characters aren't stolen by global bindings like 'd', space, etc.)
  if app.get_current_route().active_block == ActiveBlock::CreatePlaylistForm {
    handle_block_events(key, app);
    return;
  }

  // Friends has a few local keys that conflict with globals, plus inline input modes
  // that need first chance to consume typed characters.
  if should_route_friends_before_globals(key, app) {
    handle_block_events(key, app);
    return;
  }

  if app.maybe_activate_open_settings_fallback(key) {
    open_settings(app);
    if app.pending_keybinding_persist.is_some() {
      app.push_navigation_stack(
        RouteId::Dialog,
        ActiveBlock::Dialog(crate::core::app::DialogContext::PersistKeybindingFallback),
      );
    }
    return;
  }

  let effective_open_settings = app.effective_open_settings_key();
  if key_matches_open_settings_binding(key, app.user_config.keys.open_settings)
    || key_matches_open_settings_binding(key, effective_open_settings)
  {
    open_settings(app);
    return;
  }

  // First handle any global event and then move to block event
  match key {
    Key::Esc => {
      if app.get_current_route().active_block == ActiveBlock::Settings {
        settings::handler(key, app);
      } else {
        handle_escape(app);
      }
    }
    _ if key == app.user_config.keys.jump_to_album => {
      handle_jump_to_album(app);
    }
    _ if key == app.user_config.keys.jump_to_artist_album => {
      handle_jump_to_artist_album(app);
    }
    _ if key == app.user_config.keys.jump_to_context => {
      handle_jump_to_context(app);
    }
    _ if key == app.user_config.keys.manage_devices => {
      // Open the combined Source & Device picker immediately so it is reachable
      // even offline / when Local is the active source. Initial focus is the
      // Source panel under Local (devices are Spotify Connect only), else Devices.
      app.source_list_index = Source::ALL
        .iter()
        .position(|s| *s == app.active_source)
        .unwrap_or(0);
      app.source_device_focus = if app.active_source == Source::Spotify {
        SourceFocus::Devices
      } else {
        SourceFocus::Source
      };
      app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice);
      // Only Spotify needs a `me/player/devices` fetch; skip it under Local so an
      // unauthenticated/offline session doesn't surface a spurious error.
      if app.active_source == Source::Spotify {
        app.dispatch(IoEvent::GetDevices);
      }
    }
    _ if key == app.user_config.keys.decrease_volume => {
      app.decrease_volume();
    }
    _ if key == app.user_config.keys.increase_volume => {
      app.increase_volume();
    }
    // Press space to toggle playback
    _ if key == app.user_config.keys.toggle_playback => {
      app.toggle_playback();
    }
    _ if key == app.user_config.keys.seek_backwards => {
      app.seek_backwards();
    }
    _ if key == app.user_config.keys.seek_forwards => {
      app.seek_forwards();
    }
    _ if key == app.user_config.keys.next_track => {
      app.next_track();
    }
    _ if key == app.user_config.keys.previous_track => {
      app.previous_track();
    }
    _ if key == app.user_config.keys.force_previous_track => {
      app.force_previous_track();
    }
    _ if key == app.user_config.keys.help => {
      app.push_navigation_stack(RouteId::HelpMenu, ActiveBlock::HelpMenu);
    }
    _ if key == app.user_config.keys.show_queue => {
      app.dispatch(IoEvent::GetQueue);
      app.push_navigation_stack(RouteId::Queue, ActiveBlock::Queue);
    }

    _ if key == app.user_config.keys.shuffle => {
      app.shuffle();
    }
    _ if key == app.user_config.keys.repeat => {
      app.repeat();
    }
    Key::Ctrl('f')
      if app.get_current_route().active_block == ActiveBlock::TrackTable
        && app.is_playlist_track_table_context() =>
    {
      app.input.clear();
      app.input_idx = 0;
      app.input_cursor_position = 0;
      app.input_context = InputContext::PlaylistTrackSearch;
      app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));
    }
    _ if key == app.user_config.keys.search => {
      // Search is gated on the active source's capability (no `Searcher` impl
      // for Local Files), so it's a no-op with a hint there.
      if app.active_source.supports_search() {
        app.input_context = InputContext::GlobalSearch;
        app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));
      } else {
        app.set_status_message(
          format!("Search isn't available for {}", app.active_source.label()),
          4,
        );
      }
    }
    _ if key == app.user_config.keys.copy_song_url => {
      if app.active_source.supports_library() {
        app.copy_song_url();
      } else {
        app.set_status_message(
          format!("Copy URL isn't available for {}", app.active_source.label()),
          4,
        );
      }
    }
    _ if key == app.user_config.keys.copy_album_url => {
      app.copy_album_url();
    }
    _ if key == app.user_config.keys.audio_analysis => {
      app.get_audio_analysis();
    }
    _ if key == app.user_config.keys.lyrics_view => {
      app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView);
    }
    _ if key == app.user_config.keys.miniplayer_view => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        toggle_miniplayer(app);
      }
    }
    #[cfg(feature = "cover-art")]
    _ if key == app.user_config.keys.cover_art_view => {
      app.push_navigation_stack(RouteId::CoverArtView, ActiveBlock::CoverArtView);
    }
    _ if key == app.user_config.keys.listening_party => {
      app.push_navigation_stack(RouteId::Party, ActiveBlock::Party);
    }
    _ if key == app.user_config.keys.like_track => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else if app.active_source == Source::Radio {
        if app.get_current_route().active_block == ActiveBlock::SearchResultBlock {
          handle_block_events(key, app);
        } else if !search_results::favorite_current_radio_station(app) {
          app.set_status_message("No radio station selected or playing".to_string(), 4);
        }
      } else if app.active_source.supports_like() {
        playbar::toggle_like_currently_playing_item(app);
      } else {
        app.set_status_message(
          format!("Like isn't available for {}", app.active_source.label()),
          4,
        );
      }
    }
    #[cfg(feature = "scripting")]
    _ if app.user_config.plugin_command_keys.contains_key(&key) => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else if let Some(name) = app.user_config.plugin_command_keys.get(&key).cloned() {
        app.queue_plugin_command(name);
      }
    }
    _ if key == app.user_config.keys.generate_recap => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        let period = recap_period_for_current_route(app);
        app.dispatch(IoEvent::GenerateRecap(period));
      }
    }
    // Resize sidebar: { decreases, } increases width
    Key::Char('{') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        resize::decrease_sidebar_width(app);
      }
    }
    Key::Char('}') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        resize::increase_sidebar_width(app);
      }
    }
    // Resize playbar or library/playlist split depending on hovered pane:
    // ( decreases height, ) increases height
    Key::Char('(') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        match app.get_current_route().hovered_block {
          ActiveBlock::Library | ActiveBlock::MyPlaylists => resize::decrease_library_height(app),
          _ => resize::decrease_playbar_height(app),
        }
      }
    }
    Key::Char(')') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        match app.get_current_route().hovered_block {
          ActiveBlock::Library | ActiveBlock::MyPlaylists => resize::increase_library_height(app),
          _ => resize::increase_playbar_height(app),
        }
      }
    }
    // Reset all pane sizes to defaults
    Key::Char('|') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        resize::reset_layout(app);
      }
    }
    Key::Char('W') => {
      if is_input_mode(app) {
        handle_block_events(key, app);
      } else {
        playbar::add_currently_playing_track_to_playlist(app);
      }
    }
    _ => handle_block_events(key, app),
  }
}

fn is_input_mode(app: &App) -> bool {
  matches!(
    app.get_current_route().active_block,
    ActiveBlock::Input
      | ActiveBlock::Dialog(_)
      | ActiveBlock::AnnouncementPrompt
      | ActiveBlock::ExitPrompt
      | ActiveBlock::CreatePlaylistForm
      | ActiveBlock::RecapPrompt
  )
}

// Handle event for the current active block
fn handle_block_events(key: Key, app: &mut App) {
  let current_route = app.get_current_route();
  match current_route.active_block {
    ActiveBlock::Analysis => {
      analysis::handler(key, app);
    }
    ActiveBlock::ArtistBlock => {
      artist::handler(key, app);
    }
    ActiveBlock::Input => {
      input::handler(key, app);
    }
    ActiveBlock::MyPlaylists => {
      playlist::handler(key, app);
    }
    ActiveBlock::TrackTable => {
      track_table::handler(key, app);
    }
    ActiveBlock::EpisodeTable => {
      episode_table::handler(key, app);
    }
    ActiveBlock::HelpMenu => {
      help_menu::handler(key, app);
    }
    ActiveBlock::Error => {
      error_screen::handler(key, app);
    }
    ActiveBlock::SelectDevice => {
      select_device::handler(key, app);
    }
    ActiveBlock::SearchResultBlock => {
      search_results::handler(key, app);
    }
    ActiveBlock::Home => {
      home::handler(key, app);
    }
    ActiveBlock::AlbumList => {
      album_list::handler(key, app);
    }
    ActiveBlock::AlbumTracks => {
      album_tracks::handler(key, app);
    }
    ActiveBlock::Library => {
      library::handler(key, app);
    }
    ActiveBlock::Empty => {
      empty::handler(key, app);
    }
    ActiveBlock::RecentlyPlayed => {
      recently_played::handler(key, app);
    }
    ActiveBlock::Artists => {
      artists::handler(key, app);
    }
    ActiveBlock::LocalBrowser => {
      local_browser::handler(key, app);
    }
    ActiveBlock::Discover => {
      discover::handler(key, app);
    }
    ActiveBlock::Podcasts => {
      podcasts::handler(key, app);
    }
    ActiveBlock::PlayBar => {
      playbar::handler(key, app);
    }
    ActiveBlock::LyricsView => {
      lyrics_view::handler(key, app);
    }
    ActiveBlock::MiniPlayer => {
      miniplayer::handler(key, app);
    }
    ActiveBlock::CoverArtView => {
      #[cfg(feature = "cover-art")]
      cover_art_view::handler(key, app);
    }
    ActiveBlock::Dialog(_) => {
      dialog::handler(key, app);
    }

    ActiveBlock::AnnouncementPrompt => {
      announcement_prompt::handler(key, app);
    }
    ActiveBlock::ExitPrompt => {}
    ActiveBlock::Settings => {
      settings::handler(key, app);
    }
    ActiveBlock::SortMenu => {
      sort_menu::handler(key, app);
    }
    ActiveBlock::Queue => {
      queue_menu::handler(key, app);
    }
    ActiveBlock::Party => {
      party::handler(key, app);
    }
    ActiveBlock::CreatePlaylistForm => {
      create_playlist::handler(key, app);
    }
    ActiveBlock::Friends => {
      friends::handler(key, app);
    }
    ActiveBlock::Stats => {
      stats::handler(key, app);
    }
    ActiveBlock::RecapPrompt => {
      recap_prompt::handler(key, app);
    }
    ActiveBlock::PluginScreen => {
      plugin_screen::handler(key, app);
    }
  }
}

fn handle_escape(app: &mut App) {
  match app.get_current_route().active_block {
    ActiveBlock::SearchResultBlock => {
      app.search_results.selected_block = SearchResultBlock::Empty;
    }
    ActiveBlock::ArtistBlock => {
      if let Some(artist) = &mut app.artist {
        artist.artist_selected_block = ArtistBlock::Empty;
      }
    }
    ActiveBlock::Error => {
      app.pop_navigation_stack();
    }
    ActiveBlock::Dialog(dialog_context) => {
      if dialog_context == crate::core::app::DialogContext::PersistKeybindingFallback {
        app.set_status_message("Using Alt+, for this session only", 4);
      }
      app.pop_navigation_stack();
      app.clear_dialog_state();
    }
    ActiveBlock::HelpMenu => {
      app.pop_navigation_stack();
    }
    ActiveBlock::Queue => {
      app.pop_navigation_stack();
    }
    ActiveBlock::Party => {
      app.pop_navigation_stack();
    }
    ActiveBlock::SelectDevice
    | ActiveBlock::LyricsView
    | ActiveBlock::CoverArtView
    | ActiveBlock::MiniPlayer
    | ActiveBlock::PluginScreen => {
      app.pop_navigation_stack();
    }
    // This is a global view that has no active/inactive distinction so do nothing
    ActiveBlock::Analysis => {}

    // Announcement prompt must be dismissed with Enter/Esc, not global escape
    ActiveBlock::AnnouncementPrompt => {}
    ActiveBlock::ExitPrompt => {}
    // Sort menu closes on escape
    ActiveBlock::SortMenu => {
      app.sort_menu_visible = false;
      app.sort_context = None;
      app.set_current_route_state(Some(ActiveBlock::Empty), None);
    }
    ActiveBlock::CreatePlaylistForm => {
      create_playlist::handler(Key::Esc, app);
    }
    ActiveBlock::Friends => {
      friends::handler(Key::Esc, app);
    }
    // "[ESC] Later" on the recap popup: dismiss without opening.
    ActiveBlock::RecapPrompt => {
      app.recap_prompt = None;
      app.pop_navigation_stack();
    }
    _ => {
      app.set_current_route_state(Some(ActiveBlock::Empty), None);
    }
  }
}

fn toggle_miniplayer(app: &mut App) {
  if app.get_current_route().id == RouteId::MiniPlayer {
    app.pop_navigation_stack();
  } else {
    app.push_navigation_stack(RouteId::MiniPlayer, ActiveBlock::MiniPlayer);
  }
}

fn handle_jump_to_context(app: &mut App) {
  if let Some(current_playback_context) = &app.current_playback_context {
    if let Some(play_context) = current_playback_context.context.clone() {
      match play_context._type {
        rspotify::model::enums::Type::Album => handle_jump_to_album(app),
        rspotify::model::enums::Type::Artist => handle_jump_to_artist_album(app),
        rspotify::model::enums::Type::Playlist => {
          app.dispatch(IoEvent::GetPlaylistItems(play_context.uri.clone(), 0));
        }
        _ => {}
      }
    }
  }
}

fn handle_jump_to_album(app: &mut App) {
  if let Some(CurrentPlaybackContext {
    item: Some(item), ..
  }) = app.current_playback_context.to_owned()
  {
    match item {
      PlayableItem::Track(track) => {
        app.dispatch(IoEvent::GetAlbumTracks(Box::new(track.album)));
      }
      PlayableItem::Episode(episode) => {
        app.dispatch(IoEvent::GetShowEpisodes(Box::new(
          crate::core::plugin_api::ShowInfo::from(&episode.show),
        )));
      }
      _ => {}
    };
  }
}

// NOTE: this only finds the first artist of the song and jumps to their albums
fn handle_jump_to_artist_album(app: &mut App) {
  if let Some(CurrentPlaybackContext {
    item: Some(item), ..
  }) = app.current_playback_context.to_owned()
  {
    match item {
      PlayableItem::Track(track) => {
        if let Some(artist) = track.artists.first() {
          if let Some(artist_id) = &artist.id {
            app.get_artist(artist_id.id().to_string(), artist.name.clone());
          }
        }
      }
      PlayableItem::Episode(_episode) => {
        // Do nothing for episode (yet!)
      }
      _ => {}
    }
  };
}

/// The recap period the `generate_recap` key should use: the selected period
/// on the Stats screen, 30 days anywhere else.
fn recap_period_for_current_route(app: &App) -> crate::infra::history::RecapPeriod {
  if app.get_current_route().active_block == ActiveBlock::Stats {
    app.stats_period
  } else {
    crate::infra::history::RecapPeriod::ThirtyDays
  }
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::core::app::TrackTableContext;
  use crate::core::test_helpers::full_track;
  use crate::core::user_config::UserConfig;
  use chrono::Utc;
  use rspotify::model::{
    context::{Actions, CurrentPlaybackContext},
    device::DevicePayload,
    enums::{DeviceType, RepeatState},
    idtypes::PlaylistId,
    CurrentlyPlayingType, Device, PlayableItem,
  };
  use std::{
    sync::mpsc::{channel, TryRecvError},
    time::SystemTime,
  };

  fn friends_app() -> App {
    let mut app = App::default();
    app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends);
    app
  }

  #[test]
  fn recap_period_follows_stats_screen_selection() {
    let mut app = App::default();
    app.stats_period = crate::infra::history::RecapPeriod::Year;
    assert_eq!(
      recap_period_for_current_route(&app),
      crate::infra::history::RecapPeriod::ThirtyDays
    );

    app.push_navigation_stack(RouteId::Stats, ActiveBlock::Stats);
    assert_eq!(
      recap_period_for_current_route(&app),
      crate::infra::history::RecapPeriod::Year
    );
  }

  #[test]
  fn global_shift_w_adds_current_track_from_anywhere() {
    let mut app = App::default();
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    handle_app(Key::Char('W'), &mut app);

    assert_eq!(
      app.status_message.as_deref(),
      Some("No track currently playing")
    );
  }

  #[test]
  fn global_shift_w_is_not_intercepted_in_input_mode() {
    let mut app = App::default();
    app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));

    handle_app(Key::Char('W'), &mut app);

    assert_eq!(app.input, vec!['W']);
    assert!(app.status_message.is_none());
  }

  #[test]
  fn force_previous_track_dispatches_from_anywhere() {
    let mut app = App::default();
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    // Default force_previous_track is Key::Char('P')
    handle_app(Key::Char('P'), &mut app);

    // force_previous_track dispatches through App which requires no io_tx in tests,
    // so just confirm the route didn't change (it shouldn't navigate anywhere)
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Empty);
  }

  #[test]
  fn escape_exits_device_selector() {
    let mut app = App::default();
    app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice);

    handle_app(Key::Esc, &mut app);

    assert_ne!(
      app.get_current_route().active_block,
      ActiveBlock::SelectDevice
    );
  }

  #[test]
  fn enter_on_device_selector_dispatches_transfer_and_exits() {
    let (tx, rx) = channel();
    let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
    app.devices = Some(DevicePayload {
      devices: vec![Device {
        id: Some("device-1".to_string()),
        is_active: false,
        is_private_session: false,
        is_restricted: false,
        name: "Desk Speaker".to_string(),
        _type: DeviceType::Computer,
        volume_percent: Some(42),
      }],
    });
    app.selected_device_index = Some(0);
    app.push_navigation_stack(RouteId::SelectedDevice, ActiveBlock::SelectDevice);

    handle_app(Key::Enter, &mut app);

    match rx.recv().unwrap() {
      IoEvent::TransferPlaybackToDevice(device_id, persist_device_id) => {
        assert_eq!(device_id, "device-1");
        assert!(persist_device_id);
      }
      _ => panic!("unexpected event"),
    }
    assert_ne!(
      app.get_current_route().active_block,
      ActiveBlock::SelectDevice
    );
    assert_eq!(
      app.status_message.as_deref(),
      Some("Switching playback to Desk Speaker")
    );
  }

  #[test]
  fn global_shift_f_likes_current_track_from_anywhere() {
    let (tx, rx) = channel();
    let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
    let track = full_track("0000000000000000000001", "Track 1");
    let expected_track_id = track.id.clone().unwrap();

    app.current_playback_context = Some(CurrentPlaybackContext {
      device: Device {
        id: Some("device-1".to_string()),
        is_active: true,
        is_private_session: false,
        is_restricted: false,
        name: "Desk Speaker".to_string(),
        _type: DeviceType::Computer,
        volume_percent: Some(42),
      },
      repeat_state: RepeatState::Off,
      shuffle_state: false,
      context: None,
      timestamp: Utc::now(),
      progress: None,
      is_playing: false,
      item: Some(PlayableItem::Track(track)),
      currently_playing_type: CurrentlyPlayingType::Track,
      actions: Actions::default(),
    });
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    // Default like_track is Key::Char('F')
    handle_app(Key::Char('F'), &mut app);

    match rx.recv().unwrap() {
      IoEvent::ToggleSaveTrack(track_id) => {
        assert_eq!(track_id, expected_track_id.uri());
      }
      _ => panic!("unexpected event"),
    }
  }

  #[test]
  fn global_shift_f_is_not_intercepted_in_input_mode() {
    let mut app = App::default();
    app.set_current_route_state(Some(ActiveBlock::Input), Some(ActiveBlock::Input));

    handle_app(Key::Char('F'), &mut app);

    // In input mode, 'F' should be added to the input buffer
    assert_eq!(app.input, vec!['F']);
  }

  #[test]
  fn friends_a_opens_add_dialog_before_global_album_jump() {
    let (tx, rx) = channel();
    let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
    let track = full_track("0000000000000000000001", "Track 1");
    app.current_playback_context = Some(CurrentPlaybackContext {
      device: Device {
        id: Some("device-1".to_string()),
        is_active: true,
        is_private_session: false,
        is_restricted: false,
        name: "Desk Speaker".to_string(),
        _type: DeviceType::Computer,
        volume_percent: Some(42),
      },
      repeat_state: RepeatState::Off,
      shuffle_state: false,
      context: None,
      timestamp: Utc::now(),
      progress: None,
      is_playing: false,
      item: Some(PlayableItem::Track(track)),
      currently_playing_type: CurrentlyPlayingType::Track,
      actions: Actions::default(),
    });
    app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends);

    handle_app(Key::Char('a'), &mut app);

    assert!(app.friend_add_dialog_visible);
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Friends);
    assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
  }

  #[test]
  fn friends_c_prefers_friend_code_copy_over_global_song_copy() {
    let mut app = friends_app();
    app.friend_code = Some("jay-1234".to_string());
    app.clipboard = None;

    handle_app(Key::Char('c'), &mut app);

    assert_eq!(
      app.status_message.as_deref(),
      Some("Clipboard not available")
    );
    assert!(!app.friend_add_dialog_visible);
  }

  #[test]
  fn friends_search_buffer_keeps_globally_bound_characters_local() {
    let mut app = friends_app();
    app.friend_search_input = vec!['j'];

    handle_app(Key::Char('a'), &mut app);
    handle_app(Key::Char('c'), &mut app);

    assert_eq!(app.friend_search_input, vec!['j', 'a', 'c']);
    assert!(!app.friend_add_dialog_visible);
    assert!(app.status_message.is_none());
  }

  #[test]
  fn friends_without_local_state_still_allows_non_conflicting_globals() {
    let (tx, rx) = channel();
    let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
    app.push_navigation_stack(RouteId::Friends, ActiveBlock::Friends);

    handle_app(app.user_config.keys.next_track, &mut app);

    match rx.recv().unwrap() {
      IoEvent::NextTrack => {}
      _ => panic!("unexpected event"),
    }
    assert!(app.friend_search_input.is_empty());
    assert!(!app.friend_add_dialog_visible);
  }

  #[test]
  fn friends_add_dialog_keeps_priority_for_conflicting_keys() {
    let mut app = friends_app();
    app.open_friend_add_dialog();

    handle_app(Key::Char('c'), &mut app);

    assert!(app.friend_add_dialog_visible);
    assert_eq!(app.friend_add_input, vec!['c']);
    assert!(app.status_message.is_none());
  }

  #[test]
  fn ctrl_f_in_playlist_track_table_opens_playlist_search_input() {
    let mut app = App::default();
    let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6")
      .unwrap()
      .into_static();
    app.track_table.context = Some(TrackTableContext::MyPlaylists);
    app.playlist_track_table_id = Some(playlist_id);
    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);

    handle_app(Key::Ctrl('f'), &mut app);

    assert_eq!(app.input_context, InputContext::PlaylistTrackSearch);
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Input);
  }

  #[test]
  fn search_key_in_playlist_track_table_opens_global_search_input() {
    let mut app = App::default();
    let playlist_id = PlaylistId::from_id("37i9dQZF1DX4WYpdgoIcn6")
      .unwrap()
      .into_static();
    app.track_table.context = Some(TrackTableContext::MyPlaylists);
    app.playlist_track_table_id = Some(playlist_id);
    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);

    handle_app(app.user_config.keys.search, &mut app);

    assert_eq!(app.input_context, InputContext::GlobalSearch);
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Input);
  }

  #[test]
  fn search_key_outside_playlist_track_table_opens_global_search_input() {
    let mut app = App::default();
    app.track_table.context = Some(TrackTableContext::SavedTracks);
    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);

    handle_app(app.user_config.keys.search, &mut app);

    assert_eq!(app.input_context, InputContext::GlobalSearch);
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Input);
  }

  #[cfg(target_os = "macos")]
  #[test]
  fn plain_comma_fallback_opens_settings_and_prompts_to_persist() {
    let mut app = App::default();
    app.user_config.keys.open_settings = Key::Ctrl(',');
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    handle_app(Key::Char(','), &mut app);

    assert_eq!(
      app.keybinding_runtime.effective_open_settings,
      Some(Key::Alt(','))
    );
    assert_eq!(
      app.get_current_route().active_block,
      ActiveBlock::Dialog(crate::core::app::DialogContext::PersistKeybindingFallback)
    );
    assert!(app.status_message.is_some());
  }

  #[cfg(target_os = "macos")]
  #[test]
  fn plain_comma_does_not_override_track_table_sort_menu() {
    let mut app = App::default();
    app.user_config.keys.open_settings = Key::Ctrl(',');
    app.track_table.context = Some(TrackTableContext::MyPlaylists);
    app.push_navigation_stack(RouteId::TrackTable, ActiveBlock::TrackTable);

    handle_app(Key::Char(','), &mut app);

    assert_eq!(app.get_current_route().active_block, ActiveBlock::SortMenu);
  }

  // --- U5: source-gate tests ---

  #[test]
  fn like_track_shows_hint_when_local_source() {
    let mut app = App::default();
    app.active_source = Source::Local;
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    // Default like_track key is 'F' (Shift+F)
    handle_app(app.user_config.keys.like_track, &mut app);

    assert_eq!(
      app.status_message.as_deref(),
      Some("Like isn't available for Local Files")
    );
  }

  #[test]
  fn like_track_does_not_show_hint_when_spotify_source() {
    // No io_tx here so dispatch is a no-op; we just verify no status is set.
    let mut app = App::default();
    app.active_source = Source::Spotify;
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    handle_app(app.user_config.keys.like_track, &mut app);

    // No playback context, so toggle_like returns early with a different message
    // (or nothing). The important thing is it's NOT the Local-gate message.
    assert_ne!(
      app.status_message.as_deref(),
      Some("Like isn't available for Local Files")
    );
  }

  #[test]
  fn global_like_key_favorites_radio_search_station() {
    use crate::core::pagination::Paged;
    use crate::core::plugin_api::TrackInfo;
    use crate::core::user_config::UserConfigPaths;

    let dir = tempfile::tempdir().unwrap();
    let mut app = App::default();
    app.active_source = Source::Radio;
    app.user_config.path_to_config = Some(UserConfigPaths {
      config_file_path: dir.path().join("config.yml"),
    });
    app.search_results.tracks = Some(Paged {
      items: vec![TrackInfo {
        uri: Some("radio:https://example.com/stream".to_string()),
        name: "Example FM".to_string(),
        artists: vec![],
        album: String::new(),
        duration_ms: 0,
        id: None,
        album_id: None,
        artist_refs: vec![],
        is_playable: true,
        is_local: false,
        track_number: 0,
        explicit: false,
        image_url: None,
      }],
      total: 1,
      ..Default::default()
    });
    app.search_results.selected_tracks_index = Some(0);
    app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);

    let favorite_key = app.user_config.keys.like_track;
    handle_app(favorite_key, &mut app);

    assert_eq!(app.user_config.behavior.radio_stations.len(), 1);
    assert_eq!(
      app.user_config.behavior.radio_stations[0].url,
      "https://example.com/stream"
    );
    assert_eq!(
      app.status_message.as_deref(),
      Some("Favorited radio station: Example FM")
    );
  }

  #[test]
  fn copy_song_url_shows_hint_when_local_source() {
    let mut app = App::default();
    app.active_source = Source::Local;
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    handle_app(app.user_config.keys.copy_song_url, &mut app);

    assert_eq!(
      app.status_message.as_deref(),
      Some("Copy URL isn't available for Local Files")
    );
  }

  #[test]
  fn copy_song_url_proceeds_when_spotify_source() {
    // No clipboard in default App, so copy_song_url exits early; but no Local gate message.
    let mut app = App::default();
    app.active_source = Source::Spotify;
    app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));

    handle_app(app.user_config.keys.copy_song_url, &mut app);

    assert_ne!(
      app.status_message.as_deref(),
      Some("Copy URL isn't available for Local Files")
    );
  }
}
