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

//! Wrapped paragraph text with clickable link ranges — the ltk side of an
//! Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Unlike [`Text`] it
//! carries a `Msg` per link and the layout pass emits one hit rect per link
//! line so taps land on the link, not the whole paragraph.

use std::sync::Arc;

use fontdue::Font;

use crate::theme::FontStyle;
use crate::types::{ Color, Length, Rect };
use crate::render::Canvas;
use super::{ Element, MapFn };

#[ cfg( test ) ]
mod tests;

/// A clickable range `[start, end)` (byte offsets into the content) and the
/// message to emit when it is tapped.
pub struct LinkSpan<Msg>
{
	pub( crate ) start: usize,
	pub( crate ) end:   usize,
	pub( crate ) msg:   Msg,
}

/// A wrapped paragraph with clickable link ranges — the ltk counterpart of an
/// Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Each [`LinkSpan`]
/// pairs a byte range with a `Msg` emitted on tap; the layout pass yields one
/// hit rect per link line so taps land on the link rather than the paragraph.
pub struct RichText<Msg: Clone>
{
	pub( crate ) content:    String,
	pub( crate ) size:       Length,
	pub( crate ) color:      Color,
	pub( crate ) link_color: Color,
	pub( crate ) font:       Option<( String, u16, FontStyle )>,
	pub( crate ) links:      Vec<LinkSpan<Msg>>,
}

impl<Msg: Clone> RichText<Msg>
{
	/// A paragraph of `content` with no links: white text, the default blue
	/// link colour, default 16 px size and the canvas default font.
	pub fn new( content: impl Into<String> ) -> Self
	{
		Self
		{
			content:    content.into(),
			size:       Length::px( 16.0 ),
			color:      Color::WHITE,
			link_color: Color::rgb( 0.20, 0.50, 0.95 ),
			font:       None,
			links:      Vec::new(),
		}
	}

	/// Set the font size.
	pub fn size( mut self, s: impl Into<Length> ) -> Self
	{
		self.size = s.into();
		self
	}

	/// Set the colour of non-link text.
	pub fn color( mut self, c: Color ) -> Self
	{
		self.color = c;
		self
	}

	/// Set the colour of link ranges (drawn underlined).
	pub fn link_color( mut self, c: Color ) -> Self
	{
		self.link_color = c;
		self
	}

	/// Override the font with a `(family, weight, style)` triple resolved
	/// through the active theme's font registry on draw.
	pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
	{
		self.font = Some( ( family.into(), weight, style ) );
		self
	}

	/// Add a clickable range `[start, end)` (byte offsets) emitting `msg`.
	pub fn link( mut self, start: usize, end: usize, msg: Msg ) -> Self
	{
		self.links.push( LinkSpan { start, end, msg } );
		self
	}

	#[ inline ]
	fn resolved_size( &self, canvas: &Canvas ) -> f32
	{
		canvas.resolve_font( self.size )
	}

	fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
	{
		self.font.as_ref().map( |( family, weight, style )| canvas.font_for( family, *weight, *style ) )
	}

	fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
	{
		let size = self.resolved_size( canvas );
		match font
		{
			Some( f ) => canvas.measure_text_with_font( text, size, f ),
			None      => canvas.measure_text( text, size ),
		}
	}

	pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
	{
		let size   = self.resolved_size( canvas );
		let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
		let font   = self.resolve_font( canvas );
		let lines  = wrap_tracked( &self.content, size, max_width, canvas, font.as_ref() );
		( max_width, line_h * lines.len().max( 1 ) as f32 )
	}

	pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
	{
		let size   = self.resolved_size( canvas );
		let ascent = canvas.font_line_metrics( size ).map( |m| m.ascent ).unwrap_or( size * 0.8 );
		let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
		let font   = self.resolve_font( canvas );
		let lines  = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() );

		for ( i, line ) in lines.iter().enumerate()
		{
			let ty = rect.y + ascent + line_h * i as f32;
			match font.as_ref()
			{
				Some( f ) => canvas.draw_text_with_font( &line.text, rect.x, ty, size, self.color, f ),
				None      => canvas.draw_text( &line.text, rect.x, ty, size, self.color ),
			}

			for link in &self.links
			{
				let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
				let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
				let tx = rect.x + dx;
				match font.as_ref()
				{
					Some( f ) => canvas.draw_text_with_font( &sub, tx, ty, size, self.link_color, f ),
					None      => canvas.draw_text( &sub, tx, ty, size, self.link_color ),
				}
				let sw = self.measure( &sub, canvas, font.as_ref() );
				canvas.draw_line( tx, ty + 2.0, tx + sw, ty + 2.0, self.link_color, 1.0 );
			}
		}
	}

	/// Per-link hit rects for the layout pass: one rect per visual line a link
	/// covers, paired with the link's message.
	pub fn link_rects( &self, rect: Rect, canvas: &Canvas ) -> Vec<( Rect, Msg )>
	{
		let size   = self.resolved_size( canvas );
		let ascent = canvas.font_line_metrics( size ).map( |m| m.ascent ).unwrap_or( size * 0.8 );
		let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
		let font   = self.resolve_font( canvas );
		let lines  = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() );

		let mut out = Vec::new();
		for ( i, line ) in lines.iter().enumerate()
		{
			for link in &self.links
			{
				let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
				let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
				let sw = self.measure( &sub, canvas, font.as_ref() );
				let y  = rect.y + line_h * i as f32;
				out.push( ( Rect { x: rect.x + dx, y, width: sw, height: ascent.max( line_h ) }, link.msg.clone() ) );
			}
		}
		out
	}

	pub( crate ) fn map_msg<U: Clone>( self, f: &MapFn<Msg, U> ) -> RichText<U>
	{
		RichText
		{
			content:    self.content,
			size:       self.size,
			color:      self.color,
			link_color: self.link_color,
			font:       self.font,
			links:      self.links.into_iter().map( |l| LinkSpan { start: l.start, end: l.end, msg: f( l.msg ) } ).collect(),
		}
	}
}

