// Real installation engine.
//
// Runs on a worker thread and reports progress through a shared
// [`InstallSlot`] the UI drains on every tick. The engine partitions
// and formats the target chosen on the disk-management screen, copies
// the running system with rsync, writes fstab, and installs the
// platform's bootloader (`crate::platform`): GRUB via chroot on
// UEFI/BIOS machines, u-boot boot-area copy plus extlinux or boot.scr
// on the ARM boards.

use std::io::{ Read, Write };
use std::path::{ Path, PathBuf };
use std::process::{ Command, Stdio };
use std::sync::{ Arc, Mutex };

#[ derive( Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize ) ]
pub enum Phase
{
	Partition,
	Format,
	Copy,
	Bootloader,
	Finalize,
}

#[ derive( Clone, Debug ) ]
pub struct InstallStatus
{
	pub phase:    Phase,
	pub fraction: f32,
	pub done:     bool,
	pub error:    Option<String>,
}

pub type InstallSlot = Arc<Mutex<InstallStatus>>;

#[ derive( Clone, Debug, serde::Serialize, serde::Deserialize ) ]
pub enum TargetMode
{
	WholeDisk,
	Partition( u32 ),
	FreeSpace( u64, u64 ),
}

#[ derive( Clone, Debug, serde::Serialize, serde::Deserialize ) ]
pub struct AccountSetup
{
	pub username: String,
	pub hostname: String,
	pub password: String,
}

#[ derive( Clone, Debug, serde::Serialize, serde::Deserialize ) ]
pub struct InstallPlan
{
	pub disk:    PathBuf,
	pub mode:    TargetMode,
	pub locale:  String,
	pub account: Option<AccountSetup>,
	/// LUKS2 root, TPM auto-unlock when available, account password
	/// as fallback passphrase. Partition and free-space targets are
	/// split into an unencrypted /boot plus the LUKS root.
	pub encrypt: bool,
	/// Simulate every step instead of touching the disk.
	pub test:    bool,
}

const MOUNT_ROOT: &str = "/run/eydos-install/root";

/// Lifecycle markers under /var/lib/eydos-installer. The image build
/// seeds `install-pending` on the live medium; the engine swaps it for
/// `configure-pending` in the target; finishing the Configure flow
/// swaps that for `configured`. The systemd user unit launches the
/// wizard while either *-pending marker exists.
pub const INSTALL_PENDING:   &str = "/var/lib/eydos-installer/install-pending";
pub const CONFIGURE_PENDING: &str = "/var/lib/eydos-installer/configure-pending";
pub const CONFIGURED:        &str = "/var/lib/eydos-installer/configured";

const MIB: u64 = 1024 * 1024;
const ESP_START_X86: u64 = 2 * MIB;
const ESP_START_ARM: u64 = MIB;
const ESP_SIZE: u64 = 512 * MIB;
/// Unencrypted /boot for encrypted installs: the TPM unlock happens
/// in the initramfs, so GRUB must load kernel and initrd in the clear.
const BOOT_SIZE: u64 = 768 * MIB;
/// First partition on u-boot disks, matching the images' `32768s`; the
/// gap holds the raw boot loader (`crate::platform::copy_boot_area`).
const UBOOT_AREA_SIZE: u64 = 16 * MIB;
const LUKS_NAME: &str = "eydos_crypt";

// Mixed-endian GPT type GUIDs.
const GUID_BIOS_GRUB: [ u8; 16 ] = [
	0x48, 0x61, 0x68, 0x21, 0x49, 0x64, 0x6F, 0x6E,
	0x74, 0x4E, 0x65, 0x65, 0x64, 0x45, 0x46, 0x49,
];
const GUID_ESP: [ u8; 16 ] = [
	0x28, 0x73, 0x2A, 0xC1, 0x1F, 0xF8, 0xD2, 0x11,
	0xBA, 0x4B, 0x00, 0xA0, 0xC9, 0x3E, 0xC9, 0x3B,
];
const GUID_LINUX_FS: [ u8; 16 ] = [
	0xAF, 0x3D, 0xC6, 0x0F, 0x83, 0x84, 0x72, 0x47,
	0x8E, 0x79, 0x3D, 0x69, 0xD8, 0x47, 0x7D, 0xE4,
];

/// Build the plan from the wizard state, or explain why it cannot be
/// built (nothing selected, target is the running medium, target too
/// small). Runs in the wizard process, so errors are localized here.
pub fn plan_from( state: &crate::state::State ) -> Result<InstallPlan, String>
{
	use crate::i18n::tr;
	use crate::state::DiskSelection;

	let selection = state.selection.as_ref()
		.ok_or_else( || tr!( "error.no_target" ).to_string() )?;
	let disk = selection.disk_path().to_path_buf();

	if !state.test_mode
	{
		if let Some( live ) = running_system_disk()
		{
			if live == disk
			{
				return Err( tr!( "error.target_is_live", disk = disk.display().to_string() ).to_string() );
			}
		}
	}

	let mode = match selection
	{
		DiskSelection::Disk( _ ) => TargetMode::WholeDisk,
		DiskSelection::Partition( _, num ) => TargetMode::Partition( *num ),
		DiskSelection::FreeSpace( _, start ) =>
		{
			let size = state.disks.iter()
				.find( |d| d.device_path == disk )
				.and_then( |d| d.partitions.iter()
					.find( |p| p.is_free && p.start_byte == *start ) )
				.map( |p| p.size_bytes )
				.ok_or_else( || tr!( "error.free_space_gone" ).to_string() )?;
			TargetMode::FreeSpace( *start, size )
		}
	};

	let encrypt = state.account.encrypt;

	// MBR primaries are capped at four; the encrypted split needs the
	// target's slot plus one more (partition mode) or two free slots
	// (free-space mode). Refuse before anything touches the table.
	if encrypt
	{
		if let Some( group ) = state.disks.iter().find( |d| d.device_path == disk )
		{
			if group.table_type.as_deref() == Some( "msdos" )
			{
				// Targets inside the extended partition become logical
				// partitions and consume no primary slots.
				let target_in_ext = match &mode
				{
					TargetMode::WholeDisk => false,
					TargetMode::Partition( num ) =>
						group.partitions.iter().any( |p| !p.is_free && p.number == *num && p.in_extended ),
					TargetMode::FreeSpace( start, _ ) =>
						group.partitions.iter().any( |p| p.is_free && p.start_byte == *start && p.in_extended ),
				};
				let used = group.partitions.iter().filter( |p| !p.is_free && !p.in_extended ).count()
					+ if group.partitions.iter().any( |p| p.in_extended ) { 1 } else { 0 };
				let fits = target_in_ext || match mode
				{
					TargetMode::WholeDisk       => true,
					TargetMode::Partition( _ )  => used <= 3,
					TargetMode::FreeSpace( .. ) => used <= 2,
				};
				if !fits
				{
					return Err( tr!( "error.mbr_full", disk = disk.display().to_string() ).to_string() );
				}
			}
		}
	}

	if let ( Some( needed ), Some( mut capacity ) ) = ( required_target_bytes(), target_capacity_bytes( state, &mode, &disk ) )
	{
		if encrypt
		{
			capacity = capacity.saturating_sub( BOOT_SIZE + 32 * MIB );
		}
		if capacity < needed
		{
			return Err( tr!(
				"error.target_too_small",
				have = format!( "{:.1}", capacity as f64 / 1e9 ),
				need = format!( "{:.1}", needed as f64 / 1e9 ) ).to_string() );
		}
	}

	let account = if state.account.valid()
	{
		Some( AccountSetup
		{
			username: state.account.username.clone(),
			hostname: state.account.hostname.clone(),
			password: state.account.password.clone(),
		} )
	} else { None };

	Ok( InstallPlan { disk, mode, locale: state.locale.clone(), account, encrypt, test: state.test_mode } )
}

