// Top-level `App` implementation.
//
// All screens are dispatched from `view()`, all messages folded in
// `update()`, and time-driven transitions (splash dismissal, install
// progress) are handled in `poll_external()`.

use std::collections::HashMap;
use std::time::{ Duration, Instant };

use ltk::{ App, Element, Keysym };

use crate::flow::{ self, Screen };
use crate::i18n;
use crate::screens;
use crate::state::
{
	AddPartitionForm, DiskOp, DiskSelection, InstallProgress, NavigationMode, Permission, ResizeForm, SecurityMethod, State, StorageProvider,
};

// How long the splash screen stays up before auto-advancing to the
// language picker.
const SPLASH_DURATION: Duration = Duration::from_millis( 3_000 );

// ── Message ─────────────────────────────────────────────────────────────────

#[ derive( Clone, Debug ) ]
pub enum Msg
{
	// Navigation chrome
	Next,
	Back,
	Goto( Screen ),

	// Screen 02 — language
	SelectLanguage( String ),

	// Screen 04 — disk
	SelectDisk( usize ),
	SelectTarget( usize, usize ),
	DeleteDisk( usize ),
	DeletePartition( usize, usize ),
	DiscardOps,
	ApplyOps,         // open the confirmation overlay
	ConfirmApply,     // user confirmed — schedules the commit
	ApplyTick,        // poll-driven: actually runs the commit
	CancelApply,      // overlay's "cancel" — close without changes
	OpenInstallConfirm,
	ConfirmInstall,
	CancelInstallConfirm,
	OpenResize( usize, usize ),  // (disk_idx, part_idx) — open resize form
	CloseResize,
	ResizeChange( f32 ),         // 0..1 slider position → bytes
	ConfirmResize,
	OpenAddPartition( usize, u64, u64 ), // ( disk_idx, start_byte, size_bytes ) of the chosen free slot
	CloseAddPartition,
	AddPartitionChange( f32 ),   // 0..1 slider position → bytes
	ConfirmAddPartition,
	DismissError,

	// Account (install mode)
	AccountUsername( String ),
	AccountHostname( String ),
	AccountPassword( String ),
	AccountPasswordConfirm( String ),
	AccountToggleEncrypt,

	// Screen 05 — wireless
	SelectNetwork( usize ),
	PasswordChange( String ),
	TogglePasswordVisibility,
	ConfirmPassword,
	ConnectTick,           // poll-driven: actually runs the nmcli call
	CancelPassword,
	CheckConnectivity,     // poll-driven: refresh `state.network_online`
	AddNetworkManually,
	ManualSsidChange( String ),
	ManualPasswordChange( String ),
	ManualTogglePasswordVisibility,
	ConfirmManual,
	CancelManual,
	UseCellular,

	// Screen 06 — navigation mode
	SelectNavigation( NavigationMode ),

	// Screen 07 — storage
	SelectStorage( StorageProvider ),

	// Screen 07.2 — Proton form
	ProtonFullName( String ),
	ProtonEmail( String ),
	ProtonPassword( String ),
	ProtonTogglePasswordVisibility,
	ProtonSubmit,
	ProtonSkip,

	// Screen 08 — permissions
	TogglePermission( Permission ),

	// Screen 09 — security
	SelectSecurityMethod( SecurityMethod ),
	SkipSecurity,
	RegisterAnotherFingerprint,

	// Screen 10 — progress (driven by `poll_external`)
	Tick,
	CancelInstall,        // open the confirmation overlay
	ConfirmCancelInstall,
	DismissCancelInstall,

	// Screen 11
	DiscoverDevice,
	Reboot,

	// Screens 12 / 13
	SkipTutorial,
}

// ── App state ───────────────────────────────────────────────────────────────

pub struct InstallerApp
{
	pub state:       State,
	pub screen:      Screen,
	splash_started:  Option<Instant>,
	window_size:     Option<( u32, u32 )>,
}

impl InstallerApp
{
	/// Build the app with an optional fixed window size. `None` runs
	/// fullscreen.
	pub fn new( window_size: Option<( u32, u32 )>, test_mode: bool, mode: crate::state::WizardMode ) -> Self
	{
		let mut state = State::new();
		state.test_mode = test_mode;
		state.mode      = mode;
		if test_mode { state.factory_account = true; }
		if let Some( vp ) = window_size { state.viewport = vp; }
		Self
		{
			state,
			screen:         Screen::FrontPage,
			splash_started: None,
			window_size,
		}
	}

