//! DBus side of the MPRIS subsystem.
//!
//! Owns a [`zbus::blocking::Connection`] and the canonical state of
//! every `org.mpris.MediaPlayer2.*` peer currently on the bus. One
//! foreground "coordinator" thread runs the state machine; each
//! signal source (the `org.freedesktop.DBus` `NameOwnerChanged`
//! stream and one `PropertiesChanged` stream per player) lives on its
//! own thread that funnels events into a single internal mpsc. The
//! UI thread's command channel and the art worker's reply channel
//! are forwarded into the same mpsc so the coordinator's main loop
//! is a plain `recv()` over [`InternalEvent`]. That keeps the state
//! transitions linear and avoids any cross-thread locking on
//! [`PlayerData`].
//!
//! Active-player policy is deliberately simple: pick the first
//! `Playing` player by alphabetic bus name, fall back to the first
//! `Paused`, otherwise emit the empty `MediaState` sentinel that
//! [`super::MprisPoller::try_recv`] interprets as "nothing playing".
//! `Stopped` is excluded because most players sit in `Stopped` with
//! empty metadata when idle, and including them would noise up the
//! card with stale leftovers from the last track.

use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::{ self, Receiver, Sender };
use std::thread;

use zbus::zvariant::OwnedValue;

use super::art::{ ArtRequest, ArtResult };
use super::{ MediaState, MprisCommand };

const MPRIS_PREFIX:    &str = "org.mpris.MediaPlayer2.";
const PLAYER_INTERFACE:&str = "org.mpris.MediaPlayer2.Player";
const MEDIA_PATH:      &str = "/org/mpris/MediaPlayer2";

#[ derive( Clone, Copy, PartialEq, Eq ) ]
enum PlaybackStatus { Playing, Paused, Stopped }

impl PlaybackStatus
{
	fn parse( s: &str ) -> Self
	{
		match s
		{
			"Playing" => Self::Playing,
			"Paused"  => Self::Paused,
			_         => Self::Stopped,
		}
	}
}

struct PlayerData
{
	bus_name:        String,
	status:          PlaybackStatus,
	title:           String,
	artist:          String,
	art_url:         Option<String>,
	art_rgba:        Option<Arc<Vec<u8>>>,
	art_dims:        Option<( u32, u32 )>,
	can_go_next:     bool,
	can_go_previous: bool,
}

impl PlayerData
{
	fn new( bus_name: String ) -> Self
	{
		Self
		{
			bus_name,
			status:          PlaybackStatus::Stopped,
			title:           String::new(),
			artist:          String::new(),
			art_url:         None,
			art_rgba:        None,
			art_dims:        None,
			// Default to disabled so an early state push (before the
			// initial property read finishes) does not flash the
			// arrows enabled when the player will reject them.
			can_go_next:     false,
			can_go_previous: false,
		}
	}

	fn to_state( &self, playing: bool ) -> MediaState
	{
		MediaState
		{
			playing,
			title:           self.title.clone(),
			artist:          self.artist.clone(),
			player:          self.bus_name.clone(),
			art_url:         self.art_url.clone(),
			art_rgba:        self.art_rgba.clone(),
			art_dims:        self.art_dims,
			can_go_next:     self.can_go_next,
			can_go_previous: self.can_go_previous,
		}
	}
}

/// Snapshot of the relevant slice of a `PropertiesChanged` payload —
/// already extracted from `OwnedValue` on the listener thread so the
/// coordinator never has to touch zvariant. Each `Option` is `None`
/// when the corresponding key was absent from the changed-properties
/// dict, so the coordinator can distinguish "no update" from
/// "explicitly cleared".
struct PropChange
{
	playback_status: Option<PlaybackStatus>,
	title:           Option<String>,
	artist:          Option<String>,
	/// Outer `Option`: was the `Metadata` key present at all in this
	/// PropertiesChanged batch. Inner `Option`: did `mpris:artUrl`
	/// resolve to a non-empty string (`Some`) or was it absent /
	/// empty (`None`, meaning "drop the cover").
	art_url:         Option<Option<String>>,
	can_go_next:     Option<bool>,
	can_go_previous: Option<bool>,
}