/// Bytes the copied system will need on the target: current usage of
/// the running root plus 10% and 1 GiB of headroom.
pub fn required_target_bytes() -> Option<u64>
{
	let used = df_bytes( Path::new( "/" ), "used" )?;
	Some( used + used / 10 + 1024 * MIB )
}

fn target_capacity_bytes( state: &crate::state::State, mode: &TargetMode, disk: &Path ) -> Option<u64>
{
	let group = state.disks.iter().find( |d| d.device_path == *disk )?;
	match mode
	{
		TargetMode::WholeDisk => Some( group.size_bytes.saturating_sub( ESP_SIZE + 2 * MIB ) ),
		TargetMode::Partition( num ) => group.partitions.iter()
			.find( |p| !p.is_free && p.number == *num )
			.map( |p| p.size_bytes ),
		TargetMode::FreeSpace( start, _ ) => group.partitions.iter()
			.find( |p| p.is_free && p.start_byte == *start )
			.map( |p| p.size_bytes ),
	}
}

fn df_bytes( path: &Path, column: &str ) -> Option<u64>
{
	let out = Command::new( "df" )
		.arg( "-B1" )
		.arg( format!( "--output={column}" ) )
		.arg( path )
		.output().ok()?;
	if !out.status.success() { return None; }
	String::from_utf8_lossy( &out.stdout ).lines().nth( 1 )?.trim().parse().ok()
}

static CANCEL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new( false );

/// Ask the in-process engine (real or simulated) to abort at the next
/// checkpoint; a running rsync is killed. The engine ends with an
/// "installation cancelled" error.
pub fn request_cancel()
{
	CANCEL.store( true, std::sync::atomic::Ordering::SeqCst );
}

fn cancelled() -> bool
{
	CANCEL.load( std::sync::atomic::Ordering::SeqCst )
}

// Token the UI maps onto a localized message (`app::localize_error`).
fn check_cancel() -> Result<(), String>
{
	if cancelled() { Err( "cancelled".to_string() ) } else { Ok( () ) }
}

/// Spawn the worker thread. The returned slot is updated as the
/// install advances; `done` or `error` marks the end.
pub fn start( plan: InstallPlan ) -> InstallSlot
{
	CANCEL.store( false, std::sync::atomic::Ordering::SeqCst );
	let slot: InstallSlot = Arc::new( Mutex::new( InstallStatus
	{
		phase:    Phase::Partition,
		fraction: 0.0,
		done:     false,
		error:    None,
	} ) );

	let slot_thread = slot.clone();
	std::thread::spawn( move ||
	{
		let result = if plan.test
		{
			run_simulated( &slot_thread )
		}
		else
		{
			let mut mounts: Vec<PathBuf> = Vec::new();
			let result = run_install( &plan, &slot_thread, &mut mounts );
			cleanup_mounts( &mut mounts );
			if plan.encrypt
			{
				close_luks();
			}
			result
		};
		if let Ok( mut status ) = slot_thread.lock()
		{
			match result
			{
				Ok( () ) =>
				{
					status.fraction = 1.0;
					status.done     = true;
				}
				Err( e ) => status.error = Some( e ),
			}
		}
	} );
	slot
}

/// Test mode: walk the phases on a timer so the progress screen can be
/// exercised end to end without any side effect.
fn run_simulated( slot: &InstallSlot ) -> Result<(), String>
{
	use std::time::Duration;

	set_status( slot, Phase::Partition, 0.01 );
	std::thread::sleep( Duration::from_millis( 800 ) );
	check_cancel()?;
	set_status( slot, Phase::Format, 0.05 );
	std::thread::sleep( Duration::from_millis( 800 ) );
	for pct in ( 10..=85 ).step_by( 5 )
	{
		check_cancel()?;
		set_status( slot, Phase::Copy, pct as f32 / 100.0 );
		std::thread::sleep( Duration::from_millis( 300 ) );
	}
	set_status( slot, Phase::Bootloader, 0.90 );
	std::thread::sleep( Duration::from_millis( 1_200 ) );
	check_cancel()?;
	set_status( slot, Phase::Finalize, 0.96 );
	std::thread::sleep( Duration::from_millis( 800 ) );
	Ok( () )
}

fn set_status( slot: &InstallSlot, phase: Phase, fraction: f32 )
{
	if let Ok( mut status ) = slot.lock()
	{
		status.phase    = phase;
		status.fraction = fraction;
	}
}

fn run_install( plan: &InstallPlan, slot: &InstallSlot, mounts: &mut Vec<PathBuf> ) -> Result<(), String>
{
	set_status( slot, Phase::Partition, 0.01 );
	let layout = prepare_partitions( plan )?;
	check_target_capacity( &layout.root )?;
	check_cancel()?;

	set_status( slot, Phase::Format, 0.05 );
	format_partitions( &layout )?;
	check_cancel()?;

	set_status( slot, Phase::Copy, 0.10 );
	let target = Path::new( MOUNT_ROOT );
	std::fs::create_dir_all( target ).map_err( |e| format!( "mkdir {}: {e}", target.display() ) )?;
	mount( &layout.root, target, &[], mounts )?;
	if let Some( boot ) = &layout.boot
	{
		let boot_dir = target.join( "boot" );
		std::fs::create_dir_all( &boot_dir ).map_err( |e| format!( "mkdir {}: {e}", boot_dir.display() ) )?;
		mount( boot, &boot_dir, &[], mounts )?;
	}
	copy_system( target, slot )?;
	check_cancel()?;

	if let Some( esp ) = &layout.esp
	{
		let efi_dir = target.join( "boot/efi" );
		std::fs::create_dir_all( &efi_dir ).map_err( |e| format!( "mkdir {}: {e}", efi_dir.display() ) )?;
		mount( esp, &efi_dir, &[], mounts )?;
	}

	set_status( slot, Phase::Bootloader, 0.85 );
	let flavor = crate::platform::detect();
	write_fstab( target, &layout )?;
	reset_machine_id( target );
	mount_chroot_aux( target, mounts )?;
	install_bootloader( plan, target, &layout, flavor )?;
	if flavor == crate::platform::BootFlavor::Grub
	{
		enable_os_prober( target );
		run_ok( Command::new( "chroot" ).arg( target ).args( [ "update-grub" ] ) )?;
	}
	set_status( slot, Phase::Bootloader, 0.92 );
	if let Some( part ) = &layout.luks_part
	{
		if !clevis_is_bound( part )
		{
			drop_clevis_initramfs( target );
		}
	}
	if let Err( e ) = run_ok( Command::new( "chroot" ).arg( target ).args( [ "update-initramfs", "-u" ] ) )
	{
		eprintln!( "install: update-initramfs: {e}" );
	}
	if flavor == crate::platform::BootFlavor::UBootScript
	{
		if let Err( e ) = crate::platform::regen_uinitrd( target )
		{
			eprintln!( "install: uInitrd: {e}" );
		}
	}

	set_status( slot, Phase::Finalize, 0.96 );
	apply_locale( target, &plan.locale );
	if let Some( account ) = &plan.account
	{
		apply_account( target, account )?;
	}
	setup_swapfile( target );
	regenerate_ssh_host_keys( target );
	mark_configure_pending( target );
	cleanup_mounts( mounts );
	if layout.luks_part.is_some()
	{
		close_luks();
	}
	run_ok( &mut Command::new( "sync" ) ).ok();
	Ok( () )
}