	fn enter( &mut self, screen: Screen )
	{
		if screen == Screen::InstallProgress && self.state.progress.is_none()
		{
			match crate::install::plan_from( &self.state )
			{
				Ok( plan ) =>
				{
					let slot = crate::ipc::start( plan );
					self.state.progress = Some( InstallProgress
					{
						slot,
						phase:    crate::install::Phase::Partition,
						fraction: 0.0,
					} );
				}
				Err( e ) =>
				{
					self.state.last_error = Some( e );
					self.screen = Screen::DiskManagement;
					return;
				}
			}
		}
		if screen == Screen::Done
			&& self.state.mode == crate::state::WizardMode::Configure
			&& !self.state.test_mode
		{
			// The language may have been re-picked at first boot; sync
			// the system before retiring the marker the daemon's
			// ApplyLocale is gated on.
			if let Err( e ) = crate::ipc::apply_locale( &self.state.locale )
			{
				eprintln!( "installer: apply locale: {e}" );
			}
			// The factory account cannot be renamed while it is logged
			// in and the mounted root cannot be encrypted in place:
			// both are staged for the next boot, before the marker
			// swap the daemon gates the request on.
			let mut staged = false;
			if self.state.factory_account && self.state.account.valid()
			{
				match crate::ipc::stage_account(
					&self.state.account.username,
					&self.state.account.hostname,
					&self.state.account.password,
					self.state.account.encrypt )
				{
					Ok( () ) => staged = true,
					Err( e ) => eprintln!( "installer: stage account: {e}" ),
				}
			}
			if staged
			{
				// configure-pending stays: the daemon unit is gated on
				// it and firstboot retires it after applying the
				// staged account on the boot this triggers.
				let _ = std::process::Command::new( "systemctl" ).arg( "reboot" ).spawn();
			}
			else
			{
				// Direct swap works when running as root; otherwise the
				// markers live in a root-owned directory and the daemon
				// does it (idempotent, so a half-applied swap is fine).
				let direct = std::fs::remove_file( crate::install::CONFIGURE_PENDING ).is_ok()
					&& std::fs::write( crate::install::CONFIGURED, "" ).is_ok();
				if !direct
				{
					if let Err( e ) = crate::ipc::finish_configure()
					{
						eprintln!( "installer: finish configure: {e}" );
					}
				}
			}
		}
		self.screen = screen;
		// Done is terminal: nothing is drawn past it and there is no
		// window chrome to close the wizard with. A staged Configure
		// already asked for a reboot; every other real run just ends,
		// freeing the desktop session (or the medium's kiosk wrapper
		// relaunches into a fresh wizard).
		if screen == Screen::Done && !self.state.test_mode
		{
			std::process::exit( 0 );
		}
	}
}

// ── App trait ───────────────────────────────────────────────────────────────

impl App for InstallerApp
{
	type Message = Msg;

	fn app_id( &self ) -> &str { "net.eydos.Installer" }
	fn save_state( &self ) -> Option<Vec<u8>> { None }
	fn restore_state( &mut self, _state: Vec<u8> ) {}

	fn view( &self ) -> Element<Msg>
	{
		match self.screen
		{
			Screen::FrontPage                   => screens::front_page::view( &self.state ),
			Screen::SelectLanguage              => screens::select_language::view( &self.state ),
			Screen::Welcome                     => screens::welcome::view( &self.state ),
			Screen::DiskManagement              => screens::disk_management::view( &self.state ),
			Screen::Account                     => screens::account::view( &self.state ),
			Screen::Wireless                    => screens::wireless::view( &self.state ),
			Screen::Navigation                  => screens::navigation::view( &self.state ),
			Screen::DataStorage                 => screens::data_storage::view( &self.state ),
			Screen::DataStorageProton           => screens::data_storage_proton::view( &self.state ),
			Screen::Permissions                 => screens::permissions::view( &self.state ),
			Screen::SecuritySelector            => screens::security::view( &self.state ),
			Screen::SecurityFingerprintRegister => screens::security::fingerprint::view_register( &self.state ),
			Screen::SecurityFingerprintDone     => screens::security::fingerprint::view_done( &self.state ),
			Screen::InstallProgress             => screens::install_progress::view( &self.state ),
			Screen::InstallFinish               => screens::install_finish::view( &self.state ),
			Screen::TutorialGestures( step )    => screens::tutorial_gestures::view( &self.state, step ),
			Screen::TutorialSmartcast( step )   => screens::tutorial_smartcast::view( &self.state, step ),
			Screen::Done                        => screens::install_finish::view( &self.state ),
		}
	}

