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

use std::sync::Arc;
use crate::types::{ Length, Rect };
use crate::render::Canvas;
use super::Element;
use super::slider::intersect_clip;

mod theme;

#[ cfg( test ) ]
mod tests;

/// Compute the slider value `[0.0, 1.0]` from a tap/drag y position within a
/// slider's layout rect. `rect.top` maps to `1.0` and `rect.bottom` to `0.0`
/// — so the fill rises from the bottom as the user drags upward. Pure — no
/// theme / canvas dependency. Lifted out of [`VSlider`] so input handlers
/// can call it directly from [`crate::widget::LaidOutWidget`] without
/// needing the [`Element`] tree.
pub fn value_from_y_in_rect( rect: Rect, y: f32 ) -> f32
{
	let track_h = rect.height.max( 1.0 );
	( 1.0 - ( y - rect.y ) / track_h ).clamp( 0.0, 1.0 )
}

/// A vertical slider — a rounded pill that fills from bottom to top to
/// indicate its value.
///
/// Unlike [`Slider`](crate::Slider), which is horizontal and designed to
/// stretch across whatever width its parent allocates, a [`VSlider`] has
/// fixed pill dimensions (56 × 160 px by default) configurable via
/// [`VSlider::size`]. The widget reports those
/// dimensions as its preferred size and ignores the `max_width` the parent
/// offers — it is intrinsically sized, not filler.
///
/// The widget renders a rounded track in `palette.surface_alt` and, on top,
/// a rising pill in `palette.accent` whose height is proportional to its
/// `value`. No separate thumb is drawn; the top edge of the fill
/// itself acts as the value indicator.
///
/// ```rust,no_run
/// # use std::sync::Arc;
/// # #[ derive( Clone ) ] enum Msg { SetVolume( f32 ) }
/// # struct App { volume: f32 }
/// # impl App { fn _ex( &self, speaker_rgba: Arc<Vec<u8>>, speaker_w: u32, speaker_h: u32 ) -> ltk::Element<Msg> {
/// use ltk::{ stack, vslider, img_widget, HAlign, VAlign };
///
/// // Plain vertical slider.
/// let _: ltk::VSlider<Msg> = vslider( self.volume ).on_change( Msg::SetVolume );
///
/// // With a speaker icon overlaid at the top. Stacked image children are
/// // non-interactive, so drag events still reach the slider underneath.
/// stack::<Msg>()
///     .push( vslider( self.volume ).on_change( Msg::SetVolume ) )
///     .push_aligned(
///         img_widget( speaker_rgba, speaker_w, speaker_h ),
///         HAlign::Center, VAlign::Top,
///     )
/// .into()
/// # }}
/// ```
pub struct VSlider<Msg: Clone>
{
	/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
	/// whole pill.
	pub( crate ) value:     f32,
	/// Width of the pill. Defaults to 56 px; accepts any [`Length`].
	pub( crate ) width:     Length,
	/// Height of the pill. Defaults to 160 px; accepts any [`Length`].
	pub( crate ) height:    Length,
	/// Callback invoked with the new value when the slider is tapped or
	/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
	/// per-leaf handler snapshot for O(1) dispatch on input events.
	pub( crate ) on_change: Option<Arc<dyn Fn(f32) -> Msg>>,
	/// Callback invoked once with the final value when the drag ends, so an
	/// app can keep an expensive commit off the per-motion path.
	pub( crate ) on_release: Option<Arc<dyn Fn(f32) -> Msg>>,
	/// Theme slot id for the unfilled track. Defaults to
	/// `surface-slider-track`. Override with [`VSlider::track_surface`]
	/// when the slider lives inside a panel that already provides its
	/// own backdrop blur — point the slot at a `*-flat` variant
	/// (no `backdrop` field) so the pipeline does not run a redundant
	/// backdrop snapshot per slider per frame.
	pub( crate ) track_surface: &'static str,
	/// Theme slot id for the filled portion. Same role as
	/// [`Self::track_surface`] but for the rising fill.
	pub( crate ) fill_surface:  &'static str,
}

