// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
mod hardware;
mod navigation;
mod playback;

use midly::live::LiveEvent;
use parking_lot::RwLock;
use std::fmt;
use std::{
    collections::HashMap,
    error::Error,
    path::{Path, PathBuf},
    sync::{
        atomic::{AtomicBool, Ordering},
        Arc,
    },
    time::{Duration, SystemTime},
};
use tokio::{
    sync::{oneshot, Mutex},
    task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info, span, warn, Level, Span};

use crate::samples::SampleEngine;
use crate::songs::Songs;
use crate::trigger::TriggerEngine;
use crate::{
    audio, config, dmx, midi,
    playlist::{self, Playlist},
    playsync::CancelHandle,
    songs::Song,
};

/// Direction for playlist navigation.
enum PlaylistDirection {
    Next,
    Prev,
}

impl fmt::Display for PlaylistDirection {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            PlaylistDirection::Next => write!(f, "next"),
            PlaylistDirection::Prev => write!(f, "previous"),
        }
    }
}

/// Holds the ingredients for constructing a per-song `PlaybackClock`.
/// When an audio device is present, clocks are derived from its hardware
/// sample counter. Otherwise, clocks fall back to `Instant::now()`.
#[derive(Clone)]
enum ClockSource {
    Audio {
        sample_counter: Arc<std::sync::atomic::AtomicU64>,
        sample_rate: u32,
    },
    Wall,
}

impl ClockSource {
    fn new_clock(&self) -> crate::clock::PlaybackClock {
        match self {
            ClockSource::Audio {
                sample_counter,
                sample_rate,
            } => crate::clock::PlaybackClock::from_sample_counter(
                sample_counter.clone(),
                *sample_rate,
            ),
            ClockSource::Wall => crate::clock::PlaybackClock::wall(),
        }
    }
}

/// Notified when the current song changes. Implementations capture whatever
/// device handles they need at construction time, keeping the player unaware
/// of protocol-specific details.
pub trait SongChangeNotifier: Send + Sync {
    /// Called when the player advances to a new song.
    fn notify(&self, song: &Song);
}

/// Groups all hardware device state so it can be atomically swapped on reload.
#[derive(Clone)]
struct HardwareState {
    device: Option<Arc<dyn audio::Device>>,
    mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
    midi_device: Option<Arc<dyn midi::Device>>,
    dmx_engine: Option<Arc<dmx::engine::Engine>>,
    sample_engine: Option<Arc<RwLock<SampleEngine>>>,
    trigger_engine: Option<Arc<TriggerEngine>>,
    clock_source: ClockSource,
    /// Notifiers invoked on every song change.
    song_change_notifiers: Vec<Arc<dyn SongChangeNotifier>>,
    /// The hostname of the active hardware profile (None if no profile matched).
    profile_name: Option<String>,
    /// The resolved machine hostname used for profile matching.
    hostname: Option<String>,
}

/// Alias for the shared state sampler sender stored on Player.
type StateTx = Arc<
    parking_lot::Mutex<Option<Arc<tokio::sync::watch::Sender<Arc<crate::state::StateSnapshot>>>>>,
>;

/// Lightweight, change-detect-friendly view of the player's transport state.
///
/// This is broadcast on the `transport_tx` watch channel and used by
/// subscribers (e.g. the MCP `mtrack://status` notification) to detect
/// meaningful state transitions. Fields that change continuously during
/// playback — most notably `elapsed` — are deliberately excluded, so a
/// passive subscriber is only woken when the user-visible transport state
/// actually transitions.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TransportSnapshot {
    pub playlist_name: String,
    pub current_song: Option<String>,
    pub playing: bool,
    pub section_loop: Option<String>,
    pub reactive_loop_state: ReactiveLoopStateKind,
    pub locked: bool,
}

/// Variants of [`ReactiveLoopState`] without the inner `SectionBounds` payload.
/// We only care about transitions between variants, not the bounds themselves.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ReactiveLoopStateKind {
    #[default]
    Idle,
    SectionOffered,
    LoopArmed,
    Looping,
    BreakRequested,
}

impl From<&ReactiveLoopState> for ReactiveLoopStateKind {
    fn from(state: &ReactiveLoopState) -> Self {
        match state {
            ReactiveLoopState::Idle => Self::Idle,
            ReactiveLoopState::SectionOffered(_) => Self::SectionOffered,
            ReactiveLoopState::LoopArmed(_) => Self::LoopArmed,
            ReactiveLoopState::Looping(_) => Self::Looping,
            ReactiveLoopState::BreakRequested(_) => Self::BreakRequested,
        }
    }
}

struct PlayHandles {
    join: JoinHandle<()>,
    cancel: CancelHandle,
}

/// Groups the parameters needed for `play_files` to avoid excessive argument counts.
struct PlaybackContext {
    device: Option<Arc<dyn audio::Device>>,
    mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
    midi_device: Option<Arc<dyn midi::Device>>,
    dmx_engine: Option<Arc<dmx::engine::Engine>>,
    clock: crate::clock::PlaybackClock,
    song: Arc<Song>,
    cancel_handle: CancelHandle,
    play_tx: oneshot::Sender<Result<(), String>>,
    start_time: Duration,
    play_start_time: Arc<Mutex<Option<SystemTime>>>,
    /// Loop control state shared with audio/MIDI/DMX subsystems.
    loop_control: crate::playsync::LoopControl,
}

/// Groups hardware devices for constructing a Player without discovering real hardware.
pub struct PlayerDevices {
    pub audio: Option<Arc<dyn audio::Device>>,
    pub mappings: Option<Arc<HashMap<String, Vec<u16>>>>,
    pub midi: Option<Arc<dyn midi::Device>>,
    pub dmx_engine: Option<Arc<dmx::engine::Engine>>,
    pub sample_engine: Option<Arc<RwLock<SampleEngine>>>,
    pub trigger_engine: Option<Arc<TriggerEngine>>,
}

/// Status of a single hardware subsystem.
#[derive(Clone, serde::Serialize)]
pub struct SubsystemStatus {
    pub status: String,
    pub name: Option<String>,
}

/// Snapshot of all hardware subsystem statuses.
#[derive(Clone, serde::Serialize)]
pub struct HardwareStatusSnapshot {
    pub init_done: bool,
    pub hostname: Option<String>,
    pub profile: Option<String>,
    pub audio: SubsystemStatus,
    pub midi: SubsystemStatus,
    pub dmx: SubsystemStatus,
    pub trigger: SubsystemStatus,
}

/// Plays back individual wav files as multichannel audio for the configured audio interface.
#[derive(Clone)]
pub struct Player {
    /// All hardware device state, behind a lock so it can be atomically swapped on reload.
    hardware: Arc<parking_lot::RwLock<HardwareState>>,
    /// Base path for resolving sample/DMX config files. None in test paths.
    base_path: Option<PathBuf>,
    /// All playlists keyed by name. Always includes "all_songs".
    playlists: Arc<parking_lot::RwLock<HashMap<String, Arc<Playlist>>>>,
    /// The name of the currently active playlist.
    active_playlist: Arc<parking_lot::RwLock<String>>,
    /// The persisted active playlist name (last non-all_songs choice). Used by
    /// MIDI/OSC `Playlist` action to return to the user's "real" playlist.
    persisted_playlist: Arc<parking_lot::RwLock<String>>,
    /// The time that the last play action occurred.
    play_start_time: Arc<Mutex<Option<SystemTime>>>,
    /// Keeps track of the player joins. There should only be one task on here at a time.
    join: Arc<Mutex<Option<PlayHandles>>>,
    /// After stop is set, this will be set to true. This will prevent stop from being run again until
    /// it is unset, which should be handled by a cleanup async process after playback finishes.
    stop_run: Arc<AtomicBool>,
    /// The logging span.
    span: Span,
    /// Mutable configuration store for runtime config changes.
    config_store: Arc<parking_lot::Mutex<Option<Arc<config::ConfigStore>>>>,
    /// Cancellation token for the current hardware init round. On reload,
    /// the old token is cancelled and a new one is created.
    init_cancel: Arc<parking_lot::Mutex<CancellationToken>>,
    /// Broadcast channel sender, stored so async init can wire DMX engine.
    broadcast_tx: Arc<parking_lot::Mutex<Option<tokio::sync::broadcast::Sender<String>>>>,
    /// Watch channel to signal hardware init completion.
    init_done_tx: Arc<tokio::sync::watch::Sender<bool>>,
    /// State sampler watch sender. Shared so async init can start the sampler
    /// when the DMX engine becomes available, and restart it on reload.
    state_tx: StateTx,
    /// Transport-state watch channel. A low-rate poller refreshes this and
    /// uses `send_if_modified` so receivers (e.g. MCP `mtrack://status`
    /// subscribers) only wake on meaningful transitions, never on the
    /// per-tick fixture sampler.
    transport_tx: Arc<tokio::sync::watch::Sender<TransportSnapshot>>,
    /// When true, state-altering operations (config changes, song edits, etc.)
    /// are rejected. Playback controls always work. Locked by default on startup.
    locked: Arc<AtomicBool>,
    /// Active controllers (gRPC, OSC, MIDI). Replaced on reload.
    controller: Arc<parking_lot::Mutex<Option<crate::controller::Controller>>>,
    /// Signal to break out of a song loop gracefully (not a hard cancel).
    /// Set by play()/next() when the current song is looping. The playback
    /// loop checks this flag and exits cleanly, allowing the cleanup task
    /// to advance the playlist and start the next song.
    loop_break: Arc<AtomicBool>,
    /// Active section loop bounds. When Some, audio/MIDI/DMX subsystems
    /// loop the specified time region instead of the full song.
    active_section: Arc<parking_lot::RwLock<Option<SectionBounds>>>,
    /// Signal to stop section looping. The current iteration finishes and
    /// the song continues from the section end.
    section_loop_break: Arc<AtomicBool>,
    /// Accumulated real time consumed by section loop iterations.
    /// Each completed loop iteration adds section_duration to this value.
    /// Subtracted from raw elapsed to get the true song position.
    loop_time_consumed: Arc<parking_lot::Mutex<Duration>>,
    /// Reactive loop state machine.
    reactive_loop_state: Arc<parking_lot::RwLock<ReactiveLoopState>>,
    /// Notification engine for section loop audio feedback.
    notification_engine: Arc<crate::notification::NotificationEngine>,
}