fn check_target_capacity( root: &Path ) -> Result<(), String>
{
	let ( Some( needed ), Some( size ) ) = ( required_target_bytes(), block_size_bytes( root ) )
	else { return Ok( () ) };
	if size < needed
	{
		return Err( format!(
			"{} has {:.1} GB but the system needs at least {:.1} GB",
			root.display(),
			size as f64 / 1e9,
			needed as f64 / 1e9 ) );
	}
	Ok( () )
}

fn block_size_bytes( dev: &Path ) -> Option<u64>
{
	let name = dev.file_name()?.to_str()?;
	let s = std::fs::read_to_string( format!( "/sys/class/block/{name}/size" ) ).ok()?;
	s.trim().parse::<u64>().ok().map( |v| v * 512 )
}

// ── Partitioning ────────────────────────────────────────────────────────────

struct TargetLayout
{
	/// Filesystem device for the root: a plain partition, or the
	/// opened LUKS mapper when encrypted.
	root:          PathBuf,
	esp:           Option<PathBuf>,
	/// `true` when the ESP was freshly created and needs mkfs.
	esp_is_new:    bool,
	/// Dedicated /boot partition (encrypted installs only).
	boot:          Option<PathBuf>,
	/// Raw partition holding the LUKS container, for crypttab.
	luks_part:     Option<PathBuf>,
	bios_bootable: bool,
}

fn prepare_partitions( plan: &InstallPlan ) -> Result<TargetLayout, String>
{
	// Encryption needs a passphrase; without an account it is
	// silently skipped (test walkthroughs, dev runs).
	let passphrase = if plan.encrypt
	{
		plan.account.as_ref().map( |a| a.password.as_str() )
	} else { None };

	match plan.mode
	{
		TargetMode::WholeDisk => partition_whole_disk( &plan.disk, passphrase ),
		TargetMode::Partition( num ) =>
		{
			let root = crate::disks::part_path( &plan.disk, num );
			unmount_related( &root );
			if let Some( passphrase ) = passphrase
			{
				// The single selected partition cannot hold both the
				// unencrypted /boot and the LUKS root: replace it with
				// two partitions covering the same region. Inside the
				// extended partition the replacements are logical and
				// need no primary slot; outside, the freed slot hosts
				// /boot so one more must be free — checked BEFORE
				// deleting anything so a full MBR table fails with the
				// target partition intact.
				let ( start, size ) = crate::disks::partition_geometry( &plan.disk, num )
					.ok_or_else( || format!( "partition {num} not found on {}", plan.disk.display() ) )?;
				let in_ext = crate::disks::mbr_extended_range( &plan.disk )
					.map_or( false, |( es, ez )| start >= es && start + size <= es + ez );
				if !in_ext
					&& crate::disks::mbr_primary_free_slots( &plan.disk ).map_or( false, |free| free < 1 )
				{
					return Err( "the MBR partition table is full; encryption needs room for a /boot partition".to_string() );
				}
				crate::disks::delete_partitions( &plan.disk, &[ num ] )?;
				return split_region_encrypted( &plan.disk, start, size, passphrase );
			}
			Ok( TargetLayout
			{
				root,
				esp:           find_esp( &plan.disk ),
				esp_is_new:    false,
				boot:          None,
				luks_part:     None,
				bios_bootable: bios_bootable( &plan.disk ),
			} )
		}
		TargetMode::FreeSpace( start, size ) =>
		{
			let in_ext = crate::disks::mbr_extended_range( &plan.disk )
				.map_or( false, |( es, ez )| start >= es && start + size <= es + ez );
			if let Some( passphrase ) = passphrase
			{
				if !in_ext
					&& crate::disks::mbr_primary_free_slots( &plan.disk ).map_or( false, |free| free < 2 )
				{
					return Err( "the MBR partition table is full; encryption needs room for a /boot partition".to_string() );
				}
				return split_region_encrypted( &plan.disk, start, size, passphrase );
			}
			let num = crate::disks::create_partition( &plan.disk, start, size )?;
			reread_partitions( &plan.disk );
			let root = crate::disks::part_path( &plan.disk, num );
			wait_for_device( &root )?;
			Ok( TargetLayout
			{
				root,
				esp:           find_esp( &plan.disk ),
				esp_is_new:    false,
				boot:          None,
				luks_part:     None,
				bios_bootable: bios_bootable( &plan.disk ),
			} )
		}
	}
}

/// Carve an unencrypted /boot plus a LUKS root out of the region
/// `[ start, start + size )` — used when the install target is a
/// partition or a free-space slot rather than the whole disk. On MBR
/// this needs a free primary slot; the table write fails cleanly when
/// all four are taken.
fn split_region_encrypted( disk: &Path, start: u64, size: u64, passphrase: &str ) -> Result<TargetLayout, String>
{
	if size <= BOOT_SIZE + 32 * MIB
	{
		return Err( "target region too small for an encrypted install".to_string() );
	}
	let boot_num = crate::disks::create_partition( disk, start, BOOT_SIZE )?;
	let root_num = crate::disks::create_partition( disk, start + BOOT_SIZE, size - BOOT_SIZE )?;
	reread_partitions( disk );

	let boot      = crate::disks::part_path( disk, boot_num );
	let root_part = crate::disks::part_path( disk, root_num );
	wait_for_device( &boot )?;
	wait_for_device( &root_part )?;

	let mapper = setup_luks( &root_part, passphrase )?;
	Ok( TargetLayout
	{
		root:          mapper,
		esp:           find_esp( disk ),
		esp_is_new:    false,
		boot:          Some( boot ),
		luks_part:     Some( root_part ),
		bios_bootable: bios_bootable( disk ),
	} )
}