	fn update( &mut self, msg: Msg )
	{
		match msg
		{
			Msg::Next =>
			{
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}
			Msg::Back =>
			{
				let prev = flow::previous( self.screen, &self.state );
				self.enter( prev );
			}
			Msg::Goto( screen ) => self.enter( screen ),

			Msg::SelectLanguage( code ) =>
			{
				i18n::set_locale( &code );
				self.state.locale = code;
				// Disk labels were built during `State::new` with the
				// default locale; rebuild them so partitions show in
				// the picked language too.
				self.state.disks = crate::state::discover_disks();
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}

			Msg::SelectDisk( d ) =>
			{
				if let Some( disk ) = self.state.disks.get( d )
				{
					self.state.selection = Some( DiskSelection::Disk( disk.device_path.clone() ) );
				}
			}
			Msg::SelectTarget( d, p ) =>
			{
				if let Some( disk ) = self.state.disks.get( d )
				{
					if let Some( slot ) = disk.partitions.get( p )
					{
						self.state.selection = Some( crate::state::selection_for_slot( disk, slot ) );
					}
				}
			}
			Msg::DeleteDisk( d ) =>
			{
				let disk_path = self.state.disks.get( d ).map( |x| x.device_path.clone() );
				// A queued WipeDisk supersedes any earlier per-
				// partition deletion on the same disk.
				self.state.pending_ops.retain( |op| match op
				{
					DiskOp::WipeDisk( x )                => *x != d,
					DiskOp::DeletePartition( x, _ )      => *x != d,
					DiskOp::ResizePartition( x, _, _ )   => *x != d,
					DiskOp::CreatePartition( x, _, _ )   => *x != d,
				} );
				self.state.pending_ops.push( DiskOp::WipeDisk( d ) );
				if let Some( path ) = disk_path
				{
					if let Some( sel ) = self.state.selection.as_ref()
					{
						if sel.disk_path() == path { self.state.selection = None; }
					}
				}
			}
			Msg::DeletePartition( d, p ) =>
			{
				let num = self.state.disks.get( d )
					.and_then( |disk| disk.partitions.get( p ) )
					.map( |t| t.number );
				let disk_path = self.state.disks.get( d ).map( |x| x.device_path.clone() );
				if let Some( num ) = num
				{
					let already = self.state.pending_ops.iter().any( |op| matches!( op,
						DiskOp::DeletePartition( x, n ) if *x == d && *n == num ) );
					if !already
					{
						self.state.pending_ops.push( DiskOp::DeletePartition( d, num ) );
					}
				}
				if let ( Some( path ), Some( num ) ) = ( disk_path, num )
				{
					if matches!( self.state.selection.as_ref(),
						Some( DiskSelection::Partition( p, n ) ) if *p == path && *n == num )
					{
						self.state.selection = None;
					}
				}
			}
			Msg::DiscardOps =>
			{
				self.state.pending_ops.clear();
			}
			Msg::ApplyOps =>
			{
				if !self.state.pending_ops.is_empty()
				{
					self.state.confirm_apply = true;
				}
			}
			Msg::CancelApply =>
			{
				self.state.confirm_apply = false;
			}
			Msg::ConfirmApply =>
			{
				// Just close the confirm overlay and flip the
				// "applying" flag. The actual commit runs on the
				// next `poll_external` tick so the spinner overlay
				// gets at least one frame of paint before the main
				// thread blocks on table writes / fs tooling.
				self.state.confirm_apply = false;
				self.state.applying      = true;
			}
			Msg::ApplyTick =>
			{
				if self.state.applying
				{
					let errors = apply_pending_ops( &mut self.state );
					self.state.pending_ops.clear();
					self.state.disks = crate::state::discover_disks();
					let lost_selection = self.state.selection.as_ref()
						.map( |s| !s.still_valid( &self.state.disks ) )
						.unwrap_or( false );
					if lost_selection
					{
						self.state.selection = None;
					}
					if !errors.is_empty()
					{
						self.state.last_error = Some( errors.join( "\n" ) );
					}
					self.state.applying = false;
				}
			}
			Msg::OpenResize( d, p ) =>
			{
				if let Some( part ) = self.state.disks.get( d ).and_then( |disk| disk.partitions.get( p ) )
				{
					if part.is_free { return; }
					// Growing a logical partition would overrun the next
					// EBR; resize stays primary/GPT-only.
					if part.in_extended { return; }
					const MIN_FLOOR: u64 = 1024 * 1024;
					let kind = crate::fs::parse( part.fs_type.as_deref() );
					let min_bytes = if crate::fs::can_shrink( kind ) && crate::fs::tools_available( kind )
					{
						crate::fs::min_size_bytes( &part.device_path, kind )
							.unwrap_or( part.size_bytes )
					}
					else if matches!( kind, crate::fs::FsKind::Unknown )
					{
						// No recognised FS inside — there is nothing to
						// preserve, so the only floor is the hardware
						// 1 MiB minimum applied below.
						MIN_FLOOR
					}
					else
					{
						// Known FS without shrink support
						// (btrfs / xfs / exfat / swap) — grow-only, so
						// the current size is the floor.
						part.size_bytes
					};
					let min_bytes = min_bytes.max( MIN_FLOOR ).min( part.max_size_bytes );
					self.state.resize_form = Some( ResizeForm
					{
						disk_idx:      d,
						part_idx:      p,
						min_bytes,
						max_bytes:     part.max_size_bytes,
						current:       part.size_bytes,
						original:      part.size_bytes,
						is_unknown_fs: matches!( kind, crate::fs::FsKind::Unknown ),
					} );
				}
			}
			Msg::CloseResize  => self.state.resize_form = None,
			Msg::ResizeChange( pos ) =>
			{
				if let Some( form ) = self.state.resize_form.as_mut()
				{
					// Slider track represents 0..max_bytes (the
					// partition's current size). Anything below
					// `min_bytes` clamps to the FS-aware floor.
					let bytes = ( pos.clamp( 0.0, 1.0 ) as f64 * form.max_bytes as f64 ) as u64;
					form.current = bytes.clamp( form.min_bytes, form.max_bytes );
				}
			}
			Msg::ConfirmResize =>
			{
				if let Some( form ) = self.state.resize_form
				{
					if let Some( part ) = self.state.disks.get( form.disk_idx ).and_then( |d| d.partitions.get( form.part_idx ) )
					{
						let num = part.number;
						// Replace any earlier resize on this same partition.
						self.state.pending_ops.retain( |op| !matches!( op,
							DiskOp::ResizePartition( d, n, _ ) if *d == form.disk_idx && *n == num ) );
						self.state.pending_ops.push( DiskOp::ResizePartition( form.disk_idx, num, form.current ) );
					}
				}
				self.state.resize_form = None;
			}
			Msg::OpenAddPartition( d, start_byte, max_bytes ) =>
			{
				const MIN_FLOOR: u64 = 1024 * 1024;
				if max_bytes >= MIN_FLOOR
				{
					self.state.add_partition_form = Some( AddPartitionForm
					{
						disk_idx:   d,
						start_byte,
						min_bytes:  MIN_FLOOR,
						max_bytes,
						current:    max_bytes,
					} );
				}
			}
			Msg::CloseAddPartition => self.state.add_partition_form = None,
			Msg::AddPartitionChange( pos ) =>
			{
				if let Some( form ) = self.state.add_partition_form.as_mut()
				{
					let bytes = ( pos.clamp( 0.0, 1.0 ) as f64 * form.max_bytes as f64 ) as u64;
					form.current = bytes.clamp( form.min_bytes, form.max_bytes );
				}
			}
			Msg::ConfirmAddPartition =>
			{
				if let Some( form ) = self.state.add_partition_form
				{
					// Replace any earlier create on the same start byte —
					// re-opening the dialog and accepting again should
					// overwrite, not stack a second create on the same
					// slot.
					self.state.pending_ops.retain( |op| !matches!( op,
						DiskOp::CreatePartition( d, s, _ ) if *d == form.disk_idx && *s == form.start_byte ) );
					self.state.pending_ops.push(
						DiskOp::CreatePartition( form.disk_idx, form.start_byte, form.current ) );
				}
				self.state.add_partition_form = None;
			}
			Msg::DismissError => self.state.last_error = None,
			Msg::OpenInstallConfirm =>
			{
				if self.state.selection.is_some()
				{
					self.state.confirm_install = true;
				}
			}
			Msg::CancelInstallConfirm => self.state.confirm_install = false,
			Msg::ConfirmInstall =>
			{
				self.state.confirm_install = false;
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}
			Msg::AccountUsername( s )        => self.state.account.username = s,
			Msg::AccountHostname( s )        => self.state.account.hostname = s,
			Msg::AccountPassword( s )        => self.state.account.password = s,
			Msg::AccountPasswordConfirm( s ) => self.state.account.password_confirm = s,
			Msg::AccountToggleEncrypt        => self.state.account.encrypt = !self.state.account.encrypt,

			Msg::SelectNetwork( idx ) =>
			{
				let locked = self.state.networks.get( idx ).map( |n| n.locked ).unwrap_or( false );
				if locked
				{
					// Locked network — defer the actual selection
					// until the user has entered (and confirmed) a
					// password. The form's `network_idx` keeps the
					// pointer stable even if the user keeps tapping
					// other rows in the background.
					self.state.password_form = Some( crate::state::WifiPasswordForm
					{
						network_idx:      idx,
						password:         String::new(),
						password_visible: false,
					} );
				}
				else
				{
					self.state.selected_network = Some( idx );
				}
			}
			Msg::PasswordChange( s ) =>
			{
				if let Some( form ) = self.state.password_form.as_mut()
				{
					form.password = s;
				}
				// User is correcting their attempt — fade the
				// previous failure out so the dialog reads as a
				// fresh try rather than an error overlay.
				self.state.wifi_error = None;
			}
			Msg::TogglePasswordVisibility =>
			{
				if let Some( form ) = self.state.password_form.as_mut()
				{
					form.password_visible = !form.password_visible;
				}
			}
			Msg::ConfirmPassword =>
			{
				// Spawn the connect on a worker thread so the UI
				// loop keeps painting (and the spinner animates)
				// while `nmcli` is in flight. The worker owns
				// cloned copies of ssid + password and stashes its
				// result in the shared mutex slot; the main loop
				// drains the slot from `Msg::ConnectTick`.
				if self.state.wifi_connecting { return; }
				let attempt = self.state.password_form.as_ref()
					.and_then( |f|
					{
						self.state.networks.get( f.network_idx )
							.map( |n| ( n.ssid.clone(), f.password.clone() ) )
					} );
				if let Some( ( ssid, password ) ) = attempt
				{
					let slot: crate::state::WifiConnectSlot =
						std::sync::Arc::new( std::sync::Mutex::new( None ) );
					let slot_thread = slot.clone();
					let test_mode = self.state.test_mode;
					std::thread::spawn( move ||
					{
						let result = if test_mode
						{
							std::thread::sleep( std::time::Duration::from_secs( 1 ) );
							Ok( () )
						}
						else
						{
							crate::network::connect_wifi( &ssid, Some( &password ), false )
						};
						if let Ok( mut guard ) = slot_thread.lock()
						{
							*guard = Some( result );
						}
					} );
					self.state.wifi_connect_slot = Some( slot );
					self.state.wifi_connecting   = true;
					self.state.wifi_error        = None;
				}
			}
			Msg::ConnectTick =>
			{
				// Drain the shared slot — `None` inside the mutex
				// means the worker is still running, so we keep
				// `wifi_connecting` set and wait for the next tick.
				let outcome = self.state.wifi_connect_slot.as_ref()
					.and_then( |slot| slot.lock().ok().and_then( |mut g| g.take() ) );
				if let Some( result ) = outcome
				{
					self.state.wifi_connect_slot = None;
					self.state.wifi_connecting   = false;
					match result
					{
						Ok( () ) =>
						{
							self.state.wifi_error = None;
							if let Some( form ) = self.state.password_form.take()
							{
								self.state.selected_network = Some( form.network_idx );
							}
							else if let Some( form ) = self.state.manual_form.take()
							{
								// Hidden networks never show up in the scan;
								// append a row so the selection is visible.
								self.state.networks.push( crate::state::WifiNetwork
								{
									ssid:   form.ssid.trim().to_string(),
									locked: !form.password.is_empty(),
									signal: 0,
								} );
								self.state.selected_network = Some( self.state.networks.len() - 1 );
							}
						}
						Err( e ) =>
						{
							// Keep the form open with the password
							// preserved — the user retries by
							// editing the field and pressing
							// Connect again.
							self.state.wifi_error = Some( e );
						}
					}
				}
			}
			Msg::CancelPassword =>
			{
				// If a worker is in flight we still let it run to
				// completion — `nmcli` is short-lived (≤ 30 s) and
				// killing the subprocess cleanly would need extra
				// plumbing. Just orphan the slot: once the worker
				// finishes its `lock + store` is a no-op against
				// the dropped Arc. The next poll sees no slot, no
				// `wifi_connecting`, and the dialog is gone.
				self.state.password_form     = None;
				self.state.wifi_error        = None;
				self.state.wifi_connecting   = false;
				self.state.wifi_connect_slot = None;
			}
			Msg::CheckConnectivity =>
			{
				self.state.network_online = crate::network::is_online();
			}
			Msg::AddNetworkManually =>
			{
				self.state.manual_form = Some( crate::state::ManualNetworkForm::default() );
				self.state.wifi_error  = None;
			}
			Msg::ManualSsidChange( s ) =>
			{
				if let Some( form ) = self.state.manual_form.as_mut()
				{
					form.ssid = s;
				}
				self.state.wifi_error = None;
			}
			Msg::ManualPasswordChange( s ) =>
			{
				if let Some( form ) = self.state.manual_form.as_mut()
				{
					form.password = s;
				}
				self.state.wifi_error = None;
			}
			Msg::ManualTogglePasswordVisibility =>
			{
				if let Some( form ) = self.state.manual_form.as_mut()
				{
					form.password_visible = !form.password_visible;
				}
			}
			Msg::ConfirmManual =>
			{
				if self.state.wifi_connecting { return; }
				let attempt = self.state.manual_form.as_ref()
					.filter( |f| !f.ssid.trim().is_empty() )
					.map( |f| ( f.ssid.trim().to_string(), f.password.clone() ) );
				if let Some( ( ssid, password ) ) = attempt
				{
					let slot: crate::state::WifiConnectSlot =
						std::sync::Arc::new( std::sync::Mutex::new( None ) );
					let slot_thread = slot.clone();
					let test_mode = self.state.test_mode;
					std::thread::spawn( move ||
					{
						let result = if test_mode
						{
							std::thread::sleep( std::time::Duration::from_secs( 1 ) );
							Ok( () )
						}
						else
						{
							let pw = if password.is_empty() { None } else { Some( password.as_str() ) };
							crate::network::connect_wifi( &ssid, pw, true )
						};
						if let Ok( mut guard ) = slot_thread.lock()
						{
							*guard = Some( result );
						}
					} );
					self.state.wifi_connect_slot = Some( slot );
					self.state.wifi_connecting   = true;
					self.state.wifi_error        = None;
				}
			}
			Msg::CancelManual =>
			{
				self.state.manual_form       = None;
				self.state.wifi_error        = None;
				self.state.wifi_connecting   = false;
				self.state.wifi_connect_slot = None;
			}
			Msg::UseCellular          =>
			{
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}

			Msg::SelectNavigation( m ) =>
			{
				self.state.navigation = Some( m );
			}

			Msg::SelectStorage( provider ) =>
			{
				self.state.storage = Some( provider );
				match provider
				{
					StorageProvider::Local | StorageProvider::Dropbox =>
					{
						let next = flow::next( self.screen, &self.state );
						self.enter( next );
					}
					StorageProvider::Proton =>
					{
						self.enter( Screen::DataStorageProton );
					}
				}
			}

			Msg::ProtonFullName( s ) => self.state.proton.full_name = s,
			Msg::ProtonEmail( s )    => self.state.proton.email     = s,
			Msg::ProtonPassword( s ) => self.state.proton.password  = s,
			Msg::ProtonTogglePasswordVisibility =>
			{
				self.state.proton.show_password = !self.state.proton.show_password;
			}
			Msg::ProtonSubmit =>
			{
				// TODO: actually submit credentials. For the figma flow,
				// any submission jumps to the next screen.
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}
			Msg::ProtonSkip => self.enter( Screen::Permissions ),

			Msg::TogglePermission( p ) => self.state.permissions.toggle( p ),

			Msg::SelectSecurityMethod( method ) =>
			{
				self.state.security_method = Some( method );
				if method == SecurityMethod::Fingerprint
				{
					self.enter( Screen::SecurityFingerprintRegister );
				}
			}
			Msg::SkipSecurity =>
			{
				let next = flow::next( Screen::SecuritySelector, &self.state );
				self.enter( next );
			}
			Msg::RegisterAnotherFingerprint  => self.enter( Screen::SecurityFingerprintRegister ),

			Msg::Tick =>
			{
				let status = self.state.progress.as_ref()
					.and_then( |p| p.slot.lock().ok().map( |s| s.clone() ) );
				if let Some( status ) = status
				{
					if let Some( progress ) = self.state.progress.as_mut()
					{
						progress.fraction = status.fraction;
						progress.phase    = status.phase;
					}
					if self.screen == Screen::InstallProgress
					{
						if let Some( e ) = status.error
						{
							self.state.progress   = None;
							self.state.confirm_cancel_install = false;
							self.state.last_error = Some( localize_error( e ) );
							self.state.disks      = crate::state::discover_disks();
							let lost_selection = self.state.selection.as_ref()
								.map( |s| !s.still_valid( &self.state.disks ) )
								.unwrap_or( false );
							if lost_selection
							{
								self.state.selection = None;
							}
							self.enter( Screen::DiskManagement );
						}
						else if status.done
						{
							self.state.progress = None;
							self.state.confirm_cancel_install = false;
							self.enter( Screen::InstallFinish );
						}
					}
				}
			}

			Msg::CancelInstall =>
			{
				if self.state.progress.is_some()
				{
					self.state.confirm_cancel_install = true;
				}
			}
			Msg::ConfirmCancelInstall =>
			{
				self.state.confirm_cancel_install = false;
				if self.state.progress.is_some()
				{
					crate::ipc::cancel_install();
				}
			}
			Msg::DismissCancelInstall => self.state.confirm_cancel_install = false,

			Msg::DiscoverDevice =>
			{
				let next = flow::next( self.screen, &self.state );
				self.enter( next );
			}

			Msg::Reboot =>
			{
				// As the active graphical session, logind authorises the
				// reboot without extra privileges.
				if !self.state.test_mode
				{
					let _ = std::process::Command::new( "systemctl" ).arg( "reboot" ).spawn();
				}
				self.enter( Screen::Done );
			}

			Msg::SkipTutorial => self.enter( Screen::Done ),
		}
	}

