//! Backend-agnostic music renderer façade for PMOMusic.
//!
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
//! single control surface. Higher layers in PMOMusic must only interact with
//! renderers through this type so that transport, volume, and state queries
//! stay backend-neutral.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, SystemTime};

use pmodidl::{DIDLLite, MediaMetadataParser};
use tracing::{debug, error};

use crate::errors::ControlPointError;
use crate::events::RendererEventBus;
use crate::model::RendererEvent;
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
use crate::music_renderer::sleep_timer::SleepTimer;
use crate::music_renderer::upnp_renderer::UpnpRenderer;
use crate::music_renderer::watcher::{
    extract_track_metadata, playback_position_equal, playback_state_equal, WatchStrategy,
    WatchedState,
};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::{
    HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
    QueueTransportControl, TransportControl, VolumeControl,
};
use crate::online::DeviceConnectionState;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};

use tracing::warn;

/// Describes a renderer's attachment to a media server playlist container.
///
/// When a renderer is attached to a playlist, the ControlPoint monitors the container
/// for updates and automatically refreshes the queue when changes are detected.
#[derive(Clone, Debug)]
pub struct PlaylistBinding {
    /// MediaServer that owns the playlist container.
    pub(crate) server_id: DeviceId,
    /// DIDL-Lite object id of the playlist container.
    pub(crate) container_id: String,
    /// True once at least one ContainerUpdateIDs notification has been seen.
    pub(crate) has_seen_update: bool,
    /// Flag used internally to signal that the queue should be refreshed
    /// from the server container.
    pub(crate) pending_refresh: bool,
    /// Whether the next refresh should auto-start playback if the renderer is idle.
    pub(crate) auto_play_on_refresh: bool,
}

impl PlaylistBinding {
    /// Returns the ID of the MediaServer that owns this playlist container.
    pub fn server_id(&self) -> &DeviceId {
        &self.server_id
    }

    /// Returns the DIDL-Lite object ID of the playlist container.
    pub fn container_id(&self) -> &str {
        &self.container_id
    }
}

/// Backend-agnostic façade exposing transport, volume, and status contracts.
#[derive(Clone, Debug)]
pub enum MusicRendererBackend {
    /// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
    Upnp(UpnpRenderer),
    /// Renderer powered by OpenHome services.
    OpenHome(OpenHomeRenderer),
    /// Renderer controlled via the LinkPlay HTTP API.
    LinkPlay(LinkPlayRenderer),
    /// Renderer reachable through the Arylic TCP control protocol (port 8899).
    ArylicTcp(ArylicTcpRenderer),
    /// Renderer controlled via the Google Cast protocol (Chromecast).
    Chromecast(ChromecastRenderer),
    /// Combined backend using UPnP for transport + volume writes and Arylic TCP
    /// to read detailed playback information as well as live volume/mute state.
    HybridUpnpArylic {
        upnp: UpnpRenderer,
        arylic: ArylicTcpRenderer,
    },
}

/// Internal state for tracking playback and control flow.
#[derive(Debug, Clone)]
struct MusicRendererState {
    /// Last known track metadata (cached to avoid repeated queries).
    last_metadata: Option<TrackMetadata>,
    /// Source of the current playback (queue vs external).
    playback_source: PlaybackSource,
    /// Flag to distinguish user-requested stop from automatic events.
    user_stop_requested: bool,
    /// Sleep timer for auto-stop functionality.
    sleep_timer: SleepTimer,
    /// Flag indicating that a PLAYING state has been observed since the last track start.
    /// This prevents auto-advance on transient STOPPED states during track initialization.
    /// Auto-advance is only allowed when this flag is true.
    has_played_since_track_start: bool,
    /// Timestamp when the current track started playing.
    /// Used to calculate elapsed time when renderer returns unreliable position info.
    track_start_time: Option<SystemTime>,
    /// Current track duration (HH:MM:SS format).
    /// For continuous streams, this is kept stable and only updated when it increases
    /// (to avoid decreasing duration updates from radio metadata).
    current_track_duration: Option<String>,
}

impl Default for MusicRendererState {
    fn default() -> Self {
        Self {
            last_metadata: None,
            playback_source: PlaybackSource::default(),
            user_stop_requested: false,
            sleep_timer: SleepTimer::default(),
            has_played_since_track_start: false,
            track_start_time: None,
            current_track_duration: None,
        }
    }
}

#[derive(Clone)]
pub struct MusicRenderer {
    info: RendererInfo,
    connection: Arc<Mutex<DeviceConnectionState>>,
    backend: Arc<Mutex<MusicRendererBackend>>,
    playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
    state: Arc<Mutex<MusicRendererState>>,
    /// Optional event bus for emitting queue change events.
    event_bus: Option<RendererEventBus>,
    /// Cached state from last poll, used for change detection by the watcher.
    watched_state: Arc<Mutex<WatchedState>>,
    /// Flag to signal the watcher thread to stop.
    watcher_stop_flag: Arc<AtomicBool>,
    /// Handle to the watcher thread, if running.
    watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
}

impl std::fmt::Debug for MusicRenderer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MusicRenderer")
            .field("info", &self.info)
            .field("connection", &self.connection)
            .field("backend", &self.backend)
            .field("playlist_binding", &self.playlist_binding)
            .field("state", &self.state)
            .field(
                "event_bus",
                &self.event_bus.as_ref().map(|_| "RendererEventBus"),
            )
            .field("is_watching", &self.is_watching())
            .finish()
    }
}

impl MusicRenderer {
    pub fn new(
        info: RendererInfo,
        backend: Arc<Mutex<MusicRendererBackend>>,
    ) -> Arc<MusicRenderer> {
        let connection = DeviceConnectionState::new();

        let renderer = MusicRenderer {
            info,
            connection: Arc::new(Mutex::new(connection)),
            backend,
            playlist_binding: Arc::new(Mutex::new(None)),
            state: Arc::new(Mutex::new(MusicRendererState::default())),
            event_bus: None,
            watched_state: Arc::new(Mutex::new(WatchedState::default())),
            watcher_stop_flag: Arc::new(AtomicBool::new(false)),
            watcher_handle: Arc::new(Mutex::new(None)),
        };

        Arc::new(renderer)
    }

    pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
        Self::from_renderer_info_with_bus(info, None)
    }

    pub fn from_renderer_info_with_bus(
        info: &RendererInfo,
        event_bus: Option<RendererEventBus>,
    ) -> Result<MusicRenderer, ControlPointError> {
        let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
        let backend = MusicRendererBackend::make_from_renderer_info(info)?;

        let renderer = MusicRenderer {
            info: info.clone(),
            connection,
            backend,
            playlist_binding: Arc::new(Mutex::new(None)),
            state: Arc::new(Mutex::new(MusicRendererState::default())),
            event_bus,
            watched_state: Arc::new(Mutex::new(WatchedState::default())),
            watcher_stop_flag: Arc::new(AtomicBool::new(false)),
            watcher_handle: Arc::new(Mutex::new(None)),
        };

        // Start watching immediately since the renderer is created online
        renderer.start_watching();

        Ok(renderer)
    }

    /// Helper method to emit a QueueUpdated event if an event bus is available.
    fn emit_queue_updated(&self) {
        if let Some(ref bus) = self.event_bus {
            let queue_length = self.len().unwrap_or(0);
            bus.broadcast(RendererEvent::QueueUpdated {
                id: self.id(),
                queue_length,
            });
        }
    }

    /// Helper method to emit any RendererEvent if an event bus is available.
    fn emit_event(&self, event: RendererEvent) {
        if let Some(ref bus) = self.event_bus {
            bus.broadcast(event);
        }
    }

    // =========================================================================
    // Watcher Thread Management
    // =========================================================================

    /// Starts the watcher thread for this renderer.
    ///
    /// The watcher thread polls the backend at regular intervals and emits
    /// events when state changes are detected. This method is idempotent:
    /// calling it when already watching is a no-op.
    pub fn start_watching(&self) {
        let mut handle_guard = self
            .watcher_handle
            .lock()
            .expect("Watcher handle mutex poisoned");
        if handle_guard.is_some() {
            return; // Already watching
        }

        // Reset the stop flag before starting
        self.watcher_stop_flag.store(false, Ordering::SeqCst);

        // Determine the watch strategy based on backend type
        let strategy = {
            let backend = match self.backend.lock() {
                Ok(guard) => guard,
                Err(poisoned) => {
                    warn!(
                        renderer = self.info.friendly_name(),
                        "Backend mutex poisoned when starting watcher, recovering"
                    );
                    poisoned.into_inner()
                }
            };
            WatchStrategy::for_backend(&*backend)
        };

        debug!(
            renderer = self.info.friendly_name(),
            strategy = ?strategy,
            "Starting watcher thread"
        );

        let handle = self.spawn_watcher_thread(strategy);
        *handle_guard = Some(handle);
    }

    /// Stops the watcher thread gracefully.
    ///
    /// This method is idempotent: calling it when not watching is a no-op.
    /// The method will block until the watcher thread terminates.
    pub fn stop_watching(&self) {
        // Signal the watcher to stop
        self.watcher_stop_flag.store(true, Ordering::SeqCst);

        // Take the handle and wait for the thread to finish
        let mut handle_guard = self
            .watcher_handle
            .lock()
            .expect("Watcher handle mutex poisoned");
        if let Some(handle) = handle_guard.take() {
            debug!(
                renderer = self.info.friendly_name(),
                "Stopping watcher thread"
            );
            // Wait for the thread to finish (ignore join errors)
            let _ = handle.join();
        }
    }

    /// Returns true if the watcher thread is currently running.
    pub fn is_watching(&self) -> bool {
        self.watcher_handle
            .lock()
            .expect("Watcher handle mutex poisoned")
            .is_some()
    }

    /// Spawns the watcher thread with the given strategy.
    fn spawn_watcher_thread(&self, strategy: WatchStrategy) -> JoinHandle<()> {
        let renderer = self.clone();
        let stop_flag = Arc::clone(&self.watcher_stop_flag);

        thread::Builder::new()
            .name(format!("watcher-{}", self.info.friendly_name()))
            .spawn(move || {
                renderer.watcher_loop(strategy, stop_flag);
            })
            .expect("Failed to spawn watcher thread")
    }

    /// Main loop for the watcher thread.
    ///
    /// # Error policy
    ///
    /// The watcher thread runs for the lifetime of the renderer and never restarts
    /// automatically. Network/device errors during polling are logged at `debug` level
    /// and ignored — they are transient and expected when a device is temporarily
    /// unreachable. Panics inside `poll_and_emit_changes` are caught and logged at
    /// `error` level so they do not kill the watcher thread.
    fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
        let Some(base_interval) = strategy.polling_interval() else {
            // Pure push strategy - no polling needed (future implementation)
            return;
        };

        let short_interval = base_interval;
        let long_interval = Duration::from_millis(5_000);
        let inactivity_threshold = Duration::from_secs(10);

        let mut tick: u32 = 0;
        let mut next_poll_time = SystemTime::now();
        let mut last_activity_time = SystemTime::now();

        while !stop_flag.load(Ordering::SeqCst) {
            let is_active = {
                let watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
                watched.is_active
            };

            let interval = if is_active {
                short_interval
            } else {
                long_interval
            };

            if self.is_online() {
                // Wrap in catch_unwind so a panic in poll logic does not terminate the watcher.
                let poll_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                    self.poll_and_emit_changes(tick);
                }));
                if let Err(_panic) = poll_result {
                    error!(
                        renderer = self.info.friendly_name(),
                        "poll_and_emit_changes panicked; watcher continues"
                    );
                }
                last_activity_time = SystemTime::now();
            }

            tick = tick.wrapping_add(1);

            // Calculate next poll time to maintain fixed interval
            next_poll_time += interval;

            // Sleep until next poll time (or skip if we're already late)
            if let Ok(sleep_duration) = next_poll_time.duration_since(SystemTime::now()) {
                thread::sleep(sleep_duration);
            } else {
                // We're running late - log a warning and reset the schedule
                tracing::warn!(
                    renderer = self.info.friendly_name(),
                    "Watcher polling is running late, resetting schedule"
                );
                next_poll_time = SystemTime::now();
            }

            // Reset is_active flag if no activity for threshold
            if let Ok(elapsed) = SystemTime::now().duration_since(last_activity_time) {
                if elapsed >= inactivity_threshold {
                    let mut watched = self
                        .watched_state
                        .lock()
                        .expect("WatchedState mutex poisoned");
                    watched.is_active = false;
                }
            }
        }

        debug!(
            renderer = self.info.friendly_name(),
            "Watcher thread exiting"
        );
    }

    /// Polls the backend and emits events for any detected changes.
    fn poll_and_emit_changes(&self, tick: u32) {
        // Step 1: Read previous state WITHOUT holding the lock during network calls
        let prev_position = {
            let watched = self
                .watched_state
                .lock()
                .expect("WatchedState mutex poisoned");
            watched.position.clone()
        };

        // Step 2: Do all network calls WITHOUT holding any locks
        // This prevents blocking other threads that need to read watched_state
        // Errors are logged at trace level — device temporarily unreachable is expected.
        let position = self.playback_position()
            .inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_position failed"))
            .ok();
        let raw_state = self.playback_state()
            .inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_state failed"))
            .ok();

        // Poll volume and mute every other tick (1 second at 500ms interval)
        let (volume, mute, is_stream) = if tick % 2 == 0 {
            (
                self.volume().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "volume poll failed")).ok(),
                self.mute().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "mute poll failed")).ok(),
                Some(self.is_playing_a_stream()),
            )
        } else {
            (None, None, None)
        };

        // Step 3: Now acquire the lock and update state based on polling results
        let mut watched = self
            .watched_state
            .lock()
            .expect("WatchedState mutex poisoned");

        // Detect and handle metadata/track changes FIRST
        if let Some(ref pos) = position {
            if let Some(metadata) = extract_track_metadata(pos) {
                let metadata_changed = watched
                    .metadata
                    .as_ref()
                    .map(|prev| prev != &metadata)
                    .unwrap_or(true);

                if metadata_changed {
                    // Check if this is a track change (title/artist/album) to reset track_start_time
                    // Also initialize track_start_time on first metadata detection
                    let is_first_metadata = watched.metadata.is_none();
                    let track_changed = watched
                        .metadata
                        .as_ref()
                        .map(|prev| {
                            let title_changed = prev.title != metadata.title;
                            let artist_changed = prev.artist != metadata.artist;
                            let album_changed = prev.album != metadata.album;

                            if !is_first_metadata {
                                tracing::info!(
                                    "MusicRenderer [{}]: Comparing - title_changed={} ({:?} vs {:?}), artist_changed={} ({:?} vs {:?})",
                                    self.info.friendly_name(),
                                    title_changed, prev.title, metadata.title,
                                    artist_changed, prev.artist, metadata.artist
                                );
                            }

                            title_changed || artist_changed || album_changed
                        })
                        .unwrap_or(false); // Only true if there was previous metadata AND it differs

                    if is_first_metadata || track_changed {
                        tracing::info!(
                            "MusicRenderer [{}]: {} ({:?} -> {:?}), resetting track_start_time",
                            self.info.friendly_name(),
                            if is_first_metadata {
                                "First metadata"
                            } else {
                                "Track changed"
                            },
                            watched.metadata.as_ref().and_then(|m| m.title.as_ref()),
                            metadata.title
                        );
                        // Drop the lock before calling set_last_metadata to avoid nested locking
                        drop(watched);
                        self.set_last_metadata(Some(metadata.clone()));
                        watched = self
                            .watched_state
                            .lock()
                            .expect("WatchedState mutex poisoned");
                    }

                    // Emit event without holding watched lock
                    let metadata_clone = metadata.clone();
                    drop(watched);
                    self.emit_event(RendererEvent::MetadataChanged {
                        id: self.id(),
                        metadata: metadata_clone,
                    });
                    watched = self
                        .watched_state
                        .lock()
                        .expect("WatchedState mutex poisoned");
                    watched.metadata = Some(metadata);
                }
            }
        }

        // Handle position updates
        if let Some(mut position) = position {
            // For continuous streams, manage duration to prevent it from decreasing
            if let Some(stream_flag) = is_stream {
                if stream_flag {
                    if let Some(ref new_duration) = position.track_duration {
                        let mut state = self.state.lock().expect("RendererState mutex poisoned");

                        match &state.current_track_duration {
                            Some(stored_duration) => {
                                if crate::queue::stream_duration_increased(stored_duration, new_duration) {
                                    tracing::debug!(
                                        "MusicRenderer [{}]: Stream duration increased: {} -> {}",
                                        self.info.friendly_name(),
                                        stored_duration,
                                        new_duration
                                    );
                                    state.current_track_duration = Some(new_duration.clone());
                                } else if crate::queue::stream_duration_decreased(stored_duration, new_duration) {
                                    // Duration decreased: keep stored value
                                    position.track_duration = Some(stored_duration.clone());
                                }
                            }
                            None => {
                                // First time: store the duration
                                state.current_track_duration = Some(new_duration.clone());
                            }
                        }
                    }
                }
            }

            let changed = watched
                .position
                .as_ref()
                .map(|prev| {
                    let equal = playback_position_equal(prev, &position);
                    if !equal {
                        tracing::trace!(
                            "MusicRenderer [{}]: Position changed - prev_rel_time={:?}, new_rel_time={:?}",
                            self.info.friendly_name(),
                            prev.rel_time,
                            position.rel_time
                        );
                    }
                    !equal
                })
                .unwrap_or(true);

            if changed {
                let position_clone = position.clone();
                drop(watched);
                self.emit_event(RendererEvent::PositionChanged {
                    id: self.id(),
                    position: position_clone,
                });
                watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
            }

            watched.position = Some(position);
        }

        // Poll state every tick
        // Note: Device-specific bugs (like Arylic/LinkPlay reporting STOPPED while playing)
        // should be corrected in the backend's playback_state() method, not here.
        if let Some(raw_state) = raw_state {
            let changed = watched
                .state
                .as_ref()
                .map(|prev| !playback_state_equal(prev, &raw_state))
                .unwrap_or(true);

            // Emit event for all state changes including Transitioning
            if changed {
                tracing::trace!(
                    renderer = self.info.friendly_name(),
                    prev_state = ?watched.state,
                    new_state = ?raw_state,
                    "Playback state changed"
                );
                let state_clone = raw_state.clone();
                drop(watched);
                self.emit_event(RendererEvent::StateChanged {
                    id: self.id(),
                    state: state_clone.clone(),
                });

                // Handle auto-advance logic internally
                self.handle_state_change(&state_clone);

                watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
            }

            watched.state = Some(raw_state);
        }

        // Handle volume/mute updates (polled every other tick)
        if let Some(vol) = volume {
            if watched.volume != Some(vol) {
                drop(watched);
                self.emit_event(RendererEvent::VolumeChanged {
                    id: self.id(),
                    volume: vol,
                });
                watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
                watched.volume = Some(vol);
            }
        }

        if let Some(m) = mute {
            if watched.mute != Some(m) {
                drop(watched);
                self.emit_event(RendererEvent::MuteChanged {
                    id: self.id(),
                    mute: m,
                });
                watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
                watched.mute = Some(m);
            }
        }

        // Check stream state (every other tick to avoid excessive polling)
        if let Some(stream) = is_stream {
            if watched.is_stream != Some(stream) {
                tracing::info!(
                    "Stream state changed for renderer {}: is_stream={}",
                    self.id().0,
                    stream
                );
                drop(watched);
                self.emit_event(RendererEvent::StreamStateChanged {
                    id: self.id(),
                    is_stream: stream,
                });
                watched = self
                    .watched_state
                    .lock()
                    .expect("WatchedState mutex poisoned");
                watched.is_stream = Some(stream);
            }
        }
    }

    /// Handles playback state changes internally (auto-advance logic).
    ///
    /// This method is called by the watcher when a state change is detected.
    /// It handles the auto-advance behavior when playback stops.
    fn handle_state_change(&self, state: &PlaybackState) {
        match state {
            PlaybackState::Stopped => {
                {
                    let s = self.state.lock().expect("RendererState mutex poisoned");
                    tracing::debug!(
                        renderer = self.info.friendly_name(),
                        has_played = s.has_played_since_track_start,
                        playback_source = ?s.playback_source,
                        user_stop_requested = s.user_stop_requested,
                        "STOPPED detected — evaluating auto-advance"
                    );
                }
                // Check if user requested stop (via Stop button in UI)
                if self.check_and_clear_user_stop_requested() {
                    debug!(
                        renderer = self.info.friendly_name(),
                        "Renderer stopped by user request; not auto-advancing"
                    );
                    self.set_playback_source(PlaybackSource::None);
                    self.clear_has_played_flag();
                } else if self.is_playing_a_stream() {
                    // Continuous stream (radio) stopped naturally — never auto-advance
                    debug!(
                        renderer = self.info.friendly_name(),
                        "Renderer stopped during continuous stream; not auto-advancing"
                    );
                    self.set_playback_source(PlaybackSource::None);
                    self.clear_has_played_flag();
                } else if self.is_playing_from_queue() {
                    let has_played = self.check_and_clear_has_played_flag();

                    // ✅ CORRECTION BUG PRODUCTION: Garde fou contre latence DB
                    // Si la base de données est saturée (mutex >400ms) on rate parfois l'événement PLAYING.
                    // On autorise l'auto-avance si:
                    //   1. On a bien vu PLAYING OU
                    //   2. Le titre a été lancé depuis plus de 20 secondes
                    let track_start = self.state.lock().expect("RendererState mutex poisoned").track_start_time;
                    let elapsed = track_start
                        .and_then(|t| t.elapsed().ok())
                        .unwrap_or_default();
                    let force_advance = elapsed.as_secs() > 20;

                    if has_played || force_advance {
                        if force_advance {
                            debug!(
                                renderer = self.info.friendly_name(),
                                elapsed_sec = elapsed.as_secs(),
                                "⚠️ On force l'auto-avance: on a raté l'événement PLAYING (latence DB)"
                            );
                        } else {
                            debug!(
                                renderer = self.info.friendly_name(),
                                "Renderer stopped after queue-driven playback; advancing"
                            );
                        }
                        // Use catch_unwind to prevent panics in play_next_from_queue
                        // from poisoning the backend mutex
                        // Also add retry logic for transient errors from renderer
                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                            self.play_next_from_queue_with_retry()
                        }));
                        match result {
                            Ok(Ok(())) => {}
                            Ok(Err(err)) => {
                                error!(
                                    renderer = self.info.friendly_name(),
                                    error = %err,
                                    "Auto-advance failed after retries; clearing queue playback state"
                                );
                                self.set_playback_source(PlaybackSource::None);
                            }
                            Err(_panic) => {
                                error!(
                                    renderer = self.info.friendly_name(),
                                    "Auto-advance panicked; clearing queue playback state"
                                );
                                self.set_playback_source(PlaybackSource::None);
                            }
                        }
                    } else {
                        debug!(
                            renderer = self.info.friendly_name(),
                            "Renderer stopped but no PLAYING state seen yet; ignoring (likely track initialization)"
                        );
                    }
                } else {
                    self.set_playback_source(PlaybackSource::None);
                    self.clear_has_played_flag();
                }
            }
            PlaybackState::NoMedia => {
                // Handle end of track (Chromecast returns NoMedia when track ends)
                // This is equivalent to Stopped for auto-advance purposes
                let s = self.state.lock().expect("RendererState mutex poisoned");
                let playback_source = s.playback_source;
                let has_played = s.has_played_since_track_start;
                let user_stop = s.user_stop_requested;
                drop(s);

                tracing::debug!(
                    renderer = self.info.friendly_name(),
                    has_played = has_played,
                    playback_source = ?playback_source,
                    user_stop_requested = user_stop,
                    "NoMedia detected — evaluating auto-advance"
                );

                // Check if user requested stop (via Stop button in UI)
                if self.check_and_clear_user_stop_requested() {
                    debug!(
                        renderer = self.info.friendly_name(),
                        "NoMedia after user request; not auto-advancing"
                    );
                    self.set_playback_source(PlaybackSource::None);
                    self.clear_has_played_flag();
                } else if matches!(playback_source, PlaybackSource::FromQueue) {
                    // Auto-advance if we have seen a PLAYING state
                    if self.check_and_clear_has_played_flag() {
                        debug!(
                            renderer = self.info.friendly_name(),
                            "NoMedia after queue-driven playback; advancing to next track"
                        );
                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                            self.play_next_from_queue()
                        }));
                        match result {
                            Ok(Ok(())) => {}
                            Ok(Err(err)) => {
                                error!(
                                    renderer = self.info.friendly_name(),
                                    error = %err,
                                    "Auto-advance from NoMedia failed; clearing queue playback state"
                                );
                                self.set_playback_source(PlaybackSource::None);
                            }
                            Err(_panic) => {
                                error!(
                                    renderer = self.info.friendly_name(),
                                    "Auto-advance from NoMedia panicked; clearing queue playback state"
                                );
                                self.set_playback_source(PlaybackSource::None);
                            }
                        }
                    } else {
                        tracing::debug!(
                            renderer = self.info.friendly_name(),
                            "NoMedia but no PLAYING state seen yet; ignoring"
                        );
                    }
                } else {
                    self.set_playback_source(PlaybackSource::None);
                    self.clear_has_played_flag();
                }
            }
            PlaybackState::Playing => {
                self.mark_external_if_idle();
                // Mark that we have seen a PLAYING state - auto-advance is now allowed
                self.set_has_played_flag();
            }
            PlaybackState::Transitioning => {
                let s = self.state.lock().expect("RendererState mutex poisoned");
                tracing::trace!(
                    renderer = self.info.friendly_name(),
                    has_played = s.has_played_since_track_start,
                    playback_source = ?s.playback_source,
                    "TRANSITIONING detected"
                );
            }
            _ => {}
        }
    }

    /// Acquires the backend mutex, recovering from poisoned state if necessary.
    ///
    /// This method handles the case where the mutex was poisoned by a panic in another thread.
    /// Instead of panicking, it recovers the inner data and logs a warning with context.
    ///
    /// # Arguments
    /// * `context` - A description of the operation attempting to acquire the lock
    fn lock_backend_for(&self, context: &str) -> std::sync::MutexGuard<'_, MusicRendererBackend> {
        match self.backend.lock() {
            Ok(guard) => guard,
            Err(poisoned) => {
                warn!(
                    renderer = self.info.friendly_name(),
                    context = context,
                    "Backend mutex was poisoned, recovering"
                );
                poisoned.into_inner()
            }
        }
    }

    /// Acquires the backend mutex with a default context message.
    ///
    /// Convenience wrapper around `lock_backend_for` for simple cases.
    #[allow(dead_code)]
    fn lock_backend(&self) -> std::sync::MutexGuard<'_, MusicRendererBackend> {
        self.lock_backend_for("unknown operation")
    }

    pub fn info(&self) -> &RendererInfo {
        &self.info
    }

    /// Returns the protocol.
    pub fn protocol(&self) -> RendererProtocol {
        self.info.protocol()
    }

    pub fn is_upnp(&self) -> bool {
        match &*self.lock_backend_for("is_upnp") {
            MusicRendererBackend::Upnp(_) => true,
            _ => false,
        }
    }

    pub fn is_openhome(&self) -> bool {
        match &*self.lock_backend_for("is_openhome") {
            MusicRendererBackend::OpenHome(_) => true,
            _ => false,
        }
    }

    pub fn is_linkplay(&self) -> bool {
        match &*self.lock_backend_for("is_linkplay") {
            MusicRendererBackend::LinkPlay(_) => true,
            _ => false,
        }
    }

    pub fn is_arylictcp(&self) -> bool {
        match &*self.lock_backend_for("is_arylictcp") {
            MusicRendererBackend::ArylicTcp(_) => true,
            _ => false,
        }
    }

    pub fn is_chromecast(&self) -> bool {
        match &*self.lock_backend_for("is_chromecast") {
            MusicRendererBackend::Chromecast(_) => true,
            _ => false,
        }
    }

    pub fn is_hybridupnparylic(&self) -> bool {
        match &*self.lock_backend_for("is_hybridupnparylic") {
            MusicRendererBackend::HybridUpnpArylic { .. } => true,
            _ => false,
        }
    }

    /// Returns true if this renderer is known to support SetNextAVTransportURI.
    pub fn supports_set_next(&self) -> bool {
        self.info.capabilities().supports_set_next()
    }

    /// Returns true if the current track is a continuous stream (radio without duration).
    ///
    /// This method queries the backend's cached stream detection flag. The detection is based
    /// on HTTP headers analysis performed when the URL was set via play_uri or when the track
    /// changed (for OpenHome).
    ///
    /// # Returns
    ///
    /// `true` if the current track is a continuous stream (radio, live broadcast, etc.)
    /// `false` if playing a bounded media file or no track loaded
    ///
    /// Note: This returns the stream status of the current track regardless of playback state
    /// (playing, paused, or stopped).
    pub fn is_playing_a_stream(&self) -> bool {
        let backend = self.lock_backend_for("is_playing_a_stream");

        // Query backend for stream status (already cached by backends)
        match &*backend {
            MusicRendererBackend::Upnp(upnp) => upnp.is_continuous_stream(),
            MusicRendererBackend::OpenHome(oh) => oh.is_continuous_stream(),
            MusicRendererBackend::LinkPlay(lp) => lp.is_continuous_stream(),
            MusicRendererBackend::ArylicTcp(ary) => ary.is_continuous_stream(),
            MusicRendererBackend::Chromecast(cc) => cc.is_continuous_stream(),
            MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.is_continuous_stream(),
        }
    }

    /// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
    pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
        let mut backend = self.lock_backend_for("clear_for_playlist_attach");

        // Clear the queue first (ignore errors - queue will be replaced anyway by sync_queue)
        // Some backends (OpenHome) may reject DeleteAll if currently playing
        if let Err(err) = backend.clear_queue() {
            warn!(
                renderer = self.id().0.as_str(),
                error = %err,
                "Clear queue failed when preparing for playlist attach (continuing anyway)"
            );
        }

        // Then stop playback (ignore errors if already stopped)
        tracing::trace!(
            renderer = self.id().0.as_str(),
            "STOP command via clear_for_playlist_attach"
        );
        backend.stop().or_else(|err| {
            warn!(
                renderer = self.id().0.as_str(),
                error = %err,
                "Stop failed when preparing for playlist attach (continuing anyway)"
            );
            Ok(())
        })
    }

    /// Get the current queue snapshot.
    pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
        let mut snapshot = self.lock_backend_for("queue_snapshot").queue_snapshot()?;

        // Enrich snapshot with playlist_id from binding if available
        if let Some(binding) = self
            .playlist_binding
            .lock()
            .expect("Binding mutex poisoned")
            .as_ref()
        {
            // Use container_id as the playlist identifier
            snapshot.playlist_id = Some(binding.container_id.clone());
        }

        Ok(snapshot)
    }

    /// Get a clone of the queue Arc (for async sync operations).
    pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
        let backend = self.lock_backend_for("queue");
        crate::music_renderer::capabilities::HasQueue::queue(&*backend).clone()
    }

    /// Get the current queue item without advancing.
    /// Returns the item and count of remaining items after current.
    pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
        self.lock_backend_for("peek_current").peek_current()
    }

    /// Get the count of items remaining after the current index.
    pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
        self.lock_backend_for("upcoming_len").upcoming_len()
    }

    /// Play the current item from the queue with retry logic for transient renderer errors.
    ///
    /// Some renderers (like JBL Authentics 300) may fail the first Play command
    /// due to timing issues. This method retries with a small delay.
    pub fn play_current_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
        const MAX_RETRIES: usize = 3;
        const RETRY_DELAY_MS: u64 = 200;

        let mut last_error = None;

        for attempt in 0..MAX_RETRIES {
            // Reset the has_played flag before starting playback to prevent
            // auto-advance on transient STOPPED states during track initialization.
            // The flag will be set back to true when PLAYING state is detected.
            self.clear_has_played_flag();

            // Set playback_source to FromQueue BEFORE calling backend
            // to prevent race condition where watcher sees STOPPED before
            // source is set, breaking auto-advance
            self.set_playback_source(PlaybackSource::FromQueue);

            match self
                .lock_backend_for("play_current_from_queue_with_retry")
                .play_from_queue()
            {
                Ok(()) => return Ok(()),
                Err(e) => {
                    last_error = Some(e);
                    if attempt < MAX_RETRIES - 1 {
                        tracing::warn!(
                            renderer = self.info.friendly_name(),
                            attempt = attempt + 1,
                            error = %last_error.as_ref().unwrap(),
                            "Initial play attempt failed, retrying..."
                        );
                        std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
                    }
                }
            }
        }

        // All retries failed
        Err(last_error.unwrap_or_else(|| {
            ControlPointError::ControlPoint("Unknown error in retry logic".into())
        }))
    }

    /// Play the current item from the queue.
    #[deprecated(
        since = "0.1.0",
        note = "Use play_current_from_queue_with_retry instead"
    )]
    pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
        // Reset the has_played_flag before starting playback to prevent
        // auto-advance on transient STOPPED states during track initialization.
        // The flag will be set back to true when PLAYING state is detected.
        self.clear_has_played_flag();

        // Set playback_source to FromQueue BEFORE calling backend
        // to prevent race condition where watcher sees STOPPED before
        // source is set, breaking auto-advance
        self.set_playback_source(PlaybackSource::FromQueue);

        self.lock_backend_for("play_current_from_queue")
            .play_from_queue()
    }

    /// Advance to and play the next item from the queue.
    pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
        // Reset the has_played flag before starting playback to prevent
        // auto-advance on transient STOPPED states during track initialization.
        // The flag will be set back to true when PLAYING state is detected.
        self.clear_has_played_flag();

        // Set playback_source to FromQueue BEFORE calling backend
        // to prevent race condition where watcher sees STOPPED before
        // source is set, breaking auto-advance
        self.set_playback_source(PlaybackSource::FromQueue);

        self.lock_backend_for("play_next_from_queue").play_next()?;
        self.emit_queue_updated();
        Ok(())
    }

    /// Play next from queue with retry logic for transient renderer errors.
    ///
    /// Some renderers (like JBL Authentics 300) may fail the first Play command
    /// due to timing issues. This method retries with a small delay.
    pub fn play_next_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
        const MAX_RETRIES: usize = 3;
        const RETRY_DELAY_MS: u64 = 200;

        let mut last_error = None;

        for attempt in 0..MAX_RETRIES {
            match self.play_next_from_queue() {
                Ok(()) => return Ok(()),
                Err(e) => {
                    last_error = Some(e);
                    if attempt < MAX_RETRIES - 1 {
                        tracing::warn!(
                            renderer = self.info.friendly_name(),
                            attempt = attempt + 1,
                            error = %last_error.as_ref().unwrap(),
                            "Auto-advance attempt failed, retrying..."
                        );
                        std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
                    }
                }
            }
        }

        // All retries failed
        Err(last_error.unwrap_or_else(|| {
            ControlPointError::ControlPoint("Unknown error in retry logic".into())
        }))
    }

    /// Advance the queue index by one without starting playback.
    ///
    /// Used by the WebRenderer gapless path: the browser autonomously transitions
    /// to the next track, so only the backend queue pointer needs to be updated
    /// to stay in sync.
    pub fn advance_queue_index(&self) -> Result<bool, ControlPointError> {
        let mut backend = self.lock_backend_for("advance_queue_index");
        let advanced = backend.advance()?;
        drop(backend);
        if advanced {
            self.emit_queue_updated();
        }
        Ok(advanced)
    }

    /// Play from a specific index in the queue.
    pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
        // ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
        // (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
        // le flag has_played. Sinon quand le titre se termine l'auto-avance
        // pense qu'il n'a jamais démarré et s'arrête.
        tracing::debug!(
            index = index,
            renderer = self.info.friendly_name(),
            "🎯 play_from_index appelé, réinitialisation has_played_flag"
        );
        self.clear_has_played_flag();

        self.lock_backend_for("play_from_index")
            .play_from_index(index)?;

        self.set_playback_source(PlaybackSource::FromQueue);
        Ok(())
    }

    /// Transport control: play
    ///
    /// Démarre ou reprend la lecture. Si une queue non vide existe,
    /// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
    pub fn play(&self) -> Result<(), ControlPointError> {
        // Mark renderer as active for adaptive polling
        {
            let mut watched = self
                .watched_state
                .lock()
                .expect("WatchedState mutex poisoned");
            watched.is_active = true;
        }

        // Vérifier si on a une queue non vide
        let backend = self.lock_backend_for("play");
        let queue_not_empty = backend.len().unwrap_or(0) > 0;

        if queue_not_empty {
            // Set playback_source to FromQueue BEFORE calling backend
            // to prevent race condition where watcher sees STOPPED before
            // source is set, breaking auto-advance
            self.set_playback_source(PlaybackSource::FromQueue);

            // Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
            // Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
            backend.play_from_queue()
        } else {
            // Queue vide : déléguer au backend (reprend la lecture en cours, etc.)
            backend.play()
        }
    }

    /// Transport control: pause
    pub fn pause(&self) -> Result<(), ControlPointError> {
        self.lock_backend_for("pause").pause()
    }

    /// Transport control: stop
    #[track_caller]
    pub fn stop(&self) -> Result<(), ControlPointError> {
        // Mark renderer as active for adaptive polling
        {
            let mut watched = self
                .watched_state
                .lock()
                .expect("WatchedState mutex poisoned");
            watched.is_active = true;
        }

        // Reset the has_played flag when stopping playback.
        // This ensures that if we start a new track, the flag will be false
        // until PLAYING state is observed, preventing auto-advance on
        // transient STOPPED states during track initialization.
        self.clear_has_played_flag();

        let caller = std::panic::Location::caller();
        tracing::trace!(
            renderer = self.info.friendly_name(),
            caller = %caller,
            "STOP command sent to renderer"
        );
        self.lock_backend_for("stop").stop()
    }

    /// Set the next URI for gapless playback (UPnP AVTransport only).
    ///
    /// Returns Ok if the backend supports this feature and it succeeded.
    /// Returns Err if not supported or if it failed.
    pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
        let backend = self.lock_backend_for("set_next_uri");

        match &*backend {
            MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata),
            MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata),
            _ => Err(ControlPointError::ControlPoint(
                "SetNextURI not supported by this backend".to_string(),
            )),
        }
    }

    /// Transport control: seek to relative time
    pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
        // Mark renderer as active for adaptive polling
        {
            let mut watched = self
                .watched_state
                .lock()
                .expect("WatchedState mutex poisoned");
            watched.is_active = true;
        }

        self.lock_backend_for("seek_rel_time").seek_rel_time(hhmmss)
    }

    /// Seek to a specific position in seconds
    pub fn seek(&self, seconds: u32) -> Result<(), ControlPointError> {
        // Convert seconds to HH:MM:SS format
        let hours = seconds / 3600;
        let minutes = (seconds % 3600) / 60;
        let secs = seconds % 60;
        let hhmmss = format!("{:02}:{:02}:{:02}", hours, minutes, secs);
        self.seek_rel_time(&hhmmss)
    }

    /// Volume control: get current volume
    pub fn volume(&self) -> Result<u16, ControlPointError> {
        self.lock_backend_for("volume").volume()
    }

    /// Volume control: set volume
    pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
        self.lock_backend_for("set_volume").set_volume(vol)
    }

    /// Volume control: get mute state
    pub fn mute(&self) -> Result<bool, ControlPointError> {
        self.lock_backend_for("mute").mute()
    }

    /// Volume control: set mute state
    pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
        self.lock_backend_for("set_mute").set_mute(m)
    }

    /// Get playback state
    pub fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
        self.lock_backend_for("playback_state").playback_state()
    }

    /// Get playback position
    ///
    /// This method patches the position info from the backend:
    /// - If track_duration is None, try to extract it from DIDL metadata
    /// - Calculates rel_time from track_start_time (backend values are unreliable)
    ///
    /// Note: track_start_time is updated by poll_and_emit_changes when track changes
    pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
        let mut position_info = self
            .lock_backend_for("playback_position")
            .playback_position()?;

        // Si track_duration est absent, essayer de le parser depuis le DIDL metadata
        if position_info.track_duration.is_none() {
            if let Some(ref metadata_xml) = position_info.track_metadata {
                if let Some(duration) = parse_didl_duration(metadata_xml) {
                    tracing::debug!(
                        "MusicRenderer: Corrected track_duration from DIDL metadata: {}",
                        duration
                    );
                    position_info.track_duration = Some(duration);
                }
            }
        }

        // Pour les flux continus uniquement : calculer rel_time depuis track_start_time
        // Pour les fichiers normaux : garder les valeurs du backend
        // IMPORTANT: Ne calculer le temps que si le lecteur est en PLAYING
        let is_stream = self.is_playing_a_stream();
        if is_stream {
            // Vérifier que le lecteur est en lecture avant de calculer le temps écoulé
            let is_playing = matches!(self.playback_state().ok(), Some(PlaybackState::Playing));

            if is_playing {
                if let Some(start_time) = self.track_start_time() {
                    if let Ok(elapsed) = start_time.elapsed() {
                        let secs = elapsed.as_secs() as u32;
                        let hours = secs / 3600;
                        let minutes = (secs % 3600) / 60;
                        let seconds = secs % 60;
                        let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
                        tracing::debug!(
                            "MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)",
                            self.info.friendly_name(),
                            new_rel_time,
                            secs
                        );
                        position_info.rel_time = Some(new_rel_time);
                    }
                }
            }
        }

        Ok(position_info)
    }

    /// Sets the playlist binding for this renderer.
    /// Emits a `BindingChanged` event only if the binding actually changes.
    pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
        let mut guard = self
            .playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned");

        // Check if there's an actual change (both None, or different Some values)
        let old_is_some = guard.is_some();
        let new_is_some = binding.is_some();
        let has_changed = old_is_some != new_is_some || (old_is_some && new_is_some);

        *guard = binding.clone();
        drop(guard);

        if has_changed {
            self.emit_binding_changed(binding);
        }
    }

    /// Gets the current playlist binding, if any.
    pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
        self.playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned")
            .clone()
    }

    /// Clears the playlist binding.
    /// Emits a `BindingChanged` event with `None` only if there was a binding to clear.
    pub fn clear_playlist_binding(&self) {
        let mut guard = self
            .playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned");

        let had_binding = guard.is_some();
        *guard = None;
        drop(guard);

        if had_binding {
            self.emit_binding_changed(None);
        }
    }

    /// Helper method to emit a BindingChanged event if an event bus is available.
    fn emit_binding_changed(&self, binding: Option<PlaylistBinding>) {
        if let Some(ref bus) = self.event_bus {
            bus.broadcast(RendererEvent::BindingChanged {
                id: self.id(),
                binding,
            });
        }
    }

    /// Marks the current playlist binding for refresh if it matches the given server and container.
    /// Returns true if a matching binding was found and marked.
    pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool {
        let mut binding_guard = self
            .playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned");

        if let Some(binding) = binding_guard.as_mut() {
            if &binding.server_id == server_id && container_ids.contains(&binding.container_id) {
                binding.pending_refresh = true;
                binding.has_seen_update = true;
                return true;
            }
        }

        false
    }

    /// Checks if the binding has pending_refresh flag set.
    /// Returns false if no binding exists.
    pub fn has_pending_refresh(&self) -> bool {
        self.playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned")
            .as_ref()
            .map(|b| b.pending_refresh)
            .unwrap_or(false)
    }

    /// Resets the pending_refresh flag to false.
    /// Does nothing if no binding exists.
    pub fn reset_pending_refresh(&self) {
        if let Some(binding) = self
            .playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned")
            .as_mut()
        {
            binding.pending_refresh = false;
        }
    }

    /// Marks the binding as pending refresh.
    /// Returns true if a binding exists and was marked, false otherwise.
    pub fn mark_pending_refresh(&self) -> bool {
        self.playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned")
            .as_mut()
            .map(|binding| {
                binding.pending_refresh = true;
                true
            })
            .unwrap_or(false)
    }

    /// Consumes and returns the auto_play_on_refresh flag, resetting it to false.
    /// Returns false if no binding exists.
    pub fn consume_auto_play(&self) -> bool {
        self.playlist_binding
            .lock()
            .expect("Playlist binding mutex poisoned")
            .as_mut()
            .map(|binding| {
                let auto_play = binding.auto_play_on_refresh;
                binding.auto_play_on_refresh = false;
                auto_play
            })
            .unwrap_or(false)
    }

    /// Returns the number of items in the queue.
    pub fn len(&self) -> Result<usize, ControlPointError> {
        self.lock_backend_for("len").len()
    }

    /// Add items to the queue using the specified enqueue mode.
    pub fn enqueue_items(
        &self,
        items: Vec<PlaybackItem>,
        mode: EnqueueMode,
    ) -> Result<(), ControlPointError> {
        let mut backend = self.lock_backend_for("enqueue_items");
        backend.enqueue_items(items, mode)?;
        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Synchronize the queue with new items while preserving the current track.
    ///
    /// This method intelligently updates the queue:
    /// - If the current track is in the new items, it keeps playing at the new position
    /// - If the current track is NOT in the new items, it's preserved as the first item
    /// - If there's no current track, the queue is simply replaced
    pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
        self.sync_queue_with_callback(items, None, None)
    }

    /// Synchronize the queue with new items with optional cancel token and on_ready callback.
    pub fn sync_queue_with_callback(
        &self,
        items: Vec<PlaybackItem>,
        cancel_token: Option<&Arc<AtomicBool>>,
        on_ready: Option<Box<dyn FnOnce() + Send>>,
    ) -> Result<(), ControlPointError> {
        {
            let mut watched = self
                .watched_state
                .lock()
                .expect("WatchedState mutex poisoned");
            watched.is_active = true;
        }

        let mut backend = self.lock_backend_for("sync_queue");
        let dummy_token = Arc::new(AtomicBool::new(false));
        let token = cancel_token.unwrap_or(&dummy_token);
        backend.sync_queue(items, token, on_ready)?;
        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Set the current queue index (for advanced use).
    /// Note: This does NOT start playback. Use select_queue_track() to play.
    pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
        self.lock_backend_for("set_queue_index").set_index(index)
    }

    /// Clears the renderer's queue using the generic QueueBackend trait.
    pub fn clear_queue(&self) -> Result<(), ControlPointError> {
        let mut backend = self.lock_backend_for("clear_queue");
        backend.clear_queue()?;
        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Dequeues and returns the next item from the queue.
    pub fn dequeue_next(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
        self.lock_backend_for("dequeue_next").dequeue_next()
    }

    /// Sets the current index in the queue.
    pub fn set_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
        self.lock_backend_for("set_index").set_index(index)
    }

    /// Replaces the entire queue with new items and sets the current index.
    /// This is a complete replacement, unlike sync_queue which tries to preserve the current track.
    pub fn replace_queue(
        &self,
        items: Vec<PlaybackItem>,
        current_index: Option<usize>,
    ) -> Result<(), ControlPointError> {
        let mut backend = self.lock_backend_for("replace_queue");
        backend.replace_queue(items, current_index)?;
        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Adds a track to the queue.
    ///
    /// This is primarily for backends with persistent queues (OpenHome).
    /// For backends without this capability, it returns an error.
    ///
    /// Returns the backend-specific track ID if applicable.
    pub fn add_track_to_queue(
        &self,
        _uri: &str,
        _metadata: &str,
        _after_id: Option<u32>,
        _play: bool,
    ) -> Result<Option<u32>, ControlPointError> {
        // This operation requires backend-specific APIs (especially for OpenHome)
        // that aren't exposed through the generic QueueBackend trait.
        // The generic implementation cannot support this without more context
        // (media_server_id, didl_id, protocol_info, etc.).
        Err(ControlPointError::QueueError(
            "add_track_to_queue requires backend-specific implementation".to_string(),
        ))
    }

    /// Selects and plays a specific track from the queue by ID.
    ///
    /// Converts the track ID to a position using the generic QueueBackend trait,
    /// then plays the track. The operation is atomic (single lock).
    pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
        // Reset the has_played flag before starting playback
        self.clear_has_played_flag();

        let backend = self.lock_backend_for("select_queue_track");

        // Convert track_id to index and play atomically
        let index = backend.id_to_position(track_id)?;
        backend.play_from_index(index)?;

        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Synchronizes the queue state with the backend.
    ///
    /// For backends with persistent queues (OpenHome), this refreshes the local view.
    /// For others, this is essentially a no-op (just reads the current state).
    pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
        // Calling queue_snapshot() triggers a refresh for backends that need it
        let _ = self.lock_backend_for("sync_queue_state").queue_snapshot()?;
        Ok(())
    }

    /// Plays the current item from the backend queue.
    ///
    /// This is primarily for backends with persistent queues (OpenHome).
    pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
        // Reset the has_played flag before starting playback
        self.clear_has_played_flag();

        let backend = self.lock_backend_for("play_current_from_backend_queue");

        // Get current track ID and convert to index atomically
        let track_id = backend
            .current_track()?
            .ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;

        let index = backend.id_to_position(track_id)?;

        // Play from that index while still holding the lock
        backend.play_from_index(index)?;

        drop(backend);
        self.emit_queue_updated();
        Ok(())
    }

    /// Plays from the queue at the current position.
    ///
    /// Uses the backend's play_from_queue which preserves the queue for all backends.
    pub fn play_from_queue(&self) -> Result<(), ControlPointError> {
        // Reset the has_played flag before starting playback to prevent
        // auto-advance on transient STOPPED states during track initialization.
        // The flag will be set back to true when PLAYING state is detected.
        self.clear_has_played_flag();

        // Set playback_source to FromQueue BEFORE calling backend
        // to prevent race condition where watcher sees STOPPED before
        // source is set, breaking auto-advance
        self.set_playback_source(PlaybackSource::FromQueue);

        self.lock_backend_for("play_from_queue").play_from_queue()
    }

    // --- Playback State Management ---

    /// Gets the last known track metadata.
    pub fn last_metadata(&self) -> Option<TrackMetadata> {
        self.state.lock().expect("RendererState mutex poisoned").last_metadata.clone()
    }

    /// Sets the last known track metadata.
    /// Updates track_start_time and resets current_track_duration only if the metadata actually changes.
    pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        let metadata_changed = state.last_metadata != metadata;
        if metadata_changed {
            // Pour les flux continus: utiliser dc:date comme track_start_time réel de diffusion.
            // Toute source stream peut encoder son start_time en RFC 3339 dans dc:date.
            // Fallback sur now() si absent ou non parseable.
            let start_time = metadata
                .as_ref()
                .filter(|m| m.is_continuous_stream)
                .and_then(|m| m.date.as_deref())
                .and_then(parse_rfc3339_to_system_time)
                .unwrap_or_else(SystemTime::now);
            state.track_start_time = Some(start_time);
            // Reset duration cache when track changes (for streams)
            state.current_track_duration = None;
        }
        state.last_metadata = metadata;
    }

    /// Gets the timestamp when the current track started playing.
    pub fn track_start_time(&self) -> Option<SystemTime> {
        self.state.lock().expect("RendererState mutex poisoned").track_start_time
    }

    /// Gets the current playback source.
    pub fn playback_source(&self) -> PlaybackSource {
        self.state.lock().expect("RendererState mutex poisoned").playback_source
    }

    /// Sets the playback source.
    pub fn set_playback_source(&self, source: PlaybackSource) {
        self.state.lock().expect("RendererState mutex poisoned").playback_source = source;
    }

    /// Checks if currently playing from queue.
    pub fn is_playing_from_queue(&self) -> bool {
        matches!(
            self.state.lock().expect("RendererState mutex poisoned").playback_source,
            PlaybackSource::FromQueue
        )
    }

    /// Marks playback as external if currently idle.
    ///
    /// Only sets to External if we were already playing from an external source.
    /// Does NOT change None -> External because that would break queue playback
    /// (the control_point will set it to FromQueue after play_from_queue succeeds).
    pub fn mark_external_if_idle(&self) {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        if matches!(state.playback_source, PlaybackSource::External) {
            // Keep External if we were already playing externally
        } else {
            // Don't change None -> External - that breaks queue auto-advance!
            // The control_point will set playback_source to FromQueue after play_from_queue succeeds.
        }
    }

    /// Marks that the user requested a stop (to prevent auto-advance).
    pub fn mark_user_stop_requested(&self) {
        self.state.lock().expect("RendererState mutex poisoned").user_stop_requested = true;
    }

    /// Checks and clears the user stop requested flag.
    pub fn check_and_clear_user_stop_requested(&self) -> bool {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        let was_requested = state.user_stop_requested;
        state.user_stop_requested = false;
        was_requested
    }

    // --- Has-Played Flag Management (for auto-advance protection) ---

    /// Sets the has_played_since_track_start flag to true.
    /// Called when PLAYING state is detected.
    fn set_has_played_flag(&self) {
        self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = true;
    }

    /// Clears the has_played_since_track_start flag.
    /// Called when stopping playback or starting a new track.
    /// This is public so that ControlPoint can reset it when jumping to a new track.
    pub fn clear_has_played_flag(&self) {
        self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = false;
    }

    /// Checks and clears the has_played_since_track_start flag.
    /// Returns true if PLAYING was seen since last track start, false otherwise.
    /// Used to determine if auto-advance should be allowed.
    fn check_and_clear_has_played_flag(&self) -> bool {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        let has_played = state.has_played_since_track_start;
        state.has_played_since_track_start = false;
        has_played
    }

    // --- Sleep Timer Management ---

    /// Starts the sleep timer with the given duration in seconds.
    /// Maximum duration is 2 hours (7200 seconds).
    ///
    /// Returns the remaining seconds after starting.
    ///
    /// # Errors
    /// Returns an error if the duration is invalid (0 or > 7200 seconds).
    pub fn start_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        state
            .sleep_timer
            .start(duration_seconds)
            .map_err(|e| ControlPointError::ControlPoint(e))?;

        Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
    }

    /// Updates the sleep timer duration. Resets the timer to the new duration from now.
    ///
    /// Returns the new remaining seconds.
    ///
    /// # Errors
    /// Returns an error if the duration is invalid (0 or > 7200 seconds).
    pub fn update_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
        let mut state = self.state.lock().expect("RendererState mutex poisoned");
        state
            .sleep_timer
            .update(duration_seconds)
            .map_err(|e| ControlPointError::ControlPoint(e))?;

        Ok(state.sleep_timer.remaining_seconds().unwrap_or(0))
    }

    /// Cancels the sleep timer.
    pub fn cancel_sleep_timer(&self) {
        self.state.lock().expect("RendererState mutex poisoned").sleep_timer.cancel();
    }

    /// Returns the remaining seconds of the sleep timer, or None if no timer is active.
    pub fn sleep_timer_remaining(&self) -> Option<u32> {
        self.state.lock().expect("RendererState mutex poisoned").sleep_timer.remaining_seconds()
    }

    /// Returns the configured duration of the sleep timer in seconds.
    pub fn sleep_timer_duration(&self) -> u32 {
        self.state.lock().expect("RendererState mutex poisoned").sleep_timer.duration_seconds()
    }

    /// Returns true if the sleep timer is active.
    pub fn is_sleep_timer_active(&self) -> bool {
        self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_active()
    }

    /// Returns true if the sleep timer has expired.
    pub fn is_sleep_timer_expired(&self) -> bool {
        self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_expired()
    }

    /// Gets the sleep timer state as a tuple (is_active, duration_seconds, remaining_seconds).
    pub fn sleep_timer_state(&self) -> (bool, u32, Option<u32>) {
        let state = self.state.lock().expect("RendererState mutex poisoned");
        (
            state.sleep_timer.is_active(),
            state.sleep_timer.duration_seconds(),
            state.sleep_timer.remaining_seconds(),
        )
    }

    // --- Queue Shuffle ---

    /// Shuffles the current queue and restarts playback from the first track.
    ///
    /// This method:
    /// 1. Detaches the queue from any attached playlist
    /// 2. Stops playback
    /// 3. Takes a snapshot of the current queue
    /// 4. Randomizes the order of tracks
    /// 5. Replaces the queue with the shuffled items
    /// 6. Starts playback from the first track
    ///
    /// # Errors
    /// Returns an error if the queue is empty or if any backend operation fails.
    pub fn shuffle_queue(&self) -> Result<(), ControlPointError> {
        use rand::seq::SliceRandom;
        use rand::thread_rng;

        // 1. Clear the playlist binding (detach from playlist)
        self.clear_playlist_binding();

        // 2. Stop playback (ignore errors if already stopped)
        let _ = self.stop();

        // 3. Get a snapshot of the current queue
        let snapshot = self.queue_snapshot()?;

        if snapshot.items.is_empty() {
            return Err(ControlPointError::QueueError(
                "Cannot shuffle an empty queue".to_string(),
            ));
        }

        // 4. Shuffle the items
        let mut shuffled_items = snapshot.items;
        let mut rng = thread_rng();
        shuffled_items.shuffle(&mut rng);

        // 5. Replace the queue with shuffled items, starting at index 0
        self.replace_queue(shuffled_items, Some(0))?;

        // 6. Start playback from the first track with retry for JBL-like renderers
        // that fail the first Play command due to timing issues
        self.play_current_from_queue_with_retry()?;

        Ok(())
    }
}