enum InternalEvent
{
	NameOwnerChanged   { name: String, has_owner: bool },
	PropertiesChanged  { player: String, props: PropChange },
	Command            ( MprisCommand ),
	ArtDone            ( ArtResult ),
}

pub( super ) fn spawn_worker(
	state_tx:    Sender<MediaState>,
	cmd_rx:      Receiver<MprisCommand>,
	art_req_tx:  Sender<ArtRequest>,
	art_done_rx: Receiver<ArtResult>,
)
{
	if let Err( e ) = thread::Builder::new()
		.name( "mpris-bus".into() )
		.spawn( move || run( state_tx, cmd_rx, art_req_tx, art_done_rx ) )
	{
		tracing::error!( "Failed to spawn mpris-bus thread: {}", e );
	}
}

fn run(
	state_tx:    Sender<MediaState>,
	cmd_rx:      Receiver<MprisCommand>,
	art_req_tx:  Sender<ArtRequest>,
	art_done_rx: Receiver<ArtResult>,
)
{
	let conn = match zbus::blocking::Connection::session()
	{
		Ok( c )  => c,
		Err( e ) =>
		{
			tracing::error!( "mpris: cannot connect to session bus: {}", e );
			return;
		}
	};

	let ( ev_tx, ev_rx ) = mpsc::channel::<InternalEvent>();

	// Forward UI commands and art results into the unified internal
	// channel. Each forwarder dies cleanly when its input closes
	// (e.g. process shutdown).
	{
		let ev_tx = ev_tx.clone();
		thread::spawn( move ||
		{
			while let Ok( cmd ) = cmd_rx.recv()
			{
				if ev_tx.send( InternalEvent::Command( cmd ) ).is_err() { break; }
			}
		} );
	}
	{
		let ev_tx = ev_tx.clone();
		thread::spawn( move ||
		{
			while let Ok( res ) = art_done_rx.recv()
			{
				if ev_tx.send( InternalEvent::ArtDone( res ) ).is_err() { break; }
			}
		} );
	}

	spawn_name_owner_listener( &conn, ev_tx.clone() );

	let mut players: HashMap<String, PlayerData> = HashMap::new();

	// Bootstrap: list every name currently on the bus, filter by the
	// MPRIS prefix, and add each one. This catches players that were
	// already running before the shell started.
	if let Ok( names ) = list_names( &conn )
	{
		for name in names.into_iter().filter( |n| n.starts_with( MPRIS_PREFIX ) )
		{
			add_player( &conn, &mut players, &name, &ev_tx, &art_req_tx );
		}
	}
	emit_state( &players, &state_tx );

	while let Ok( ev ) = ev_rx.recv()
	{
		match ev
		{
			InternalEvent::NameOwnerChanged { name, has_owner } =>
			{
				if !name.starts_with( MPRIS_PREFIX ) { continue; }
				if has_owner
				{
					if !players.contains_key( &name )
					{
						add_player( &conn, &mut players, &name, &ev_tx, &art_req_tx );
					}
				}
				else
				{
					players.remove( &name );
				}
			}
			InternalEvent::PropertiesChanged { player, props } =>
			{
				if let Some( p ) = players.get_mut( &player )
				{
					// Snapshot which keys this signal carried before we
					// move the inner Option values out, so the
					// capability-refresh below can decide whether to
					// re-fetch without fighting the borrow checker.
					let had_can_next = props.can_go_next.is_some();
					let had_can_prev = props.can_go_previous.is_some();

					if let Some( s ) = props.playback_status { p.status          = s; }
					if let Some( t ) = props.title           { p.title           = t; }
					if let Some( a ) = props.artist          { p.artist          = a; }
					if let Some( v ) = props.can_go_next     { p.can_go_next     = v; }
					if let Some( v ) = props.can_go_previous { p.can_go_previous = v; }

					// Always re-fetch `CanGoNext` / `CanGoPrevious`
					// on any `PropertiesChanged` that did not already
					// carry the new value. Some MPRIS implementations
					// (Firefox notably, on YouTube playlist
					// navigation) flip these silently — neither in
					// `changed_properties` nor in
					// `invalidated_properties` — so the only reliable
					// way to track them is a `Get` round-trip. The
					// listener already filtered to PropertiesChanged
					// from the Player interface, so this fires at
					// most a few times per second per active player
					// (negligible on UHD-class GPUs, ~10 ms/s on the
					// Librem 5).
					if !had_can_next
					{
						if let Some( v ) = read_bool_property( &conn, &player, "CanGoNext" )
						{
							p.can_go_next = v;
						}
					}
					if !had_can_prev
					{
						if let Some( v ) = read_bool_property( &conn, &player, "CanGoPrevious" )
						{
							p.can_go_previous = v;
						}
					}

					if let Some( new_url ) = props.art_url
					{
						// New track or new URL → drop cached cover and
						// kick off a fetch. Same URL → reuse existing
						// `art_rgba` so the view does not flash an empty
						// placeholder mid-play.
						if new_url != p.art_url
						{
							p.art_url  = new_url.clone();
							p.art_rgba = None;
							p.art_dims = None;
							if let Some( url ) = new_url
							{
								let _ = art_req_tx.send( ArtRequest
								{
									player: player.clone(),
									url,
								} );
							}
						}
					}
				}
			}
			InternalEvent::Command( cmd ) =>
			{
				handle_command( &conn, cmd );
				continue; // command does not change cached state directly
			}
			InternalEvent::ArtDone( res ) =>
			{
				if let Some( p ) = players.get_mut( &res.player )
				{
					// Only attach the art if the player is still on
					// the same URL — covers the race where the user
					// skipped a track while the previous cover was
					// still downloading.
					if p.art_url.as_deref() == Some( res.url.as_str() )
					{
						p.art_rgba = Some( res.rgba );
						p.art_dims = Some( ( res.w, res.h ) );
					}
				}
			}
		}
		emit_state( &players, &state_tx );
	}
}

