// Domain types and global application state for the installer.
//
// State is plain data — no IO, no widgets — so the screen modules can
// build their views from `&State` without pulling in side effects, and
// `app::update` is the single place that mutates it.

use std::sync::{ Arc, Mutex };

/// Slot the wifi connect background thread fills with the result of
/// `nmcli connection up`. `None` means "still in flight"; `Some( … )`
/// is the final outcome the main loop drains on the next tick.
pub type WifiConnectSlot = Arc<Mutex<Option<Result<(), String>>>>;

/// Which of the two wizard scenarios this run covers.
///
/// `Install` runs from the installation medium: language, target disk
/// and the actual copy — no personalisation. `Configure` runs on the
/// first boot of the already-copied system: wireless, navigation,
/// storage, permissions, security and tutorials — no disk steps.
/// Detected at startup from [`crate::install::CONFIGURE_PENDING`]
/// (written by the install engine into the target) and overridable
/// with `--install` / `--configure`.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum WizardMode
{
	Install,
	Configure,
}

// ── Disk / target ────────────────────────────────────────────────────────────

/// Hardware-icon category the screen uses to pick a glyph. The
/// classification stays here (not in `disks::BusKind`) so the rest
/// of the toolkit doesn't have to know which icon set the installer
/// ships.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum DiskIcon
{
	/// Internal mechanical / SATA SSD drive.
	HardDisk,
	/// External / USB-attached spinning or SSD drive.
	HardDrive,
	/// Solid-state silicon storage soldered to the board: NVMe,
	/// eMMC, UFS.
	Chip,
	/// USB pen drive.
	Usb,
	/// Removable card-format media: SD, microSD, MMC.
	SdCard,
}

/// One slot in the partition table the user can interact with.
/// `is_free == true` represents an unallocated gap — these have no
/// number, no fs_type and no destructive actions.
#[ derive( Clone, Debug ) ]
pub struct InstallTarget
{
	pub label:       String,
	pub size_bytes:  u64,
	pub device_path: std::path::PathBuf,
	/// On-disk partition number (1-based, matches GPT/MBR entry
	/// index). `0` when `is_free`.
	pub number:      u32,
	pub max_size_bytes: u64,
	pub fs_type:     Option<String>,
	pub is_free:     bool,
	pub start_byte:  u64,
	/// MBR: logical partition, or free space inside the extended
	/// partition (creations there become logical partitions).
	pub in_extended: bool,
}

/// A whole disk grouped with its partitions so the UI can render
/// each disk as a card with the partitions listed inside.
#[ derive( Clone, Debug ) ]
pub struct DiskGroup
{
	pub label:       String,
	pub icon:        DiskIcon,
	pub size_bytes:  u64,
	pub device_path: std::path::PathBuf,
	/// "gpt" / "msdos" as reported by the table readers, `None` when
	/// no table was recognised.
	pub table_type:  Option<String>,
	pub partitions:  Vec<InstallTarget>,
}

/// What the user has currently picked as the install target.
/// Stored by stable identifiers (disk path, GPT/MBR partition
/// number, free-space start byte) instead of `Vec` indices so a
/// commit that reorders / merges slots cannot silently re-point the
/// selection to a different target.
#[ derive( Clone, Debug, PartialEq, Eq ) ]
pub enum DiskSelection
{
	Disk( std::path::PathBuf ),
	Partition( std::path::PathBuf, u32 ),
	FreeSpace( std::path::PathBuf, u64 ),
}

impl DiskSelection
{
	pub fn disk_path( &self ) -> &std::path::Path
	{
		match self
		{
			DiskSelection::Disk( p )         => p,
			DiskSelection::Partition( p, _ ) => p,
			DiskSelection::FreeSpace( p, _ ) => p,
		}
	}

	/// `true` when this selection still points at a slot present in
	/// `disks`. Use to clear a stale selection after a commit.
	pub fn still_valid( &self, disks: &[ DiskGroup ] ) -> bool
	{
		let Some( disk ) = disks.iter().find( |d| d.device_path == *self.disk_path() ) else { return false };
		match self
		{
			DiskSelection::Disk( _ )            => true,
			DiskSelection::Partition( _, num )  =>
				disk.partitions.iter().any( |p| !p.is_free && p.number == *num ),
			DiskSelection::FreeSpace( _, start ) =>
				disk.partitions.iter().any( |p| p.is_free && p.start_byte == *start ),
		}
	}
}