/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
pub(crate) fn build_didl_lite_metadata(
    metadata: &TrackMetadata,
    uri: &str,
    protocol_info: &str,
) -> String {
    use pmodidl::ToXmlElement;
    use pmodidl::{DIDLLite, Item, Resource};

    // Construire l'Item DIDL avec toutes les métadonnées
    let item = Item {
        id: "0".to_string(),
        parent_id: "-1".to_string(),
        restricted: Some("1".to_string()),
        title: metadata
            .title
            .clone()
            .unwrap_or_else(|| "Unknown Title".to_string()),
        creator: metadata.creator.clone().or_else(|| metadata.artist.clone()),
        class: "object.item.audioItem.musicTrack".to_string(),
        artist: metadata.artist.clone(),
        album: metadata.album.clone(),
        genre: metadata.genre.clone(),
        album_art: metadata.album_art_uri.clone(),
        album_art_pk: None,
        date: metadata.date.clone(),
        original_track_number: metadata.track_number.clone(),
        resources: vec![Resource {
            protocol_info: protocol_info.to_string(),
            bits_per_sample: None,
            sample_frequency: None,
            nr_audio_channels: None,
            duration: metadata.duration.clone(),
            url: uri.to_string(),
        }],
        descriptions: vec![],
    };

    // Construire le DIDL-Lite complet
    let didl = DIDLLite {
        items: vec![item],
        ..Default::default()
    };

    // Sérialiser en XML via pmodidl
    didl.to_xml()
}

