// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Orientation-aware wallpaper helper.
//!
//! Theme assets only ship a single landscape image per variant; portrait
//! surfaces (phones held vertically, the lock screen on a tablet, …) take a
//! left-aligned crop of that image down to the surface aspect ratio. The crop
//! result is cached so panning around different windows on the same display
//! does not redecode anything.
//!
//! Construct with [`WallpaperBundle::from_path`] (or one of the
//! `…_or_fallback` variants) at startup, then call
//! [`WallpaperBundle::for_size`] from each `view()` to get the variant
//! appropriate for the current surface.

use std::path::Path;
use std::sync::{ Arc, Mutex };

/// Decoded RGBA image: (pixel buffer, width, height).
pub type ImageData = ( Arc<Vec<u8>>, u32, u32 );

/// A wallpaper that adapts to landscape vs portrait surfaces.
///
/// Holds a single full-resolution landscape image and lazily produces
/// left-cropped portrait variants on demand, caching the most recently
/// requested crop dimensions.
pub struct WallpaperBundle
{
	landscape: ImageData,
	cache:     Mutex<Option<( ( u32, u32 ), ImageData )>>,
}

impl WallpaperBundle
{
	/// Wrap an already-decoded landscape image.
	pub fn from_decoded( landscape: ImageData ) -> Self
	{
		Self
		{
			landscape,
			cache: Mutex::new( None ),
		}
	}

	/// Decode `bytes` (PNG/JPEG) as the landscape image.
	pub fn from_bytes( bytes: &[u8] ) -> Result<Self, image::ImageError>
	{
		Ok( Self::from_decoded( decode_bytes( bytes )? ) )
	}

	/// Load the landscape image from `path`.
	pub fn from_path( path: &Path ) -> Result<Self, image::ImageError>
	{
		Ok( Self::from_decoded( decode_path( path )? ) )
	}

	/// Try to load the landscape image from `path`; on failure fall back to
	/// decoding `bundled_fallback` (typically a `include_bytes!` asset shipped
	/// inside the consumer binary). Errors loading the path are reported on
	/// stderr.
	pub fn from_path_or_bytes( path: Option<&Path>, bundled_fallback: &[u8] ) -> Self
	{
		if let Some( p ) = path
		{
			match decode_path( p )
			{
				Ok( img ) => return Self::from_decoded( img ),
				Err( e )  => eprintln!(
					"ltk: failed to load wallpaper {}: {} — falling back to bundled",
					p.display(), e,
				),
			}
		}
		Self::from_bytes( bundled_fallback ).expect( "bundled wallpaper must decode" )
	}

	/// Try to load from `path`; on failure produce a 1×1 solid-colour bundle
	/// using `(r, g, b)` instead of requiring embedded PNG bytes. Reports path
	/// load errors on stderr.
	pub fn from_path_or_solid( path: Option<&Path>, r: u8, g: u8, b: u8 ) -> Self
	{
		if let Some( p ) = path
		{
			match decode_path( p )
			{
				Ok( img ) => return Self::from_decoded( img ),
				Err( e )  => eprintln!(
					"ltk: failed to load wallpaper {}: {} — falling back to solid colour",
					p.display(), e,
				),
			}
		}
		let rgba = Arc::new( vec![ r, g, b, 255u8 ] );
		Self::from_decoded( ( rgba, 1, 1 ) )
	}

	/// The original landscape image. Cheap clone — only bumps the inner `Arc`.
	pub fn landscape( &self ) -> ImageData
	{
		( Arc::clone( &self.landscape.0 ), self.landscape.1, self.landscape.2 )
	}

	/// Return the wallpaper variant appropriate for a surface of `(sw, sh)`.
	///
	/// - Landscape surface (`sw >= sh`): returns the full landscape image.
	/// - Portrait surface (`sw < sh`):  returns a left-cropped variant whose
	///   aspect ratio matches `(sw, sh)`. The crop is computed once per unique
	///   `(sw, sh)` pair and reused on subsequent calls.
	///
	/// `(0, 0)` is treated as landscape (returns the original image) so that
	/// callers can use this before the surface has been sized.
	pub fn for_size( &self, sw: u32, sh: u32 ) -> ImageData
	{
		if sw == 0 || sh == 0 || sw >= sh
		{
			return self.landscape();
		}

		let key = ( sw, sh );
		let mut guard = self.cache.lock().expect( "wallpaper cache poisoned" );
		if let Some( ( cached_key, data ) ) = guard.as_ref()
		{
			if *cached_key == key
			{
				return ( Arc::clone( &data.0 ), data.1, data.2 );
			}
		}

		let cropped = crop_left_to_aspect( &self.landscape, sw, sh );
		let result  = ( Arc::clone( &cropped.0 ), cropped.1, cropped.2 );
		*guard = Some( ( key, cropped ) );
		result
	}
}