/// Helper exposed so the disk-management screen can build a
/// selection from a slot it just rendered without duplicating the
/// path/number plumbing.
pub fn selection_for_slot( disk: &DiskGroup, slot: &InstallTarget ) -> DiskSelection
{
	if slot.is_free
	{
		DiskSelection::FreeSpace( disk.device_path.clone(), slot.start_byte )
	}
	else
	{
		DiskSelection::Partition( disk.device_path.clone(), slot.number )
	}
}

/// One queued destructive change. Nothing in the kernel changes
/// until the user confirms `apply`.
///
/// `DeletePartition` carries the GPT/MBR partition number (not its
/// position in the list) so a queued op stays valid even if the
/// surrounding numbering shifts between display and apply.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum DiskOp
{
	WipeDisk( usize ),
	DeletePartition( usize, u32 ),
	/// Resize partition `(disk_idx, part_num)` so its on-disk size
	/// matches `new_size_bytes`. Only the partition table entry is
	/// touched — resizing the filesystem inside is *not* part of
	/// this op; `crate::fs` covers that with the per-FS userspace
	/// tools.
	ResizePartition( usize, u32, u64 ),
	/// Create a new partition entry in the table on disk `disk_idx`
	/// starting at `start_byte` with `size_bytes` bytes. The free
	/// space slot is captured at queue time so the start byte stays
	/// stable even if the user keeps interacting with the editor;
	/// the apply step picks the lowest free GPT/MBR slot index. No
	/// filesystem is created — the partition is written empty and
	/// the install step (or the user) is responsible for formatting
	/// it later.
	CreatePartition( usize, u64, u64 ),
}

/// Open form for the resize confirmation overlay. The slider's
/// granularity lives here so a draft size doesn't pollute the queue
/// until the user confirms.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub struct ResizeForm
{
	pub disk_idx:     usize,
	pub part_idx:     usize,
	pub min_bytes:    u64,
	pub max_bytes:    u64,
	pub current:      u64,
	/// Original on-disk size of the partition. Sits between
	/// `min_bytes` and `max_bytes`; the slider position relative to
	/// it tells the user whether they are shrinking or growing.
	pub original:     u64,
	/// `true` when the partition has no recognised filesystem (the
	/// raw / FS-detection-failed case). The dialog renders a
	/// data-loss warning in this state — there is no FS-aware shrink
	/// path, so a shrink here trims the table entry over whatever
	/// raw bytes happened to live in the truncated tail.
	pub is_unknown_fs: bool,
}

/// Open form for the "add partition" overlay. Captured against a
/// specific free-space slot at open time — the user-facing slider
/// only chooses the size; the start byte is fixed.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub struct AddPartitionForm
{
	pub disk_idx:   usize,
	pub start_byte: u64,
	/// Hard floor (1 MiB).
	pub min_bytes:  u64,
	/// Total bytes available in the chosen free-space slot.
	pub max_bytes:  u64,
	/// Size the user has currently dialled in. Defaults to
	/// `max_bytes` (use the whole slot).
	pub current:    u64,
}

pub fn disk_pending_wipe( ops: &[ DiskOp ], disk_idx: usize ) -> bool
{
	ops.iter().any( |op| matches!( op, DiskOp::WipeDisk( d ) if *d == disk_idx ) )
}

pub fn partition_pending_delete( ops: &[ DiskOp ], disk_idx: usize, part_num: u32 ) -> bool
{
	if disk_pending_wipe( ops, disk_idx ) { return true; }
	ops.iter().any( |op| matches!( op,
		DiskOp::DeletePartition( d, n ) if *d == disk_idx && *n == part_num ) )
}

/// New size of a partition that has a queued resize, in bytes.
/// `None` when there is no resize pending for that partition.
pub fn partition_pending_resize( ops: &[ DiskOp ], disk_idx: usize, part_num: u32 ) -> Option<u64>
{
	ops.iter().rev().find_map( |op| match op
	{
		DiskOp::ResizePartition( d, n, b ) if *d == disk_idx && *n == part_num => Some( *b ),
		_ => None,
	} )
}

/// All queued [`DiskOp::CreatePartition`] entries that land entirely
/// inside the free-space slot `[ slot_start, slot_start + slot_size )`
/// on `disk_idx`, returned as `( start, size )` pairs sorted by
/// start. The screen consults this when drawing the partition list
/// so a ghost row can be rendered for each pending create plus a
/// residual free-space row for whatever space is left between or
/// after them.
pub fn pending_creates_in_slot( ops: &[ DiskOp ], disk_idx: usize, slot_start: u64, slot_size: u64 ) -> Vec<( u64, u64 )>
{
	let slot_end = slot_start.saturating_add( slot_size );
	let mut out: Vec<( u64, u64 )> = ops.iter().filter_map( |op| match op
	{
		DiskOp::CreatePartition( d, s, sz )
			if *d == disk_idx
				&& *s >= slot_start
				&& s.saturating_add( *sz ) <= slot_end
			=> Some( ( *s, *sz ) ),
		_ => None,
	} ).collect();
	out.sort_by_key( |( s, _ )| *s );
	out
}