/// Enriches a [`PlaybackPositionInfo`] with metadata from the backend's queue.
///
/// Overwrites `track_metadata` and `track_uri` with the values stored for the
/// current queue item.  This is a no-op when the queue is empty or has no
/// current item.
///
/// All backends that hold a local queue (UPnP, LinkPlay, Arylic, Chromecast)
/// should call this after fetching the raw device position, so that callers
/// always receive consistent, queue-authoritative metadata rather than
/// potentially stale or absent metadata coming from the device itself.
pub(crate) fn enrich_position_from_queue<B: HasQueue>(
    backend: &B,
    position: &mut PlaybackPositionInfo,
) {
    let mut queue = backend.queue().lock().expect("queue mutex poisoned");
    if let Ok(Some((current_item, _))) = queue.peek_current() {
        if let Some(ref metadata) = current_item.metadata {
            position.track_metadata = Some(build_didl_lite_metadata(
                metadata,
                &current_item.uri,
                &current_item.protocol_info,
            ));
        }
        position.track_uri = Some(current_item.uri.clone());
    }
}

impl DeviceIdentity for MusicRenderer {
    fn id(&self) -> DeviceId {
        self.info.id()
    }

    fn udn(&self) -> &str {
        &*self.info.udn()
    }

    fn friendly_name(&self) -> &str {
        &self.info.friendly_name()
    }

