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

//! Theme asset resolution: wallpapers, lockscreens, branding logos, app
//! icons, launcher icon, and the SVG rasterisation pipeline (with
//! process-wide cache) that the canvas consumes.

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

use crate::types::Color;

use super::active::ensure_active;
use super::prefs::{ ThemeMode, WallpaperFit, WallpaperSpec };
use super::system_fontdb;

// ─── Wallpaper / lockscreen ──────────────────────────────────────────────────

/// The active mode's homescreen / shell wallpaper. Prefers an explicit
/// declaration in `theme.json`; when absent, falls back to the
/// convention path `branding/{mode}/wallpaper.svg` via [`branding_asset`]
/// (with mode → opposite-mode → no-mode fallback). Returns `None` when
/// neither source resolves to an existing file.
///
/// Always returns the SVG. Use [`branding_image( "wallpaper", sw, sh
/// )`](branding_image) when the surface size is known to prefer a
/// pre-rendered raster variant under `branding/{mode}/wallpaper/`.
pub fn wallpaper() -> Option<WallpaperSpec>
{
	let state = ensure_active();
	if let Some( spec ) = state.document.mode( state.mode ).wallpaper.clone()
	{
		return Some( spec );
	}
	branding_asset( "wallpaper", "svg" )
		.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
}

/// The active mode's lockscreen / greeter wallpaper. Same resolution
/// strategy as [`wallpaper`]: explicit `theme.json` declaration first,
/// otherwise `branding/{mode}/lockscreen.svg` via [`branding_asset`].
/// Use [`branding_image( "lockscreen", sw, sh )`](branding_image) for
/// the size-aware raster lookup.
pub fn lockscreen() -> Option<WallpaperSpec>
{
	let state = ensure_active();
	if let Some( spec ) = state.document.mode( state.mode ).lockscreen.clone()
	{
		return Some( spec );
	}
	branding_asset( "lockscreen", "svg" )
		.map( |path| WallpaperSpec { path: Some( path ), fit: WallpaperFit::Cover } )
}

// ─── App icons ───────────────────────────────────────────────────────────────

/// Path to the named app icon SVG inside the active theme's `icons/apps/`
/// directory. `name` is the bare stem (e.g. `"firefox"`, `"calculator"`)
/// without the `.svg` extension. Returns `None` when the active document
/// has no on-disk root or the file does not exist.
pub fn app_icon( name: &str ) -> Option<PathBuf>
{
	let state = ensure_active();
	let root  = state.document.root.as_ref()?;
	let path  = root.join( "icons/apps" ).join( format!( "{}.svg", name ) );
	if path.is_file() { Some( path ) } else { None }
}

/// Path to `app-default.svg` inside the active theme's icon directory.
/// Returns `None` when the document has no on-disk root or the file is
/// absent.
pub fn app_default_icon() -> Option<PathBuf>
{
	let state = ensure_active();
	let root  = state.document.root.as_ref()?;
	let path  = root.join( "icons/app-default.svg" );
	if path.is_file() { Some( path ) } else { None }
}

/// Path to the launcher-logo SVG for the currently active mode.
/// Resolves through [`branding_asset`] using the convention
/// `branding/{mode}/launcher.svg`, with the standard mode →
/// opposite-mode → no-mode fallback chain.
pub fn launcher_icon() -> Option<PathBuf>
{
	branding_asset( "launcher", "svg" )
}

// ─── Logos ───────────────────────────────────────────────────────────────────

/// Path to the brand logo SVG for the currently active mode.
/// Convention: `branding/{mode}/logo/logo.svg`. The "main" logo —
/// usually the wordmark variant a shell would put in an "About"
/// dialog or a splash. Pair with [`logo_square`] / [`logo_horizontal`]
/// when the surface dictates a different aspect ratio.
///
/// Resolves through [`branding_asset`] with the same three-step
/// fallback (active mode → opposite mode → no-mode), so a theme that
/// only ships one mode still produces a usable path.
pub fn logo() -> Option<PathBuf>
{
	branding_asset( "logo/logo", "svg" )
}