/// Pick the active player and send the resulting [`MediaState`] to
/// the UI thread. Sends the "no active player" sentinel (empty
/// strings, `playing == false`) when no player is in `Playing` or
/// `Paused`, which `MprisPoller::try_recv` translates into the outer
/// `None`.
fn emit_state( players: &HashMap<String, PlayerData>, state_tx: &Sender<MediaState> )
{
	let mut active: Option<&PlayerData> = None;
	let mut active_priority = 3u8;
	for p in players.values()
	{
		let prio = match p.status
		{
			PlaybackStatus::Playing => 0,
			PlaybackStatus::Paused  => 1,
			PlaybackStatus::Stopped => continue,
		};
		// Lower priority wins. Tie-break by alphabetic bus name so
		// the choice is deterministic across restarts.
		let beat = prio < active_priority
			|| ( prio == active_priority
				&& active.map( |a| p.bus_name < a.bus_name ).unwrap_or( true ) );
		if beat
		{
			active          = Some( p );
			active_priority = prio;
		}
	}

	let state = match active
	{
		Some( p ) => p.to_state( p.status == PlaybackStatus::Playing ),
		None      => MediaState::default(),
	};
	let _ = state_tx.send( state );
}

fn add_player(
	conn:       &zbus::blocking::Connection,
	players:    &mut HashMap<String, PlayerData>,
	name:       &str,
	ev_tx:      &Sender<InternalEvent>,
	art_req_tx: &Sender<ArtRequest>,
)
{
	let mut data = PlayerData::new( name.to_string() );

	if let Some( status ) = read_status( conn, name )
	{
		data.status = status;
	}
	if let Some( v ) = read_bool_property( conn, name, "CanGoNext" )
	{
		data.can_go_next = v;
	}
	if let Some( v ) = read_bool_property( conn, name, "CanGoPrevious" )
	{
		data.can_go_previous = v;
	}
	if let Some( meta ) = read_metadata( conn, name )
	{
		let ( title, artist, art_url ) = parse_metadata( &meta );
		if let Some( t ) = title  { data.title  = t; }
		if let Some( a ) = artist { data.artist = a; }
		data.art_url = art_url.clone();
		if let Some( url ) = art_url
		{
			let _ = art_req_tx.send( ArtRequest
			{
				player: name.to_string(),
				url,
			} );
		}
	}

	spawn_properties_changed_listener( conn, name, ev_tx.clone() );

	players.insert( name.to_string(), data );
}