    fn model_name(&self) -> &str {
        &self.info.model_name()
    }
    fn manufacturer(&self) -> &str {
        &self.info.manufacturer()
    }
    fn location(&self) -> &str {
        &self.info.location()
    }
    fn server_header(&self) -> &str {
        &self.info.server_header()
    }
}

impl DeviceOnline for MusicRenderer {
    fn is_online(&self) -> bool {
        self.connection
            .lock()
            .expect("Connection mutex poisoned")
            .is_online()
    }

    fn last_seen(&self) -> SystemTime {
        self.connection
            .lock()
            .expect("Connection mutex poisoned")
            .last_seen()
    }

    fn has_been_seen_now(&self, max_age: u32) {
        let was_online = self.is_online();
        self.connection
            .lock()
            .expect("Connection mutex poisoned")
            .has_been_seen_now(max_age);

        // Start watching if transitioning from offline to online
        if !was_online {
            self.start_watching();
        }
    }

    fn mark_as_offline(&self) {
        // Stop watching before marking offline
        self.stop_watching();
        self.connection
            .lock()
            .expect("Connection mutex poisoned")
            .mark_as_offline()
    }

    fn max_age(&self) -> u32 {
        self.connection
            .lock()
            .expect("Connection mutex poisoned")
            .max_age()
    }
}

