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

//! Runtime font registry: families → (weight, style) → `fontdue::Font`.
//!
//! The registry is the live, loaded-in-memory counterpart of the theme's
//! `fonts` block ([`super::FontFamilyDef`]). It is built once at theme load
//! time by calling [`FontRegistry::from_families`], then handed to the
//! render backends as an `Arc<FontRegistry>` so they can resolve specific
//! sources on demand without re-reading TTFs from disk.
//!
//! # Resolution order
//!
//! [`FontRegistry::resolve`] looks up a family / weight / style triple with
//! the following precedence:
//!
//! 1. Exact `(family, weight, style)` match.
//! 2. Same family and style, weight closest to the request (absolute diff).
//! 3. Same family, any style, weight closest to the request.
//! 4. Walk the family's `fallbacks` chain, recursing into each fallback
//!    family with the original weight/style.
//!
//! When nothing matches, [`FontRegistry::resolve`] returns `None`; callers
//! fall back to the canvas' system font.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use fontdue::{ Font, FontSettings };

use super::fonts::FontFamilyDef;
use super::text_style::FontStyle;

// ─── Key ─────────────────────────────────────────────────────────────────────

/// Composite key identifying a single font source within a family.
///
/// `family` is the id under which the family was declared in the theme's
/// `fonts` block (e.g. `"sora"`), not its human-readable name.
#[ derive( Debug, Clone, PartialEq, Eq, Hash ) ]
pub struct FontKey
{
	pub family: String,
	pub weight: u16,
	pub style:  FontStyle,
}

// ─── Errors ──────────────────────────────────────────────────────────────────

/// Error raised when loading a font source fails.
#[ derive( Debug ) ]
pub enum FontLoadError
{
	/// The source file could not be read.
	Io( PathBuf, std::io::Error ),
	/// The source file was read but `fontdue` rejected its contents.
	Parse( PathBuf, String ),
}

impl std::fmt::Display for FontLoadError
{
	fn fmt( &self, f: &mut std::fmt::Formatter<'_> ) -> std::fmt::Result
	{
		match self
		{
			FontLoadError::Io( p, e )    => write!( f, "reading font {}: {}", p.display(), e ),
			FontLoadError::Parse( p, m ) => write!( f, "parsing font {}: {}", p.display(), m ),
		}
	}
}

impl std::error::Error for FontLoadError {}

// ─── Registry ────────────────────────────────────────────────────────────────

/// A loaded font registry: the theme's declared families materialised into
/// live `fontdue::Font` handles, indexed by family id / weight / style.
#[ derive( Debug, Default ) ]
pub struct FontRegistry
{
	by_key:    HashMap<FontKey, Arc<Font>>,
	fallbacks: HashMap<String, Vec<String>>,
}

impl FontRegistry
{
	/// Create an empty registry. Use [`Self::insert`] / [`Self::set_fallbacks`]
	/// to populate it, or [`Self::from_families`] to load a whole theme in
	/// one go.
	pub fn new() -> Self
	{
		Self { by_key: HashMap::new(), fallbacks: HashMap::new() }
	}

	/// Register a single font source.
	pub fn insert
	(
		&mut self,
		family: impl Into<String>,
		weight: u16,
		style:  FontStyle,
		font:   Arc<Font>,
	)
	{
		self.by_key.insert( FontKey { family: family.into(), weight, style }, font );
	}

	/// Set the fallback chain for a family.
	pub fn set_fallbacks( &mut self, family: impl Into<String>, chain: Vec<String> )
	{
		self.fallbacks.insert( family.into(), chain );
	}

	/// Number of loaded sources across all families. Useful in tests.
	pub fn len( &self ) -> usize { self.by_key.len() }

	/// Whether the registry has no sources loaded.
	pub fn is_empty( &self ) -> bool { self.by_key.is_empty() }

	/// Resolve a triple to a loaded [`Font`]. See the module docs for the
	/// precedence order.
	pub fn resolve( &self, family: &str, weight: u16, style: FontStyle ) -> Option<Arc<Font>>
	{
		// 1. Exact match.
		let exact = FontKey { family: family.to_string(), weight, style };
		if let Some( f ) = self.by_key.get( &exact )
		{
			return Some( Arc::clone( f ) );
		}

		// 2. Same family + style, closest weight.
		let best_same_style = self.by_key.iter()
			.filter( |( k, _ )| k.family == family && k.style == style )
			.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
		if let Some( ( _, f ) ) = best_same_style
		{
			return Some( Arc::clone( f ) );
		}

		// 3. Same family, any style, closest weight.
		let best_same_family = self.by_key.iter()
			.filter( |( k, _ )| k.family == family )
			.min_by_key( |( k, _ )| (k.weight as i32 - weight as i32).abs() );
		if let Some( ( _, f ) ) = best_same_family
		{
			return Some( Arc::clone( f ) );
		}

		// 4. Walk fallback chain.
		if let Some( chain ) = self.fallbacks.get( family )
		{
			for fb in chain
			{
				if let Some( f ) = self.resolve( fb, weight, style )
				{
					return Some( f );
				}
			}
		}

		None
	}

