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

//! Damage tracking: what rects need to be repainted this frame, and
//! what rects need to be declared to Wayland.
//!
//! Two entry points with different jobs:
//!
//! * [`compute_interaction_dirty_rects`] — called on the partial-redraw
//!   path BEFORE painting. Takes the previous and current interaction
//!   snapshots (focus / hover / pressed) and emits the union of the
//!   `paint_rect`s of the widgets whose state transitioned. The caller
//!   uses these to install a clip mask before the partial repaint and
//!   to stamp `wl_surface.damage_buffer` afterwards.
//! * [`compute_damage`] — called on the full-redraw path AFTER painting.
//!   Compares the previous-frame widget rects with the just-produced
//!   ones; if layout moved or changed, returns an empty vec (meaning
//!   "damage the whole surface"), otherwise returns a tight list of
//!   rects for the widgets whose interaction state changed.
//!
//! The 50%-of-screen heuristic is shared by both: if the accumulated
//! damage covers more than half the surface, the single full-surface
//! damage rect is cheaper than the per-region list.

use crate::types::Rect;
use crate::widget::LaidOutWidget;

/// Intersect `r` with `bounds`, returning a rect that is entirely
/// inside `bounds`. Returns a zero-size rect if they do not overlap.
pub( super ) fn clamp_rect_to( r: Rect, bounds: Rect ) -> Rect
{
	let x0 = r.x.max( bounds.x );
	let y0 = r.y.max( bounds.y );
	let x1 = ( r.x + r.width  ).min( bounds.x + bounds.width  );
	let y1 = ( r.y + r.height ).min( bounds.y + bounds.height );
	if x1 <= x0 || y1 <= y0
	{
		Rect { x: x0, y: y0, width: 0.0, height: 0.0 }
	} else {
		Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
	}
}

/// Build the dirty-rect list for an interaction-only frame: union of
/// the `paint_rect`s of the widgets whose focus / hover / pressed
/// transitioned. Each widget's `paint_rect` already encloses its hover
/// halo, focus ring, and any other overdraw, so no extra padding is
/// needed here.
pub( crate ) fn compute_interaction_dirty_rects<Msg: Clone>(
	widget_rects: &[ LaidOutWidget<Msg> ],
	prev_focused: Option<usize>, prev_hovered: Option<usize>, prev_pressed: Option<usize>,
	new_focused:  Option<usize>, new_hovered:  Option<usize>, new_pressed:  Option<usize>,
	pw:           u32,
	ph:           u32,
) -> Vec<Rect>
{
	let mut rects: Vec<Rect> = Vec::new();
	let visit = |idx_opt: Option<usize>, sink: &mut Vec<Rect>|
	{
		if let Some( idx ) = idx_opt
		{
			if let Some( w ) = widget_rects.iter().find( |w| w.flat_idx == idx )
			{
				if !w.handlers.is_slider()
				{
					sink.push( w.paint_rect );
				}
			}
		}
	};
	if prev_focused != new_focused
	{
		visit( prev_focused, &mut rects );
		visit( new_focused,  &mut rects );
	}
	if prev_hovered != new_hovered
	{
		visit( prev_hovered, &mut rects );
		visit( new_hovered,  &mut rects );
	}
	if prev_pressed != new_pressed
	{
		visit( prev_pressed, &mut rects );
		visit( new_pressed,  &mut rects );
	}
	// Snap to integer pixel boundaries before clamping. The clip mask is
	// rasterized with anti_alias=false (binary, sampled at pixel centers), and
	// `Canvas::clear_rects_transparent` uses `as i32` floor on the min and
	// `.ceil() as i32` on the max. If `paint_rect` carries fractional coords
	// (e.g. from `expand( 14.5 )` on icon-button hover halos), those two paths
	// can disagree by 1 px at the edge — leaving a pixel cleared to transparent
	// black but not repainted on the next frame. Floor min / ceil max here so
	// both paths see the same whole-pixel rect.
	let sw = pw as f32;
	let sh = ph as f32;
	for r in &mut rects
	{
		let x0 = r.x.floor().max( 0.0 );
		let y0 = r.y.floor().max( 0.0 );
		let x1 = ( r.x + r.width  ).ceil().min( sw );
		let y1 = ( r.y + r.height ).ceil().min( sh );
		r.x      = x0;
		r.y      = y0;
		r.width  = ( x1 - x0 ).max( 0.0 );
		r.height = ( y1 - y0 ).max( 0.0 );
	}
	rects.retain( |r| r.width > 0.0 && r.height > 0.0 );
	rects
}