/// Bounds of an active section loop.
///
/// Used together with `section_loop_break: Arc<AtomicBool>` to form a
/// state machine shared by the audio, MIDI, and DMX engine threads:
///
/// | State    | `active_section`  | `section_loop_break` |
/// |----------|-------------------|----------------------|
/// | Idle     | `None`            | `false`              |
/// | Looping  | `Some(bounds)`    | `false`              |
/// | Breaking | `Some(bounds)`*   | `true`               |
///
/// Transitions:
///   - `start_section_loop()`: Idle → Looping (sets `active_section`,
///     clears `section_loop_break`)
///   - `stop_section_loop()`: Looping → Breaking → Idle (sets
///     `section_loop_break` first, then clears `active_section`)
///   - Each engine thread detects the break and exits its loop
///
/// (*) `active_section` is cleared shortly after `section_loop_break` is
/// set. DMX caches the section bounds so it can compute a resume position
/// even after the field is cleared.
///
/// Trigger scheduling within each engine is handled by
/// [`crate::section_loop::SectionLoopTrigger`].
#[derive(Debug, Clone, PartialEq)]
pub struct SectionBounds {
    pub name: String,
    pub start_time: Duration,
    pub end_time: Duration,
}

/// State machine for reactive section looping.
///
/// In reactive mode, sections are "offered" to the performer as playback
/// enters each section boundary. The performer can ack to arm the loop,
/// and later break to exit.
#[derive(Debug, Clone, Default)]
pub enum ReactiveLoopState {
    /// No section is currently being offered or looped.
    #[default]
    Idle,
    /// Playback entered a section; waiting for performer ack.
    SectionOffered(SectionBounds),
    /// Performer acked; loop will engage at section end.
    LoopArmed(SectionBounds),
    /// Section loop is active.
    Looping(SectionBounds),
    /// Break requested; will exit at end of current iteration.
    BreakRequested(SectionBounds),
}