/// Wipe the disk and lay out a fresh GPT. On GRUB platforms: on x86 a
/// bios_grub slot, an ESP and a root partition. On u-boot platforms:
/// no ESP, first partition at [`UBOOT_AREA_SIZE`] so the raw boot area
/// stays free for the loader. With a passphrase a /boot partition is
/// added and the root becomes a LUKS2 container opened as
/// [`LUKS_NAME`].
fn partition_whole_disk( disk: &Path, passphrase: Option<&str> ) -> Result<TargetLayout, String>
{
	if crate::platform::detect() != crate::platform::BootFlavor::Grub
	{
		return partition_whole_disk_uboot( disk, passphrase );
	}
	let is_x86 = cfg!( target_arch = "x86_64" ) || cfg!( target_arch = "x86" );
	let encrypt = passphrase.is_some();

	unmount_related( disk );
	crate::disks::wipe_disk( disk )?;

	let mut file = std::fs::OpenOptions::new()
		.read( true )
		.write( true )
		.open( disk )
		.map_err( |e| format!( "open {}: {e}", disk.display() ) )?;

	let sector = crate::disks::logical_block_size( disk ) as u64;
	let mut gpt = gptman::GPT::new_from( &mut file, sector, crate::disks::gen_unique_guid() )
		.map_err( |e| format!( "new gpt on {}: {e:?}", disk.display() ) )?;

	let esp_start = if is_x86 { ESP_START_X86 } else { ESP_START_ARM } / sector;
	let esp_end   = esp_start + ESP_SIZE / sector - 1;
	let boot_end  = esp_end + BOOT_SIZE / sector;
	let root_start = if encrypt { boot_end + 1 } else { esp_end + 1 };
	let root_end   = gpt.header.last_usable_lba;
	if root_start >= root_end
	{
		return Err( format!( "{} is too small for an install", disk.display() ) );
	}

	let entry = |type_guid: [ u8; 16 ], start: u64, end: u64, name: &str| gptman::GPTPartitionEntry
	{
		partition_type_guid:   type_guid,
		unique_partition_guid: crate::disks::gen_unique_guid(),
		starting_lba:          start,
		ending_lba:            end,
		attribute_bits:        0,
		partition_name:        name.into(),
	};

	gpt[ 1 ] = entry( GUID_ESP, esp_start, esp_end, "esp" );
	let ( root_num, bios_num ) = if encrypt
	{
		gpt[ 2 ] = entry( GUID_LINUX_FS, esp_end + 1, boot_end, "boot" );
		gpt[ 3 ] = entry( GUID_LINUX_FS, root_start, root_end, "root" );
		( 3, 4 )
	}
	else
	{
		gpt[ 2 ] = entry( GUID_LINUX_FS, root_start, root_end, "root" );
		( 2, 3 )
	};
	if is_x86
	{
		gpt[ bios_num ] = entry( GUID_BIOS_GRUB, MIB / sector, ESP_START_X86 / sector - 1, "bios_grub" );
	}

	gptman::GPT::write_protective_mbr_into( &mut file, sector )
		.map_err( |e| format!( "protective mbr on {}: {e:?}", disk.display() ) )?;
	gpt.write_into( &mut file )
		.map_err( |e| format!( "gpt write on {}: {e:?}", disk.display() ) )?;
	file.sync_all().ok();
	drop( file );

	reread_partitions( disk );
	let esp       = crate::disks::part_path( disk, 1 );
	let root_part = crate::disks::part_path( disk, root_num );
	wait_for_device( &root_part )?;
	wait_for_device( &esp )?;

	let ( root, boot, luks_part ) = if let Some( passphrase ) = passphrase
	{
		let boot = crate::disks::part_path( disk, 2 );
		wait_for_device( &boot )?;
		let mapper = setup_luks( &root_part, passphrase )?;
		( mapper, Some( boot ), Some( root_part ) )
	}
	else
	{
		( root_part, None, None )
	};

	Ok( TargetLayout
	{
		root,
		esp:           Some( esp ),
		esp_is_new:    true,
		boot,
		luks_part,
		bios_bootable: is_x86,
	} )
}

fn partition_whole_disk_uboot( disk: &Path, passphrase: Option<&str> ) -> Result<TargetLayout, String>
{
	let encrypt = passphrase.is_some();

	unmount_related( disk );
	crate::disks::wipe_disk( disk )?;

	let mut file = std::fs::OpenOptions::new()
		.read( true )
		.write( true )
		.open( disk )
		.map_err( |e| format!( "open {}: {e}", disk.display() ) )?;

	let sector = crate::disks::logical_block_size( disk ) as u64;
	let mut gpt = gptman::GPT::new_from( &mut file, sector, crate::disks::gen_unique_guid() )
		.map_err( |e| format!( "new gpt on {}: {e:?}", disk.display() ) )?;

	let first      = UBOOT_AREA_SIZE / sector;
	let root_start = if encrypt { first + BOOT_SIZE / sector } else { first };
	let root_end   = gpt.header.last_usable_lba;
	if root_start >= root_end
	{
		return Err( format!( "{} is too small for an install", disk.display() ) );
	}

	let entry = |start: u64, end: u64, name: &str| gptman::GPTPartitionEntry
	{
		partition_type_guid:   GUID_LINUX_FS,
		unique_partition_guid: crate::disks::gen_unique_guid(),
		starting_lba:          start,
		ending_lba:            end,
		attribute_bits:        0,
		partition_name:        name.into(),
	};

	let root_num = if encrypt
	{
		gpt[ 1 ] = entry( first, root_start - 1, "boot" );
		gpt[ 2 ] = entry( root_start, root_end, "root" );
		2
	}
	else
	{
		gpt[ 1 ] = entry( root_start, root_end, "root" );
		1
	};

	gptman::GPT::write_protective_mbr_into( &mut file, sector )
		.map_err( |e| format!( "protective mbr on {}: {e:?}", disk.display() ) )?;
	gpt.write_into( &mut file )
		.map_err( |e| format!( "gpt write on {}: {e:?}", disk.display() ) )?;
	file.sync_all().ok();
	drop( file );

	reread_partitions( disk );
	let root_part = crate::disks::part_path( disk, root_num );
	wait_for_device( &root_part )?;

	let ( root, boot, luks_part ) = if let Some( passphrase ) = passphrase
	{
		let boot = crate::disks::part_path( disk, 1 );
		wait_for_device( &boot )?;
		let mapper = setup_luks( &root_part, passphrase )?;
		( mapper, Some( boot ), Some( root_part ) )
	}
	else
	{
		( root_part, None, None )
	};

	Ok( TargetLayout
	{
		root,
		esp:           None,
		esp_is_new:    false,
		boot,
		luks_part,
		bios_bootable: false,
	} )
}

/// LUKS2-format `part` with `passphrase`, open it as [`LUKS_NAME`],
/// and bind the slot to the TPM via clevis so boot unlocks without a
/// prompt. No TPM (or a failed bind) degrades to the passphrase
/// prompt, never to an error.
fn setup_luks( part: &Path, passphrase: &str ) -> Result<PathBuf, String>
{
	run_with_stdin(
		Command::new( "cryptsetup" )
			.args( [ "luksFormat", "--type", "luks2", "--batch-mode", "--key-file", "-" ] )
			.arg( part ),
		passphrase,
	)?;
	run_with_stdin(
		Command::new( "cryptsetup" )
			.args( [ "open", "--key-file", "-" ] )
			.arg( part )
			.arg( LUKS_NAME ),
		passphrase,
	)?;
	let mapper = PathBuf::from( format!( "/dev/mapper/{LUKS_NAME}" ) );
	wait_for_device( &mapper )?;

	if has_tpm2()
	{
		// PCR 7 (Secure Boot state): stable across kernel and GRUB
		// updates, changes if the firmware boot policy is tampered.
		if let Err( e ) = run_with_stdin(
			Command::new( "clevis" )
				.args( [ "luks", "bind", "-f", "-k", "-", "-d" ] )
				.arg( part )
				.args( [ "tpm2", "{\"pcr_ids\":\"7\"}" ] ),
			passphrase,
		)
		{
			eprintln!( "install: TPM bind failed, boot will ask for the password: {e}" );
		}
	}
	else
	{
		eprintln!( "install: no TPM 2.0 present, boot will ask for the password" );
	}
	Ok( mapper )
}

/// `/dev/tpm0` alone is not enough: TPM 1.2 chips expose it too and the
/// clevis tpm2 pin cannot use them. The resource-manager node and the
/// sysfs version are only present for 2.0.
pub( crate ) fn has_tpm2() -> bool
{
	if Path::new( "/dev/tpmrm0" ).exists()
	{
		return true;
	}
	std::fs::read_to_string( "/sys/class/tpm/tpm0/tpm_version_major" )
		.map( |v| v.trim() == "2" )
		.unwrap_or( false )
}

pub( crate ) fn clevis_is_bound( part: &Path ) -> bool
{
	Command::new( "clevis" )
		.args( [ "luks", "list", "-d" ] )
		.arg( part )
		.output()
		.map( |o| !String::from_utf8_lossy( &o.stdout ).trim().is_empty() )
		.unwrap_or( false )
}