/// Path to the square (1:1) brand logo SVG for the currently active
/// mode. Convention: `branding/{mode}/logo/square.svg`. Use when the
/// surface is roughly square — app icons, login avatars, splash
/// screens, lockscreen brand badges. Same fallback chain as [`logo`].
pub fn logo_square() -> Option<PathBuf>
{
	branding_asset( "logo/square", "svg" )
}

/// Path to the horizontal (wordmark) brand logo SVG for the currently
/// active mode. Convention: `branding/{mode}/logo/horizontal.svg`.
/// Use when there is meaningful horizontal space — header bars, menu
/// bars, "About" dialogs, sign-in screens. Same fallback chain as
/// [`logo`].
pub fn logo_horizontal() -> Option<PathBuf>
{
	branding_asset( "logo/horizontal", "svg" )
}

// ─── Branding asset resolution ───────────────────────────────────────────────

/// Resolve a branding asset (launcher logo, wallpaper, lockscreen, …)
/// against the active theme's `branding/` tree. Tries three candidate
/// paths in order and returns the first one that exists on disk:
///
/// 1. `branding/{active_mode}/{name}.{ext}` — preferred variant.
/// 2. `branding/{opposite_mode}/{name}.{ext}` — graceful degradation
///    when the theme only ships one mode of the asset.
/// 3. `branding/{name}.{ext}` — mode-agnostic asset, for themes that
///    do not bother with light/dark variants.
///
/// Returns `None` when none of the candidates exist or the active
/// document has no on-disk root.
pub fn branding_asset( name: &str, ext: &str ) -> Option<PathBuf>
{
	let state    = ensure_active();
	let root     = state.document.root.as_ref()?;
	let branding = root.join( "branding" );
	let filename = format!( "{name}.{ext}" );
	let ( pref_dir, alt_dir ) = match state.mode
	{
		ThemeMode::Dark  => ( "dark",  "light" ),
		ThemeMode::Light => ( "light", "dark"  ),
	};
	let preferred = branding.join( pref_dir ).join( &filename );
	if preferred.is_file() { return Some( preferred ); }
	let alternate = branding.join( alt_dir ).join( &filename );
	if alternate.is_file() { return Some( alternate ); }
	let modeless  = branding.join( &filename );
	if modeless.is_file()  { return Some( modeless );  }
	None
}