impl Player {
    /// Creates a new player and spawns asynchronous hardware discovery.
    ///
    /// Returns immediately with no hardware initialized. Device discovery
    /// runs in background tasks that retry perpetually until each device is
    /// found or the init round is cancelled (via `reload_hardware`). Use
    /// `await_hardware_ready()` to wait for init to complete (mainly useful
    /// in tests).
    pub fn new(
        playlists: HashMap<String, Arc<Playlist>>,
        active_playlist: String,
        config: &config::Player,
        base_path: Option<&Path>,
    ) -> Result<Arc<Player>, Box<dyn Error>> {
        let devices = PlayerDevices {
            audio: None,
            mappings: None,
            midi: None,
            dmx_engine: None,
            sample_engine: None,
            trigger_engine: None,
        };

        let player = Arc::new(Self::new_with_devices(
            devices,
            playlists,
            active_playlist,
            base_path,
        )?);

        // Mark as not ready — async init will set to true when complete.
        // Use send_modify() because send() is a no-op when no receivers exist yet.
        player.init_done_tx.send_modify(|v| *v = false);

        // Spawn async hardware init.
        let init_player = player.clone();
        let config = config.clone();
        let bp = base_path.map(Path::to_path_buf);
        tokio::spawn(async move {
            init_player.init_hardware_async(config, bp).await;
        });

        // Spawn the low-rate transport-state poller. Holds a `Weak` so it
        // self-terminates when the player is dropped. The poll cadence
        // (200ms / 5Hz) caps subscribers' notification rate while staying
        // responsive enough for UI use.
        let weak = Arc::downgrade(&player);
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(Duration::from_millis(200));
            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
            loop {
                interval.tick().await;
                match weak.upgrade() {
                    Some(p) => p.refresh_transport_state().await,
                    None => break,
                }
            }
        });

        Ok(player)
    }

    /// Creates a new player with pre-constructed devices.
    ///
    /// This is the core constructor used by `new()` after device discovery,
    /// and can be called directly in tests with mock devices.
    pub fn new_with_devices(
        devices: PlayerDevices,
        playlists: HashMap<String, Arc<Playlist>>,
        active_playlist: String,
        base_path: Option<&Path>,
    ) -> Result<Player, Box<dyn Error>> {
        // Store the clock source so each song can create a fresh PlaybackClock.
        let clock_source = match devices
            .audio
            .as_ref()
            .and_then(|d| Some((d.sample_counter()?, d.sample_rate()?)))
        {
            Some((counter, rate)) => ClockSource::Audio {
                sample_counter: counter,
                sample_rate: rate,
            },
            None => ClockSource::Wall,
        };

        let hw = HardwareState {
            device: devices.audio,
            mappings: devices.mappings,
            midi_device: devices.midi,
            dmx_engine: devices.dmx_engine,
            sample_engine: devices.sample_engine,
            trigger_engine: devices.trigger_engine,
            clock_source,
            song_change_notifiers: Vec::new(),
            profile_name: None,
            hostname: None,
        };

        let (init_done_tx, _init_done_rx) = tokio::sync::watch::channel(true);

        // Resolve the active playlist: use the requested name if it exists, else fall back to all_songs.
        let resolved_active = if playlists.contains_key(&active_playlist) {
            active_playlist
        } else {
            "all_songs".to_string()
        };

        Ok(Player {
            hardware: Arc::new(parking_lot::RwLock::new(hw)),
            base_path: base_path.map(Path::to_path_buf),
            playlists: Arc::new(parking_lot::RwLock::new(playlists)),
            active_playlist: Arc::new(parking_lot::RwLock::new(resolved_active.clone())),
            persisted_playlist: Arc::new(parking_lot::RwLock::new(
                if resolved_active == "all_songs" {
                    "playlist".to_string()
                } else {
                    resolved_active
                },
            )),
            play_start_time: Arc::new(Mutex::new(None)),
            join: Arc::new(Mutex::new(None)),
            stop_run: Arc::new(AtomicBool::new(false)),
            span: span!(Level::INFO, "player"),
            config_store: Arc::new(parking_lot::Mutex::new(None)),
            init_cancel: Arc::new(parking_lot::Mutex::new(CancellationToken::new())),
            broadcast_tx: Arc::new(parking_lot::Mutex::new(None)),
            init_done_tx: Arc::new(init_done_tx),
            state_tx: Arc::new(parking_lot::Mutex::new(None)),
            transport_tx: Arc::new(tokio::sync::watch::channel(TransportSnapshot::default()).0),
            locked: Arc::new(AtomicBool::new(true)),
            controller: Arc::new(parking_lot::Mutex::new(None)),
            loop_break: Arc::new(AtomicBool::new(false)),
            active_section: Arc::new(parking_lot::RwLock::new(None)),
            section_loop_break: Arc::new(AtomicBool::new(false)),
            loop_time_consumed: Arc::new(parking_lot::Mutex::new(Duration::ZERO)),
            reactive_loop_state: Arc::new(parking_lot::RwLock::new(ReactiveLoopState::Idle)),
            notification_engine: Arc::new(crate::notification::NotificationEngine::with_defaults(
                44100,
            )),
        })
    }

    /// Waits until hardware initialization is complete. Mainly useful in tests.
    #[cfg(test)]
    pub async fn await_hardware_ready(&self) {
        let mut rx = self.init_done_tx.subscribe();
        while !*rx.borrow_and_update() {
            if rx.changed().await.is_err() {
                break;
            }
        }
    }

    /// Gets the audio device currently in use by the player.
    #[cfg(test)]
    pub fn audio_device(&self) -> Option<Arc<dyn audio::Device>> {
        self.hardware.read().device.clone()
    }

    /// Gets the MIDI device currently in use by the player.
    pub fn midi_device(&self) -> Option<Arc<dyn midi::Device>> {
        self.hardware.read().midi_device.clone()
    }

    /// Processes a MIDI event for triggered samples.
    /// This should be called by the MIDI controller when events are received.
    /// Uses std::sync::RwLock for minimal latency (no async overhead).
    pub fn process_sample_trigger(&self, raw_event: &[u8]) {
        let sample_engine = self.hardware.read().sample_engine.clone();
        if let Some(ref sample_engine) = sample_engine {
            let engine = sample_engine.read();
            engine.process_midi_event(raw_event);
        }
    }

    /// Loads the sample configuration for a song.
    /// This preloads samples for the song so they're ready for instant playback.
    /// Note: Active voices continue playing through song transitions.
    fn load_song_samples(&self, song: &Song) {
        let sample_engine = self.hardware.read().sample_engine.clone();
        if let Some(ref sample_engine) = sample_engine {
            // Load the new song's sample config if it has one
            let samples_config = song.samples_config();
            if !samples_config.samples().is_empty() || !samples_config.sample_triggers().is_empty()
            {
                let mut engine = sample_engine.write();
                if let Err(e) = engine.load_song_config(samples_config, song.base_path()) {
                    warn!(
                        song = song.name(),
                        error = %e,
                        "Failed to load song sample config"
                    );
                } else {
                    info!(
                        song = song.name(),
                        samples = samples_config.samples().len(),
                        triggers = samples_config.sample_triggers().len(),
                        "Loaded song sample config"
                    );
                }
            }
        }
    }

    /// Stops all triggered sample playback.
    pub fn stop_samples(&self) {
        let sample_engine = self.hardware.read().sample_engine.clone();
        if let Some(ref sample_engine) = sample_engine {
            let engine = sample_engine.read();
            engine.stop_all();
        }
    }

    /// Gets the DMX engine currently in use by the player, if any.
    pub fn dmx_engine(&self) -> Option<Arc<dmx::engine::Engine>> {
        self.hardware.read().dmx_engine.clone()
    }

    /// Gets all cues from the current song's lighting timeline.
    pub fn get_cues(&self) -> Vec<(Duration, usize)> {
        let dmx_engine = self.hardware.read().dmx_engine.clone();
        if let Some(ref dmx_engine) = dmx_engine {
            dmx_engine.get_timeline_cues()
        } else {
            Vec::new()
        }
    }

    /// Returns handles needed for reading lighting state, or None if no DMX engine is configured.
    pub fn broadcast_handles(&self) -> Option<dmx::engine::BroadcastHandles> {
        self.hardware
            .read()
            .dmx_engine
            .clone()
            .map(|e| e.broadcast_handles())
    }

    /// Stores the broadcast channel and wires it to the DMX engine if one exists.
    /// If the DMX engine hasn't initialized yet, the channel is stored and will
    /// be wired when the engine comes up during async init.
    pub fn set_broadcast_tx(&self, tx: tokio::sync::broadcast::Sender<String>) {
        let dmx_engine = self.hardware.read().dmx_engine.clone();
        if let Some(ref engine) = dmx_engine {
            engine.set_broadcast_tx(tx.clone());
        }
        *self.broadcast_tx.lock() = Some(tx);
    }

    /// Stores the state sampler watch sender. When the DMX engine comes up
    /// during async init, the sampler will be started using this sender.
    pub fn set_state_tx(&self, tx: tokio::sync::watch::Sender<Arc<crate::state::StateSnapshot>>) {
        *self.state_tx.lock() = Some(Arc::new(tx));
    }

    /// Returns a fresh state-watch receiver, if a state sender has been wired.
    /// Each call produces an independent receiver via `watch::Sender::subscribe`.
    pub fn state_rx(
        &self,
    ) -> Option<tokio::sync::watch::Receiver<Arc<crate::state::StateSnapshot>>> {
        self.state_tx.lock().as_ref().map(|tx| tx.subscribe())
    }

    /// Returns the state-watch sender, if one has been wired. Lets callers
    /// push state snapshots into the player from outside the normal sampler
    /// path — primarily useful for tests, but also for headless integrations
    /// that want to drive the state stream without a DMX engine running.
    pub fn state_tx(
        &self,
    ) -> Option<Arc<tokio::sync::watch::Sender<Arc<crate::state::StateSnapshot>>>> {
        self.state_tx.lock().clone()
    }

    /// Returns a fresh receiver for the transport-state watch channel.
    /// The channel only emits a "changed" wake-up when the underlying
    /// [`TransportSnapshot`] transitions to a new value.
    pub fn transport_rx(&self) -> tokio::sync::watch::Receiver<TransportSnapshot> {
        self.transport_tx.subscribe()
    }

    /// Computes the current transport snapshot from live player state.
    ///
    /// `is_playing()` briefly acquires the play-join lock, so this should
    /// not be called from contexts that hold that lock.
    pub async fn compute_transport_snapshot(&self) -> TransportSnapshot {
        let playlist = self.get_playlist();
        let playlist_name = playlist.name().to_string();
        let current_song = playlist.current().map(|s| s.name().to_string());
        let playing = self.is_playing().await;
        let section_loop = self.active_section.read().as_ref().map(|b| b.name.clone());
        let reactive_loop_state = ReactiveLoopStateKind::from(&*self.reactive_loop_state.read());
        let locked = self.is_locked();
        TransportSnapshot {
            playlist_name,
            current_song,
            playing,
            section_loop,
            reactive_loop_state,
            locked,
        }
    }

    /// Recomputes the transport snapshot and broadcasts it iff it differs
    /// from the last published value. Safe to call repeatedly — only real
    /// transitions wake subscribers.
    pub async fn refresh_transport_state(&self) {
        let next = self.compute_transport_snapshot().await;
        self.transport_tx.send_if_modified(|cur| {
            if *cur != next {
                *cur = next.clone();
                true
            } else {
                false
            }
        });
    }

    /// Sets the config store on the player. Called once after startup.
    pub fn set_config_store(&self, store: Arc<config::ConfigStore>) {
        *self.config_store.lock() = Some(store);
    }

    /// Returns the config store, if one has been set.
    pub fn config_store(&self) -> Option<Arc<config::ConfigStore>> {
        self.config_store.lock().clone()
    }

    /// Returns the track-to-output-channel mappings, if audio is configured.
    pub fn track_mappings(&self) -> Option<Arc<HashMap<String, Vec<u16>>>> {
        self.hardware.read().mappings.clone()
    }

    /// Returns true if the player is in locked mode (state-altering operations blocked).
    pub fn is_locked(&self) -> bool {
        self.locked.load(Ordering::Relaxed)
    }

    /// Sets the locked state. When locked, state-altering operations are rejected
    /// but playback controls continue to work.
    pub fn set_locked(&self, locked: bool) {
        self.locked.store(locked, Ordering::Relaxed);
    }

    /// Returns the effect engine, if a DMX engine is configured.
    #[cfg(test)]
    pub fn effect_engine(&self) -> Option<Arc<parking_lot::Mutex<crate::lighting::EffectEngine>>> {
        self.hardware
            .read()
            .dmx_engine
            .clone()
            .map(|e| e.effect_engine())
    }

    /// Gets a formatted string listing all active lighting effects
    pub fn format_active_effects(&self) -> Option<String> {
        self.hardware
            .read()
            .dmx_engine
            .clone()
            .map(|engine| engine.format_active_effects())
    }

    /// Emits the per-song MIDI event and notifies all song-change notifiers.
    fn emit_song_change(&self, song: &Song) {
        let hw = self.hardware.read();
        let midi_device = hw.midi_device.clone();
        let notifiers = hw.song_change_notifiers.clone();
        drop(hw);

        if let Some(ref device) = midi_device {
            if let Err(e) = device.emit(song.midi_event()) {
                error!("Error emitting MIDI event: {:?}", e);
            }
        }
        for notifier in &notifiers {
            notifier.notify(song);
        }
    }
}