/// The clevis initramfs hook installs itself whether or not anything is
/// bound, and its unlock loop then retries every five seconds for as
/// long as the passphrase prompt is up, running cryptsetup and scanning
/// /proc each round to unlock a device nothing can unlock.
pub( crate ) fn drop_clevis_initramfs( target: &Path )
{
	eprintln!( "install: nothing bound to a TPM, removing the clevis initramfs hook" );
	if let Err( e ) = run_ok(
		Command::new( "chroot" ).arg( target )
			.args( [ "apt-get", "-y", "--purge", "remove", "clevis-initramfs" ] ),
	)
	{
		eprintln!( "install: could not remove clevis-initramfs: {e}" );
	}
}

fn close_luks()
{
	run_ok( Command::new( "cryptsetup" ).args( [ "close", LUKS_NAME ] ) ).ok();
}

fn format_partitions( layout: &TargetLayout ) -> Result<(), String>
{
	run_ok( Command::new( "mkfs.ext4" ).args( [ "-F", "-q", "-L", "eydos-root" ] ).arg( &layout.root ) )?;
	if let Some( boot ) = &layout.boot
	{
		run_ok( Command::new( "mkfs.ext4" ).args( [ "-F", "-q", "-L", "eydos-boot" ] ).arg( boot ) )?;
	}
	if layout.esp_is_new
	{
		if let Some( esp ) = &layout.esp
		{
			run_ok( Command::new( "mkfs.vfat" ).args( [ "-F", "32", "-n", "EFI" ] ).arg( esp ) )?;
		}
	}
	Ok( () )
}

/// First ESP-typed partition on the disk (GPT type GUID, or MBR type
/// 0xEF), as an existing device path.
fn find_esp( disk: &Path ) -> Option<PathBuf>
{
	let mut file = std::fs::File::open( disk ).ok()?;
	if let Ok( gpt ) = gptman::GPT::find_from( &mut file )
	{
		for ( i, p ) in gpt.iter()
		{
			if p.is_used() && p.partition_type_guid == GUID_ESP
			{
				return Some( crate::disks::part_path( disk, i as u32 ) );
			}
		}
		return None;
	}
	use std::io::Seek;
	file.seek( std::io::SeekFrom::Start( 0 ) ).ok()?;
	let sector = crate::disks::logical_block_size( disk );
	if let Ok( mbr ) = mbrman::MBR::read_from( &mut file, sector )
	{
		for ( i, p ) in mbr.iter()
		{
			if p.is_used() && p.sys == 0xEF
			{
				return Some( crate::disks::part_path( disk, i as u32 ) );
			}
		}
	}
	None
}

/// A GPT disk needs a bios_grub partition for the i386-pc target; an
/// MBR disk embeds core.img in the post-MBR gap.
fn bios_bootable( disk: &Path ) -> bool
{
	let Ok( mut file ) = std::fs::File::open( disk ) else { return false };
	if let Ok( gpt ) = gptman::GPT::find_from( &mut file )
	{
		return gpt.iter().any( |( _, p )| p.is_used() && p.partition_type_guid == GUID_BIOS_GRUB );
	}
	use std::io::Seek;
	if file.seek( std::io::SeekFrom::Start( 0 ) ).is_err() { return false; }
	let sector = crate::disks::logical_block_size( disk );
	mbrman::MBR::read_from( &mut file, sector ).is_ok()
}

fn reread_partitions( disk: &Path )
{
	if let Err( e ) = run_ok( Command::new( "blockdev" ).arg( "--rereadpt" ).arg( disk ) )
	{
		eprintln!( "install: rereadpt {}: {e}", disk.display() );
	}
}

fn wait_for_device( path: &Path ) -> Result<(), String>
{
	for _ in 0..50
	{
		if path.exists() { return Ok( () ); }
		std::thread::sleep( std::time::Duration::from_millis( 200 ) );
	}
	Err( format!( "{} did not appear after partitioning", path.display() ) )
}

// ── System copy ─────────────────────────────────────────────────────────────

/// rsync the running root into the target, mapping its `--info=progress2`
/// percentage onto the 0.10‥0.85 span of the overall bar.
fn copy_system( target: &Path, slot: &InstallSlot ) -> Result<(), String>
{
	let mut cmd = Command::new( "rsync" );
	cmd.args( [
		"-aHAXx",
		"--numeric-ids",
		"--no-inc-recursive",
		"--info=progress2",
	] );
	for ex in [ "/dev/*", "/proc/*", "/sys/*", "/run/*", "/tmp/*", "/var/tmp/*", "/mnt/*", "/media/*", "/boot/efi/*", "/lost+found" ]
	{
		cmd.arg( "--exclude" ).arg( ex );
	}
	cmd.arg( "/" ).arg( format!( "{}/", target.display() ) );
	cmd.stdout( Stdio::piped() ).stderr( Stdio::piped() );

	let mut child = cmd.spawn().map_err( |e| format!( "spawn rsync: {e}" ) )?;

	let stderr = child.stderr.take();
	let stderr_thread = std::thread::spawn( move ||
	{
		let mut buf = String::new();
		if let Some( mut err ) = stderr { err.read_to_string( &mut buf ).ok(); }
		buf
	} );

	if let Some( stdout ) = child.stdout.take()
	{
		let mut line = String::new();
		for byte in stdout.bytes().flatten()
		{
			if byte == b'\r' || byte == b'\n'
			{
				if let Some( pct ) = parse_rsync_percent( &line )
				{
					set_status( slot, Phase::Copy, 0.10 + 0.75 * ( pct as f32 / 100.0 ) );
				}
				line.clear();
				if cancelled()
				{
					let _ = child.kill();
					break;
				}
			}
			else
			{
				line.push( byte as char );
			}
		}
	}

	let status = child.wait().map_err( |e| format!( "wait rsync: {e}" ) )?;
	let errors = stderr_thread.join().unwrap_or_default();
	if cancelled()
	{
		return Err( "cancelled".to_string() );
	}
	// 23/24 = partial transfer (vanished or unreadable files) — routine
	// on a live system and not fatal.
	match status.code()
	{
		Some( 0 ) | Some( 23 ) | Some( 24 ) => Ok( () ),
		code =>
		{
			let tail: String = errors.lines().rev().take( 5 ).collect::<Vec<_>>().into_iter().rev()
				.collect::<Vec<_>>().join( "\n" );
			Err( format!( "rsync failed ({code:?}): {tail}" ) )
		}
	}
}

fn parse_rsync_percent( line: &str ) -> Option<u32>
{
	let idx = line.find( '%' )?;
	line[ ..idx ].split_whitespace().last()?.parse().ok()
}

// ── Configuration inside the target ─────────────────────────────────────────

