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

//! End-session dialog: shown when the user asks to log out, reboot or
//! power off while application windows are still open. Offers to cancel,
//! proceed anyway, or ask every application to close first and wait for
//! them; applications that do not go away within [`CLOSE_TIMEOUT`] are
//! listed so the user can save their work or force the session to end.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use rust_i18n::t;

use crate::launcher::icon_cache::IconCache;
use crate::launcher::AppEntry;
use crate::message::Message;
use crate::powermenu::PowerAction;
use crate::running::RunningApps;

/// How long "close applications first" waits before reporting the
/// applications that are still open.
pub const CLOSE_TIMEOUT: Duration = Duration::from_secs( 15 );

/// Fallback delay before the next relaunch when an app never maps.
const RESTORE_STAGGER: Duration = Duration::from_secs( 4 );

const ICON_DP: f32 = 32.0;
const ROW_DP:  f32 = 40.0;
const MAX_VISIBLE_ROWS: usize = 6;
const WINDOW_PADDING_DP: f32 = 20.0;
const WINDOW_WIDTH_DP: f32 = 460.0;

type RgbaIcon = ( Arc<Vec<u8>>, u32, u32 );

#[ derive( Clone ) ]
pub struct SessionApp
{
	pub desktop_id: String,
	pub name:       String,
	pub icon:       Option<PathBuf>,
}

pub enum EndSession
{
	Hidden,
	Confirm { action: PowerAction, apps: Vec<SessionApp> },
	Closing { action: PowerAction, apps: Vec<SessionApp> },
	Stuck   { action: PowerAction, apps: Vec<SessionApp>, remaining: Vec<SessionApp> },
}

impl EndSession
{
	pub fn new() -> Self { Self::Hidden }

	pub fn is_visible( &self ) -> bool
	{
		!matches!( self, Self::Hidden )
	}

	pub fn is_closing( &self ) -> bool
	{
		matches!( self, Self::Closing { .. } )
	}

	pub fn action( &self ) -> Option<PowerAction>
	{
		match self
		{
			Self::Hidden => None,
			Self::Confirm { action, .. } | Self::Closing { action, .. } | Self::Stuck { action, .. } => Some( *action ),
		}
	}

	/// The applications that were open when the dialog was opened.
	pub fn snapshot( &self ) -> &[ SessionApp ]
	{
		match self
		{
			Self::Hidden => &[],
			Self::Confirm { apps, .. } | Self::Closing { apps, .. } | Self::Stuck { apps, .. } => apps,
		}
	}

	pub fn open( &mut self, action: PowerAction, apps: Vec<SessionApp> )
	{
		*self = Self::Confirm { action, apps };
	}

	pub fn begin_closing( &mut self )
	{
		if let Self::Confirm { action, apps } = std::mem::replace( self, Self::Hidden )
		{
			*self = Self::Closing { action, apps };
		}
	}

	pub fn mark_stuck( &mut self, remaining: Vec<SessionApp> )
	{
		if let Self::Closing { action, apps, .. } = std::mem::replace( self, Self::Hidden )
		{
			*self = Self::Stuck { action, apps, remaining };
		}
	}

	pub fn hide( &mut self )
	{
		*self = Self::Hidden;
	}

	/// Fixed size, in dp, of the closing-state window: padding, title,
	/// subtitle, the visible rows and the Cancel button.
	pub fn window_size_dp( &self ) -> ( f32, f32 )
	{
		let rows = self.snapshot().len().clamp( 1, MAX_VISIBLE_ROWS ) as f32;
		let h = WINDOW_PADDING_DP * 2.0 + 28.0 + 22.0 + rows * ROW_DP + ( rows - 1.0 ) * 8.0 + 40.0 + 12.0 * 3.0;
		( WINDOW_WIDTH_DP, h )
	}

	/// Resolve every running desktop id to a name and icon; entries that
	/// no installed `.desktop` claims keep the raw id as their name. The
	/// shell's own windows (`shell_app_id`) are not applications to close.
	pub fn collect_apps( running: &RunningApps, all_apps: &[ AppEntry ], shell_app_id: &str ) -> Vec<SessionApp>
	{
		running.in_order().iter()
			.filter( |id| !running.wayland_ids_for( id ).iter().any( |w| w == shell_app_id ) )
			.map( |id|
		{
			match all_apps.iter().find( |a| &a.app_id == id )
			{
				Some( entry ) => SessionApp {
					desktop_id: id.clone(),
					name:       entry.name.clone(),
					icon:       entry.icon.clone(),
				},
				None => SessionApp { desktop_id: id.clone(), name: id.clone(), icon: None },
			}
		} ).collect()
	}