/// Describes how to report status via MIDI.
pub struct StatusEvents {
    /// The events to emit to clear the status.
    off_events: Vec<LiveEvent<'static>>,
    /// The events to emit to indicate that the player is idling and waiting for input.
    idling_events: Vec<LiveEvent<'static>>,
    /// The events to emit to indicate that the player is currently playing.
    playing_events: Vec<LiveEvent<'static>>,
}

impl StatusEvents {
    /// Creates a new status events configuration.
    pub fn new(
        config: Option<config::StatusEvents>,
    ) -> Result<Option<StatusEvents>, Box<dyn Error>> {
        Ok(match config {
            Some(config) => Some(StatusEvents {
                off_events: config.off_events()?,
                idling_events: config.idling_events()?,
                playing_events: config.playing_events()?,
            }),
            None => None,
        })
    }
}

/// The result of receiving a playback completion signal.
#[derive(Debug)]
enum PlaybackResult {
    Success,
    Failed(String),
    SenderDropped,
}

/// What the cleanup task should do after playback finishes.
#[derive(Debug, PartialEq)]
enum CleanupAction {
    AdvancePlaylist,
    StopCancelled,
    /// Loop was broken via play/next — advance playlist and auto-play next song.
    LoopBreakAndPlay,
}

/// Decides whether to advance the playlist or stop after playback finishes.
fn decide_cleanup_action(
    result: PlaybackResult,
    cancelled: bool,
    loop_broken: bool,
) -> CleanupAction {
    // Loop break takes priority over cancel — we intentionally cancel
    // playback to break out of a loop immediately, but the intent is
    // to advance and play, not to stop.
    if loop_broken {
        return CleanupAction::LoopBreakAndPlay;
    }
    if cancelled {
        return CleanupAction::StopCancelled;
    }
    match &result {
        PlaybackResult::Failed(e) => {
            warn!(
                err = %e,
                "Advancing playlist despite playback failure so user is not stuck"
            );
        }
        PlaybackResult::SenderDropped => {
            error!("Error receiving playback signal (receiver dropped)");
        }
        PlaybackResult::Success => {}
    }
    CleanupAction::AdvancePlaylist
}

/// Resolves the final playback outcome from the audio thread result.
fn resolve_playback_outcome(
    has_audio: bool,
    audio_outcome: Option<Result<(), String>>,
) -> Result<(), String> {
    if has_audio {
        audio_outcome.unwrap_or_else(|| {
            warn!(
                "Audio thread did not set outcome (e.g. panicked before setting); \
                 treating as success so playlist is not stuck"
            );
            Ok(())
        })
    } else {
        Ok(())
    }
}

/// Loads all playlists from a directory and/or legacy playlist path.
/// Always includes the computed "all_songs" playlist.
pub fn load_playlists(
    playlists_dir: Option<&Path>,
    legacy_playlist_path: Option<&Path>,
    songs: Arc<Songs>,
) -> Result<HashMap<String, Arc<Playlist>>, Box<dyn Error>> {
    let mut playlists = HashMap::new();

    // Always create all_songs.
    playlists.insert(
        "all_songs".to_string(),
        playlist::from_songs(songs.clone())?,
    );

    // Load playlists from directory.
    if let Some(dir) = playlists_dir {
        if dir.is_dir() {
            let mut entries: Vec<_> = std::fs::read_dir(dir)?
                .filter_map(|e| e.ok())
                .filter(|e| {
                    e.path().is_file()
                        && e.path()
                            .extension()
                            .is_some_and(|ext| ext == "yaml" || ext == "yml")
                })
                .collect();
            entries.sort_by_key(|e| e.file_name());

            for entry in entries {
                let path = entry.path();
                let name = path
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or_default()
                    .to_string();
                if name.is_empty() || name == "all_songs" {
                    continue;
                }
                match config::Playlist::deserialize(&path) {
                    Ok(playlist_config) => {
                        match Playlist::new(&name, &playlist_config, songs.clone()) {
                            Ok(pl) => {
                                info!(name = %name, "Loaded playlist from directory");
                                playlists.insert(name, pl);
                            }
                            Err(e) => {
                                warn!(name = %name, error = %e, "Playlist references missing songs, skipping");
                            }
                        }
                    }
                    Err(e) => {
                        warn!(path = ?path, error = %e, "Failed to parse playlist file, skipping");
                    }
                }
            }
        }
    }

    // Load legacy playlist file as name "playlist" (if not already loaded from dir).
    if let Some(legacy_path) = legacy_playlist_path {
        if !playlists.contains_key("playlist") {
            match config::Playlist::deserialize(legacy_path) {
                Ok(playlist_config) => {
                    match Playlist::new("playlist", &playlist_config, songs.clone()) {
                        Ok(pl) => {
                            info!("Loaded legacy playlist");
                            playlists.insert("playlist".to_string(), pl);
                        }
                        Err(e) => {
                            info!("Legacy playlist references missing songs ({}); skipping", e);
                        }
                    }
                }
                Err(_) => {
                    info!("Legacy playlist file not found or invalid; skipping");
                }
            }
        }
    }

    Ok(playlists)
}

#[cfg(test)]
mod test {
    use std::{collections::HashMap, error::Error, fs, path::Path, sync::Arc};

    use crate::{
        config,
        playlist::Playlist,
        songs,
        testutil::{eventually, eventually_async},
    };

    use super::*;