impl<Msg: Clone> VSlider<Msg>
{
	/// Create a vertical slider at the given value (clamped to `[0.0, 1.0]`).
	pub fn new( value: f32 ) -> Self
	{
		Self
		{
			value:         value.clamp( 0.0, 1.0 ),
			width:         Length::widget( theme::WIDTH ),
			height:        Length::widget( theme::HEIGHT ),
			on_change:     None,
			on_release:    None,
			track_surface: theme::SURFACE_TRACK,
			fill_surface:  theme::SURFACE_FILL,
		}
	}

	/// Override the theme slot id used for the unfilled track. See
	/// [`Self::track_surface`] for the use case.
	pub fn track_surface( mut self, id: &'static str ) -> Self
	{
		self.track_surface = id;
		self
	}

	/// Override the theme slot id used for the rising fill. See
	/// [`Self::track_surface`] for the use case.
	pub fn fill_surface( mut self, id: &'static str ) -> Self
	{
		self.fill_surface = id;
		self
	}

	/// Override the pill size. Accepts logical `f32` pixels or any [`Length`]
	/// variant (e.g. `Length::vw(16.0)` for 16 % of the viewport width). Both
	/// are clamped to a minimum of `2.0` after viewport-relative values
	/// resolve, so a rounded pill can always be drawn.
	pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
	{
		self.width  = width.into();
		self.height = height.into();
		self
	}

	/// Set the callback invoked when the slider value changes.
	pub fn on_change( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
	{
		self.on_change = Some( Arc::new( f ) );
		self
	}

	/// Set the callback invoked once when the drag ends, with the final
	/// value. Counterpart of [`Self::on_change`] for expensive commits.
	pub fn on_release( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self
	{
		self.on_release = Some( Arc::new( f ) );
		self
	}

	/// Return the preferred `(width, height)`. `max_width` is ignored — see
	/// the type-level docs on intrinsic sizing.
	pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32)
	{
		let w  = canvas.resolve_geom( self.width ).max( 2.0 );
		let h  = canvas.resolve_geom( self.height ).max( 2.0 );
		( w, h )
	}

	/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.
	pub fn value_from_y( &self, rect: Rect, y: f32 ) -> f32
	{
		value_from_y_in_rect( rect, y )
	}

	/// VSlider paints strictly inside its layout rect — no hover halo, no
	/// thumb overshoot. The partial-redraw path gets a tight bound.
	pub fn paint_bounds( &self, rect: Rect ) -> Rect { rect }

	/// Draw the slider into `canvas` at `rect`. The track fills `rect` as a
	/// rounded pill; the value rises from the bottom edge.
	///
	/// The track and fill both resolve to Glass surfaces when the active
	/// theme ships the `surface-slider-track` / `surface-slider-fill`
	/// slots (the default does). When the slots are absent we fall back
	/// to a flat pill in `palette.surface_alt` / `palette.accent` — this
	/// is how a bare-bones third-party theme still paints a usable
	/// slider without having to replicate the full inset-shadow stack.
	pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool )
	{
		let radius_bg = ( rect.width.min( rect.height ) ) / 2.0;

		// Track outer shadow only — BG fill and insets are deferred to
		// above the water line so the fill's silhouette AA doesn't pick
		// up the track's translucent white as a 1-px rim.
		if let Some( ( _surf, outer ) ) = crate::theme::resolve_surface( self.track_surface )
		{
			for shadow in &outer
			{
				canvas.fill_shadow_outer( rect, shadow, radius_bg );
			}
		}
		else
		{
			canvas.fill_rect( rect, theme::track_bg(), radius_bg );
		}

		// Fill rises from the bottom as a "liquid level". Sub-pixel
		// heights are skipped so we don't draw a hairline at value=0.
		//
		// The fill is rendered with the TRACK's full geometry (same
		// rect, same radius) and scissor-clipped to the visible band
		// at the bottom. The visible silhouette is the intersection of
		// the track pill with the band, so:
		//
		// * sides and bottom of the fill follow the track's pill
		//   curve at all values — no "sticking out" at low fills;
		// * top of the fill is a flat horizontal line — the water
		//   level — at all values, not a droplet cap;
		// * inset shadows / backdrop of the fill's Glass surface are
		//   anchored to the track rect, not to a shrinking fill rect,
		//   so the rim / highlight geometry stays stable as the user
		//   drags. Only the clip band changes with value.
		//
		// The scissor is save/restored via `canvas.clip_bounds()` so
		// the tighter clip does not stomp on any outer partial-redraw
		// scissor.
		let fill_h = ( rect.height * self.value ).clamp( 0.0, rect.height );
		if fill_h > 0.5
		{
			let visible = Rect
			{
				x:      rect.x,
				y:      rect.y + rect.height - fill_h,
				width:  rect.width,
				height: fill_h,
			};
			let saved_clip = canvas.clip_bounds();
			let band       = intersect_clip( &saved_clip, visible );
			if !band.is_empty()
			{
				canvas.set_clip_rects( &band );
				// Fill paint + the fill surface's bottom-biased insets.
				//
				// Any inset with a negative Y offset (the top-left
				// Glass highlight, `offset = [-3.6, -3.6]` in the
				// default theme) lives near the TOP rim of the full
				// track pill. With the surface anchored to the track
				// rect and clipped to the water band, that highlight
				// would be sliced by the scissor exactly at the water
				// line, painting a visibly rectangular bright/dark
				// edge across the liquid. Bottom-biased insets
				// (offset.y >= 0) live near the track's bottom curve,
				// always inside the visible band regardless of level,
				// so their rim is continuous.
				//
				// Outer shadows / backdrop are dropped too: outer
				// shadows would only be visible outside the fill
				// silhouette (and the scissor kills them anyway);
				// re-running the backdrop blur on the track rect
				// every time the value changes is expensive and
				// produces the same visible result as letting the
				// track's own Glass backdrop show through.
				if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.fill_surface )
				{
					canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
					for inset in surf.inset_shadows.iter().filter( |s| s.offset[1] >= 0.0 )
					{
						canvas.fill_shadow_inset( rect, inset, radius_bg );
					}
				}
				else
				{
					canvas.fill_rect( rect, theme::track_fill(), radius_bg );
				}
				canvas.set_clip_rects( &saved_clip );
			}
		}

		// Track BG + insets, clipped above the water line. Floor the
		// height so the scissor doesn't overlap the fill scissor by
		// 1 px when `fill_h` is fractional.
		let above_h = ( rect.height - fill_h ).floor();
		if above_h > 0.5
		{
			if let Some( ( surf, _ ) ) = crate::theme::resolve_surface( self.track_surface )
			{
				let above = Rect
				{
					x:      rect.x,
					y:      rect.y,
					width:  rect.width,
					height: above_h,
				};
				let saved_clip = canvas.clip_bounds();
				let band       = intersect_clip( &saved_clip, above );
				if !band.is_empty()
				{
					canvas.set_clip_rects( &band );
					canvas.fill_paint_rect( rect, &surf.fill, radius_bg );
					for inset in &surf.inset_shadows
					{
						canvas.fill_shadow_inset( rect, inset, radius_bg );
					}
					canvas.set_clip_rects( &saved_clip );
				}
			}
		}
	}

	pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> VSlider<U>
	where
		U: Clone + 'static,
		Msg: 'static,
	{
		let on_change = self.on_change.map( |old| -> Arc<dyn Fn( f32 ) -> U>
		{
			let mapper = Arc::clone( f );
			Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
		} );
		let on_release = self.on_release.map( |old| -> Arc<dyn Fn( f32 ) -> U>
		{
			let mapper = Arc::clone( f );
			Arc::new( move |v| ( *mapper )( ( *old )( v ) ) )
		} );
		VSlider
		{
			value:         self.value,
			width:         self.width,
			height:        self.height,
			on_change,
			on_release,
			track_surface: self.track_surface,
			fill_surface:  self.fill_surface,
		}
	}
}

/// Create a [`VSlider`] at the given value (clamped to `[0.0, 1.0]`).
pub fn vslider<Msg: Clone>( value: f32 ) -> VSlider<Msg>
{
	VSlider::new( value )
}

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