fn decode_bytes( bytes: &[u8] ) -> Result<ImageData, image::ImageError>
{
	use image::GenericImageView as _;
	let img      = image::load_from_memory( bytes )?;
	let ( w, h ) = img.dimensions();
	Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) )
}

fn decode_path( path: &Path ) -> Result<ImageData, image::ImageError>
{
	use image::GenericImageView as _;
	let img      = image::open( path )?;
	let ( w, h ) = img.dimensions();
	Ok( ( Arc::new( img.to_rgba8().into_raw() ), w, h ) )
}

/// Take the left-most slice of `src` whose width matches the `(target_w, target_h)`
/// aspect ratio. If `src` is already narrower than the target, it is returned
/// unchanged (callers will scale it up — letterboxing avoidance is the
/// renderer's job, not this helper's).
fn crop_left_to_aspect( src: &ImageData, target_w: u32, target_h: u32 ) -> ImageData
{
	let ( ref rgba, sw, sh ) = *src;
	if sw == 0 || sh == 0 || target_w == 0 || target_h == 0
	{
		return ( Arc::clone( rgba ), sw, sh );
	}
	let target_aspect = target_w as f32 / target_h as f32;
	let new_w_f       = ( sh as f32 * target_aspect ).round();
	let new_w         = ( new_w_f as u32 ).clamp( 1, sw );
	if new_w >= sw
	{
		return ( Arc::clone( rgba ), sw, sh );
	}

	let mut out = Vec::with_capacity( ( new_w as usize ) * ( sh as usize ) * 4 );
	let row_bytes_src = ( sw as usize ) * 4;
	let row_bytes_new = ( new_w as usize ) * 4;
	for y in 0..( sh as usize )
	{
		let start = y * row_bytes_src;
		out.extend_from_slice( &rgba[ start .. start + row_bytes_new ] );
	}
	( Arc::new( out ), new_w, sh )
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[ cfg( test ) ]
mod tests
{
	use super::*;

	/// Build a tiny gradient image where each pixel's red channel encodes its
	/// x coordinate. Lets us assert that a left-crop really starts at column 0.
	fn xgrad( w: u32, h: u32 ) -> ImageData
	{
		let mut buf = Vec::with_capacity( ( w * h * 4 ) as usize );
		for _y in 0..h
		{
			for x in 0..w
			{
				buf.extend_from_slice( &[ x as u8, 0, 0, 255 ] );
			}
		}
		( Arc::new( buf ), w, h )
	}

	#[ test ]
	fn landscape_surface_returns_full_image()
	{
		let bundle = WallpaperBundle::from_decoded( xgrad( 16, 8 ) );
		let ( _, w, h ) = bundle.for_size( 32, 16 );
		assert_eq!( ( w, h ), ( 16, 8 ) );
	}

	#[ test ]
	fn portrait_surface_left_crops()
	{
		// 100x10 source, portrait surface 9:16 → crop width = round(10 * 9/16) = 6.
		let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) );
		let ( bytes, w, h ) = bundle.for_size( 9, 16 );
		assert_eq!( h, 10 );
		assert_eq!( w, 6 );
		// First pixel of the cropped image must come from column 0 of the source.
		assert_eq!( bytes[0], 0 );
		// Last pixel of the first row must come from column (w-1) of the crop = 5.
		let last = ( ( w as usize - 1 ) * 4 ) as usize;
		assert_eq!( bytes[ last ], 5 );
	}

	#[ test ]
	fn portrait_crop_is_cached()
	{
		let bundle = WallpaperBundle::from_decoded( xgrad( 100, 10 ) );
		let a = bundle.for_size( 9, 16 );
		let b = bundle.for_size( 9, 16 );
		// Same Arc pointer ⇒ second call hit the cache.
		assert!( Arc::ptr_eq( &a.0, &b.0 ) );
	}

	#[ test ]
	fn portrait_narrower_than_target_returns_source()
	{
		// Source already 1:10 (very tall) — taller than any portrait surface,
		// so the helper returns it unchanged.
		let bundle = WallpaperBundle::from_decoded( xgrad( 1, 10 ) );
		let ( _, w, h ) = bundle.for_size( 9, 16 );
		assert_eq!( ( w, h ), ( 1, 10 ) );
	}

	#[ test ]
	fn zero_size_returns_landscape()
	{
		let bundle = WallpaperBundle::from_decoded( xgrad( 4, 4 ) );
		let ( _, w, h ) = bundle.for_size( 0, 0 );
		assert_eq!( ( w, h ), ( 4, 4 ) );
	}
}