    /// Test helper: builds a playlists map from a single playlist + songs registry.
    fn test_playlists(
        playlist: Arc<Playlist>,
        songs: Arc<Songs>,
    ) -> HashMap<String, Arc<Playlist>> {
        let mut playlists = HashMap::new();
        playlists.insert(
            "all_songs".to_string(),
            playlist::from_songs(songs).unwrap(),
        );
        playlists.insert("playlist".to_string(), playlist);
        playlists
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_player() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                None,
                HashMap::new(),
                "assets/songs",
            ),
            None,
        )?;
        player.await_hardware_ready().await;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;
        let midi_device = player
            .midi_device()
            .expect("MIDI should be present")
            .to_mock()?;

        // Direct the player.
        println!("Playlist -> Song 1");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        player.next().await;
        println!("Playlist -> Song 3");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");

        player.prev().await;
        println!("Playlist -> Song 1");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        println!("Switch to AllSongs");
        player.switch_to_playlist("all_songs").await.unwrap();
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        player.next().await;
        println!("AllSongs -> Song 10");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 10");

        // No emitted events yet
        assert!(midi_device.get_emitted_event().is_none());

        player.next().await;
        println!("AllSongs -> Song 2");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 2");

        let expected_event = midly::live::LiveEvent::Midi {
            channel: 15.into(),
            message: midly::MidiMessage::ProgramChange { program: 0.into() },
        };
        let actual_event_buf = midi_device
            .get_emitted_event()
            .expect("expected emitted event");
        let actual_event = midly::live::LiveEvent::parse(&actual_event_buf)?;
        assert_eq!(expected_event, actual_event);

        midi_device.reset_emitted_event();

        player.next().await;
        println!("AllSongs -> Song 3");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");

        assert!(midi_device.get_emitted_event().is_none());

        player.switch_to_playlist("playlist").await.unwrap();
        println!("Switch to Playlist");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        player.next().await;
        println!("Playlist -> Song 3");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");

        player.play().await?;

        // Playlist should have moved to next song.
        eventually(
            || player.get_playlist().current().unwrap().name() == "Song 5",
            format!(
                "Song never moved to next, on song {}",
                player.get_playlist().current().unwrap().name()
            )
            .as_str(),
        );

        // Next song should have emitted an event.
        let expected_event = midly::live::LiveEvent::Midi {
            channel: 15.into(),
            message: midly::MidiMessage::ProgramChange { program: 5.into() },
        };
        let actual_event_buf = midi_device
            .get_emitted_event()
            .expect("expected emitted event");
        let actual_event = midly::live::LiveEvent::parse(&actual_event_buf)?;
        assert_eq!(expected_event, actual_event);

        midi_device.reset_emitted_event();

        // Play a song and cancel it.
        player.play().await?;
        println!("Play Song 5.");
        eventually(|| device.is_playing(), "Song never started playing");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");

        // Player should not have moved to the next song.
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 5");

        assert!(midi_device.get_emitted_event().is_none());

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_player_rejects_invalid_lighting_shows() -> Result<(), Box<dyn Error>> {
        // Create a temporary directory for test files
        let temp_dir = tempfile::tempdir()?;
        let temp_path = temp_dir.path();

        // Create a valid lighting show file with invalid group reference
        let lighting_show_content = r#"show "Test Show" {
    @00:00.000
    invalid_group: static color: "blue", duration: 5s, dimmer: 60%
}"#;
        let lighting_file = temp_path.join("invalid_show.light");
        fs::write(&lighting_file, lighting_show_content)?;

        // Create a song with the invalid lighting show
        let song_config = config::Song::new(
            "Test Song",
            None,
            None,
            None,
            None,
            Some(vec![config::LightingShow::new(
                lighting_file
                    .file_name()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string(),
            )]),
            vec![],
            HashMap::new(),
            Vec::new(),
        );

        // Create a lighting config with valid groups (but not "invalid_group")
        let mut groups = HashMap::new();
        groups.insert(
            "front_wash".to_string(),
            config::lighting::LogicalGroup::new(
                "front_wash".to_string(),
                vec![config::lighting::GroupConstraint::AllOf(vec![
                    "wash".to_string(),
                    "front".to_string(),
                ])],
            ),
        );
        let lighting_config =
            config::Lighting::new(Some("test_venue".to_string()), None, Some(groups), None);

        // Create DMX config with lighting
        let dmx_config = config::Dmx::new(
            Some(1.0),
            Some("0s".to_string()),
            Some(9090),
            vec![config::Universe::new(1, "test_universe".to_string())],
            Some(lighting_config),
        );

        // Create a simple playlist with one song
        let playlist_songs = vec!["Test Song".to_string()];
        let playlist_config = config::Playlist::new(&playlist_songs);
        let song = songs::Song::new(temp_path, &song_config)?;
        let songs_map = HashMap::from([("Test Song".to_string(), Arc::new(song))]);
        let songs = Arc::new(songs::Songs::new(songs_map));
        let playlist = Playlist::new("Test Playlist", &playlist_config, songs.clone())?;

        // Create player with DMX engine that has lighting config
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                Some(dmx_config),
                HashMap::new(),
                temp_path.to_str().unwrap(),
            ),
            Some(temp_path),
        )?;
        player.await_hardware_ready().await;

        // Try to play the song - it should fail due to invalid lighting show
        let result = player.play().await;
        assert!(
            result.is_err(),
            "Player should reject song with invalid lighting show"
        );

        Ok(())
    }

    /// Flexible helper to create a player with the standard test assets and
    /// optional subsystem configs. Waits for hardware init to complete.
    async fn make_test_player_with_config(
        audio: Option<config::Audio>,
        midi: Option<config::Midi>,
        dmx: Option<config::Dmx>,
    ) -> Result<Arc<Player>, Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let player = Player::new(
            test_playlists(playlist, songs),
            "playlist".to_string(),
            &config::Player::new(vec![], audio, midi, dmx, HashMap::new(), "assets/songs"),
            None,
        )?;
        player.await_hardware_ready().await;
        Ok(player)
    }

    /// Helper to create a player with the standard test assets (audio + MIDI).
    async fn make_test_player() -> Result<Arc<Player>, Box<dyn Error>> {
        make_test_player_with_config(
            Some(config::Audio::new("mock-device")),
            Some(config::Midi::new("mock-midi-device", None)),
            None,
        )
        .await
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_stop_when_not_playing() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // stop() when nothing is playing should return None.
        let result = player.stop().await;
        assert!(result.is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_is_playing() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        assert!(!player.is_playing().await);

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");
        assert!(player.is_playing().await);

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_elapsed_stopped() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // elapsed() when not playing should be Ok(None).
        let elapsed = player.elapsed().await?;
        assert!(elapsed.is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_elapsed_while_playing() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // play_start_time is set inside play_files after clock.start(),
        // so there may be a brief gap after is_playing becomes true.
        let deadline = std::time::Instant::now() + Duration::from_secs(3);
        loop {
            if player.elapsed().await?.is_some() {
                break;
            }
            assert!(
                std::time::Instant::now() < deadline,
                "elapsed should have a value while playing"
            );
            tokio::time::sleep(Duration::from_millis(10)).await;
        }

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_concurrent_play_returns_none() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        // First play should succeed.
        let result = player.play().await?;
        assert!(result.is_some());
        eventually(|| device.is_playing(), "Song never started playing");

        // Second play while already playing should return Ok(None).
        let result = player.play().await?;
        assert!(result.is_none(), "play() while playing should return None");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_navigation() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        let song = player.next().await.unwrap();
        assert_eq!(song.name(), "Song 3");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 3");

        let song = player.prev().await.unwrap();
        assert_eq!(song.name(), "Song 1");
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_switch_playlists() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        assert_eq!(player.get_playlist().name(), "playlist");
        player.switch_to_playlist("all_songs").await.unwrap();
        assert_eq!(player.get_playlist().name(), "all_songs");
        player.switch_to_playlist("playlist").await.unwrap();
        assert_eq!(player.get_playlist().name(), "playlist");

        Ok(())
    }

    /// `refresh_transport_state` must broadcast only when the snapshot
    /// actually changes — repeated refreshes against unchanged state
    /// must not wake subscribers. This is what keeps `mtrack://status`
    /// subscribers quiet during steady-state playback.
    #[tokio::test(flavor = "multi_thread")]
    async fn test_transport_channel_coalesces_no_op_refreshes() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        // Prime the channel so it reflects the real (post-init) snapshot,
        // not the default value the watch was constructed with.
        player.refresh_transport_state().await;
        let mut rx = player.transport_rx();
        rx.mark_unchanged();

        // Refresh repeatedly against unchanged state. None should wake the receiver.
        for _ in 0..10 {
            player.refresh_transport_state().await;
        }
        let result = tokio::time::timeout(Duration::from_millis(50), rx.changed()).await;
        assert!(
            result.is_err(),
            "transport channel emitted on no-op refresh: {:?}",
            rx.borrow().clone()
        );

        // A real transport transition must wake the receiver.
        player.switch_to_playlist("all_songs").await.unwrap();
        player.refresh_transport_state().await;
        let result = tokio::time::timeout(Duration::from_secs(2), rx.changed()).await;
        assert!(result.is_ok(), "transport channel did not emit on switch");
        assert_eq!(rx.borrow().playlist_name, "all_songs");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_get_all_songs_playlist() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let all = player
            .get_all_songs_playlist()
            .expect("all_songs should be present in test");
        assert_eq!(all.name(), "all_songs");
        assert!(!all.songs().is_empty());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_format_active_effects_no_dmx() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        // No DMX engine configured, should return None.
        assert!(player.format_active_effects().is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_dmx_engine_none_without_config() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        assert!(player.dmx_engine().is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_get_cues_empty_without_lighting() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let cues = player.get_cues();
        assert!(cues.is_empty());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_player_rejects_song_with_multiple_invalid_groups() -> Result<(), Box<dyn Error>> {
        // Create a temporary directory for test files
        let temp_dir = tempfile::tempdir()?;
        let temp_path = temp_dir.path();

        // Create a valid lighting show file with multiple invalid group references
        let lighting_show_content = r#"show "Test Show" {
    @00:00.000
    invalid_group_1: static color: "blue", duration: 5s, dimmer: 60%
    invalid_group_2: static color: "red", duration: 5s, dimmer: 80%
}"#;
        let lighting_file = temp_path.join("invalid_groups.light");
        fs::write(&lighting_file, lighting_show_content)?;

        // Create a song with the invalid lighting show
        let song_config = config::Song::new(
            "Test Song",
            None,
            None,
            None,
            None,
            Some(vec![config::LightingShow::new(
                lighting_file
                    .file_name()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string(),
            )]),
            vec![],
            HashMap::new(),
            Vec::new(),
        );

        // Create a lighting config with valid groups (but not the invalid ones)
        let mut groups = HashMap::new();
        groups.insert(
            "front_wash".to_string(),
            config::lighting::LogicalGroup::new(
                "front_wash".to_string(),
                vec![config::lighting::GroupConstraint::AllOf(vec![
                    "wash".to_string(),
                    "front".to_string(),
                ])],
            ),
        );
        let lighting_config =
            config::Lighting::new(Some("test_venue".to_string()), None, Some(groups), None);

        // Create DMX config with lighting
        let dmx_config = config::Dmx::new(
            Some(1.0),
            Some("0s".to_string()),
            Some(9090),
            vec![config::Universe::new(1, "test_universe".to_string())],
            Some(lighting_config),
        );

        // Create a simple playlist with one song
        let playlist_songs = vec!["Test Song".to_string()];
        let playlist_config = config::Playlist::new(&playlist_songs);
        let song = songs::Song::new(temp_path, &song_config)?;
        let songs_map = HashMap::from([("Test Song".to_string(), Arc::new(song))]);
        let songs = Arc::new(songs::Songs::new(songs_map));
        let playlist = Playlist::new("Test Playlist", &playlist_config, songs.clone())?;

        // Create player with DMX engine that has lighting config
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                Some(dmx_config),
                HashMap::new(),
                temp_path.to_str().unwrap(),
            ),
            Some(temp_path),
        )?;
        player.await_hardware_ready().await;

        // Try to play the song - it should fail due to invalid lighting show groups
        let result = player.play().await;
        assert!(
            result.is_err(),
            "Player should reject song with invalid lighting show groups"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_stop_returns_current_song() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        let song = player.stop().await;
        assert!(song.is_some(), "stop() should return the current song");
        assert_eq!(song.unwrap().name(), "Song 1");

        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_play_from_nonzero_start() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        let result = player.play_from(Duration::from_millis(100)).await?;
        assert!(result.is_some(), "play_from should succeed");

        eventually(|| device.is_playing(), "Song never started playing");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_play_after_stop_restarts() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        // First play/stop cycle.
        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");
        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");

        // Second play should succeed (stop_run flag was reset).
        let result = player.play().await?;
        assert!(
            result.is_some(),
            "play() after stop should start a new song"
        );
        eventually(|| device.is_playing(), "Song never restarted");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_audio_only_no_midi() -> Result<(), Box<dyn Error>> {
        let player =
            make_test_player_with_config(Some(config::Audio::new("mock-device")), None, None)
                .await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        assert!(
            player.midi_device().is_none(),
            "MIDI device should be absent"
        );

        // Song 2 has no midi_file, so barrier is audio-only.
        // Navigate to Song 2 (default playlist starts at Song 1).
        player.get_playlist().next(); // Song 3
        player.get_playlist().next(); // Song 5
                                      // Use the all songs playlist to reach Song 2 more easily.
        player.switch_to_playlist("all_songs").await.unwrap();
        // all_songs starts at Song 1, navigate to Song 2.
        player.get_playlist().next(); // Song 10
        player.get_playlist().next(); // Song 2
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 2");

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_midi_only_no_audio() -> Result<(), Box<dyn Error>> {
        let player = make_test_player_with_config(
            None,
            Some(config::Midi::new("mock-midi-device", None)),
            None,
        )
        .await?;

        assert!(
            player.audio_device().is_none(),
            "Audio device should be absent"
        );

        // Song 1 has midi_file. Barrier = 1 (MIDI only).
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        player.play().await?;

        // Natural finish: playlist should advance.
        eventually_async(
            || async { player.get_playlist().current().unwrap().name() != "Song 1" },
            "Playlist never advanced after MIDI-only playback",
        )
        .await;

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_no_subsystems_completes_immediately() -> Result<(), Box<dyn Error>> {
        // Build a Player directly with no subsystems to exercise the
        // num_barriers == 0 early-return in play_files().
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let devices = PlayerDevices {
            audio: None,
            mappings: None,
            midi: None,
            dmx_engine: None,
            sample_engine: None,
            trigger_engine: None,
        };
        let player = Player::new_with_devices(
            devices,
            test_playlists(playlist, songs),
            "playlist".to_string(),
            None,
        )?;

        assert!(player.audio_device().is_none());
        assert!(player.midi_device().is_none());
        assert!(player.dmx_engine().is_none());

        player.play().await?;

        // num_barriers == 0 → play_files returns immediately → playlist advances.
        eventually_async(
            || async { player.get_playlist().current().unwrap().name() != "Song 1" },
            "Playlist never advanced after no-subsystem playback",
        )
        .await;

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_natural_finish_clears_play_state() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // Song 1 is short (~0.7s). Let it finish naturally.
        player.play().await?;

        // Wait for natural finish: playlist advances.
        eventually_async(
            || async { !player.is_playing().await },
            "Player never stopped after natural finish",
        )
        .await;

        let elapsed = player.elapsed().await?;
        assert!(
            elapsed.is_none(),
            "elapsed() should be None after natural finish"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_play_with_dmx_engine() -> Result<(), Box<dyn Error>> {
        let dmx_config = config::Dmx::new(
            None,
            None,
            Some(9090),
            vec![config::Universe::new(1, "test".to_string())],
            None,
        );
        let player = make_test_player_with_config(
            Some(config::Audio::new("mock-device")),
            None,
            Some(dmx_config),
        )
        .await?;

        assert!(
            player.dmx_engine().is_some(),
            "DMX engine should be present"
        );

        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_switch_playlist_while_playing_stays() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        assert_eq!(player.get_playlist().name(), "playlist");

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // Attempt switch while playing — should return error.
        let result = player.switch_to_playlist("all_songs").await;
        assert!(
            result.is_err(),
            "switch_to_playlist should fail while playing"
        );
        assert_eq!(
            player.get_playlist().name(),
            "playlist",
            "playlist should not change while playing"
        );

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_playlist_clamps_at_end_on_natural_finish() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // Navigate to Song 9 (last in playlist).
        // Playlist: Song 1, Song 3, Song 5, Song 7, Song 9
        player.next().await; // Song 3
        player.next().await; // Song 5
        player.next().await; // Song 7
        player.next().await; // Song 9
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 9");

        // Play Song 9 — short audio (0.5s), should finish naturally.
        player.play().await?;

        // After natural finish, playlist next() clamps at the last song.
        eventually_async(
            || async { !player.is_playing().await },
            "Player never stopped after Song 9 finished",
        )
        .await;

        // Playlist should still be at Song 9 (clamped, no wrap).
        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 9");

        Ok(())
    }

    // --- resolve_playback_outcome tests ---

    #[test]
    fn playback_outcome_no_audio() {
        assert_eq!(resolve_playback_outcome(false, None), Ok(()));
    }

    #[test]
    fn playback_outcome_audio_ok() {
        assert_eq!(resolve_playback_outcome(true, Some(Ok(()))), Ok(()));
    }

    #[test]
    fn playback_outcome_audio_err() {
        let err_msg = "device disconnected".to_string();
        assert_eq!(
            resolve_playback_outcome(true, Some(Err(err_msg.clone()))),
            Err(err_msg)
        );
    }

    #[test]
    fn playback_outcome_audio_none_panicked() {
        // Thread panicked before setting outcome — treated as success
        assert_eq!(resolve_playback_outcome(true, None), Ok(()));
    }

    // --- decide_cleanup_action tests ---

    #[test]
    fn cleanup_success_not_cancelled() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Success, false, false),
            CleanupAction::AdvancePlaylist
        );
    }

    #[test]
    fn cleanup_success_cancelled() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Success, true, false),
            CleanupAction::StopCancelled
        );
    }

    #[test]
    fn cleanup_failed_not_cancelled() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Failed("err".into()), false, false),
            CleanupAction::AdvancePlaylist
        );
    }

    #[test]
    fn cleanup_failed_cancelled() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Failed("err".into()), true, false),
            CleanupAction::StopCancelled
        );
    }

    #[test]
    fn cleanup_sender_dropped_not_cancelled() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::SenderDropped, false, false),
            CleanupAction::AdvancePlaylist
        );
    }

    #[test]
    fn cleanup_loop_broken() {
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Success, false, true),
            CleanupAction::LoopBreakAndPlay
        );
    }

    #[test]
    fn cleanup_loop_broken_takes_priority_over_cancel() {
        // If both cancelled and loop_broken, loop_broken wins — we intentionally
        // cancel to break out of the loop immediately, but the intent is to advance.
        assert_eq!(
            decide_cleanup_action(PlaybackResult::Success, true, true),
            CleanupAction::LoopBreakAndPlay
        );
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_dmx_utility_methods() -> Result<(), Box<dyn Error>> {
        let dmx_config = config::Dmx::new(
            None,
            None,
            Some(9090),
            vec![config::Universe::new(1, "test".to_string())],
            None,
        );
        let player = make_test_player_with_config(
            Some(config::Audio::new("mock-device")),
            None,
            Some(dmx_config),
        )
        .await?;

        // get_cues() with DMX engine present (no timeline loaded → empty)
        let cues = player.get_cues();
        assert!(cues.is_empty());

        // broadcast_handles() returns Some when DMX engine is present
        assert!(
            player.broadcast_handles().is_some(),
            "broadcast_handles should be Some with DMX engine"
        );

        // set_broadcast_tx() should not panic
        let (tx, _rx) = tokio::sync::broadcast::channel(1);
        player.set_broadcast_tx(tx);

        // effect_engine() returns Some when DMX engine is present
        assert!(
            player.effect_engine().is_some(),
            "effect_engine should be Some with DMX engine"
        );

        // format_active_effects() returns Some when DMX engine is present
        assert!(
            player.format_active_effects().is_some(),
            "format_active_effects should be Some with DMX engine"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_track_mappings() -> Result<(), Box<dyn Error>> {
        // Player with audio → track_mappings is Some
        let player = make_test_player().await?;
        assert!(
            player.track_mappings().is_some(),
            "track_mappings should be Some when audio is configured"
        );

        // Player without audio → track_mappings is None
        let player = make_test_player_with_config(
            None,
            Some(config::Midi::new("mock-midi-device", None)),
            None,
        )
        .await?;
        assert!(
            player.track_mappings().is_none(),
            "track_mappings should be None without audio"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_next_while_playing_returns_current() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        assert_eq!(player.get_playlist().current().unwrap().name(), "Song 1");

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // next() while playing should return the current song without advancing
        let song = player.next().await.unwrap();
        assert_eq!(
            song.name(),
            "Song 1",
            "next() while playing should return current song"
        );
        assert_eq!(
            player.get_playlist().current().unwrap().name(),
            "Song 1",
            "playlist should not advance while playing"
        );

        // prev() while playing should also return the current song
        let song = player.prev().await.unwrap();
        assert_eq!(
            song.name(),
            "Song 1",
            "prev() while playing should return current song"
        );

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[test]
    fn status_events_new_none() {
        let result = StatusEvents::new(None).unwrap();
        assert!(result.is_none());
    }

    /// Helper to create a player with no subsystems via new_with_devices.
    fn make_bare_player() -> Result<Player, Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let devices = PlayerDevices {
            audio: None,
            mappings: None,
            midi: None,
            dmx_engine: None,
            sample_engine: None,
            trigger_engine: None,
        };
        Player::new_with_devices(
            devices,
            test_playlists(playlist, songs),
            "playlist".to_string(),
            None,
        )
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_process_sample_trigger_no_engine() -> Result<(), Box<dyn Error>> {
        let player = make_bare_player()?;
        player.process_sample_trigger(&[0x90, 60, 127]);
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_stop_samples_no_engine() -> Result<(), Box<dyn Error>> {
        let player = make_bare_player()?;
        player.stop_samples();
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_broadcast_handles_no_dmx() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        assert!(player.broadcast_handles().is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_effect_engine_no_dmx() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        assert!(player.effect_engine().is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_set_broadcast_tx_no_dmx() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let (tx, _rx) = tokio::sync::broadcast::channel(1);
        player.set_broadcast_tx(tx);
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn emit_song_change_no_device() -> Result<(), Box<dyn Error>> {
        let player = make_test_player_with_config(None, None, None).await?;
        let song = Song::new_for_test("test", &[]);
        // Should not panic when no MIDI device is configured.
        player.emit_song_change(&song);
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_switch_to_playlist_while_playing_stays() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.switch_to_playlist("all_songs").await.unwrap();
        assert_eq!(player.get_playlist().name(), "all_songs");

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        let result = player.switch_to_playlist("playlist").await;
        assert!(
            result.is_err(),
            "switch_to_playlist should fail while playing"
        );
        assert_eq!(
            player.get_playlist().name(),
            "all_songs",
            "playlist should not change while playing"
        );

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_navigate_no_midi() -> Result<(), Box<dyn Error>> {
        let player = make_test_player_with_config(None, None, None).await?;

        player.next().await;
        let song = player.prev().await.unwrap();
        assert_eq!(song.name(), "Song 1");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_new_with_devices_all_none() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "test",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let devices = PlayerDevices {
            audio: None,
            mappings: None,
            midi: None,
            dmx_engine: None,
            sample_engine: None,
            trigger_engine: None,
        };
        let player = Player::new_with_devices(
            devices,
            test_playlists(playlist, songs),
            "test".to_string(),
            None,
        )?;
        assert!(player.audio_device().is_none());
        assert!(player.midi_device().is_none());
        assert!(player.dmx_engine().is_none());
        assert!(player.track_mappings().is_none());
        assert!(player.broadcast_handles().is_none());
        assert!(player.effect_engine().is_none());
        assert!(player.format_active_effects().is_none());
        assert!(player.get_cues().is_empty());
        assert!(!player.is_playing().await);
        assert!(player.elapsed().await?.is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_play_from_while_playing_returns_none() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        let result = player.play_from(Duration::from_secs(1)).await?;
        assert!(
            result.is_none(),
            "play_from while playing should return None"
        );

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_no_subsystem_player_play_and_navigate() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let devices = PlayerDevices {
            audio: None,
            mappings: None,
            midi: None,
            dmx_engine: None,
            sample_engine: None,
            trigger_engine: None,
        };
        let player = Player::new_with_devices(
            devices,
            test_playlists(playlist, songs),
            "playlist".to_string(),
            None,
        )?;

        player.process_sample_trigger(&[0x90, 60, 127]);
        player.stop_samples();

        let song = player.next().await.unwrap();
        assert_eq!(song.name(), "Song 3");

        let song = player.prev().await.unwrap();
        assert_eq!(song.name(), "Song 1");

        player.play().await?;
        eventually_async(
            || async { !player.is_playing().await },
            "Player never stopped after no-subsystem playback",
        )
        .await;

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_midi_device_accessor() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        assert!(player.midi_device().is_some());

        let player =
            make_test_player_with_config(Some(config::Audio::new("mock-device")), None, None)
                .await?;
        assert!(player.midi_device().is_none());
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_config_store_getter_setter() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // Initially None.
        assert!(player.config_store().is_none());

        // Set a config store.
        let dir = tempfile::tempdir()?;
        let path = dir.path().join("config.yaml");
        std::fs::write(&path, "songs: songs\n")?;
        let cfg = config::Player::deserialize(&path)?;
        let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
        player.set_config_store(store.clone());

        // Now it should be Some.
        let retrieved = player.config_store();
        assert!(retrieved.is_some());

        // Should be the same Arc (read_yaml returns same checksum).
        let (_, checksum1) = store.read_yaml().await?;
        let (_, checksum2) = retrieved.unwrap().read_yaml().await?;
        assert_eq!(checksum1, checksum2);

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_reload_hardware_when_idle() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        assert!(player.audio_device().is_some());

        // Set up a config store with a profile that has no audio.
        let dir = tempfile::tempdir()?;
        let path = dir.path().join("config.yaml");
        let yaml = "songs: songs\nprofiles:\n  - midi:\n      device: mock-midi-device\n";
        std::fs::write(&path, yaml)?;
        let cfg = config::Player::deserialize(&path)?;
        let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
        player.set_config_store(store);

        // Reload should swap hardware — audio device should now be None.
        player.reload_hardware().await?;
        player.await_hardware_ready().await;
        assert!(
            player.audio_device().is_none(),
            "Audio device should be None after reload with no audio profile"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_reload_hardware_during_playback_rejected() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        // Set up config store (needed for reload_hardware).
        let dir = tempfile::tempdir()?;
        let path = dir.path().join("config.yaml");
        let yaml = "songs: songs\nprofiles:\n  - audio:\n      device: mock-device\n      track_mappings:\n        click: [1]\n";
        std::fs::write(&path, yaml)?;
        let cfg = config::Player::deserialize(&path)?;
        let store = std::sync::Arc::new(config::ConfigStore::new(cfg, path));
        player.set_config_store(store);

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // reload_hardware should fail during playback.
        let result = player.reload_hardware().await;
        assert!(
            result.is_err(),
            "reload_hardware should fail during playback"
        );
        assert!(
            result.unwrap_err().to_string().contains("during playback"),
            "error should mention playback"
        );

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_reload_hardware_no_config_store() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // No config store set — reload should fail.
        let result = player.reload_hardware().await;
        assert!(
            result.is_err(),
            "reload_hardware should fail without config store"
        );

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_hardware_status_no_devices() -> Result<(), Box<dyn Error>> {
        let player = make_test_player_with_config(None, None, None).await?;
        let status = player.hardware_status();

        assert!(status.init_done);
        assert_eq!(status.audio.status, "not_connected");
        assert_eq!(status.midi.status, "not_connected");
        assert_eq!(status.dmx.status, "not_connected");
        assert_eq!(status.trigger.status, "not_connected");
        assert!(status.audio.name.is_none());
        assert!(status.midi.name.is_none());
        assert!(status.dmx.name.is_none());
        assert!(status.trigger.name.is_none());

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_hardware_status_with_devices() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let status = player.hardware_status();

        assert!(status.init_done);
        assert_eq!(status.audio.status, "connected");
        assert!(status.audio.name.is_some());
        assert_eq!(status.midi.status, "connected");
        assert!(status.midi.name.is_some());

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_list_playlists() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let names = player.list_playlists();
        // Should contain at least "all_songs" and "playlist", sorted.
        assert!(names.contains(&"all_songs".to_string()));
        assert!(names.contains(&"playlist".to_string()));
        assert_eq!(
            names,
            {
                let mut sorted = names.clone();
                sorted.sort();
                sorted
            },
            "list_playlists should return sorted names"
        );
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_persisted_playlist_name() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        // Active playlist is "playlist", so persisted should also be "playlist".
        assert_eq!(player.persisted_playlist_name(), "playlist");

        // After switching to all_songs, persisted should still be "playlist".
        player.switch_to_playlist("all_songs").await.unwrap();
        assert_eq!(player.persisted_playlist_name(), "playlist");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_playlists_snapshot() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let snapshot = player.playlists_snapshot();
        assert!(snapshot.contains_key("all_songs"));
        assert!(snapshot.contains_key("playlist"));
        assert_eq!(snapshot.len(), 2);
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_reload_songs() -> Result<(), Box<dyn Error>> {
        let player = make_bare_player()?;
        let initial_count = player.songs().len();

        // Create a temp directory that mirrors assets/ layout with an extra song.
        let temp_dir = tempfile::tempdir()?;
        let songs_dir = temp_dir.path().join("songs");
        fs::create_dir_all(&songs_dir)?;

        // Copy existing song YAML configs into temp songs dir.
        let src = Path::new("assets/songs");
        for entry in fs::read_dir(src)? {
            let entry = entry?;
            if entry.path().extension().is_some_and(|e| e == "yaml") {
                fs::copy(entry.path(), songs_dir.join(entry.file_name()))?;
            }
        }

        // Copy required audio/MIDI assets into the temp parent (songs configs use ../ paths).
        let assets = Path::new("assets");
        for entry in fs::read_dir(assets)? {
            let entry = entry?;
            if entry.path().is_file() {
                fs::copy(entry.path(), temp_dir.path().join(entry.file_name()))?;
            }
        }

        // Add a new song config referencing a copied audio file.
        let new_song_yaml =
            "name: \"New Test Song\"\ntracks:\n  - name: click\n    file: ../1Channel44.1k.wav\n";
        fs::write(songs_dir.join("newsong.yaml"), new_song_yaml)?;

        player.reload_songs(&songs_dir, None, None);
        assert!(
            player.songs().len() > initial_count,
            "reload_songs should discover the new song (was {}, now {})",
            initial_count,
            player.songs().len(),
        );
        Ok(())
    }

    #[test]
    fn test_load_playlists_standalone() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;

        // Load with legacy playlist only.
        let playlists =
            super::load_playlists(None, Some(Path::new("assets/playlist.yaml")), songs.clone())?;
        assert!(playlists.contains_key("all_songs"));
        assert!(playlists.contains_key("playlist"));
        assert_eq!(playlists["playlist"].songs().len(), 5);

        // Load with no playlists dir and no legacy — just all_songs.
        let playlists = super::load_playlists(None, None, songs.clone())?;
        assert!(playlists.contains_key("all_songs"));
        assert_eq!(playlists.len(), 1);

        // Load from a playlists directory.
        let temp_dir = tempfile::tempdir()?;
        let pl_dir = temp_dir.path();
        fs::write(pl_dir.join("my_set.yaml"), "songs:\n- Song 1\n- Song 3\n")?;
        let playlists = super::load_playlists(Some(pl_dir), None, songs)?;
        assert!(playlists.contains_key("all_songs"));
        assert!(playlists.contains_key("my_set"));
        assert_eq!(playlists["my_set"].songs().len(), 2);

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn play_song_from_valid() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                None,
                HashMap::new(),
                "assets/songs",
            ),
            None,
        )?;
        player.await_hardware_ready().await;
        let device = player.audio_device().expect("audio device").to_mock()?;

        let result = player
            .play_song_from("Song 2", std::time::Duration::ZERO)
            .await?;
        assert!(result.is_some());
        assert_eq!(result.unwrap().name(), "Song 2");
        eventually(|| device.is_playing(), "Song never started playing");

        // Switches to all_songs (session-only) for the duration of playback
        assert_eq!(player.get_playlist().name(), "all_songs");

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn play_song_from_not_found() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                None,
                HashMap::new(),
                "assets/songs",
            ),
            None,
        )?;
        player.await_hardware_ready().await;

        let result = player
            .play_song_from("Nonexistent Song", std::time::Duration::ZERO)
            .await;
        assert!(result.is_err());
        let err = result.err().unwrap();
        assert!(err.to_string().contains("not found"));
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn play_song_from_while_playing() -> Result<(), Box<dyn Error>> {
        let songs = songs::get_all_songs(Path::new("assets/songs"))?;
        let playlist = Playlist::new(
            "playlist",
            &config::Playlist::deserialize(Path::new("assets/playlist.yaml"))?,
            songs.clone(),
        )?;
        let player = Player::new(
            test_playlists(playlist, songs.clone()),
            "playlist".to_string(),
            &config::Player::new(
                vec![],
                Some(config::Audio::new("mock-device")),
                Some(config::Midi::new("mock-midi-device", None)),
                None,
                HashMap::new(),
                "assets/songs",
            ),
            None,
        )?;
        player.await_hardware_ready().await;
        let device = player.audio_device().expect("audio device").to_mock()?;

        // Start playing first
        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // play_song_from while already playing should return None
        let result = player
            .play_song_from("Song 2", std::time::Duration::ZERO)
            .await?;
        assert!(result.is_none());

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_loop_section_not_playing() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // loop_section when nothing is playing should error.
        let result = player.loop_section("verse").await;
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("no song is playing"));
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_loop_section_not_found() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;
        let binding = player
            .audio_device()
            .expect("audio device should be present");
        let device = binding.to_mock()?;

        player.play().await?;
        eventually(|| device.is_playing(), "Song never started playing");

        // Test songs have no sections/beat grid, so any section name should fail.
        let result = player.loop_section("nonexistent").await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));

        player.stop().await;
        eventually(|| !device.is_playing(), "Song never stopped playing");
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_stop_section_loop_clears_state() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // active_section should be None initially.
        assert!(player.active_section().is_none());

        // Manually set active section state to simulate an active loop.
        {
            let mut active = player.active_section.write();
            *active = Some(SectionBounds {
                name: "test".to_string(),
                start_time: Duration::from_secs(1),
                end_time: Duration::from_secs(5),
            });
        }
        assert!(player.active_section().is_some());

        // stop_section_loop should clear it.
        player.stop_section_loop();
        assert!(player.active_section().is_none());
        assert!(player.section_loop_break.load(Ordering::Relaxed));

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_add_loop_time_consumed() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // Initially zero.
        assert_eq!(*player.loop_time_consumed.lock(), Duration::ZERO);

        // Accumulates correctly.
        player.add_loop_time_consumed(Duration::from_secs(2));
        assert_eq!(*player.loop_time_consumed.lock(), Duration::from_secs(2));

        player.add_loop_time_consumed(Duration::from_secs(3));
        assert_eq!(*player.loop_time_consumed.lock(), Duration::from_secs(5));

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_active_section_getter() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        assert!(player.active_section().is_none());

        let bounds = SectionBounds {
            name: "chorus".to_string(),
            start_time: Duration::from_secs(10),
            end_time: Duration::from_secs(20),
        };
        *player.active_section.write() = Some(bounds.clone());

        let active = player.active_section().unwrap();
        assert_eq!(active.name, "chorus");
        assert_eq!(active.start_time, Duration::from_secs(10));
        assert_eq!(active.end_time, Duration::from_secs(20));

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn test_is_current_song_looping() -> Result<(), Box<dyn Error>> {
        let player = make_test_player().await?;

        // Test songs don't have loop_playback set, so this should be false.
        assert!(!player.is_current_song_looping());

        Ok(())
    }
}
