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

//! LTK side of `crustace-notifier`.
//!
//! Hosts the per-frame state (the stack of visible toasts), receives
//! [`Msg`] from the D-Bus side via the LTK channel sender, drives the
//! auto-dismiss timer from `poll_external`, and renders each toast as
//! a card on a layer-shell surface anchored to the bottom of the
//! screen — same place crustace's quicksettings panel pops up from.

use std::sync::{ Arc, Mutex };
use std::time::{ Duration, Instant };

use ltk::
{
	Anchor, App, ChannelSender, Color, Element,
	InvalidationScope, Layer, Rect, ShellMode, ThemeMode,
};
use ltk::theme::{ ColorStop, GradientSpace, LinearGradient, Paint };
use tokio::sync::mpsc::UnboundedSender;

use crate::notifications::{ LiveNotification, NotificationType };

/// Event LTK pushes back to the D-Bus thread whenever a toast is
/// dismissed locally (auto-expire or user click). The receiving task
/// emits `NotificationClosed( id, reason )` so apps that listen for
/// it see every dismiss path, not just explicit `CloseNotification`
/// calls. Reasons follow the spec:
/// `1` = expired, `2` = user-dismissed, `3` = closed-by-call,
/// `4` = undefined.
#[ derive( Debug, Clone ) ]
pub struct DismissEvent
{
	pub id:     u32,
	pub reason: u32,
}

pub type DismissSink = UnboundedSender<DismissEvent>;

/// Messages the LTK loop consumes.
#[ derive( Clone ) ]
pub enum Msg
{
	/// A new notification arrived on D-Bus. Push the card onto the
	/// stack and arm its auto-dismiss timer.
	Show { id: u32, data: LiveNotification },
	/// Remove a notification, either because the timer fired
	/// (`reason = 1`), the user clicked the card (`reason = 2`), or a
	/// client called `CloseNotification` (`reason = 3`).
	Dismiss { id: u32, reason: u32 },
	/// User tapped the surface outside any toast card — drop every
	/// visible toast, all with `reason = 2` (user-dismissed).
	DismissAll,
	/// No-op pulse that keeps the runtime ticking so `poll_external`
	/// gets a chance to fire expired timers even when no D-Bus event
	/// has arrived since the last frame.
	Tick,
}

/// One visible toast. Mirrors the D-Bus payload plus the deadline at
/// which the auto-dismiss timer fires.
struct Toast
{
	id:       u32,
	data:     LiveNotification,
	deadline: Instant,
}

/// Card width as specified by the Crustace notification design.
const CARD_WIDTH:    f32 = 344.0;
/// Inset between the card edge and its content.
const CARD_PADDING:  f32 = 16.0;
/// Corner radius of the card and its icon circle.
const CARD_RADIUS:   f32 = 16.0;
/// Diameter of the type-coloured circle on the left of every card.
const ICON_DIAMETER: f32 = 32.0;
/// Horizontal margin between the column edge and the screen edge.
const SURFACE_HMARGIN: u32 = 16;
/// Top inset from the screen edge to the top of the toast stack —
/// clears Forge's 28 dp topbar (mirror of `TOPBAR_PX` in
/// `crustace/src/homescreen/geometry.rs`).
const SURFACE_TOP_MARGIN: f32 = 28.0;

/// Default expiry when the client passes `expire_timeout = -1`
/// ("server's choice"). Five seconds matches the freedesktop spec
/// suggestion and the GNOME / KDE defaults.
const DEFAULT_EXPIRE_MS: u64 = 5_000;

pub struct ToastApp
{
	sender_slot: Arc<Mutex<Option<ChannelSender<Msg>>>>,
	dismiss_tx:  DismissSink,
	toasts:      Vec<Toast>,
}

impl ToastApp
{
	pub fn new(
		sender_slot: Arc<Mutex<Option<ChannelSender<Msg>>>>,
		dismiss_tx:  DismissSink,
	) -> Self
	{
		Self { sender_slot, dismiss_tx, toasts: Vec::new() }
	}
}