impl RendererFromMediaRendererInfo for MusicRendererBackend {
    fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
        // Try OpenHome first for OpenHome-capable renderers
        match info.protocol() {
            RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
                if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) {
                    return Ok(backend);
                }
                // If OpenHomeOnly failed, it's an error
                if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
                    return Err(ControlPointError::MusicRendererBackendBuild(format!(
                        "OpenHomeOnly renderer {} has no usable OpenHome services",
                        info.friendly_name()
                    )));
                }
                // OpenHomeHybrid: fall through to try UPnP-based backends
            }
            RendererProtocol::ChromecastOnly => {
                return ChromecastRenderer::build_from_renderer_info(info);
            }
            RendererProtocol::UpnpAvOnly => {
                // Will be handled below
            }
        }

        // UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback)
        let has_arylic = info.capabilities().has_arylic_tcp;
        let has_avtransport = info.capabilities().has_avtransport();

        // Try Hybrid UPnP + Arylic (special case: combines two backends)
        if has_arylic && has_avtransport {
            let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?;
            if let MusicRendererBackend::Upnp(upnp) = upnp_backend {
                // Share the UPnP queue with Arylic so metadata is consistent
                let shared_queue = upnp.queue().clone();
                match ArylicTcpRenderer::with_shared_queue(info, shared_queue) {
                    Ok(arylic) => {
                        return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
                    }
                    Err(err) => {
                        warn!(
                            renderer = info.friendly_name(),
                            error = %err,
                            "Failed to build Arylic TCP backend, falling back to UPnP only"
                        );
                        return Ok(MusicRendererBackend::Upnp(upnp));
                    }
                    _ => unreachable!(
                        "ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant"
                    ),
                }
            } else {
                unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant");
            }
        }

        // Try LinkPlay
        if info.capabilities().has_linkplay_http() {
            if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) {
                return Ok(backend);
            }
        }

        // Fallback to UPnP
        if has_avtransport {
            return UpnpRenderer::build_from_renderer_info(info);
        }

        // No suitable backend found
        Err(ControlPointError::MusicRendererBackendBuild(format!(
            "No suitable backend for renderer {}",
            info.friendly_name()
        )))
    }

    fn to_backend(self) -> MusicRendererBackend {
        self
    }
}

