//! Cover-art download + decode worker.
//!
//! Reads `mpris:artUrl` strings sent by [`super::bus`], turns them
//! into RGBA buffers ready for `ltk::img_widget`, and streams the
//! results back. Runs on its own thread so HTTPS round-trips never
//! stall the DBus thread (which still has to keep
//! `PropertiesChanged` signals flowing).
//!
//! Two URL schemes are handled:
//!
//! * `file://...` — read with `std::fs::read`. This is the Spotify
//!   case (it points at a thumbnail it pre-downloaded into its own
//!   cache dir) and a few desktop players.
//! * `http://` / `https://` — fetched with `ureq`. The encoded body
//!   is cached on disk under `$XDG_CACHE_HOME/crustace/mpris-art/`,
//!   keyed on a hash of the URL so the same track in the same
//!   browser tab does not redownload across QS opens, restarts, or
//!   even reboots. The cache stores the *encoded* bytes (typically
//!   ~30 KB JPEG) rather than the decoded RGBA — orders of magnitude
//!   smaller on disk, decoding is ~5 ms with the `image` crate.
//!
//! All decodes pass through `DynamicImage::thumbnail(256, 256)` —
//! the QS card paints the cover at ~96 px square; downsampling once
//! at decode time is cheaper than carrying the full-res buffer
//! through every render.

use std::hash::{ Hash, Hasher };
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{ Receiver, Sender };
use std::thread;

/// Message sent from the bus thread to ask for one piece of art.
/// `player` is the bus name of the player the art belongs to so the
/// reply can be matched back to the right state even when the user
/// switches players in the middle of a download.
pub( super ) struct ArtRequest
{
	pub player: String,
	pub url:    String,
}

/// Reply with decoded RGBA. Sent only on success — bus thread
/// silently keeps the prior `art_rgba` if a fetch fails, since a
/// stale cover is less jarring than a sudden "no art" flash.
pub( super ) struct ArtResult
{
	pub player: String,
	pub url:    String,
	pub rgba:   Arc<Vec<u8>>,
	pub w:      u32,
	pub h:      u32,
}

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

fn run( req_rx: Receiver<ArtRequest>, done_tx: Sender<ArtResult> )
{
	let cache_dir = cache_dir();
	if let Some( ref dir ) = cache_dir
	{
		// Best-effort: if mkdir fails (read-only filesystem, ENOSPC,
		// odd XDG paths), the worker still functions but every fetch
		// goes through the network.
		let _ = std::fs::create_dir_all( dir );
	}

	while let Ok( mut req ) = req_rx.recv()
	{
		// Coalesce: if a burst of requests arrived for the same
		// player while we were idle (track skip → skip → skip), keep
		// only the latest URL for each player. Different players can
		// still each get their latest.
		let mut by_player: std::collections::HashMap<String, ArtRequest> =
			std::collections::HashMap::new();
		by_player.insert( req.player.clone(), req );
		while let Ok( more ) = req_rx.try_recv()
		{
			by_player.insert( more.player.clone(), more );
		}

		for ( _, r ) in by_player.drain()
		{
			req = r;
			match fetch_and_decode( &req.url, cache_dir.as_deref() )
			{
				Ok( ( rgba, w, h ) ) =>
				{
					let _ = done_tx.send( ArtResult
					{
						player: req.player,
						url:    req.url,
						rgba,
						w,
						h,
					} );
				}
				Err( e ) =>
				{
					tracing::debug!( "mpris art {}: {}", req.url, e );
				}
			}
		}
	}
}

fn fetch_and_decode(
	url:       &str,
	cache_dir: Option<&std::path::Path>,
) -> Result<( Arc<Vec<u8>>, u32, u32 ), String>
{
	let bytes = if let Some( path ) = strip_file_scheme( url )
	{
		std::fs::read( &path )
			.map_err( |e| format!( "read {}: {}", path.display(), e ) )?
	}
	else if url.starts_with( "http://" ) || url.starts_with( "https://" )
	{
		http_fetch_cached( url, cache_dir )?
	}
	else
	{
		return Err( format!( "unsupported scheme: {}", url ) );
	};

	decode_to_thumbnail( &bytes )
}