pub fn discover_disks() -> Vec<DiskGroup>
{
	use crate::disks;

	let mut out = Vec::new();
	for d in disks::enumerate()
	{
		let icon = classify_icon( d.bus, d.removable );
		let label = disk_head_label( &d );

		let partitions: Vec<InstallTarget> = d.partitions.iter().enumerate().map( |( i, p )|
		{
			// Max growth: distance from this slot's start to the
			// next slot's start (or to the end of the disk). Same
			// computation works for free space.
			let next_start = d.partitions.iter()
				.skip( i + 1 )
				.find( |q| !q.is_free )
				.map( |q| q.start_byte )
				.unwrap_or( d.size_bytes );
			let max_size_bytes = next_start.saturating_sub( p.start_byte );

			let label = if p.is_free
			{
				crate::i18n::tr!( "disk.free_space" ).to_string()
			}
			else
			{
				let mut s = crate::i18n::tr!( "disk.partition", num = p.number.to_string() ).to_string();
				if let Some( fs ) = &p.fs_type { s.push_str( &format!( " ({})", fs ) ); }
				s
			};

			InstallTarget
			{
				label,
				size_bytes:  p.size_bytes,
				device_path: p.path.clone(),
				number:      p.number,
				max_size_bytes,
				fs_type:     p.fs_type.clone(),
				is_free:     p.is_free,
				start_byte:  p.start_byte,
				in_extended: p.in_extended,
			}
		} ).collect();

		out.push( DiskGroup
		{
			label,
			icon,
			size_bytes:  d.size_bytes,
			device_path: d.path.clone(),
			table_type:  d.table_type.clone(),
			partitions,
		} );
	}
	out
}

fn classify_icon( bus: crate::disks::BusKind, removable: bool ) -> DiskIcon
{
	use crate::disks::BusKind;
	match bus
	{
		BusKind::Nvme   => DiskIcon::Chip,
		BusKind::Usb    => DiskIcon::Usb,
		BusKind::Mmc    => if removable { DiskIcon::SdCard } else { DiskIcon::Chip },
		BusKind::Sata   => if removable { DiskIcon::HardDrive } else { DiskIcon::HardDisk },
		BusKind::Scsi   => if removable { DiskIcon::HardDrive } else { DiskIcon::HardDisk },
		BusKind::Ide    => DiskIcon::HardDisk,
		BusKind::Virtio => DiskIcon::HardDisk,
		BusKind::Loop
		| BusKind::Md
		| BusKind::Dm
		| BusKind::Ram
		| BusKind::Pmem
		| BusKind::Other => if removable { DiskIcon::HardDrive } else { DiskIcon::HardDisk },
	}
}

fn disk_head_label( d: &crate::disks::DiskInfo ) -> String
{
	let model = d.model.trim();
	if !model.is_empty()
	{
		format!( "{} {}", d.bus.label(), model )
	} else if d.bus != crate::disks::BusKind::Other {
		format!( "{} ({})", d.bus.label(), d.path.display() )
	} else {
		d.path.display().to_string()
	}
}

// ── Account (install mode) ───────────────────────────────────────────────────

/// Form for the account screen: the image ships a fixed `liberux`
/// user that the install engine renames to this identity in the
/// target.
#[ derive( Clone, Debug ) ]
pub struct AccountForm
{
	pub username:         String,
	pub hostname:         String,
	pub password:         String,
	pub password_confirm: String,
	pub show_password:    bool,
	/// LUKS2 on the target root (whole-disk installs only). The TPM
	/// auto-unlocks at boot; without one the account password is
	/// asked instead.
	pub encrypt:          bool,
}

impl Default for AccountForm
{
	fn default() -> Self
	{
		Self
		{
			username:         String::new(),
			hostname:         "eydos".to_string(),
			password:         String::new(),
			password_confirm: String::new(),
			show_password:    false,
			encrypt:          true,
		}
	}
}