impl App for ToastApp
{
	type Message = Msg;

	fn shell_mode( &self ) -> ShellMode { ShellMode::Layer( Layer::Overlay ) }

	fn background_color( &self ) -> Color
	{
		// Layer surface spans the whole output. The default
		// `theme_palette().bg` would paint every pixel of the screen
		// with the shell's solid background; we need a fully
		// transparent canvas so only the toast cards themselves are
		// visible and the rest stays click-through to whatever the
		// user has below.
		Color::TRANSPARENT
	}

	fn layer_anchor( &self ) -> Anchor
	{
		// Span the entire output so a tap anywhere off a card hits the
		// outer `Pressable` and dismisses every visible toast. The
		// `input_region` override below opens up click-through whenever
		// the toast stack is empty so the surface does not block apps
		// between notifications.
		Anchor::ALL
	}

	fn layer_size( &self ) -> ( u32, u32 ) { ( 0, 0 ) }

	fn input_region( &self ) -> Option<Vec<Rect>>
	{
		if self.toasts.is_empty()
		{
			// Empty region = the layer surface ignores every pointer
			// event; clicks fall straight through to whatever the user
			// has below.
			Some( Vec::new() )
		}
		else
		{
			// Receive input everywhere so the outer pressable can fire
			// `DismissAll` on taps that miss the cards.
			None
		}
	}

	fn set_channel_sender( &mut self, sender: ChannelSender<Self::Message> )
	{
		// SAFETY-ish: the slot is shared with the D-Bus thread; we
		// publish the freshly-built sender so the D-Bus handlers can
		// start pushing messages our way.
		if let Ok( mut g ) = self.sender_slot.lock()
		{
			*g = Some( sender );
		}
	}

	fn view( &self ) -> Element<Self::Message>
	{
		// Toast stack: narrow column capped at the design card width so
		// each toast lays out at the expected 344 dp, no matter how
		// wide the host output is.
		// `fit_content` makes the column report its intrinsic width
		// instead of claiming the whole screen — without it the column
		// always answers `max_width` to its parent and the row's leading
		// spacer has nothing to flex into, so the stack ends up centered
		// inside a screen-wide rect rather than anchored to the right
		// edge.
		let mut stack = ltk::column()
			.padding( SURFACE_HMARGIN as f32 )
			.spacing( 12.0 )
			.max_width( CARD_WIDTH + ( SURFACE_HMARGIN as f32 ) * 2.0 )
			.fit_content();
		for toast in &self.toasts
		{
			stack = stack.push( render_toast( toast ) );
		}

		// Pin the stack to the right edge — a single flex spacer to its
		// left claims the rest of the screen width and the stack's
		// `max_width` caps its own growth. The column's
		// `SURFACE_HMARGIN` padding holds the cards off the right
		// bezel.
		let anchored = ltk::row()
			.push( ltk::spacer() )
			.push( stack );

		// Outer column pins the row to the top of the output. The
		// leading fixed-height spacer clears Forge's topbar; the
		// trailing flex spacer claims the remaining vertical space so
		// the stack does not stretch downwards.
		let body = ltk::column()
			.push( ltk::spacer().height( SURFACE_TOP_MARGIN ) )
			.push( anchored )
			.push( ltk::spacer() );

		// Wrap everything in a pressable so any tap that misses a card
		// dismisses every visible toast. Per-card pressables nested
		// below win on taps that land on a card (ltk's hit-testing
		// iterates deepest-first).
		ltk::pressable( body )
			.on_press( Msg::DismissAll )
			.into()
	}