fn list_names( conn: &zbus::blocking::Connection ) -> zbus::Result<Vec<String>>
{
	let proxy = zbus::blocking::fdo::DBusProxy::new( conn )?;
	let names = proxy.list_names()?;
	Ok( names.into_iter().map( |n| n.to_string() ).collect() )
}

fn read_status( conn: &zbus::blocking::Connection, bus_name: &str ) -> Option<PlaybackStatus>
{
	let val = get_property( conn, bus_name, "PlaybackStatus" )?;
	let s   = String::try_from( val ).ok()?;
	Some( PlaybackStatus::parse( &s ) )
}

fn read_metadata(
	conn: &zbus::blocking::Connection,
	bus_name: &str,
) -> Option<HashMap<String, OwnedValue>>
{
	let val = get_property( conn, bus_name, "Metadata" )?;
	HashMap::<String, OwnedValue>::try_from( val ).ok()
}

fn read_bool_property(
	conn:     &zbus::blocking::Connection,
	bus_name: &str,
	prop:     &str,
) -> Option<bool>
{
	let val = get_property( conn, bus_name, prop )?;
	bool::try_from( val ).ok()
}

/// Generic `org.freedesktop.DBus.Properties.Get` call, mirroring the
/// pattern used in `system::dbus_service::get_prop`. Returns `None`
/// on any RPC error so the caller can fall back to an empty
/// [`PlayerData`] without bringing down the whole bus thread.
fn get_property(
	conn: &zbus::blocking::Connection,
	bus_name: &str,
	prop: &str,
) -> Option<OwnedValue>
{
	let reply = conn.call_method(
		Some( bus_name ),
		MEDIA_PATH,
		Some( "org.freedesktop.DBus.Properties" ),
		"Get",
		&( PLAYER_INTERFACE, prop ),
	).ok()?;
	reply.body().deserialize::<OwnedValue>().ok()
}

fn handle_command( conn: &zbus::blocking::Connection, cmd: MprisCommand )
{
	let ( bus_name, method ) = match cmd
	{
		MprisCommand::PlayPause( p ) => ( p, "PlayPause" ),
		MprisCommand::Next( p )      => ( p, "Next"      ),
		MprisCommand::Previous( p )  => ( p, "Previous"  ),
	};

	if let Err( e ) = conn.call_method(
		Some( bus_name.as_str() ),
		MEDIA_PATH,
		Some( PLAYER_INTERFACE ),
		method,
		&(),
	)
	{
		tracing::warn!( "mpris {} on {}: {}", method, bus_name, e );
	}
}

fn spawn_name_owner_listener(
	conn:  &zbus::blocking::Connection,
	ev_tx: Sender<InternalEvent>,
)
{
	let conn = conn.clone();
	thread::spawn( move ||
	{
		let proxy = match zbus::blocking::fdo::DBusProxy::new( &conn )
		{
			Ok( p )  => p,
			Err( e ) =>
			{
				tracing::error!( "mpris: DBusProxy: {}", e );
				return;
			}
		};
		let it = match proxy.receive_name_owner_changed()
		{
			Ok( i )  => i,
			Err( e ) =>
			{
				tracing::error!( "mpris: subscribe NameOwnerChanged: {}", e );
				return;
			}
		};
		for sig in it
		{
			let Ok( args ) = sig.args() else { continue };
			let name = args.name().to_string();
			// `new_owner` is wrapped in zvariant's `Optional`
			// (which derefs to a plain `Option`). Round-tripping
			// through `to_string()` handles both layouts without
			// committing to the exact inner string accessor —
			// empty = "name was released", non-empty = "now owned
			// by some unique connection".
			let new_owner = args.new_owner().as_ref()
				.map( | n | n.to_string() )
				.unwrap_or_default();
			let has_owner = !new_owner.is_empty();
			if ev_tx.send( InternalEvent::NameOwnerChanged { name, has_owner } ).is_err()
			{
				break;
			}
		}
	} );
}