impl AccountForm
{
	pub fn username_valid( &self ) -> bool
	{
		let u = &self.username;
		!u.is_empty()
			&& u.len() <= 32
			&& u.chars().next().map_or( false, |c| c.is_ascii_lowercase() || c == '_' )
			&& u.chars().all( |c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_' )
	}

	pub fn hostname_valid( &self ) -> bool
	{
		let h = &self.hostname;
		!h.is_empty()
			&& h.len() <= 63
			&& !h.starts_with( '-' )
			&& !h.ends_with( '-' )
			&& h.chars().all( |c| c.is_ascii_alphanumeric() || c == '-' )
	}

	pub fn passwords_match( &self ) -> bool
	{
		!self.password.is_empty() && self.password == self.password_confirm
	}

	pub fn valid( &self ) -> bool
	{
		self.username_valid() && self.hostname_valid() && self.passwords_match()
	}
}

// ── Wireless ─────────────────────────────────────────────────────────────────

#[ derive( Clone, Debug ) ]
pub struct WifiNetwork
{
	pub ssid:   String,
	pub locked: bool,
	pub signal: u8, // 0..=4
}

pub fn discover_networks() -> Vec<WifiNetwork>
{
	crate::network::scan_wifi_networks()
}

/// Open form for the WPA-password prompt that fires when the user
/// taps a locked network. Holds the in-flight password value so the
/// secure `text_edit` can drive it; on confirm the password is
/// handed straight to `nmcli` and the form is dropped. The
/// `secure( true )` flag on the text edit makes the *per-frame
/// handler snapshot* wipe its plaintext on drop, which trims the
/// window during which copies of the buffer exist on the heap.
#[ derive( Clone, Debug ) ]
pub struct WifiPasswordForm
{
	pub network_idx:      usize,
	pub password:         String,
	/// `true` when the user has tapped the eye icon to reveal the
	/// password in clear text. Drives the `text_edit.secure( ... )`
	/// flag and the icon swap (`actions/visible` ↔
	/// `actions/invisible`).
	pub password_visible: bool,
}

/// Open form for the manually-added (possibly hidden) network dialog.
/// An empty password means an open network.
#[ derive( Clone, Debug, Default ) ]
pub struct ManualNetworkForm
{
	pub ssid:             String,
	pub password:         String,
	pub password_visible: bool,
}

// ── Navigation mode ──────────────────────────────────────────────────────────

#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum NavigationMode
{
	Gestures,
	ThreeButton,
}

// ── Cloud storage ────────────────────────────────────────────────────────────

#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum StorageProvider
{
	Local,
	Proton,
	Dropbox,
}

#[ derive( Clone, Default, Debug ) ]
pub struct ProtonForm
{
	pub full_name:     String,
	pub email:         String,
	pub password:      String,
	pub show_password: bool,
}

// ── Permissions ──────────────────────────────────────────────────────────────

#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum Permission
{
	Location,
	Microphone,
	Camera,
	Notifications,
}

#[ derive( Clone, Debug ) ]
pub struct Permissions
{
	pub location:      bool,
	pub microphone:    bool,
	pub camera:        bool,
	pub notifications: bool,
}

impl Default for Permissions
{
	fn default() -> Self
	{
		// Mirrors the figma mock: notifications on by default, the rest
		// off so the user must opt in.
		Self { location: false, microphone: false, camera: false, notifications: true }
	}
}

impl Permissions
{
	pub fn get( &self, p: Permission ) -> bool
	{
		match p
		{
			Permission::Location      => self.location,
			Permission::Microphone    => self.microphone,
			Permission::Camera        => self.camera,
			Permission::Notifications => self.notifications,
		}
	}