fn write_fstab( target: &Path, layout: &TargetLayout ) -> Result<(), String>
{
	let root_uuid = blkid_uuid( &layout.root )
		.ok_or_else( || format!( "no UUID for {}", layout.root.display() ) )?;
	let mut fstab = format!( "UUID={root_uuid}\t/\text4\terrors=remount-ro\t0\t1\n" );
	if let Some( boot ) = &layout.boot
	{
		if let Some( boot_uuid ) = blkid_uuid( boot )
		{
			fstab.push_str( &format!( "UUID={boot_uuid}\t/boot\text4\tdefaults\t0\t2\n" ) );
		}
	}
	if let Some( esp ) = &layout.esp
	{
		if let Some( esp_uuid ) = blkid_uuid( esp )
		{
			fstab.push_str( &format!( "UUID={esp_uuid}\t/boot/efi\tvfat\tumask=0077\t0\t1\n" ) );
		}
	}
	std::fs::write( target.join( "etc/fstab" ), fstab )
		.map_err( |e| format!( "write fstab: {e}" ) )?;

	// crypttab must exist before update-initramfs so the cryptroot
	// hook (and clevis-initramfs) pick the container up.
	if let Some( luks ) = &layout.luks_part
	{
		let luks_uuid = blkid_uuid( luks )
			.ok_or_else( || format!( "no UUID for {}", luks.display() ) )?;
		std::fs::write(
			target.join( "etc/crypttab" ),
			format!( "{LUKS_NAME}\tUUID={luks_uuid}\tnone\t{}\n", crypttab_options( target ) ),
		).map_err( |e| format!( "write crypttab: {e}" ) )?;
	}
	Ok( () )
}

pub( crate ) const UNL0KR_KEYSCRIPT: &str = "/usr/share/initramfs-tools/scripts/unl0kr-keyscript";

/// Without a TPM the passphrase is typed at every boot; when unl0kr is
/// in the target its keyscript gives that prompt an on-screen keyboard
/// (the cryptroot hook copies keyscripts into the initramfs).
pub( crate ) fn crypttab_options( target: &Path ) -> String
{
	let mut options = "luks,discard".to_string();
	if !has_tpm2() && target.join( UNL0KR_KEYSCRIPT.trim_start_matches( '/' ) ).exists()
	{
		options.push_str( &format!( ",keyscript={UNL0KR_KEYSCRIPT}" ) );
	}
	options
}

fn blkid_uuid( path: &Path ) -> Option<String>
{
	let out = Command::new( "blkid" )
		.args( [ "-o", "value", "-s", "UUID" ] )
		.arg( path )
		.output().ok()?;
	if !out.status.success() { return None; }
	let s = String::from_utf8_lossy( &out.stdout ).trim().to_string();
	if s.is_empty() { None } else { Some( s ) }
}

/// Empty machine-id so systemd regenerates one on the installed
/// system's first boot.
fn reset_machine_id( target: &Path )
{
	std::fs::write( target.join( "etc/machine-id" ), "" ).ok();
	let dbus_id = target.join( "var/lib/dbus/machine-id" );
	if dbus_id.is_file() && !dbus_id.is_symlink()
	{
		std::fs::remove_file( dbus_id ).ok();
	}
}

fn mount_chroot_aux( target: &Path, mounts: &mut Vec<PathBuf> ) -> Result<(), String>
{
	mount( Path::new( "/dev" ), &target.join( "dev" ), &[ "--bind" ], mounts )?;
	mount( Path::new( "devpts" ), &target.join( "dev/pts" ), &[ "-t", "devpts" ], mounts )?;
	mount( Path::new( "proc" ), &target.join( "proc" ), &[ "-t", "proc" ], mounts )?;
	mount( Path::new( "sysfs" ), &target.join( "sys" ), &[ "-t", "sysfs" ], mounts )?;
	if Path::new( "/sys/firmware/efi/efivars" ).exists()
	{
		mount( Path::new( "efivarfs" ), &target.join( "sys/firmware/efi/efivars" ), &[ "-t", "efivarfs" ], mounts ).ok();
	}
	Ok( () )
}

fn install_bootloader( plan: &InstallPlan, target: &Path, layout: &TargetLayout, flavor: crate::platform::BootFlavor ) -> Result<(), String>
{
	match flavor
	{
		crate::platform::BootFlavor::Grub => install_grub( plan, target, layout ),
		flavor => install_uboot( plan, target, layout, flavor ),
	}
}

/// Copy the raw u-boot area from the running medium's disk (whole-disk
/// installs; on partition and free-space targets the disk is assumed to
/// boot already) and point the target's boot config at the new root.
fn install_uboot( plan: &InstallPlan, target: &Path, layout: &TargetLayout, flavor: crate::platform::BootFlavor ) -> Result<(), String>
{
	if matches!( plan.mode, TargetMode::WholeDisk )
	{
		match running_system_disk()
		{
			Some( src ) if src != plan.disk => crate::platform::copy_boot_area( &src, &plan.disk )?,
			Some( _ ) => {}
			None => eprintln!( "install: cannot locate the medium's disk, u-boot area not copied" ),
		}
	}

	let root_val = if layout.luks_part.is_some()
	{
		format!( "/dev/mapper/{LUKS_NAME}" )
	}
	else
	{
		let uuid = blkid_uuid( &layout.root )
			.ok_or_else( || format!( "no UUID for {}", layout.root.display() ) )?;
		format!( "UUID={uuid}" )
	};

	if flavor == crate::platform::BootFlavor::UBootExtlinux
	{
		crate::platform::configure_uboot_extlinux( target, &format!( "root={root_val}" ) )
	}
	else
	{
		crate::platform::configure_uboot_script( target, &root_val )
	}
}

fn install_grub( plan: &InstallPlan, target: &Path, layout: &TargetLayout ) -> Result<(), String>
{
	let efi_booted = Path::new( "/sys/firmware/efi" ).exists();
	let mut errors: Vec<String> = Vec::new();
	let mut installed = false;

	if layout.esp.is_some()
	{
		let efi_target = if cfg!( target_arch = "aarch64" ) { "arm64-efi" } else { "x86_64-efi" };
		let target_arg = format!( "--target={efi_target}" );
		let result = run_ok( Command::new( "chroot" ).arg( target )
			.arg( "grub-install" )
			.arg( &target_arg )
			.args( [ "--efi-directory=/boot/efi", "--boot-directory=/boot", "--bootloader-id=Liberux", "--removable", "--recheck" ] ) );
		match result
		{
			Ok( () ) =>
			{
				installed = true;
				if efi_booted
				{
					// Second pass without --removable registers the NVRAM
					// boot entry; best-effort.
					run_ok( Command::new( "chroot" ).arg( target )
						.arg( "grub-install" )
						.arg( &target_arg )
						.args( [ "--efi-directory=/boot/efi", "--boot-directory=/boot", "--bootloader-id=Liberux", "--recheck" ] ) ).ok();
				}
			}
			Err( e ) =>
			{
				if efi_booted { return Err( format!( "EFI grub-install: {e}" ) ); }
				errors.push( format!( "EFI grub-install: {e}" ) );
			}
		}
	}

	let is_x86 = cfg!( target_arch = "x86_64" ) || cfg!( target_arch = "x86" );
	if is_x86 && layout.bios_bootable
	{
		let result = run_ok( Command::new( "chroot" ).arg( target )
			.args( [ "grub-install", "--target=i386-pc", "--boot-directory=/boot", "--recheck" ] )
			.arg( &plan.disk ) );
		match result
		{
			Ok( () ) => installed = true,
			Err( e ) =>
			{
				if !efi_booted { return Err( format!( "BIOS grub-install: {e}" ) ); }
				errors.push( format!( "BIOS grub-install: {e}" ) );
			}
		}
	}
	else if is_x86 && !efi_booted && !installed
	{
		return Err( format!(
			"This machine booted in BIOS mode but {} has no bios_grub partition; select the whole disk to repartition it",
			plan.disk.display() ) );
	}

	if installed
	{
		for e in errors { eprintln!( "install: {e}" ); }
		Ok( () )
	}
	else
	{
		Err( if errors.is_empty() { "no bootloader could be installed".to_string() } else { errors.join( "; " ) } )
	}
}