/// Resolve a raster variant of a branded asset for the given surface
/// dimensions. Looks under `branding/{mode}/{name}/` (with the
/// standard mode → opposite-mode → no-mode fallback chain on the
/// *directory*), parses each filename as `WIDTHxHEIGHT.<ext>` where
/// `<ext>` is `webp`, `png`, `jpg`, or `jpeg`. Returns the best match
/// in the *first existing* directory:
///
/// - If one or more entries cover the surface (`W ≥ sw && H ≥ sh`),
///   returns the smallest such by area — the smallest raster that
///   fits without upscaling.
/// - Otherwise returns the largest entry available — better to
///   upscale a fast-decoding raster than to fall back to the
///   comparatively expensive SVG rasterisation.
///
/// Ties on area are broken by `(width, height)` lexicographic order
/// for determinism.
///
/// `(sw, sh)` of `(0, 0)` means "give me the smallest available
/// raster" — every entry trivially covers a zero-sized surface, so
/// the smallest by area wins. Useful at startup before the
/// surface-configure event has reported the real dimensions.
///
/// Returns `None` only when no directory in the fallback chain
/// contains any parseable raster file.
///
/// Note: only the *first existing* mode-directory in the chain is
/// considered. If `branding/{active_mode}/{name}/` has any raster,
/// the loader uses it; it does not cross over to the opposite-mode
/// directory. Cross-mode fallback happens at the SVG layer through
/// [`branding_asset`], where a colour-wrong raster would be more
/// jarring than a colour-correct vector.
pub fn branding_raster( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
{
	let state    = ensure_active();
	let root     = state.document.root.as_ref()?;
	let branding = root.join( "branding" );
	let ( pref_dir, alt_dir ) = match state.mode
	{
		ThemeMode::Dark  => ( "dark",  "light" ),
		ThemeMode::Light => ( "light", "dark"  ),
	};

	let candidates = [
		branding.join( pref_dir ).join( name ),
		branding.join( alt_dir  ).join( name ),
		branding.join( name                 ),
	];
	for dir in &candidates
	{
		if !dir.is_dir() { continue; }
		if let Some( best ) = pick_best_raster( dir, sw, sh )
		{
			return Some( best );
		}
	}
	None
}

/// Resolve a branded image asset, preferring a raster variant (WebP /
/// PNG / JPEG) over the canonical SVG. Tries
/// [`branding_raster(name, sw, sh)`] first; on `None` (no raster files
/// at all under `branding/{mode}/{name}/`) falls back to
/// [`branding_asset(name, "svg")`].
///
/// Pass `(0, 0)` to get the smallest available raster — useful at
/// startup before the surface size is known.
pub fn branding_image( name: &str, sw: u32, sh: u32 ) -> Option<PathBuf>
{
	branding_raster( name, sw, sh )
		.or_else( || branding_asset( name, "svg" ) )
}

/// Walk `dir` for files named `WIDTHxHEIGHT.<ext>` (case-insensitive
/// `x`, recognised raster extensions: webp, png, jpg, jpeg). Returns
/// the smallest entry whose dimensions cover `( sw, sh )`; if none
/// cover, returns the largest entry available (better to upscale a
/// fast raster than fall through to SVG rasterisation). Ties are
/// broken by `( width, height )` lexicographic order. `None` only
/// when the directory holds no parseable raster files.
fn pick_best_raster( dir: &Path, sw: u32, sh: u32 ) -> Option<PathBuf>
{
	let entries = std::fs::read_dir( dir ).ok()?;
	let mut covering: Option<( u32, u32, PathBuf )> = None;
	let mut fallback: Option<( u32, u32, PathBuf )> = None;
	for entry in entries.flatten()
	{
		let path = entry.path();
		let Some( stem ) = path.file_stem().and_then( |s| s.to_str() ) else { continue };
		let ext = path.extension().and_then( |e| e.to_str() ).unwrap_or( "" );
		if !matches!( ext.to_ascii_lowercase().as_str(), "webp" | "png" | "jpg" | "jpeg" )
		{
			continue;
		}
		let Some( ( w_str, h_str ) ) = stem.split_once( |c: char| c == 'x' || c == 'X' ) else { continue };
		let ( Ok( w ), Ok( h ) ) = ( w_str.parse::<u32>(), h_str.parse::<u32>() ) else { continue };
		let new_area = ( w as u64 ) * ( h as u64 );
		if w >= sw && h >= sh
		{
			let take = match &covering
			{
				None                  => true,
				Some( ( bw, bh, _ ) ) =>
				{
					let cur_area = ( *bw as u64 ) * ( *bh as u64 );
					new_area < cur_area || ( new_area == cur_area && ( w, h ) < ( *bw, *bh ) )
				}
			};
			if take { covering = Some( ( w, h, path ) ); }
		}
		else
		{
			let take = match &fallback
			{
				None                  => true,
				Some( ( bw, bh, _ ) ) =>
				{
					let cur_area = ( *bw as u64 ) * ( *bh as u64 );
					new_area > cur_area || ( new_area == cur_area && ( w, h ) > ( *bw, *bh ) )
				}
			};
			if take { fallback = Some( ( w, h, path ) ); }
		}
	}
	covering.or( fallback ).map( |( _, _, p )| p )
}

// ─── Symbolic icon tinting ───────────────────────────────────────────────────

/// Re-tint a symbolic RGBA icon: replace every pixel's RGB with `tint` while
/// keeping the source alpha (weighted by `tint.a`).
///
/// Input `rgba` must be straight-alpha RGBA8 with 4 bytes per pixel.
/// Returns a freshly allocated `Vec<u8>` of the same length.
///
/// Useful for flattening Papirus / freedesktop icons to a single theme colour
/// so they stay legible against both light and dark backgrounds.
pub fn tint_symbolic( rgba: &[u8], tint: Color ) -> Vec<u8>
{
	let r = (tint.r.clamp( 0.0, 1.0 ) * 255.0) as u8;
	let g = (tint.g.clamp( 0.0, 1.0 ) * 255.0) as u8;
	let b = (tint.b.clamp( 0.0, 1.0 ) * 255.0) as u8;
	let ta = tint.a.clamp( 0.0, 1.0 );

	let mut out = Vec::with_capacity( rgba.len() );
	for px in rgba.chunks_exact( 4 )
	{
		let a = (px[3] as f32 / 255.0) * ta;
		out.extend_from_slice( &[ r, g, b, (a * 255.0) as u8 ] );
	}
	out
}

// ─── SVG rasterisation ───────────────────────────────────────────────────────

/// Process-wide cache of rasterised theme icons, keyed by (absolute path on
/// disk, target longest-edge size in physical pixels). Entries are produced
/// by [`icon_rgba`]. The key embeds the absolute path and the icon files
/// are read-only on disk, so entries never go stale; [`clear_svg_cache`]
/// empties the map on `set_active_document` purely to drop dead memory.
static SVG_CACHE: Mutex<Option<HashMap<( PathBuf, u32 ), ( Arc<Vec<u8>>, u32, u32 )>>>
	= Mutex::new( None );

/// Drop every entry from the SVG cache. Called when the active document
/// is replaced — keys embed absolute paths so stale entries are never
/// wrong, just dead memory.
pub ( super ) fn clear_svg_cache()
{
	if let Ok( mut cache ) = SVG_CACHE.lock()
	{
		if let Some( ref mut map ) = *cache { map.clear(); }
	}
}

/// Decode a UTF-8 SVG document into a premultiplied RGBA8 pixmap of
/// the requested longest-edge `size` in physical pixels. The shorter
/// edge is scaled proportionally so the icon's aspect ratio is
/// preserved; the returned `(width, height)` reflect the final
/// pixmap.
///
/// Returns `None` for malformed SVG input or when the size is too
/// small (≤ 0 px on the longest edge).
///
/// The implementation uses [`resvg`] (which bundles `usvg` and
/// `tiny-skia`) so the rasteriser is the same as the rest of ltk's
/// software-canvas path. Use [`icon_rgba`] when you want
/// path-resolution + caching against the active theme tree.
pub fn decode_svg_bytes( svg_bytes: &[u8], size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
{
	if size == 0 { return None; }
	// SVGs that declare `width="100%"` without an explicit pixel size
	// fall back to usvg's `default_size` (100×100). Match it to the
	// requested size so percentage-only documents rasterise at the
	// dimensions the caller asked for instead of a tiny default.
	let mut opts = resvg::usvg::Options::default();
	if let Some( ds ) = resvg::usvg::Size::from_wh( size as f32, size as f32 )
	{
		opts.default_size = ds;
	}
	opts.fontdb = system_fontdb();
	let tree = resvg::usvg::Tree::from_data( svg_bytes, &opts ).ok()?;
	let svg_size = tree.size();
	let longest  = svg_size.width().max( svg_size.height() );
	if longest <= 0.0 { return None; }
	let scale    = size as f32 / longest;
	let w = ( svg_size.width()  * scale ).ceil() as u32;
	let h = ( svg_size.height() * scale ).ceil() as u32;
	let mut pixmap = resvg::tiny_skia::Pixmap::new( w.max( 1 ), h.max( 1 ) )?;
	let transform  = resvg::tiny_skia::Transform::from_scale( scale, scale );
	resvg::render( &tree, transform, &mut pixmap.as_mut() );
	Some( ( Arc::new( pixmap.take() ), w, h ) )
}

/// Resolve a theme-relative icon name to an absolute path inside the
/// active theme's `icons/catalogue/` tree.
///
/// `name` is the slash-separated path **without** the `.svg`
/// extension (e.g. `"general/right-simple"`,
/// `"system/wifi-signal-full"`). The lookup tries the
/// `catalogue/filled/<name>.svg` variant first and falls back to
/// `catalogue/line/<name>.svg`; returns `None` when neither file
/// exists or the active document has no on-disk root.
pub fn icon_path( name: &str ) -> Option<PathBuf>
{
	let state = ensure_active();
	let root  = state.document.root.as_ref()?;
	let filled = root.join( "icons/catalogue/filled" ).join( format!( "{name}.svg" ) );
	if filled.is_file() { return Some( filled ); }
	let line = root.join( "icons/catalogue/line" ).join( format!( "{name}.svg" ) );
	if line.is_file() { return Some( line ); }
	None
}

/// Rasterise a theme icon to a premultiplied RGBA8 pixmap.
///
/// Combines [`icon_path`] (path resolution against the active theme)
/// with [`decode_svg_bytes`] (SVG → RGBA), and caches the result by
/// `(absolute path, size)` so a widget redrawn every frame pays the
/// rasterisation cost only on first access.
///
/// Returns `None` when the icon cannot be located, the file cannot be
/// read, or the SVG is malformed.
pub fn icon_rgba( name: &str, size: u32 ) -> Option<( Arc<Vec<u8>>, u32, u32 )>
{
	let path = icon_path( name )?;
	let key  = ( path.clone(), size );

	// Fast path: cache hit.
	{
		let mut guard = SVG_CACHE.lock().ok()?;
		let cache     = guard.get_or_insert_with( HashMap::new );
		if let Some( v ) = cache.get( &key )
		{
			return Some( ( Arc::clone( &v.0 ), v.1, v.2 ) );
		}
	}

	// Slow path: read + decode + populate cache.
	let bytes  = std::fs::read( &path ).ok()?;
	let result = decode_svg_bytes( &bytes, size )?;
	if let Ok( mut guard ) = SVG_CACHE.lock()
	{
		let cache = guard.get_or_insert_with( HashMap::new );
		cache.insert( key, ( Arc::clone( &result.0 ), result.1, result.2 ) );
	}
	Some( result )
}

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

	#[ test ]
	fn tint_preserves_source_alpha()
	{
		let src = [ 255, 0, 0, 255,   0, 255, 0, 128 ];
		let out = tint_symbolic( &src, Color::hex( 0x0B, 0x1B, 0x38 ) );
		assert_eq!( out[0..3], [ 0x0B, 0x1B, 0x38 ] );
		assert_eq!( out[3], 255 );
		assert_eq!( out[4..7], [ 0x0B, 0x1B, 0x38 ] );
		assert_eq!( out[7], 128 );
	}

	#[ test ]
	fn tint_respects_tint_alpha()
	{
		let src = [ 255, 255, 255, 255 ];
		let out = tint_symbolic( &src, Color { r: 1.0, g: 1.0, b: 1.0, a: 0.5 } );
		assert_eq!( out[3], 127 );
	}

	#[ test ]
	fn decode_svg_bytes_returns_pixmap_for_minimal_svg()
	{
		// 16×16 red square. Double-pound raw byte string so the `#`
		// inside `fill="#ff0000"` does not close the literal.
		let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#ff0000"/></svg>"##;
		let ( rgba, w, h ) = decode_svg_bytes( svg, 16 ).expect( "decode" );
		assert_eq!( w, 16 );
		assert_eq!( h, 16 );
		assert_eq!( rgba.len(), ( 16 * 16 * 4 ) as usize );
		// Centre pixel should be opaque red (premultiplied → R=255 A=255).
		let centre = ( ( 8 * 16 + 8 ) * 4 ) as usize;
		assert!( rgba[ centre + 0 ] > 200, "red channel" );
		assert!( rgba[ centre + 1 ] < 50,  "green channel" );
		assert!( rgba[ centre + 2 ] < 50,  "blue channel" );
		assert_eq!( rgba[ centre + 3 ], 255, "alpha" );
	}

	#[ test ]
	fn decode_svg_bytes_scales_to_requested_size()
	{
		// 16×16 source rasterised at 32 px → output is 32×32.
		let svg = br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><rect width="16" height="16" fill="#000"/></svg>"##;
		let ( _, w, h ) = decode_svg_bytes( svg, 32 ).expect( "decode" );
		assert_eq!( w, 32 );
		assert_eq!( h, 32 );
	}

	#[ test ]
	fn decode_svg_bytes_rejects_garbage()
	{
		assert!( decode_svg_bytes( b"not valid svg",      32 ).is_none() );
		assert!( decode_svg_bytes( b"<svg></svg>",         0  ).is_none() );  // size 0
	}
}