	pub fn toggle( &mut self, p: Permission )
	{
		match p
		{
			Permission::Location      => self.location      = !self.location,
			Permission::Microphone    => self.microphone    = !self.microphone,
			Permission::Camera        => self.camera        = !self.camera,
			Permission::Notifications => self.notifications = !self.notifications,
		}
	}
}

// ── Security ─────────────────────────────────────────────────────────────────

#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum SecurityMethod
{
	Pin,
	Fingerprint,
	Face,
	Pattern,
}

// ── Install progress ─────────────────────────────────────────────────────────

/// Live view of the background install worker. `slot` is shared with
/// the thread in `crate::install`; `phase` and `fraction` are the last
/// values drained from it so `view()` never has to lock.
#[ derive( Clone, Debug ) ]
pub struct InstallProgress
{
	pub slot:     crate::install::InstallSlot,
	pub phase:    crate::install::Phase,
	pub fraction: f32, // 0.0 ..= 1.0
}

// ── Aggregate state ──────────────────────────────────────────────────────────

#[ derive( Clone, Debug ) ]
pub struct State
{
	pub locale:           String,
	pub viewport:         ( u32, u32 ),
	/// `--test`: walk the wizard with disk operations, wifi connects
	/// and the install itself simulated.
	pub test_mode:        bool,
	pub mode:             WizardMode,
	/// Configure only: the account is still the image's factory
	/// `liberux` user, so the flow asks for one and stages it for the
	/// next boot.
	pub factory_account:  bool,
	/// TPM 2.0 present. Without one encryption defaults to off and
	/// the account screen warns when it is enabled.
	pub has_tpm:          bool,
	pub disks:           Vec<DiskGroup>,
	/// User's pick. `None` until they choose a disk or a partition.
	pub selection:       Option<DiskSelection>,
	/// Queued destructive operations, in insertion order. The kernel
	/// is not touched until `apply`.
	pub pending_ops:     Vec<DiskOp>,
	/// `true` while the "are you sure?" overlay is up.
	pub confirm_apply:   bool,
	/// `true` while the queue is being committed to disk. The screen
	/// shows a spinner overlay until `apply_pending_ops` returns.
	pub applying:        bool,
	/// `true` while the "data on X will be erased" overlay shown
	/// after Continue is up.
	pub confirm_install: bool,
	/// `true` while the "cancel the installation?" overlay is up on
	/// the progress screen.
	pub confirm_cancel_install: bool,
	/// Open resize form, or `None` when the overlay is closed.
	pub resize_form:     Option<ResizeForm>,
	/// Open "add partition" form, or `None` when the overlay is
	/// closed.
	pub add_partition_form: Option<AddPartitionForm>,
	/// Last operational error to surface to the user. The screen
	/// renders this as a modal overlay until they dismiss it.
	pub last_error:      Option<String>,
	pub account:          AccountForm,
	pub networks:         Vec<WifiNetwork>,
	pub selected_network: Option<usize>,
	/// Open WPA-password form, or `None` when no locked network is
	/// being configured.
	pub password_form:    Option<WifiPasswordForm>,
	/// Open manual-network form, or `None` when the dialog is closed.
	pub manual_form:      Option<ManualNetworkForm>,
	/// `true` while a wifi connect attempt is in flight. The screen
	/// swaps the password dialog for a "Connecting…" overlay and
	/// the runtime keeps polling at 100 ms so the spinner animates
	/// while the worker thread runs `nmcli connection up`.
	pub wifi_connecting:  bool,
	/// Shared result slot for the background `connect_wifi` thread.
	/// `Some( Arc )` while a connect is in flight; the worker
	/// stores `Some( Ok / Err )` into the inner `Option` once
	/// nmcli returns, and the main loop drains it on the next tick
	/// from `Msg::ConnectTick`.
	pub wifi_connect_slot: Option<WifiConnectSlot>,
	/// Last connection-attempt error (typically the trimmed nmcli
	/// stderr — wrong password, AP unreachable, timeout). Surfaced
	/// inline inside the password dialog so the user can correct the
	/// password and retry without losing context.
	pub wifi_error:       Option<String>,
	/// `true` when NetworkManager last reported `full` connectivity.
	/// Refreshed once per second from `Msg::CheckConnectivity` while
	/// the wireless screen is up; the wireless screen's "Continue"
	/// CTA gates on this so the user can only advance with a live
	/// internet connection (matched against any interface, not just
	/// the WiFi row they selected — pre-connected ethernet / LTE
	/// counts).
	pub network_online:   bool,
	pub navigation:       Option<NavigationMode>,
	pub storage:          Option<StorageProvider>,
	pub proton:           ProtonForm,
	pub permissions:      Permissions,
	pub security_method:  Option<SecurityMethod>,
	pub progress:         Option<InstallProgress>,
}

impl State
{
	pub fn new() -> Self
	{
		let has_tpm = crate::install::has_tpm2();
		let mut account = AccountForm::default();
		account.encrypt = has_tpm;
		Self
		{
			locale:           super::i18n::DEFAULT.to_string(),
			viewport:         ( 1280, 800 ),
			test_mode:        false,
			mode:             WizardMode::Install,
			factory_account:  std::path::Path::new( "/home/liberux" ).is_dir(),
			has_tpm,
			disks:           discover_disks(),
			selection:       None,
			pending_ops:     Vec::new(),
			confirm_apply:   false,
			applying:        false,
			confirm_install: false,
			confirm_cancel_install: false,
			resize_form:     None,
			add_partition_form: None,
			last_error:      None,
			account,
			networks:         discover_networks(),
			selected_network: None,
			password_form:    None,
			manual_form:      None,
			wifi_connecting:  false,
			wifi_connect_slot: None,
			wifi_error:       None,
			network_online:   crate::network::is_online(),
			navigation:       None,
			storage:          None,
			proton:           ProtonForm::default(),
			permissions:      Permissions::default(),
			security_method:  None,
			progress:         None,
		}
	}
}