/// Parse duration from DIDL-Lite metadata XML.
///
/// Extracts the duration attribute from the <res> element in DIDL metadata.
/// This is used as a fallback when the renderer doesn't provide track_duration
/// in GetPositionInfo or similar calls.
pub(crate) fn parse_didl_duration(didl_xml: &str) -> Option<String> {
    // Parse DIDL-Lite XML properly using pmodidl
    let didl = match DIDLLite::parse(didl_xml) {
        Ok(d) => d,
        Err(e) => {
            tracing::trace!("Failed to parse DIDL metadata: {}", e);
            return None;
        }
    };

    // Extract duration from the first item's first resource
    let duration = didl.items.first()?.resources.first()?.duration.clone();

    if let Some(ref dur) = duration {
        tracing::debug!(
            "MusicRenderer: Extracted duration from DIDL metadata: {}",
            dur
        );
    } else {
        tracing::trace!("No duration attribute found in DIDL metadata");
    }

    duration
}

/// Parse un datetime RFC 3339 en SystemTime.
///
/// Utilisé pour calibrer track_start_time sur le début réel de diffusion
/// d'un segment, encodé dans dc:date du DIDL par la source stream.
fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
    let dt = chrono::DateTime::parse_from_rfc3339(s).ok()?;
    let secs = dt.timestamp();
    if secs < 0 {
        return None;
    }
    Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
}