	fn update( &mut self, msg: Self::Message )
	{
		match msg
		{
			Msg::Show { id, data } =>
			{
				let timeout = effective_timeout( &data );
				// Replace an existing toast with the same id (the spec's
				// `replaces_id` path) by removing first then pushing.
				self.toasts.retain( | t | t.id != id );
				self.toasts.push( Toast
				{
					id,
					data,
					deadline: Instant::now() + timeout,
				} );
			}
			Msg::Dismiss { id, reason } =>
			{
				let was_visible = self.toasts.iter().any( | t | t.id == id );
				self.toasts.retain( | t | t.id != id );
				// Only surface a dismiss event for toasts we were
				// actually rendering: if a CloseNotification call
				// drove this update, the D-Bus side already emitted
				// `NotificationClosed` itself and re-emitting would
				// double-send. The `was_visible` filter is the
				// honest signal — any other source comes through here.
				if was_visible && reason != crate::notifications::close_reason::CLOSED_BY_CALL
				{
					let _ = self.dismiss_tx.send( DismissEvent { id, reason } );
				}
			}
			Msg::DismissAll =>
			{
				for t in self.toasts.drain( .. )
				{
					let _ = self.dismiss_tx.send( DismissEvent { id: t.id, reason: 2 } );
				}
			}
			Msg::Tick => {}
		}
	}

	fn invalidate_after( &self, msg: &Self::Message ) -> InvalidationScope
	{
		match msg
		{
			// `Tick` is bookkeeping only — it gives `poll_external` a
			// chance to fire expired timers, no visual change in itself.
			Msg::Tick => InvalidationScope::Only( Vec::new() ),
			_         => InvalidationScope::All,
		}
	}

	fn poll_external( &mut self ) -> Vec<Self::Message>
	{
		let now = Instant::now();
		let mut out: Vec<Msg> = self.toasts.iter()
			.filter( | t | t.deadline <= now )
			.map( | t | Msg::Dismiss { id: t.id, reason: 1 } )
			.collect();
		// Always emit a Tick so the runtime ticks at our poll rate;
		// otherwise the loop only wakes on Wayland / channel events
		// and the expiry timer can drift by several seconds.
		if out.is_empty() { out.push( Msg::Tick ); }
		out
	}

	fn poll_interval( &self ) -> Option<Duration>
	{
		Some( Duration::from_millis( 200 ) )
	}

	fn is_animating( &self ) -> bool { !self.toasts.is_empty() }
}

/// Map the spec's `expire_timeout` (signed ms; `0` = sticky;
/// `-1` = server picks) to a real `Duration`. Sticky notifications
/// (urgency = critical, e.g. battery-critical) still need to go
/// somewhere; for now we apply a very long timeout instead of
/// genuine pinning until the UI gains a "keep visible" affordance.
fn effective_timeout( data: &LiveNotification ) -> Duration
{
	match data.expire_timeout
	{
		t if t > 0  => Duration::from_millis( t as u64 ),
		0           => Duration::from_secs( 60 ),
		_           => Duration::from_millis( DEFAULT_EXPIRE_MS ),
	}
}

/// Render one toast as a clickable card: an icon circle on the left
/// and a column with `summary` (bold) plus `body` on the right. The
/// whole card is pressable and dismisses on click
/// (`reason = 2`, user-dismissed).
fn render_toast( t: &Toast ) -> Element<Msg>
{
	let id    = t.id;
	let mode  = ltk::active_mode();
	let theme = palette_for( t.data.kind, mode );

	let icon: Element<Msg> = ltk::container( ltk::spacer().width( 12.0 ).height( 12.0 ) )
		.background( theme.icon_bg )
		.radius( ICON_DIAMETER / 2.0 )
		.padding( ( ICON_DIAMETER - 12.0 ) / 2.0 )
		.into();

	let summary = ltk::text( t.data.summary.clone() )
		.weight( 700 )
		.size( 14.0 )
		.color( theme.text )
		.wrap( true );
	let body = ltk::text( t.data.body.clone() )
		.size( 14.0 )
		.color( theme.text_muted )
		.wrap( true );
	let timestamp = ltk::text( t.data.created_at.format( "%H:%M" ).to_string() )
		.size( 14.0 )
		.color( theme.text_muted );

	// Header row: bold summary on the left, timestamp pinned to the
	// right. `flex( summary )` claims the leftover width so the
	// timestamp sticks to the right edge of the text column even when
	// the summary is short.
	let header: Element<Msg> = ltk::row()
		.spacing( 8.0 )
		.push( ltk::flex( summary ) )
		.push( timestamp )
		.into();

	let text_column: Element<Msg> = ltk::column()
		.spacing( 4.0 )
		.push( header )
		.push( body )
		.into();

	let inner: Element<Msg> = ltk::row()
		.spacing( 12.0 )
		.push( icon )
		.push( ltk::flex( text_column ) )
		.into();

	let card: Element<Msg> = ltk::container( inner )
		.padding( CARD_PADDING )
		.background( theme.card_bg )
		.radius( CARD_RADIUS )
		.into();

	ltk::pressable( card )
		.on_press( Msg::Dismiss { id, reason: 2 } )
		.into()
}