/// Trixie disables os-prober by default; the installed system should
/// offer any other OS on the machine in its boot menu.
fn enable_os_prober( target: &Path )
{
	let path = target.join( "etc/default/grub" );
	let current = std::fs::read_to_string( &path ).unwrap_or_default();
	let mut new = String::new();
	for line in current.lines()
	{
		if !line.trim_start().starts_with( "GRUB_DISABLE_OS_PROBER" )
			&& line.trim_start() != "#GRUB_DISABLE_OS_PROBER=false"
		{
			new.push_str( line );
			new.push( '\n' );
		}
	}
	new.push_str( "GRUB_DISABLE_OS_PROBER=false\n" );
	std::fs::write( &path, new ).ok();
}

/// Locale plus keyboard-layout and timezone defaults derived from the
/// chosen language; all three remain editable on the installed system.
/// Also reused by the daemon against `/` when the Configure flow ends
/// with a different language than the one installed.
pub fn apply_locale( target: &Path, code: &str )
{
	let ( full, keyboard, timezone ) = match code
	{
		"en" => ( "en_US.UTF-8", "us", "Etc/UTC" ),
		"es" => ( "es_ES.UTF-8", "es", "Europe/Madrid" ),
		"zh" => ( "zh_CN.UTF-8", "cn", "Asia/Shanghai" ),
		"hi" => ( "hi_IN.UTF-8", "in", "Asia/Kolkata" ),
		"fr" => ( "fr_FR.UTF-8", "fr", "Europe/Paris" ),
		"de" => ( "de_DE.UTF-8", "de", "Europe/Berlin" ),
		"pt" => ( "pt_PT.UTF-8", "pt", "Europe/Lisbon" ),
		"ja" => ( "ja_JP.UTF-8", "jp", "Asia/Tokyo" ),
		"ru" => ( "ru_RU.UTF-8", "ru", "Europe/Moscow" ),
		"ko" => ( "ko_KR.UTF-8", "kr", "Asia/Seoul" ),
		_    => return,
	};
	std::fs::write( target.join( "etc/default/locale" ), format!( "LANG={full}\n" ) ).ok();
	let gen_path = target.join( "etc/locale.gen" );
	let line = format!( "{full} UTF-8" );
	let current = std::fs::read_to_string( &gen_path ).unwrap_or_default();
	if !current.lines().any( |l| l.trim() == line )
	{
		std::fs::write( &gen_path, format!( "{current}{line}\n" ) ).ok();
	}
	run_ok( Command::new( "chroot" ).arg( target ).arg( "locale-gen" ) ).ok();

	std::fs::write(
		target.join( "etc/default/keyboard" ),
		format!( "XKBMODEL=\"pc105\"\nXKBLAYOUT=\"{keyboard}\"\nXKBVARIANT=\"\"\nXKBOPTIONS=\"\"\nBACKSPACE=\"guess\"\n" ),
	).ok();

	std::fs::write( target.join( "etc/timezone" ), format!( "{timezone}\n" ) ).ok();
	let localtime = target.join( "etc/localtime" );
	std::fs::remove_file( &localtime ).ok();
	std::os::unix::fs::symlink( format!( "/usr/share/zoneinfo/{timezone}" ), &localtime ).ok();
}

/// Rename the image's fixed `liberux` user to the chosen identity,
/// set its password and the hostname.
pub( crate ) fn apply_account( target: &Path, account: &AccountSetup ) -> Result<(), String>
{
	let home = format!( "/home/{}", account.username );
	run_ok( Command::new( "chroot" ).arg( target )
		.args( [ "usermod", "-l" ] ).arg( &account.username ).arg( "liberux" ) )?;
	run_ok( Command::new( "chroot" ).arg( target )
		.args( [ "usermod", "-d" ] ).arg( &home ).arg( "-m" ).arg( &account.username ) )?;
	run_ok( Command::new( "chroot" ).arg( target )
		.args( [ "usermod", "-aG", "sudo" ] ).arg( &account.username ) ).ok();
	run_with_stdin(
		Command::new( "chroot" ).arg( target ).arg( "chpasswd" ),
		&format!( "{}:{}\n", account.username, account.password ),
	)?;

	std::fs::write( target.join( "etc/hostname" ), format!( "{}\n", account.hostname ) )
		.map_err( |e| format!( "write hostname: {e}" ) )?;
	let hosts_path = target.join( "etc/hosts" );
	let hosts = std::fs::read_to_string( &hosts_path ).unwrap_or_default();
	let mut new_hosts = String::new();
	let mut replaced = false;
	for line in hosts.lines()
	{
		if line.trim_start().starts_with( "127.0.1.1" )
		{
			new_hosts.push_str( &format!( "127.0.1.1\t{}\n", account.hostname ) );
			replaced = true;
		}
		else
		{
			new_hosts.push_str( line );
			new_hosts.push( '\n' );
		}
	}
	if !replaced
	{
		new_hosts.push_str( &format!( "127.0.1.1\t{}\n", account.hostname ) );
	}
	std::fs::write( &hosts_path, new_hosts ).map_err( |e| format!( "write hosts: {e}" ) )?;
	Ok( () )
}

/// Best-effort 2 GiB (capped at RAM size) swapfile on the target root,
/// skipped when the disk lacks comfortable headroom.
fn setup_swapfile( target: &Path )
{
	let mem_kib: u64 = std::fs::read_to_string( "/proc/meminfo" )
		.ok()
		.and_then( |s| s.lines().find( |l| l.starts_with( "MemTotal:" ) )
			.and_then( |l| l.split_whitespace().nth( 1 )?.parse().ok() ) )
		.unwrap_or( 0 );
	if mem_kib == 0 { return; }
	let size = ( mem_kib * 1024 ).min( 2048 * MIB );
	let Some( avail ) = df_bytes( target, "avail" ) else { return };
	if avail < size * 2
	{
		eprintln!( "install: skipping swapfile, only {avail} bytes free on target" );
		return;
	}

	let cleanup = |reason: String|
	{
		eprintln!( "install: skipping swapfile: {reason}" );
		std::fs::remove_file( target.join( "swapfile" ) ).ok();
	};
	if let Err( e ) = run_ok( Command::new( "chroot" ).arg( target )
		.args( [ "fallocate", "-l" ] ).arg( size.to_string() ).arg( "/swapfile" ) )
	{
		return cleanup( e );
	}
	use std::os::unix::fs::PermissionsExt;
	std::fs::set_permissions( target.join( "swapfile" ), std::fs::Permissions::from_mode( 0o600 ) ).ok();
	if let Err( e ) = run_ok( Command::new( "chroot" ).arg( target ).args( [ "mkswap", "/swapfile" ] ) )
	{
		return cleanup( e );
	}
	let fstab = target.join( "etc/fstab" );
	if let Ok( mut f ) = std::fs::OpenOptions::new().append( true ).open( &fstab )
	{
		f.write_all( b"/swapfile\tnone\tswap\tsw\t0\t0\n" ).ok();
	}
}

/// The image's SSH host keys were copied to the target; every install
/// must get its own.
fn regenerate_ssh_host_keys( target: &Path )
{
	let dir = target.join( "etc/ssh" );
	if !dir.is_dir() { return; }
	let mut removed = false;
	if let Ok( entries ) = std::fs::read_dir( &dir )
	{
		for entry in entries.flatten()
		{
			if entry.file_name().to_string_lossy().starts_with( "ssh_host_" )
			{
				std::fs::remove_file( entry.path() ).ok();
				removed = true;
			}
		}
	}
	if removed
	{
		run_ok( Command::new( "chroot" ).arg( target ).args( [ "ssh-keygen", "-A" ] ) ).ok();
	}
}