/// Compare previous and current widget rects + interaction state to
/// find damaged regions. Returns a list of damage rects, or empty vec
/// if everything changed (full redraw).
pub( crate ) fn compute_damage<Msg: Clone>(
	old_rects:   &[ LaidOutWidget<Msg> ],
	new_rects:   &[ LaidOutWidget<Msg> ],
	old_focused: Option<usize>,
	old_hovered: Option<usize>,
	old_pressed: Option<usize>,
	new_focused: Option<usize>,
	new_hovered: Option<usize>,
	new_pressed: Option<usize>,
	screen_w: u32,
	screen_h: u32,
) -> Vec<Rect>
{
	// Widget tree structure changed: full redraw.
	if old_rects.len() != new_rects.len()
	{
		return Vec::new();
	}

	let mut damage = Vec::new();

	let changed_indices: Vec<usize> = [
		old_focused, new_focused,
		old_hovered, new_hovered,
		old_pressed, new_pressed,
	].iter().filter_map( |&idx| idx ).collect();

	for &idx in &changed_indices
	{
		// Each widget's declared `paint_rect` already encloses its overdraw.
		if let Some( w ) = old_rects.iter().find( |w| w.flat_idx == idx )
		{
			damage.push( w.paint_rect );
		}
		if let Some( w ) = new_rects.iter().find( |w| w.flat_idx == idx )
		{
			damage.push( w.paint_rect );
		}
	}

	// Layout shift: any rect moved or resized => full redraw.
	for ( i, old_w ) in old_rects.iter().enumerate()
	{
		if let Some( new_w ) = new_rects.get( i )
		{
			let old_r = old_w.rect;
			let new_r = new_w.rect;
			if ( old_r.x - new_r.x ).abs() > 0.5
				|| ( old_r.y - new_r.y ).abs() > 0.5
				|| ( old_r.width - new_r.width ).abs() > 0.5
				|| ( old_r.height - new_r.height ).abs() > 0.5
			{
				return Vec::new();
			}
		}
	}

	// No interaction changes but a redraw was requested => content changed
	// (e.g. clock tick) and a full redraw is the right answer.
	if damage.is_empty()
	{
		return Vec::new();
	}

	// Dilate every damage rect by 1 px on each side. The GPU rect/gradient
	// shaders draw their quads expanded by 1 px so the outer half of the
	// SDF antialiasing band has fragments to cover; under a partial
	// redraw the scissor would otherwise clip that band at the widget's
	// `paint_rect` boundary, re-introducing the aliased step on the
	// straight edges of pills / rounded rects. The cost is negligible
	// (one extra pixel of clear + repaint per damage rect).
	for r in &mut damage
	{
		r.x      -= 1.0;
		r.y      -= 1.0;
		r.width  += 2.0;
		r.height += 2.0;
	}

	let sw = screen_w as f32;
	let sh = screen_h as f32;
	for r in &mut damage
	{
		r.x = r.x.max( 0.0 );
		r.y = r.y.max( 0.0 );
		r.width  = r.width.min( sw - r.x );
		r.height = r.height.min( sh - r.y );
	}

	// Total damage > 50% of screen: full redraw is no slower and emits a
	// single damage rect instead of many.
	let total_damage: f32 = damage.iter().map( |r| r.width * r.height ).sum();
	if total_damage > sw * sh * 0.5
	{
		return Vec::new();
	}

	damage
}

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

	fn r( x: f32, y: f32, w: f32, h: f32 ) -> Rect
	{
		Rect { x, y, width: w, height: h }
	}

	fn lw( idx: usize, rect: Rect, paint: Rect ) -> LaidOutWidget<()>
	{
		LaidOutWidget
		{
			rect,
			flat_idx:           idx,
			id:                 None,
			paint_rect:         paint,
			handlers:           WidgetHandlers::None,
			keyboard_focusable: true,
			cursor:             crate::types::CursorShape::Default,
			tooltip:            None,
			accessible_label:   None,
			is_live_region:     false,
		}
	}

	// ── clamp_rect_to ─────────────────────────────────────────────────────────

	#[ test ]
	fn clamp_rect_to_returns_intersection()
	{
		let bounds = r( 0.0, 0.0, 100.0, 100.0 );
		let inside = r( 10.0, 10.0, 20.0, 20.0 );
		assert_eq!( clamp_rect_to( inside, bounds ), inside );
	}

	#[ test ]
	fn clamp_rect_to_clips_to_bounds()
	{
		let bounds = r( 0.0, 0.0, 100.0, 100.0 );
		let bleed  = r( 80.0, 80.0, 50.0, 50.0 );
		assert_eq!( clamp_rect_to( bleed, bounds ), r( 80.0, 80.0, 20.0, 20.0 ) );
	}

	#[ test ]
	fn clamp_rect_to_disjoint_returns_zero_size()
	{
		let bounds = r( 0.0, 0.0, 100.0, 100.0 );
		let off    = r( 200.0, 200.0, 50.0, 50.0 );
		let out    = clamp_rect_to( off, bounds );
		assert_eq!( ( out.width, out.height ), ( 0.0, 0.0 ) );
	}

	// ── compute_interaction_dirty_rects ───────────────────────────────────────

	#[ test ]
	fn no_state_change_yields_empty_dirty_rects()
	{
		let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
		let rects = compute_interaction_dirty_rects(
			&widgets,
			Some( 1 ), None, None,
			Some( 1 ), None, None,
			800, 600,
		);
		assert!( rects.is_empty() );
	}

	#[ test ]
	fn focus_change_emits_both_old_and_new_paint_rects()
	{
		let widgets = vec![
			lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
			lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
		];
		let rects = compute_interaction_dirty_rects(
			&widgets,
			Some( 1 ), None, None,
			Some( 2 ), None, None,
			800, 600,
		);
		assert_eq!( rects.len(), 2 );
	}

	#[ test ]
	fn hover_change_emits_paint_rects_independent_of_focus()
	{
		let widgets = vec![
			lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
			lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
		];
		let rects = compute_interaction_dirty_rects(
			&widgets,
			Some( 1 ), Some( 1 ), None,
			Some( 1 ), Some( 2 ), None,
			800, 600,
		);
		assert_eq!( rects.len(), 2 );
	}

	#[ test ]
	fn dirty_rects_are_snapped_and_clamped_to_screen()
	{
		let widgets = vec![
			lw( 1, r( -10.0, -10.0, 30.5, 40.5 ), r( -10.0, -10.0, 30.5, 40.5 ) ),
		];
		let rects = compute_interaction_dirty_rects(
			&widgets,
			None,        None, None,
			Some( 1 ),   None, None,
			100, 100,
		);
		assert_eq!( rects.len(), 1 );
		// Floor of -10 is -10 → max(0) = 0; ceil of -10+30.5=20.5 → 21.
		assert_eq!( rects[ 0 ].x, 0.0 );
		assert_eq!( rects[ 0 ].y, 0.0 );
		assert_eq!( rects[ 0 ].width, 21.0 );
		assert_eq!( rects[ 0 ].height, 31.0 );
	}

	#[ test ]
	fn dirty_rects_outside_screen_are_dropped()
	{
		let widgets = vec![
			lw( 1, r( 1000.0, 1000.0, 50.0, 50.0 ), r( 1000.0, 1000.0, 50.0, 50.0 ) ),
		];
		let rects = compute_interaction_dirty_rects(
			&widgets,
			None,      None, None,
			Some( 1 ), None, None,
			100, 100,
		);
		assert!( rects.is_empty() );
	}

	#[ test ]
	fn missing_widget_idx_silently_skipped()
	{
		let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
		// Old focus references an idx that no longer exists in widget_rects —
		// this happens during a layout where a widget disappeared between
		// frames. The function must not panic; it just emits whatever new
		// state's rect it can find.
		let rects = compute_interaction_dirty_rects(
			&widgets,
			Some( 99 ), None, None,
			Some( 1 ),  None, None,
			800, 600,
		);
		assert_eq!( rects.len(), 1 );
	}

	// ── compute_damage ────────────────────────────────────────────────────────

	#[ test ]
	fn tree_size_change_returns_full_redraw()
	{
		let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
		let new = vec![
			lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
			lw( 2, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ),
		];
		let damage = compute_damage(
			&old, &new,
			None, None, None,
			None, None, None,
			800, 600,
		);
		assert!( damage.is_empty(), "tree size change must signal full redraw via empty vec" );
	}

	#[ test ]
	fn layout_shift_returns_full_redraw()
	{
		let old = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
		let new = vec![ lw( 1, r( 0.0, 60.0, 50.0, 50.0 ), r( 0.0, 60.0, 50.0, 50.0 ) ) ];
		let damage = compute_damage(
			&old, &new,
			Some( 1 ), None, None,
			Some( 1 ), None, None,
			800, 600,
		);
		assert!( damage.is_empty() );
	}

	#[ test ]
	fn focus_change_emits_partial_damage()
	{
		let widgets = vec![
			lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ),
			lw( 2, r( 100.0, 0.0, 50.0, 50.0 ), r( 100.0, 0.0, 50.0, 50.0 ) ),
		];
		let damage = compute_damage(
			&widgets, &widgets,
			Some( 1 ), None, None,
			Some( 2 ), None, None,
			800, 600,
		);
		assert!( !damage.is_empty(), "focus change must produce non-empty damage list" );
		assert!(
			damage.len() >= 2,
			"both old and new focused widgets contribute their paint_rects"
		);
	}

	#[ test ]
	fn no_change_with_redraw_request_returns_full_redraw()
	{
		// `damage.is_empty()` after the changed_indices loop means nothing
		// interaction-level changed but the caller still asked to redraw —
		// content tick (e.g. clock). The function returns the empty vec to
		// signal "redraw everything" rather than nothing.
		let widgets = vec![ lw( 1, r( 0.0, 0.0, 50.0, 50.0 ), r( 0.0, 0.0, 50.0, 50.0 ) ) ];
		let damage = compute_damage(
			&widgets, &widgets,
			None, None, None,
			None, None, None,
			800, 600,
		);
		assert!( damage.is_empty() );
	}

	#[ test ]
	fn damage_rects_are_dilated_by_one_pixel_each_side()
	{
		// Every damage rect grows by 1 px on each side to cover the SDF
		// antialiasing band; the rect is then clamped to the surface.
		let widgets = vec![ lw( 1, r( 100.0, 100.0, 50.0, 50.0 ), r( 100.0, 100.0, 50.0, 50.0 ) ) ];
		let damage = compute_damage(
			&widgets, &widgets,
			None,      None, None,
			Some( 1 ), None, None,
			800, 600,
		);
		assert_eq!( damage.len(), 2 );
		for d in &damage
		{
			assert_eq!( d.x, 99.0 );
			assert_eq!( d.y, 99.0 );
			assert_eq!( d.width, 52.0 );
			assert_eq!( d.height, 52.0 );
		}
	}

	#[ test ]
	fn damage_above_fifty_percent_collapses_to_full_redraw()
	{
		// Widget covers 60 % of a 100×100 surface. The dilated rect easily
		// exceeds the 50 % threshold and the function falls back to full
		// redraw.
		let widgets = vec![
			lw( 1, r( 0.0, 0.0, 80.0, 80.0 ), r( 0.0, 0.0, 80.0, 80.0 ) ),
		];
		let damage = compute_damage(
			&widgets, &widgets,
			None,      None, None,
			Some( 1 ), None, None,
			100, 100,
		);
		assert!( damage.is_empty(), "damage > 50 % of surface must signal full redraw" );
	}

	#[ test ]
	fn damage_below_fifty_percent_returns_partial()
	{
		// 10×10 widget on a 100×100 surface → ~1 % per rect, well under 50 %.
		let widgets = vec![
			lw( 1, r( 5.0, 5.0, 10.0, 10.0 ), r( 5.0, 5.0, 10.0, 10.0 ) ),
		];
		let damage = compute_damage(
			&widgets, &widgets,
			None,      None, None,
			Some( 1 ), None, None,
			100, 100,
		);
		assert!( !damage.is_empty() );
	}

	#[ test ]
	fn damage_clamped_to_surface_extents()
	{
		// Widget paint_rect at the right edge — dilation can push it past the
		// surface; the clamp must keep width/height within the surface.
		let widgets = vec![
			lw( 1, r( 90.0, 90.0, 5.0, 5.0 ), r( 90.0, 90.0, 5.0, 5.0 ) ),
		];
		let damage = compute_damage(
			&widgets, &widgets,
			None,      None, None,
			Some( 1 ), None, None,
			100, 100,
		);
		for d in &damage
		{
			assert!( d.x + d.width  <= 100.0 + f32::EPSILON );
			assert!( d.y + d.height <= 100.0 + f32::EPSILON );
		}
	}
}