	fn on_resize( &mut self, w: u32, h: u32 )
	{
		self.state.viewport = ( w, h );
	}

	fn on_key( &mut self, key: Keysym ) -> Option<Msg>
	{
		if key == Keysym::Escape
		{
			std::process::exit( 0 );
		}
		None
	}

	fn is_animating( &self ) -> bool
	{
		self.state.applying
			|| self.state.wifi_connecting
			|| matches!(
				self.screen,
				Screen::FrontPage | Screen::InstallProgress | Screen::Wireless
			)
	}

	fn poll_interval( &self ) -> Option<Duration>
	{
		if self.state.applying || self.state.wifi_connecting
		{
			return Some( Duration::from_millis( 100 ) );
		}
		match self.screen
		{
			Screen::FrontPage       => Some( Duration::from_millis( 100 ) ),
			Screen::InstallProgress => Some( Duration::from_millis( 100 ) ),
			// 1 Hz is enough to make Continue gate-on-connectivity
			// feel live without burning cycles on subprocess spawns.
			Screen::Wireless        => Some( Duration::from_secs( 1 ) ),
			_                       => None,
		}
	}

	fn poll_external( &mut self ) -> Vec<Msg>
	{
		if self.state.applying
		{
			// Defer the actual commit by one tick so view() has a
			// chance to paint the spinner before the main thread
			// blocks on table writes / fs tooling.
			return vec![ Msg::ApplyTick ];
		}
		if self.state.wifi_connecting
		{
			// Same one-tick deferral, this time for the synchronous
			// `nmcli device wifi connect` call that freezes the loop
			// for up to 30 seconds while the AP associates.
			return vec![ Msg::ConnectTick ];
		}
		if matches!( self.screen, Screen::Wireless )
		{
			return vec![ Msg::CheckConnectivity ];
		}
		match self.screen
		{
			Screen::FrontPage =>
			{
				let started = *self.splash_started.get_or_insert_with( Instant::now );
				if started.elapsed() >= SPLASH_DURATION
				{
					self.splash_started = None;
					return vec![ Msg::Next ];
				}
				vec![]
			}
			Screen::InstallProgress => vec![ Msg::Tick ],
			_                       => vec![],
		}
	}