/// Dispatch a method call to the inner backend, using the UPnP field for
/// HybridUpnpArylic.
macro_rules! dispatch_upnp {
    ($self:expr, $method:ident($($arg:expr),*)) => {
        match $self {
            MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
            MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
            MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
            MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
            MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
            MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
        }
    };
}

/// Dispatch a method call to the inner backend, using the Arylic field for
/// HybridUpnpArylic.
macro_rules! dispatch_arylic {
    ($self:expr, $method:ident($($arg:expr),*)) => {
        match $self {
            MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
            MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
            MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
            MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
            MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
            MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.$method($($arg),*),
        }
    };
}

/// Transport control façade that dispatches to whichever backend can fulfill
/// the request, returning a standardized error if the backend lacks support.
impl TransportControl for MusicRendererBackend {
    fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
        match self {
            MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
            MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
            MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
            MusicRendererBackend::ArylicTcp(_) => Err(
                ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"),
            ),
            MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
            MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
        }
    }

    fn play(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, play()) }
    fn pause(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, pause()) }
    fn stop(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, stop()) }

    fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
        match self {
            MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
            MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
            MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
            MusicRendererBackend::ArylicTcp(_) => Err(
                ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"),
            ),
            MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
            MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
        }
    }
}

/// Volume and mute controls exposed via the façade.
///
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
/// always depend on a single [`VolumeControl`] entry point.
impl VolumeControl for MusicRendererBackend {
    fn volume(&self) -> Result<u16, ControlPointError> { dispatch_arylic!(self, volume()) }
    fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { dispatch_upnp!(self, set_volume(vol)) }
    fn mute(&self) -> Result<bool, ControlPointError> { dispatch_arylic!(self, mute()) }
    fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { dispatch_arylic!(self, set_mute(m)) }
}

/// Playback-state queries sourced from the backend best suited for the job.
///
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
/// to reason about protocol-specific state machines.
impl PlaybackStatus for MusicRendererBackend {
    fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
        match self {
            MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
            MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
            MusicRendererBackend::LinkPlay(r) => r.playback_state(),
            MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
            MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
            MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
        }
    }
}

/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
/// regardless of the backend providing the raw transport data.
impl PlaybackPosition for MusicRendererBackend {
    fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
        dispatch_arylic!(self, playback_position())
    }
}

impl HasQueue for MusicRendererBackend {
    fn queue(&self) -> &Arc<Mutex<MusicQueue>> { dispatch_upnp!(self, queue()) }
}

impl HasContinuousStream for MusicRendererBackend {
    fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
        dispatch_arylic!(self, continuous_stream())
    }
}

impl QueueTransportControl for MusicRendererBackend {
    fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
        dispatch_upnp!(self, play_item(item))
    }
    fn play_from_queue(&self) -> Result<(), ControlPointError> {
        dispatch_upnp!(self, play_from_queue())
    }
    fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
        dispatch_upnp!(self, play_from_index(index))
    }
}