	pub fn view(
		&self,
		icon_cache:   &mut IconCache,
		default_icon: &RgbaIcon,
		running:      &RunningApps,
	) -> ltk::Element<Message>
	{
		let palette = ltk::theme_palette();

		let dialog = match self
		{
			Self::Hidden => return ltk::spacer().into(),
			Self::Confirm { action, apps } =>
			{
				let list = Self::app_list( apps, icon_cache, default_icon, |_| true );
				ltk::dialog::<Message>()
					.title( Self::title_for( *action ) )
					.subtitle( t!( "endsession_open_apps" ).to_string() )
					.body( list )
					.action( Self::actions( vec![
						Self::button( "endsession_close_anyway", ltk::ButtonVariant::Primary, Message::EndSessionAnyway ),
						Self::button( "endsession_close_first", ltk::ButtonVariant::Secondary, Message::EndSessionCloseFirst ),
						Self::button( "endsession_cancel", ltk::ButtonVariant::Tertiary, Message::EndSessionCancel ),
					] ) )
			}
			// A plain card: this state is its own window, decorated by the
			// compositor, so there is no scrim to draw.
			Self::Closing { action, apps } =>
			{
				let list = Self::app_list( apps, icon_cache, default_icon, |a| running.is_running( &a.desktop_id ) );
				let card = ltk::column::<Message>()
					.padding( ltk::Length::dp( WINDOW_PADDING_DP ) )
					.spacing( ltk::Length::dp( 12.0 ) )
					.push( ltk::text( Self::title_for( *action ) ).size( 20.0 ).color( palette.text_primary ) )
					.push( ltk::text( t!( "endsession_closing" ).to_string() ).size( 15.0 ).color( palette.text_secondary ) )
					.push(
						ltk::row::<Message>()
							.padding( 0.0 )
							.spacing( ltk::Length::dp( 16.0 ) )
							.push( ltk::spinner().color( palette.accent ) )
							.push( list ),
					)
					.push( Self::actions( vec![ Self::button( "endsession_cancel", ltk::ButtonVariant::Tertiary, Message::EndSessionCancel ) ] ) );
				return ltk::container( card ).background( palette.surface ).into();
			}
			Self::Stuck { remaining, .. } =>
			{
				let list = Self::app_list( remaining, icon_cache, default_icon, |_| true );
				ltk::dialog::<Message>()
					.title( t!( "endsession_stuck_title" ).to_string() )
					.subtitle( t!( "endsession_stuck_hint" ).to_string() )
					.body( list )
					.action( Self::actions( vec![
						Self::button( "endsession_force", ltk::ButtonVariant::Primary, Message::EndSessionForce ),
						Self::button( "endsession_cancel", ltk::ButtonVariant::Tertiary, Message::EndSessionCancel ),
					] ) )
			}
		};

		dialog
			.modal( false )
			.cancel( Message::EndSessionCancel )
			.max_width( 600.0 )
			.into()
	}

	/// Text buttons default to the surface's fluid font scale, which on the
	/// full-screen shell surface is far larger than the dialog's own text.
	fn button( key: &str, variant: ltk::ButtonVariant, msg: Message ) -> ltk::Element<Message>
	{
		ltk::button::<Message>( t!( key ).to_string() )
			.variant( variant )
			.font_size( 15.0 )
			.height( ltk::Length::dp( 40.0 ) )
			.on_press( msg )
			.into()
	}

	/// The dialog's action row shares its width between every button and
	/// elides long labels; stacking them keeps each label whole.
	fn actions( buttons: Vec<ltk::Element<Message>> ) -> ltk::Element<Message>
	{
		let mut column = ltk::column::<Message>()
			.padding( 0.0 )
			.spacing( ltk::Length::dp( 8.0 ) )
			.align_center_x( true );
		for b in buttons
		{
			column = column.push( b );
		}
		column.into()
	}

	fn title_for( action: PowerAction ) -> String
	{
		match action
		{
			PowerAction::Reboot   => t!( "endsession_title_reboot" ).to_string(),
			PowerAction::PowerOff => t!( "endsession_title_poweroff" ).to_string(),
			_                     => t!( "endsession_title_logout" ).to_string(),
		}
	}