/// Resolved colour set for one notification card.
struct CardTheme
{
	/// Card background — usually the 152.77° accent → base gradient.
	card_bg:    Paint,
	/// Icon-circle background — the 135° accent → highlight gradient.
	icon_bg:    Paint,
	/// Primary text colour (summary).
	text:       Color,
	/// Secondary text colour (body / time).
	text_muted: Color,
}

/// Translate the design tokens (accent colour per [`NotificationType`],
/// base + text colours per [`ThemeMode`]) into a [`CardTheme`] the
/// renderer can plug into [`Container::background`].
fn palette_for( kind: NotificationType, mode: ThemeMode ) -> CardTheme
{
	let accent = accent_color( kind );
	let ( base, text, text_muted ) = match mode
	{
		ThemeMode::Dark =>
		(
			Color::hex( 0x0A, 0x03, 0x2E ),
			Color::hex( 0xFF, 0xFF, 0xFF ),
			Color::rgba( 1.0, 1.0, 1.0, 0.75 ),
		),
		ThemeMode::Light =>
		(
			Color::hex( 0xFF, 0xFF, 0xFF ),
			Color::hex( 0x0A, 0x03, 0x2E ),
			Color::rgba( 10.0 / 255.0, 3.0 / 255.0, 46.0 / 255.0, 0.65 ),
		),
	};

	let card_bg = Paint::Linear( LinearGradient
	{
		angle_deg: 152.77,
		stops: vec!
		[
			ColorStop { position: -1.1654, color: accent },
			ColorStop { position:  1.2332, color: base },
		],
		space: GradientSpace::LinearRgb,
	} );

	let icon_bg = Paint::Linear( LinearGradient
	{
		angle_deg: 135.0,
		stops: vec!
		[
			ColorStop { position: 0.0, color: accent },
			ColorStop { position: 1.0, color: icon_highlight( kind ) },
		],
		space: GradientSpace::LinearRgb,
	} );

	CardTheme { card_bg, icon_bg, text, text_muted }
}

/// Lead colour for each notification type. The card gradient pins this
/// at `-116.54%` (well off-canvas) so it only tints the top-left
/// corner; the icon circle uses it at full saturation as its 0% stop.
fn accent_color( kind: NotificationType ) -> Color
{
	match kind
	{
		NotificationType::Error   => Color::hex( 0xFF, 0x93, 0xA9 ),
		NotificationType::Warning => Color::hex( 0xFF, 0xD1, 0x1E ),
		NotificationType::Info    => Color::hex( 0x04, 0xD9, 0xFE ),
		NotificationType::Success => Color::hex( 0x00, 0xD7, 0x94 ),
		// "Default" has no real accent — we use the design's neutral
		// near-white so the icon circle reads as a generic chip.
		NotificationType::Default => Color::hex( 0xFF, 0xFF, 0xFF ),
	}
}

/// Trailing colour for the icon-circle gradient (the 100% stop on the
/// 135° sweep). The design lightens the accent towards pure white for
/// the four flavoured types, and steps the neutral chip towards a soft
/// grey so it still reads as a gradient rather than a flat dot.
fn icon_highlight( kind: NotificationType ) -> Color
{
	match kind
	{
		NotificationType::Default => Color::hex( 0xD1, 0xD1, 0xD1 ),
		_                         => Color::hex( 0xFF, 0xFF, 0xFF ),
	}
}