	/// Load every source declared in `families` into the registry.
	///
	/// `families` is keyed by family id (the string the theme JSON uses in
	/// `fonts.<id>` and that [`super::FontRef::Named`] references). The
	/// family's `name` field is carried for human display only; lookups go
	/// through the id.
	pub fn from_families
	(
		families: &HashMap<String, FontFamilyDef>,
	) -> Result<Self, FontLoadError>
	{
		let mut reg = Self::new();
		for ( id, family ) in families
		{
			if !family.fallbacks.is_empty()
			{
				reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
			}
			for src in &family.sources
			{
				let bytes = std::fs::read( &src.path )
					.map_err( |e| FontLoadError::Io( src.path.clone(), e ) )?;
				let font  = Font::from_bytes( bytes.as_slice(), FontSettings::default() )
					.map_err( |e| FontLoadError::Parse( src.path.clone(), e.to_string() ) )?;
				reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
			}
		}
		Ok( reg )
	}

	/// Like [`Self::from_families`] but tolerant: sources that fail to load
	/// are logged via `eprintln!` and skipped instead of aborting the whole
	/// registry build. Intended for the runtime path where a missing TTF
	/// on a user's machine should degrade to "fall back to system font for
	/// that weight" rather than crash the shell.
	///
	/// Returns a registry that may be partial — callers cannot assume any
	/// specific weight is loaded, only that what COULD be loaded is
	/// loaded. Font resolution's fallback ladder (family → closest weight
	/// → fallback chain → `Canvas::font`) handles the gaps gracefully.
	pub fn from_families_lenient
	(
		families: &HashMap<String, FontFamilyDef>,
	) -> Self
	{
		let mut reg = Self::new();
		for ( id, family ) in families
		{
			if !family.fallbacks.is_empty()
			{
				reg.set_fallbacks( id.clone(), family.fallbacks.clone() );
			}
			for src in &family.sources
			{
				let bytes = match std::fs::read( &src.path )
				{
					Ok( b )  => b,
					Err( e ) =>
					{
						eprintln!
						(
							"[ltk] skipping font {} (weight {}, {:?}): {}",
							src.path.display(), src.weight, src.style, e
						);
						continue;
					}
				};
				let font = match Font::from_bytes( bytes.as_slice(), FontSettings::default() )
				{
					Ok( f )  => f,
					Err( e ) =>
					{
						eprintln!
						(
							"[ltk] skipping font {} (weight {}, {:?}): parse error: {}",
							src.path.display(), src.weight, src.style, e
						);
						continue;
					}
				};
				reg.insert( id.clone(), src.weight, src.style, Arc::new( font ) );
			}
		}
		reg
	}
}

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

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

	/// Load a real TTF from the system via the same search chain the
	/// software canvas uses. Only used by tests that need an actual
	/// `Font` — skipped when no font is found (keeps CI green on images
	/// without the usual system fonts).
	fn system_font() -> Option<Arc<Font>>
	{
		let path = crate::system_fonts::find_font_opt()?;
		let bytes = std::fs::read( path ).ok()?;
		let font  = Font::from_bytes( bytes.as_slice(), FontSettings::default() ).ok()?;
		Some( Arc::new( font ) )
	}

	#[ test ]
	fn empty_registry_resolves_to_none()
	{
		let reg = FontRegistry::new();
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_none() );
		assert!( reg.is_empty() );
	}

	#[ test ]
	fn exact_match_wins()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
		reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );
		assert!( reg.resolve( "sora", 700, FontStyle::Normal ).is_some() );
		assert_eq!( reg.len(), 2 );
	}

	#[ test ]
	fn closest_weight_is_picked_when_exact_missing()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 300, FontStyle::Normal, Arc::clone( &font ) );
		reg.insert( "sora", 700, FontStyle::Normal, Arc::clone( &font ) );

		// Ask for 400: closer to 300 than to 700. Both rounds 100 away or
		// 300 away respectively; 300 wins.
		assert!( reg.resolve( "sora", 400, FontStyle::Normal ).is_some() );

		// Ask for 800: closer to 700.
		assert!( reg.resolve( "sora", 800, FontStyle::Normal ).is_some() );
	}

	#[ test ]
	fn fallback_chain_is_walked_when_family_unknown()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, Arc::clone( &font ) );
		reg.set_fallbacks( "display", vec![ "sora".to_string() ] );

		// `display` has no direct entries, but its fallback chain leads to
		// `sora` which is loaded.
		assert!( reg.resolve( "display", 400, FontStyle::Normal ).is_some() );
	}

	#[ test ]
	fn unreachable_family_returns_none()
	{
		let Some( font ) = system_font() else { return; };
		let mut reg = FontRegistry::new();
		reg.insert( "sora", 400, FontStyle::Normal, font );
		assert!( reg.resolve( "roboto", 400, FontStyle::Normal ).is_none() );
	}

	#[ test ]
	fn from_families_reports_io_error_for_missing_path()
	{
		use crate::theme::fonts::FontSource;
		let mut fams = HashMap::new();
		fams.insert( "sora".to_string(), FontFamilyDef
		{
			name:      "Sora".to_string(),
			fallbacks: Vec::new(),
			sources:   vec!
			[
				FontSource
				{
					weight: 400,
					style:  FontStyle::Normal,
					path:   "/this/path/does/not/exist.ttf".into(),
				},
			],
		});
		match FontRegistry::from_families( &fams )
		{
			Err( FontLoadError::Io( p, _ ) ) =>
			{
				assert!( p.to_string_lossy().contains( "does/not/exist" ) );
			}
			other => panic!( "expected Io error, got {:?}", other ),
		}
	}
}