fn strip_file_scheme( url: &str ) -> Option<PathBuf>
{
	// `file:///abs/path` and (rare) `file:/abs/path` both legal.
	// No host parsing — MPRIS art URLs are always local with empty
	// host, so anything between the scheme and the first `/` of the
	// absolute path is just collapsed.
	url.strip_prefix( "file://" )
		.or_else( || url.strip_prefix( "file:" ) )
		.map( PathBuf::from )
}

fn http_fetch_cached(
	url:       &str,
	cache_dir: Option<&std::path::Path>,
) -> Result<Vec<u8>, String>
{
	if let Some( dir ) = cache_dir
	{
		let cache_path = dir.join( format!( "{:016x}.bin", url_hash( url ) ) );
		if let Ok( cached ) = std::fs::read( &cache_path )
		{
			return Ok( cached );
		}

		let bytes = http_get( url )?;
		// Cache write is best-effort. A failure here just means the
		// next session re-downloads.
		let _ = std::fs::write( &cache_path, &bytes );
		Ok( bytes )
	}
	else
	{
		http_get( url )
	}
}

fn http_get( url: &str ) -> Result<Vec<u8>, String>
{
	// Short read timeout so a hung server cannot block subsequent
	// track covers behind it. `minreq` exposes a single `with_timeout`
	// in seconds, applied to the whole call (connect + read combined).
	// The QS card without art is a perfectly acceptable degraded
	// state — the user clicking through tracks expects responsiveness
	// over completeness.
	let resp = minreq::get( url )
		.with_timeout( 10 )
		.send()
		.map_err( | e | format!( "http {}: {}", url, e ) )?;
	if resp.status_code < 200 || resp.status_code >= 300
	{
		return Err( format!( "http {}: status {}", url, resp.status_code ) );
	}
	let bytes = resp.as_bytes();
	if bytes.len() > 8 * 1024 * 1024
	{
		return Err( format!( "http {}: body too large ({} bytes)", url, bytes.len() ) );
	}
	Ok( bytes.to_vec() )
}

fn decode_to_thumbnail( bytes: &[u8] ) -> Result<( Arc<Vec<u8>>, u32, u32 ), String>
{
	let img = image::load_from_memory( bytes )
		.map_err( |e| format!( "decode: {}", e ) )?;
	// `thumbnail` uses a fast lanczos downscale and never upscales,
	// so a smaller source survives unchanged. The QS card cover can
	// reach ~500 px on a large portrait panel, so 512² is the
	// headroom target without wasting RAM on 1024×1024 buffers.
	// `dimensions()` and `into_raw()` are intrinsic on `RgbaImage`
	// (the `to_rgba8()` return type), so no `GenericImageView` import
	// is needed.
	let img = img.thumbnail( 512, 512 ).to_rgba8();
	let ( w, h ) = img.dimensions();
	Ok( ( Arc::new( img.into_raw() ), w, h ) )
}

/// Stable, fast, non-cryptographic hash for cache key derivation.
/// `DefaultHasher` is unstable across Rust versions, but within one
/// build the output is deterministic, which is all the on-disk
/// cache needs to keep collisions to ~zero in practice (the
/// expected entry count is at most "number of unique tracks the
/// user has played" — never more than a few thousand).
fn url_hash( url: &str ) -> u64
{
	let mut h = std::collections::hash_map::DefaultHasher::new();
	url.hash( &mut h );
	h.finish()
}

fn cache_dir() -> Option<PathBuf>
{
	dirs::cache_dir().map( |d| d.join( "crustace" ).join( "mpris-art" ) )
}