fn spawn_properties_changed_listener(
	conn:  &zbus::blocking::Connection,
	name:  &str,
	ev_tx: Sender<InternalEvent>,
)
{
	let conn     = conn.clone();
	let bus_name = name.to_string();
	thread::spawn( move ||
	{
		let proxy = match build_properties_proxy( &conn, &bus_name )
		{
			Ok( p )  => p,
			Err( e ) =>
			{
				tracing::warn!( "mpris properties proxy {}: {}", bus_name, e );
				return;
			}
		};
		let it = match proxy.receive_properties_changed()
		{
			Ok( i )  => i,
			Err( e ) =>
			{
				tracing::warn!( "mpris subscribe PropertiesChanged {}: {}", bus_name, e );
				return;
			}
		};
		for sig in it
		{
			let Ok( args ) = sig.args() else { continue };
			// Coerce via `Deref<Target = str>` rather than calling
			// `.as_str()` — same `str_as_str` MSRV concern as the
			// `changed_properties` keys below.
			let iface: &str = &args.interface_name();
			if iface != PLAYER_INTERFACE { continue; }

			let mut props = PropChange
			{
				playback_status: None,
				title:           None,
				artist:          None,
				art_url:         None,
				can_go_next:     None,
				can_go_previous: None,
			};

			for ( key, val ) in args.changed_properties()
			{
				let Ok( owned ) = val.try_clone() else { continue };
				// `changed_properties()` yields `(&&str, &Value)` in
				// zbus 4. Peel the outer ref so we match against
				// plain `&str` literals — `key.as_str()` would call
				// the still-unstable `str::as_str` (rust-lang #130366).
				let key: &str = *key;
				match key
				{
					"PlaybackStatus" =>
					{
						if let Ok( s ) = String::try_from( owned )
						{
							props.playback_status = Some( PlaybackStatus::parse( &s ) );
						}
					}
					"Metadata" =>
					{
						if let Ok( meta ) = HashMap::<String, OwnedValue>::try_from( owned )
						{
							let ( t, a, u ) = parse_metadata( &meta );
							props.title   = t;
							props.artist  = a;
							props.art_url = Some( u );
						}
					}
					"CanGoNext" =>
					{
						if let Ok( v ) = bool::try_from( owned )
						{
							props.can_go_next = Some( v );
						}
					}
					"CanGoPrevious" =>
					{
						if let Ok( v ) = bool::try_from( owned )
						{
							props.can_go_previous = Some( v );
						}
					}
					_ => {}
				}
			}
			if ev_tx.send( InternalEvent::PropertiesChanged
			{
				player: bus_name.clone(),
				props,
			} ).is_err() { break; }
		}
	} );
}

fn build_properties_proxy<'a>(
	conn:     &'a zbus::blocking::Connection,
	bus_name: &str,
) -> zbus::Result<zbus::blocking::fdo::PropertiesProxy<'a>>
{
	zbus::blocking::fdo::PropertiesProxy::builder( conn )
		.destination( bus_name.to_string() )?
		.path( MEDIA_PATH )?
		.build()
}

/// Pull `xesam:title`, the first entry of `xesam:artist`, and
/// `mpris:artUrl` out of a Metadata `a{sv}` dict. Each return slot
/// is `None` when the corresponding key was absent — that matters
/// because `Some("")` would otherwise overwrite a previously valid
/// title with an empty string when a player emits sparse Metadata
/// updates.
fn parse_metadata(
	meta: &HashMap<String, OwnedValue>,
) -> ( Option<String>, Option<String>, Option<String> )
{
	let title  = meta.get( "xesam:title" )
		.and_then( | v | v.try_clone().ok() )
		.and_then( | v | String::try_from( v ).ok() )
		.filter( | s | !s.is_empty() );

	let artist = meta.get( "xesam:artist" )
		.and_then( | v | v.try_clone().ok() )
		.and_then( | v | Vec::<String>::try_from( v ).ok() )
		.and_then( | v | v.into_iter().next() )
		.filter( | s | !s.is_empty() );

	let art_url = meta.get( "mpris:artUrl" )
		.and_then( | v | v.try_clone().ok() )
		.and_then( | v | String::try_from( v ).ok() )
		.filter( | s | !s.is_empty() );

	( title, artist, art_url )
}