	// ── Window geometry ─────────────────────────────────────────────
	//
	// Default `ShellMode::Window` (xdg-shell). When the user passes
	// `--windowsize WxH` we forward it as a size hint that ltk turns
	// into matching min/max-size requests on the toplevel; without it
	// the compositor decides the size (typically maximised on first
	// configure for a freshly mapped window).

	fn window_size_hint( &self ) -> Option<( u32, u32 )>
	{
		self.window_size
	}

	fn window_resizable( &self ) -> bool
	{
		false
	}

	fn start_fullscreen( &self ) -> bool
	{
		self.window_size.is_none()
	}

	fn window_config( &self ) -> Option<( &str, &str )>
	{
		match self.window_size
		{
			Some( _ ) if self.state.test_mode => Some( ( "Eydos Installer (test)", "net.eydos.Installer" ) ),
			Some( _ )                         => Some( ( "Eydos Installer", "net.eydos.Installer" ) ),
			None                              => None,
		}
	}
}

/// Map the engine/daemon error tokens onto localized messages; raw
/// technical errors (tool stderr) pass through untranslated.
fn localize_error( raw: String ) -> String
{
	match raw.as_str()
	{
		"cancelled"   => crate::i18n::tr!( "error.cancelled" ).to_string(),
		"busy"        => crate::i18n::tr!( "error.install_busy" ).to_string(),
		"not-pending" => crate::i18n::tr!( "error.not_pending" ).to_string(),
		"no-daemon"   => crate::i18n::tr!( "error.no_daemon" ).to_string(),
		_             => raw,
	}
}