/// One visual row of the wrapped paragraph. `text` is the rendered line (words
/// joined by single spaces); `offsets` maps each rendered char to its source
/// byte offset in the original content, with a trailing sentinel = line end.
struct VisualLine
{
	text:    String,
	offsets: Vec<usize>,
}

impl VisualLine
{
	/// The rendered substring covered by source range `[start, end)`, as
	/// `( char_prefix_len, substring_byte_len, substring )` — or None when the
	/// link does not touch this line. `char_prefix_len` is a byte index into
	/// `self.text` (the rendered chars before the link on this line).
	fn segment( &self, start: usize, end: usize ) -> Option<( usize, usize, String )>
	{
		let mut byte = 0;
		let mut first: Option<usize> = None;
		let mut last_byte = 0;
		for ( ci, ch ) in self.text.chars().enumerate()
		{
			let src = self.offsets.get( ci ).copied().unwrap_or( usize::MAX );
			if src >= start && src < end
			{
				if first.is_none() { first = Some( byte ); }
				last_byte = byte + ch.len_utf8();
			}
			byte += ch.len_utf8();
		}
		let f = first?;
		Some( ( f, last_byte - f, self.text[f..last_byte].to_string() ) )
	}
}

fn wrap_tracked( content: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<VisualLine>
{
	let measure = |s: &str| -> f32
	{
		match font
		{
			Some( f ) => canvas.measure_text_with_font( s, size, f ),
			None      => canvas.measure_text( s, size ),
		}
	};

	// Tokenise into words (byte ranges) and hard breaks (from '\n').
	let mut words: Vec<( usize, usize )> = Vec::new();
	let mut breaks: Vec<usize> = Vec::new(); // word-index after which a hard break sits
	let mut start: Option<usize> = None;
	for ( b, ch ) in content.char_indices()
	{
		if ch == '\n'
		{
			if let Some( s ) = start.take() { words.push( ( s, b ) ); }
			breaks.push( words.len() );
		}
		else if ch.is_whitespace()
		{
			if let Some( s ) = start.take() { words.push( ( s, b ) ); }
		}
		else if start.is_none()
		{
			start = Some( b );
		}
	}
	if let Some( s ) = start { words.push( ( s, content.len() ) ); }

	let space_w = measure( " " );
	let mut lines: Vec<VisualLine> = Vec::new();
	let mut cur: Vec<( usize, usize )> = Vec::new();
	let mut cur_w = 0.0_f32;

	for ( wi, &( ws, we ) ) in words.iter().enumerate()
	{
		let word_w = measure( &content[ws..we] );
		if cur.is_empty()
		{
			cur.push( ( ws, we ) );
			cur_w = word_w;
		}
		else if max_width > 0.0 && cur_w + space_w + word_w > max_width
		{
			flush_line( content, &mut cur, &mut lines );
			cur.push( ( ws, we ) );
			cur_w = word_w;
		}
		else
		{
			cur.push( ( ws, we ) );
			cur_w += space_w + word_w;
		}

		if breaks.contains( &( wi + 1 ) )
		{
			flush_line( content, &mut cur, &mut lines );
			cur_w = 0.0;
		}
	}
	if !cur.is_empty() { flush_line( content, &mut cur, &mut lines ); }
	if lines.is_empty() { lines.push( VisualLine { text: String::new(), offsets: vec![ 0 ] } ); }
	lines
}

fn flush_line( content: &str, cur: &mut Vec<( usize, usize )>, lines: &mut Vec<VisualLine> )
{
	let mut text = String::new();
	let mut offsets = Vec::new();
	for ( wi, &( ws, we ) ) in cur.iter().enumerate()
	{
		if wi > 0
		{
			offsets.push( ws );
			text.push( ' ' );
		}
		for ( b, ch ) in content[ws..we].char_indices()
		{
			offsets.push( ws + b );
			text.push( ch );
		}
	}
	offsets.push( cur.last().map( |&( _, we )| we ).unwrap_or( 0 ) );
	lines.push( VisualLine { text, offsets } );
	cur.clear();
}

impl<Msg: Clone + 'static> From<RichText<Msg>> for Element<Msg>
{
	fn from( t: RichText<Msg> ) -> Self
	{
		Element::RichText( t )
	}
}

/// Free-function shorthand for [`RichText::new`].
pub fn rich_text<Msg: Clone>( content: impl Into<String> ) -> RichText<Msg>
{
	RichText::new( content )
}