fn run_with_stdin( cmd: &mut Command, input: &str ) -> Result<(), String>
{
	let program = cmd.get_program().to_string_lossy().to_string();
	let mut child = cmd
		.stdin( Stdio::piped() )
		.stdout( Stdio::null() )
		.stderr( Stdio::piped() )
		.spawn()
		.map_err( |e| format!( "spawn {program}: {e}" ) )?;
	if let Some( mut stdin ) = child.stdin.take()
	{
		stdin.write_all( input.as_bytes() ).map_err( |e| format!( "write to {program}: {e}" ) )?;
	}
	let out = child.wait_with_output().map_err( |e| format!( "wait {program}: {e}" ) )?;
	if out.status.success() { return Ok( () ); }
	let stderr = String::from_utf8_lossy( &out.stderr );
	let tail: Vec<&str> = stderr.lines().rev().take( 3 ).collect();
	let tail: Vec<&str> = tail.into_iter().rev().collect();
	Err( format!( "{program} exited with {}: {}", out.status, tail.join( " | " ) ) )
}

fn mark_configure_pending( target: &Path )
{
	let marker = target.join( CONFIGURE_PENDING.trim_start_matches( '/' ) );
	if let Some( dir ) = marker.parent()
	{
		std::fs::create_dir_all( dir ).ok();
	}
	std::fs::remove_file( target.join( INSTALL_PENDING.trim_start_matches( '/' ) ) ).ok();
	std::fs::write( marker, "" ).ok();
}

/// Execute committed disk-management operations, in list order. Runs
/// in the daemon (or in-process for root dev runs); shrinks touch the
/// filesystem before the table, grows the other way around.
pub fn apply_disk_ops( ops: &[ crate::ipc::RemoteDiskOp ] ) -> Vec<String>
{
	use crate::ipc::RemoteDiskOp;

	let mut errors: Vec<String> = Vec::new();
	for op in ops
	{
		match op
		{
			RemoteDiskOp::Resize { disk, part, number, current_bytes, new_bytes, fs } =>
			{
				let kind = crate::fs::parse( fs.as_deref() );
				if new_bytes < current_bytes
				{
					if matches!( kind, crate::fs::FsKind::Unknown )
					{
						// No FS to shrink; only the table entry moves.
					}
					else if !crate::fs::can_shrink( kind )
					{
						errors.push( format!( "FS shrink unsupported for {:?} on {}", kind, part.display() ) );
						continue;
					}
					else if let Err( e ) = crate::fs::shrink( part, kind, *new_bytes )
					{
						errors.push( format!( "FS shrink {}: {e}", part.display() ) );
						continue;
					}
					if let Err( e ) = crate::disks::resize_partition( disk, *number, *new_bytes )
					{
						errors.push( format!( "resize partition: {e}" ) );
					}
				}
				else if new_bytes > current_bytes
				{
					if let Err( e ) = crate::disks::resize_partition( disk, *number, *new_bytes )
					{
						errors.push( format!( "resize partition: {e}" ) );
						continue;
					}
					if crate::fs::can_grow( kind )
					{
						if let Err( e ) = crate::fs::grow( part, kind, *new_bytes )
						{
							errors.push( format!( "FS grow {}: {e}", part.display() ) );
						}
					}
				}
			}
			RemoteDiskOp::Create { disk, start_byte, size_bytes } =>
			{
				if let Err( e ) = crate::disks::create_partition( disk, *start_byte, *size_bytes )
				{
					errors.push( format!( "create partition: {e}" ) );
				}
			}
			RemoteDiskOp::Delete { disk, numbers } =>
			{
				if let Err( e ) = crate::disks::delete_partitions( disk, numbers )
				{
					errors.push( format!( "delete partitions: {e}" ) );
				}
			}
			RemoteDiskOp::Wipe { disk } =>
			{
				unmount_related( disk );
				if let Err( e ) = crate::disks::wipe_disk( disk )
				{
					errors.push( format!( "wipe disk: {e}" ) );
				}
			}
		}
	}
	errors
}

// ── Helpers ─────────────────────────────────────────────────────────────────

/// Whole disk backing the running root filesystem, when it can be
/// determined (`None` under overlayfs).
pub( crate ) fn running_system_disk() -> Option<PathBuf>
{
	let mounts = std::fs::read_to_string( "/proc/self/mounts" ).ok()?;
	let source = mounts.lines()
		.find_map( |l|
		{
			let mut it = l.split_whitespace();
			let src = it.next()?;
			let mnt = it.next()?;
			if mnt == "/" { Some( src.to_string() ) } else { None }
		} )?;
	if !source.starts_with( "/dev/" ) { return None; }
	let name = Path::new( &source ).file_name()?.to_str()?.to_string();
	let sys = std::fs::canonicalize( format!( "/sys/class/block/{name}" ) ).ok()?;
	if sys.join( "partition" ).exists()
	{
		let parent = sys.parent()?.file_name()?.to_str()?.to_string();
		Some( PathBuf::from( "/dev" ).join( parent ) )
	}
	else
	{
		Some( PathBuf::from( "/dev" ).join( name ) )
	}
}

/// Unmount anything mounted from `dev` itself or, when `dev` is a
/// whole disk, from any of its partitions (auto-mounted media).
fn unmount_related( dev: &Path )
{
	let Ok( mounts ) = std::fs::read_to_string( "/proc/self/mounts" ) else { return };
	let prefix = dev.display().to_string();
	let mut targets: Vec<String> = mounts.lines()
		.filter_map( |l|
		{
			let mut it = l.split_whitespace();
			let src = it.next()?;
			let mnt = it.next()?;
			let related = src == prefix
				|| ( src.starts_with( &prefix )
					&& src[ prefix.len().. ].chars().all( |c| c.is_ascii_digit() || c == 'p' ) );
			if related { Some( mnt.to_string() ) } else { None }
		} )
		.collect();
	targets.sort();
	for t in targets.iter().rev()
	{
		run_ok( Command::new( "umount" ).arg( t ) ).ok();
	}
}

fn mount( source: &Path, target: &Path, extra: &[ &str ], mounts: &mut Vec<PathBuf> ) -> Result<(), String>
{
	run_ok( Command::new( "mount" ).args( extra ).arg( source ).arg( target ) )?;
	mounts.push( target.to_path_buf() );
	Ok( () )
}

fn cleanup_mounts( mounts: &mut Vec<PathBuf> )
{
	while let Some( m ) = mounts.pop()
	{
		if run_ok( Command::new( "umount" ).arg( &m ) ).is_err()
		{
			run_ok( Command::new( "umount" ).arg( "-l" ).arg( &m ) ).ok();
		}
	}
}

pub( crate ) fn run_ok( cmd: &mut Command ) -> Result<(), String>
{
	let program = cmd.get_program().to_string_lossy().to_string();
	let out = cmd.output().map_err( |e| format!( "spawn {program}: {e}" ) )?;
	if out.status.success() { return Ok( () ); }
	let stderr = String::from_utf8_lossy( &out.stderr );
	let tail: Vec<&str> = stderr.lines().rev().take( 3 ).collect();
	let tail: Vec<&str> = tail.into_iter().rev().collect();
	Err( format!( "{program} exited with {}: {}", out.status, tail.join( " | " ) ) )
}