/// Resolve the queued operations to device paths and hand them to the
/// privileged side via `ipc::apply_disk_ops` (the wizard cannot open
/// block devices itself). Order: resizes first (the FS tooling still
/// finds the partition), then creates, deletes, and wipes which
/// clobber the whole table. Callers refresh `state.disks` afterwards
/// from the new on-disk reality.
fn apply_pending_ops( state: &mut State ) -> Vec<String>
{
	use std::path::PathBuf;

	use crate::ipc::RemoteDiskOp;

	if state.test_mode { return Vec::new(); }

	let mut wipes:   Vec<PathBuf>               = Vec::new();
	let mut deletes: HashMap<PathBuf, Vec<u32>> = HashMap::new();
	let mut resizes: Vec<RemoteDiskOp>          = Vec::new();
	let mut creates: Vec<RemoteDiskOp>          = Vec::new();

	for op in &state.pending_ops
	{
		match op
		{
			DiskOp::WipeDisk( d ) =>
			{
				if let Some( disk ) = state.disks.get( *d )
				{
					wipes.push( disk.device_path.clone() );
					deletes.remove( &disk.device_path );
				}
			}
			DiskOp::DeletePartition( d, num ) =>
			{
				if let Some( disk ) = state.disks.get( *d )
				{
					deletes.entry( disk.device_path.clone() ).or_default().push( *num );
				}
			}
			DiskOp::ResizePartition( d, num, new_bytes ) =>
			{
				if let Some( disk ) = state.disks.get( *d )
				{
					if let Some( part ) = disk.partitions.iter().find( |p| p.number == *num )
					{
						resizes.push( RemoteDiskOp::Resize
						{
							disk:          disk.device_path.clone(),
							part:          part.device_path.clone(),
							number:        *num,
							current_bytes: part.size_bytes,
							new_bytes:     *new_bytes,
							fs:            part.fs_type.clone(),
						} );
					}
				}
			}
			DiskOp::CreatePartition( d, start_byte, size_bytes ) =>
			{
				if let Some( disk ) = state.disks.get( *d )
				{
					creates.push( RemoteDiskOp::Create
					{
						disk:       disk.device_path.clone(),
						start_byte: *start_byte,
						size_bytes: *size_bytes,
					} );
				}
			}
		}
	}

	let mut ops = resizes;
	ops.extend( creates );
	for ( disk, numbers ) in deletes
	{
		ops.push( RemoteDiskOp::Delete { disk, numbers } );
	}
	for disk in wipes
	{
		ops.push( RemoteDiskOp::Wipe { disk } );
	}
	crate::ipc::apply_disk_ops( &ops )
		.into_iter()
		.map( localize_error )
		.collect()
}