	fn app_list(
		apps:         &[ SessionApp ],
		icon_cache:   &mut IconCache,
		default_icon: &RgbaIcon,
		is_live:      impl Fn( &SessionApp ) -> bool,
	) -> ltk::Element<Message>
	{
		let palette  = ltk::theme_palette();
		let icon_sz  = ltk::Length::dp( ICON_DP );
		let icon_dec = ( ICON_DP * ltk::density().max( 1.0 ) ).round() as u32;

		let mut column = ltk::column::<Message>()
			.padding( 0.0 )
			.spacing( ltk::Length::dp( 8.0 ) );
		for app in apps
		{
			let ( rgba, w, h ) = app.icon.as_ref()
				.and_then( |p| icon_cache.get_or_load_sized( p, icon_dec ) )
				.unwrap_or_else( || default_icon.clone() );
			let color = if is_live( app ) { palette.text_primary } else { palette.text_secondary };
			column = column.push(
				ltk::row::<Message>()
					.padding( 0.0 )
					.spacing( ltk::Length::dp( 12.0 ) )
					.push( ltk::img_widget( rgba, w, h ).size( icon_sz, icon_sz ) )
					.push( ltk::text( app.name.clone() ).size( 15.0 ).color( color ) ),
			);
		}

		if apps.len() > MAX_VISIBLE_ROWS
		{
			let height = ROW_DP * MAX_VISIBLE_ROWS as f32 * ltk::density().max( 1.0 );
			ltk::viewport( ltk::scroll( column ) ).height( height ).into()
		} else {
			column.into()
		}
	}
}

// Shell-side orchestration: what happens once the user has decided.
impl crate::state::Crustace
{
	/// Desktop ids from the dialog snapshot that can be relaunched.
	pub( crate ) fn session_snapshot_ids( &self ) -> Vec<String>
	{
		self.end_session.snapshot().iter()
			.filter( |a| self.launcher.apps.iter().any( |e| e.app_id == a.desktop_id ) )
			.map( |a| a.desktop_id.clone() )
			.collect()
	}

	/// Save the session list and carry out `action`. For Logout this does
	/// not return.
	pub( crate ) fn proceed( &mut self, action: PowerAction, apps: &[ String ] )
	{
		self.end_session.hide();
		self.end_session_gen = self.end_session_gen.wrapping_add( 1 );
		if let Err( e ) = crate::session_restore::save( action, apps )
		{
			tracing::warn!( "could not save session list: {e}" );
		}
		match action
		{
			PowerAction::Logout =>
			{
				// `crustace.service` is a no-op stub whose only job is to
				// hold `graphical-session.target` up (forge runs outside
				// systemd-user so libseat grants it DRM); stopping it takes
				// the target down and with it the notifier and the portals.
				// What ends the session itself is the `exit(0)` below: forge
				// SIGTERMs itself when its `--exec` child dies.
				let _ = std::process::Command::new( "systemctl" )
					.args( [ "--user", "stop", "crustace.service" ] )
					.spawn();
				std::process::exit( 0 );
			}
			PowerAction::Reboot | PowerAction::PowerOff | PowerAction::Suspend =>
			{
				crate::powermenu::PowerMenu::execute( action );
			}
			PowerAction::Lock | PowerAction::Cancel => {}
		}
	}

	/// While "close applications first" is waiting, proceed as soon as
	/// nothing is left running.
	pub( crate ) fn check_end_session_progress( &mut self )
	{
		let shell = ltk::App::app_id( self ).to_string();
		if self.end_session.is_closing()
			&& EndSession::collect_apps( &self.running_apps, &self.launcher.apps, &shell ).is_empty()
		{
			if let Some( action ) = self.end_session.action()
			{
				let ids = self.session_snapshot_ids();
				self.proceed( action, &ids );
			}
		}
	}

	/// Launch the next app of the restore queue through the normal
	/// activation-token path, tagged so it starts with
	/// `LTK_SESSION_RESTORE=1`.
	pub( crate ) fn restore_launch_next( &mut self )
	{
		let Some( id ) = self.restore_queue.next() else { return };
		if let Some( entry ) = self.launcher.apps.iter().find( |a| a.app_id == id )
		{
			let timeout = std::time::Duration::from_secs( self.config.shell.splash_timeout_secs );
			self.splash.start( entry, timeout );
		}
		self.restore_tags.insert( id.clone() );
		self.pending_token_requests.push( id );

		let generation = self.restore_queue.generation;
		if let Some( sender ) = self.sender.clone()
		{
			let _ = std::thread::Builder::new()
				.name( "restore-timer".into() )
				.spawn( move ||
				{
					std::thread::sleep( RESTORE_STAGGER );
					let _ = sender.send( Message::SessionRestoreNext( generation ) );
				} );
		}
	}
}
