// Disk and partition discovery via sysfs.
//
// Kept entirely independent from the UI layer: nothing in this module
// builds an `Element`, reads `State`, or knows the i18n keys. The
// installer's domain layer (`state.rs`) adapts the structures defined
// here to the shape the screens consume.
//
// Enumeration walks `/sys/block` directly; partition tables are read
// with gptman / mbrman straight off the raw block device. Privileged
// access is still needed to open `/dev/<disk>` for table edits, but
// listing disks works as any user.

use std::path::{ Path, PathBuf };

/// Transport / bus the disk hangs off, derived from a sysfs
/// `subsystem` walk plus the device name prefix. SATA and PATA both
/// surface through the kernel `ata` subsystem; we map that to `Sata`
/// because pure-PATA hardware is rare today, and fall back to `Ide`
/// only when the legacy `ide` subsystem is present.
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub enum BusKind
{
	Nvme,
	Ide,
	Sata,
	Scsi,
	Usb,
	Mmc,
	Virtio,
	Loop,
	Md,
	Dm,
	Ram,
	Pmem,
	Other,
}

impl BusKind
{
	/// Short human label for the screen ("NVMe", "SATA", "USB", …).
	pub fn label( self ) -> &'static str
	{
		match self
		{
			BusKind::Nvme   => "NVMe",
			BusKind::Ide    => "IDE",
			BusKind::Sata   => "SATA",
			BusKind::Scsi   => "SCSI",
			BusKind::Usb    => "USB",
			BusKind::Mmc    => "SD/MMC",
			BusKind::Virtio => "VirtIO",
			BusKind::Loop   => "Loop",
			BusKind::Md     => "MD RAID",
			BusKind::Dm     => "Device Mapper",
			BusKind::Ram    => "RAM",
			BusKind::Pmem   => "PMEM",
			BusKind::Other  => "Disk",
		}
	}
}

/// One whole-disk device the kernel exposes.
#[ derive( Clone, Debug ) ]
pub struct DiskInfo
{
	pub path:        PathBuf,
	pub model:       String,
	pub bus:         BusKind,
	/// `true` when `/sys/block/<dev>/removable` reads `1` — pen
	/// drives, hot-swap eSATA bays, SD cards. Used by the UI to
	/// pick between an "external" and "internal" hardware icon.
	pub removable:   bool,
	pub size_bytes:  u64,
	pub sector_size: u64,
	pub table_type:  Option<String>,
	pub partitions:  Vec<PartitionInfo>,
}

/// One slot inside a [`DiskInfo`] — either a real partition or an
/// unallocated gap spliced in by [`splice_free_spaces`].
#[ derive( Clone, Debug ) ]
pub struct PartitionInfo
{
	pub path:       PathBuf,
	pub number:     u32,
	pub name:       Option<String>,
	pub fs_type:    Option<String>,
	pub start_byte: u64,
	pub size_bytes: u64,
	/// `true` when this entry represents free space rather than a
	/// real partition. The slicer marks gaps wider than
	/// [`FREE_FLOOR_BYTES`].
	pub is_free:    bool,
	/// MBR only: a logical partition, or free space inside the
	/// extended partition. Creating here builds a logical partition
	/// and consumes no primary slot.
	pub in_extended: bool,
}

/// Walk `/sys/block` and return one entry per top-level block device,
/// sorted by device name so the UI sees a stable order across runs.
/// Devices with `size == 0` are filtered out — that catches empty
/// card readers, CD-ROM drives without a disc, and any other
/// removable bay with no media inserted, none of which the user can
/// install onto. Errors reading individual disks are swallowed: the
/// disk still appears, with `table_type = None` and an empty
/// partition list, so the screen can still show it as "raw".
pub fn enumerate() -> Vec<DiskInfo>
{
	let mut names: Vec<String> = match std::fs::read_dir( "/sys/block" )
	{
		Ok( it ) => it.flatten()
			.filter_map( |e| e.file_name().into_string().ok() )
			.collect(),
		Err( e ) =>
		{
			eprintln!( "disks::enumerate: read /sys/block: {e}" );
			return Vec::new();
		}
	};
	names.sort();

	let mut out = Vec::with_capacity( names.len() );
	for name in names
	{
		if is_mmc_hw_partition( &name ) { continue; }
		let path        = PathBuf::from( "/dev" ).join( &name );
		let sector_size = logical_block_size( &path ) as u64;
		let size_bytes  = read_size_bytes( &name );
		if size_bytes == 0 { continue; }
		let model       = read_model( &name ).unwrap_or_else( || name.clone() );
		let bus         = classify_bus( &name );
		let removable   = read_removable( &path );

		let ( table_type, partitions ) = read_table( &path, sector_size, size_bytes )
			.unwrap_or_else( ||
			{
				eprintln!( "disks::enumerate: native readers found no table for {} — sysfs fallback", path.display() );
				( None, partitions_from_sysfs( &path, sector_size, size_bytes ) )
			} );

		out.push( DiskInfo
		{
			path,
			model,
			bus,
			removable,
			size_bytes,
			sector_size,
			table_type,
			partitions,
		} );
	}
	out
}

/// eMMC hardware boot/RPMB areas surface in `/sys/block` as top-level
/// devices (`mmcblk0boot0`, `mmcblk0boot1`, `mmcblk0rpmb`); they hold
/// the SoC's boot loader and replay-protected data, not installable
/// storage.
fn is_mmc_hw_partition( dev_name: &str ) -> bool
{
	dev_name.starts_with( "mmcblk" )
		&& ( dev_name.contains( "boot" ) || dev_name.contains( "rpmb" ) )
}

/// `/sys/block/<dev>/size` is in 512-byte units regardless of the
/// device's logical block size — a long-standing kernel convention.
fn read_size_bytes( dev_name: &str ) -> u64
{
	let p = PathBuf::from( "/sys/block" ).join( dev_name ).join( "size" );
	let sectors_512: u64 = std::fs::read_to_string( p )
		.ok()
		.and_then( |s| s.trim().parse().ok() )
		.unwrap_or( 0 );
	sectors_512.saturating_mul( 512 )
}

/// Best-effort device model string. SCSI/SATA/USB present
/// `device/vendor` and `device/model`; NVMe collapses both into
/// `device/model`; MMC uses `device/name`. Returns `None` when none of
/// those are readable so the caller can fall back to the dev name.
fn read_model( dev_name: &str ) -> Option<String>
{
	let base = PathBuf::from( "/sys/block" ).join( dev_name ).join( "device" );

	let read = |f: &str| -> Option<String>
	{
		let s = std::fs::read_to_string( base.join( f ) ).ok()?;
		let t = s.trim();
		if t.is_empty() { None } else { Some( t.to_string() ) }
	};

	let model  = read( "model" );
	let vendor = read( "vendor" );
	match ( vendor, model )
	{
		( Some( v ), Some( m ) ) => Some( format!( "{v} {m}" ) ),
		( None,      Some( m ) ) => Some( m ),
		( Some( v ), None      ) => Some( v ),
		( None,      None      ) => read( "name" ),
	}
}

/// Wipe every partition table signature from the disk by zeroing
/// the head and tail of the device. Covers MBR (sector 0), GPT
/// primary (sectors 1..), and GPT backup (last sectors). Avoids
/// `wipefs` so it does not depend on the host having util-linux
/// available with permissions to open the disk.
pub fn wipe_disk( disk_path: &Path ) -> Result<(), String>
{
	use std::io::{ Seek, SeekFrom, Write };
	let mut file = open_rw( disk_path )?;
	let zero = vec![ 0u8; 1024 * 1024 ];
	file.write_all( &zero ).map_err( |e| format!( "wipe head {}: {e}", disk_path.display() ) )?;
	let len = file.seek( SeekFrom::End( 0 ) ).map_err( |e| format!( "seek end {}: {e}", disk_path.display() ) )?;
	if len > zero.len() as u64
	{
		file.seek( SeekFrom::Start( len - zero.len() as u64 ) )
			.map_err( |e| format!( "seek tail {}: {e}", disk_path.display() ) )?;
		file.write_all( &zero ).map_err( |e| format!( "wipe tail {}: {e}", disk_path.display() ) )?;
	}
	file.sync_all().ok();
	Ok( () )
}

/// Shrink/grow the partition-table entry for `part_num` so its
/// on-disk length matches `new_size_bytes`. Reads the table with
/// gptman / mbrman, edits the entry, writes it back. The
/// filesystem inside is *not* resized — `crate::fs` covers that.
pub fn resize_partition( disk_path: &Path, part_num: u32, new_size_bytes: u64 ) -> Result<(), String>
{
	use std::io::{ Seek, SeekFrom };
	let mut file = open_rw( disk_path )?;

	if let Ok( mut gpt ) = gptman::GPT::find_from( &mut file )
	{
		let sector = gpt.sector_size;
		let entry = gpt.iter_mut()
			.find( |( i, _ )| *i == part_num )
			.map( |( _, e )| e )
			.ok_or_else( || format!( "p{part_num}: not in GPT" ) )?;
		let new_sectors = new_size_bytes / sector;
		if new_sectors == 0 { return Err( "size below one sector".into() ); }
		entry.ending_lba = entry.starting_lba.saturating_add( new_sectors - 1 );
		return gpt.write_into( &mut file )
			.map( |_| () )
			.map_err( |e| format!( "gpt write {}: {e:?}", disk_path.display() ) );
	}

	file.seek( SeekFrom::Start( 0 ) ).ok();
	let sector_size = logical_block_size( disk_path );
	if let Ok( mut mbr ) = mbrman::MBR::read_from( &mut file, sector_size )
	{
		let entry = mbr.get_mut( part_num as usize )
			.ok_or_else( || format!( "p{part_num}: not in MBR" ) )?;
		let new_sectors = new_size_bytes / sector_size as u64;
		if new_sectors == 0 || new_sectors > u32::MAX as u64
		{
			return Err( format!( "size {new_size_bytes} out of MBR range" ) );
		}
		entry.sectors = new_sectors as u32;
		return mbr.write_into( &mut file )
			.map_err( |e| format!( "mbr write {}: {e:?}", disk_path.display() ) );
	}

	Err( format!( "no recognized table on {}", disk_path.display() ) )
}

/// Insert a new partition into the table on `disk_path` starting at
/// `start_byte` and spanning `size_bytes` bytes, returning its
/// partition number. On GPT the lowest unused entry is marked as a
/// Linux filesystem; on MBR a region inside the extended partition
/// becomes a logical partition (mbrman generates the EBR; the data
/// starts one alignment block past `start_byte`), anything else uses
/// the lowest free primary slot with type `0x83`. The filesystem
/// inside is *not* created.
pub fn create_partition( disk_path: &Path, start_byte: u64, size_bytes: u64 ) -> Result<u32, String>
{
	use std::io::{ Seek, SeekFrom };
	let mut file = open_rw( disk_path )?;

	if let Ok( mut gpt ) = gptman::GPT::find_from( &mut file )
	{
		let sector = gpt.sector_size;
		if sector == 0 { return Err( "gpt sector size is 0".into() ); }
		let start_lba   = start_byte / sector;
		let new_sectors = size_bytes / sector;
		if new_sectors == 0 { return Err( "size below one sector".into() ); }
		let end_lba = start_lba.saturating_add( new_sectors - 1 );

		// Linux filesystem type GUID, mixed-endian:
		// 0FC63DAF-8483-4772-8E79-3D69D8477DE4.
		const LINUX_FS_GUID: [ u8; 16 ] = [
			0xAF, 0x3D, 0xC6, 0x0F,
			0x83, 0x84,
			0x72, 0x47,
			0x8E, 0x79,
			0x3D, 0x69, 0xD8, 0x47, 0x7D, 0xE4,
		];

		let idx = gpt.iter()
			.find( |( _, e )| e.is_unused() )
			.map( |( i, _ )| i )
			.ok_or_else( || "no free GPT entry".to_string() )?;
		gpt[ idx ] = gptman::GPTPartitionEntry
		{
			partition_type_guid:   LINUX_FS_GUID,
			unique_partition_guid: gen_unique_guid(),
			starting_lba:          start_lba,
			ending_lba:            end_lba,
			attribute_bits:        0,
			partition_name:        "".into(),
		};
		gpt.write_into( &mut file )
			.map_err( |e| format!( "gpt write {}: {e:?}", disk_path.display() ) )?;
		return Ok( idx );
	}

	file.seek( SeekFrom::Start( 0 ) ).ok();
	let sector_size = logical_block_size( disk_path );
	if let Ok( mut mbr ) = mbrman::MBR::read_from( &mut file, sector_size )
	{
		let start_lba   = start_byte / sector_size as u64;
		let new_sectors = size_bytes / sector_size as u64;
		if new_sectors == 0 || new_sectors > u32::MAX as u64
			|| start_lba > u32::MAX as u64
		{
			return Err( format!( "size {size_bytes} or start {start_byte} out of MBR range" ) );
		}

		let in_extended = mbr_extended_entry( &mbr ).map_or( false, |e|
			start_lba >= e.starting_lba as u64
				&& start_lba < e.starting_lba as u64 + e.sectors as u64 );
		if in_extended
		{
			mbr.push( 0x83, start_lba as u32, new_sectors as u32 )
				.map_err( |e| format!( "push logical on {}: {e:?}", disk_path.display() ) )?;
			let num = ( 4 + mbr.logical_partitions.len() ) as u32;
			mbr.write_into( &mut file )
				.map_err( |e| format!( "mbr write {}: {e:?}", disk_path.display() ) )?;
			return Ok( num );
		}

		let idx = mbr.iter_mut()
			.find( |( i, e )| *i <= 4 && !e.is_used() )
			.map( |( i, _ )| i )
			.ok_or_else( || "no free MBR entry (4-primary limit)".to_string() )?;
		mbr[ idx ] = mbrman::MBRPartitionEntry
		{
			boot:         mbrman::BOOT_INACTIVE,
			first_chs:    mbrman::CHS::empty(),
			sys:          0x83,
			last_chs:     mbrman::CHS::empty(),
			starting_lba: start_lba   as u32,
			sectors:      new_sectors as u32,
		};
		mbr.write_into( &mut file )
			.map_err( |e| format!( "mbr write {}: {e:?}", disk_path.display() ) )?;
		return Ok( idx as u32 );
	}

	Err( format!( "no recognized table on {}", disk_path.display() ) )
}

/// Build a 16-byte GUID seeded from the system clock — good enough
/// for installer-time uniqueness within a single session.
pub fn gen_unique_guid() -> [ u8; 16 ]
{
	use std::time::{ SystemTime, UNIX_EPOCH };
	let nanos = SystemTime::now()
		.duration_since( UNIX_EPOCH )
		.map( |d| d.as_nanos() )
		.unwrap_or( 0 );
	nanos.to_le_bytes()
}

/// Apply a batch of partition deletions to a single disk in one
/// table rewrite. Empties each entry's type and writes the table
/// back — gptman / mbrman do all the on-disk surgery, no shell to
/// sfdisk / parted.
pub fn delete_partitions( disk_path: &Path, part_nums: &[ u32 ] ) -> Result<(), String>
{
	use std::io::{ Seek, SeekFrom };
	if part_nums.is_empty() { return Ok( () ); }
	let mut file = open_rw( disk_path )?;

	if let Ok( mut gpt ) = gptman::GPT::find_from( &mut file )
	{
		for n in part_nums
		{
			if let Some( ( _, e ) ) = gpt.iter_mut().find( |( i, _ )| i == n )
			{
				e.partition_type_guid = [ 0; 16 ];
			}
		}
		return gpt.write_into( &mut file )
			.map( |_| () )
			.map_err( |e| format!( "gpt write {}: {e:?}", disk_path.display() ) );
	}

	file.seek( SeekFrom::Start( 0 ) ).ok();
	let sector_size = logical_block_size( disk_path );
	if let Ok( mut mbr ) = mbrman::MBR::read_from( &mut file, sector_size )
	{
		// Logical partitions are removed from the EBR chain, which
		// shifts the numbers after them — delete highest-first so the
		// remaining requested numbers stay valid.
		let mut nums: Vec<u32> = part_nums.to_vec();
		nums.sort_unstable_by( |a, b| b.cmp( a ) );
		for n in nums
		{
			if n >= 5
			{
				if ( n as usize ) < 5 + mbr.logical_partitions.len()
				{
					mbr.remove( n as usize );
				}
			}
			else if let Some( e ) = mbr.get_mut( n as usize )
			{
				*e = mbrman::MBRPartitionEntry::empty();
			}
		}
		return mbr.write_into( &mut file )
			.map_err( |e| format!( "mbr write {}: {e:?}", disk_path.display() ) );
	}

	Err( format!( "no recognized table on {}", disk_path.display() ) )
}

/// mbrman keeps `get_extended_partition` private; find the extended
/// container among the four primaries ourselves.
fn mbr_extended_entry( mbr: &mbrman::MBR ) -> Option<&mbrman::MBRPartitionEntry>
{
	mbr.iter()
		.filter( |( i, _ )| *i <= 4 )
		.map( |( _, p )| p )
		.find( |p| p.is_used() && p.is_extended() )
}

/// Free primary slots in an MBR table, or `None` when the disk is not
/// MBR (GPT has no practical slot limit). Logical partitions inside an
/// extended partition are not usable by the installer, so only the
/// four primary entries count.
pub fn mbr_primary_free_slots( disk_path: &Path ) -> Option<u32>
{
	use std::io::{ Seek, SeekFrom };
	let mut file = std::fs::File::open( disk_path ).ok()?;
	if gptman::GPT::find_from( &mut file ).is_ok() { return None; }
	file.seek( SeekFrom::Start( 0 ) ).ok()?;
	let sector_size = logical_block_size( disk_path );
	let mbr = mbrman::MBR::read_from( &mut file, sector_size ).ok()?;
	Some( mbr.iter().filter( |( i, p )| *i <= 4 && !p.is_used() ).count() as u32 )
}

/// Byte range `( start, size )` of the MBR extended partition, `None`
/// on GPT or when there is no extended partition.
pub fn mbr_extended_range( disk_path: &Path ) -> Option<( u64, u64 )>
{
	use std::io::{ Seek, SeekFrom };
	let mut file = std::fs::File::open( disk_path ).ok()?;
	if gptman::GPT::find_from( &mut file ).is_ok() { return None; }
	file.seek( SeekFrom::Start( 0 ) ).ok()?;
	let sector_size = logical_block_size( disk_path ) as u64;
	let mbr = mbrman::MBR::read_from( &mut file, sector_size as u32 ).ok()?;
	mbr_extended_entry( &mbr )
		.map( |e| ( e.starting_lba as u64 * sector_size, e.sectors as u64 * sector_size ) )
}

/// On-disk geometry (start, size) in bytes of partition `num`, from
/// either table kind.
pub fn partition_geometry( disk_path: &Path, num: u32 ) -> Option<( u64, u64 )>
{
	use std::io::{ Seek, SeekFrom };
	let mut file = std::fs::File::open( disk_path ).ok()?;

	if let Ok( gpt ) = gptman::GPT::find_from( &mut file )
	{
		let sector = gpt.sector_size;
		return gpt.iter()
			.find( |( i, p )| *i == num && p.is_used() )
			.map( |( _, p )| (
				p.starting_lba * sector,
				( p.ending_lba - p.starting_lba + 1 ) * sector,
			) );
	}

	file.seek( SeekFrom::Start( 0 ) ).ok()?;
	let sector_size = logical_block_size( disk_path ) as u64;
	if let Ok( mbr ) = mbrman::MBR::read_from( &mut file, sector_size as u32 )
	{
		return mbr.iter()
			.find( |( i, p )| *i as u32 == num && p.is_used() )
			.map( |( _, p )| (
				p.starting_lba as u64 * sector_size,
				p.sectors as u64 * sector_size,
			) );
	}
	None
}

fn open_rw( disk_path: &Path ) -> Result<std::fs::File, String>
{
	std::fs::OpenOptions::new()
		.read( true )
		.write( true )
		.open( disk_path )
		.map_err( |e| format!( "open {}: {e}", disk_path.display() ) )
}

pub fn logical_block_size( disk_path: &Path ) -> u32
{
	let dev_name = disk_path.file_name().and_then( |s| s.to_str() ).unwrap_or( "" );
	let p = std::path::PathBuf::from( "/sys/block" ).join( dev_name ).join( "queue/logical_block_size" );
	std::fs::read_to_string( p )
		.ok()
		.and_then( |s| s.trim().parse().ok() )
		.unwrap_or( 512 )
}

const FREE_FLOOR_BYTES: u64 = 2 * 1024 * 1024;

/// Try to read the partition table from the raw block device with
/// pure-Rust crates (gptman / mbrman). Returns `None` when neither
/// recognises the disk so the caller can fall back to sysfs.
fn read_table( disk_path: &Path, sector_size: u64, disk_size_bytes: u64 ) -> Option<( Option<String>, Vec<PartitionInfo> )>
{
	use std::fs::File;
	use std::io::{ Seek, SeekFrom };

	let mut file = File::open( disk_path ).ok()?;

	// GPT first — its protective MBR would otherwise look like a
	// degenerate `0xEE`-only MBR to mbrman.
	if let Ok( gpt ) = gptman::GPT::find_from( &mut file )
	{
		let parts = partitions_from_gpt( &gpt, disk_path, sector_size, disk_size_bytes );
		return Some( ( Some( "gpt".to_string() ), parts ) );
	}

	// MBR — mbrman parses the 512-byte sector regardless of any
	// Apple Driver Descriptor garbage that other parsers refuse the
	// disk over.
	if file.seek( SeekFrom::Start( 0 ) ).is_ok()
	{
		if let Ok( mbr ) = mbrman::MBR::read_from( &mut file, sector_size as u32 )
		{
			let parts = partitions_from_mbr( &mbr, disk_path, sector_size );
			return Some( ( Some( "msdos".to_string() ), parts ) );
		}
	}

	None
}

fn partitions_from_gpt( gpt: &gptman::GPT, disk_path: &Path, sector_size: u64, disk_size: u64 ) -> Vec<PartitionInfo>
{
	let mut parts: Vec<PartitionInfo> = Vec::new();
	for ( i, p ) in gpt.iter()
	{
		if p.is_used()
		{
			let start = p.starting_lba.saturating_mul( sector_size );
			let size  = ( p.ending_lba.saturating_sub( p.starting_lba ).saturating_add( 1 ) ).saturating_mul( sector_size );
			let num       = i as u32;
			let part_path = part_path( disk_path, num );
			let fs_type   = blkid_fs_type( &part_path );
			let name      = p.partition_name.as_str().to_string();
			parts.push( PartitionInfo
			{
				path:        part_path,
				number:      num,
				name:        if name.is_empty() { None } else { Some( name ) },
				fs_type,
				start_byte:  start,
				size_bytes:  size,
				is_free:     false,
				in_extended: false,
			} );
		}
	}
	splice_free_spaces( parts, disk_path, disk_size )
}

/// MBR enumeration. `mbr.iter()` yields the four primaries and then
/// the logical partitions (numbered 5+, absolute LBAs). The extended
/// container itself is scaffolding, not an install target — its
/// logical partitions and inner free space stand in for it. Free
/// spots come from mbrman's own EBR-aware scan instead of
/// [`splice_free_spaces`], which cannot see inside the extended
/// partition.
fn partitions_from_mbr( mbr: &mbrman::MBR, disk_path: &Path, sector_size: u64 ) -> Vec<PartitionInfo>
{
	let ext_range = mbr_extended_entry( mbr ).map( |e|
	(
		e.starting_lba as u64 * sector_size,
		( e.starting_lba as u64 + e.sectors as u64 ) * sector_size,
	) );

	let mut parts: Vec<PartitionInfo> = Vec::new();
	for ( i, p ) in mbr.iter()
	{
		if !p.is_used() { continue; }
		if i <= 4 && p.is_extended() { continue; }
		let start     = ( p.starting_lba as u64 ).saturating_mul( sector_size );
		let size      = ( p.sectors as u64 ).saturating_mul( sector_size );
		let num       = i as u32;
		let part_path = part_path( disk_path, num );
		let fs_type   = blkid_fs_type( &part_path );
		parts.push( PartitionInfo
		{
			path:        part_path,
			number:      num,
			name:        None,
			fs_type,
			start_byte:  start,
			size_bytes:  size,
			is_free:     false,
			in_extended: i >= 5,
		} );
	}

	for ( start_lba, sectors ) in mbr.find_free_sectors()
	{
		let start = start_lba as u64 * sector_size;
		let size  = sectors as u64 * sector_size;
		if size <= FREE_FLOOR_BYTES { continue; }
		let in_ext = ext_range.map_or( false, |( s, e )| start >= s && start < e );
		parts.push( free_slot( disk_path, start, size, in_ext ) );
	}

	parts.sort_by_key( |p| p.start_byte );
	parts
}

pub fn part_path( disk_path: &Path, num: u32 ) -> PathBuf
{
	let dev_name = disk_path.file_name().and_then( |s| s.to_str() ).unwrap_or( "" );
	let suffix = if dev_name.chars().last().map_or( false, |c| c.is_ascii_digit() )
	{
		format!( "p{num}" )
	} else {
		num.to_string()
	};
	PathBuf::from( format!( "/dev/{}{}", dev_name, suffix ) )
}

/// Sort partitions by start_byte, then walk and insert free-space
/// pseudo-slots for any gap larger than `FREE_FLOOR_BYTES`. Shared
/// between the GPT, MBR and sysfs paths so all three render the
/// same shape.
fn splice_free_spaces( mut parts: Vec<PartitionInfo>, disk_path: &Path, disk_size: u64 ) -> Vec<PartitionInfo>
{
	parts.sort_by_key( |p| p.start_byte );
	let mut out: Vec<PartitionInfo> = Vec::with_capacity( parts.len() * 2 + 1 );
	let mut cursor = 0u64;
	for p in parts.into_iter()
	{
		if p.start_byte > cursor
		{
			let gap = p.start_byte - cursor;
			if gap > FREE_FLOOR_BYTES
			{
				out.push( free_slot( disk_path, cursor, gap, false ) );
			}
		}
		cursor = p.start_byte.saturating_add( p.size_bytes );
		out.push( p );
	}
	if disk_size > cursor
	{
		let gap = disk_size - cursor;
		if gap > FREE_FLOOR_BYTES
		{
			out.push( free_slot( disk_path, cursor, gap, false ) );
		}
	}
	out
}

fn free_slot( disk_path: &Path, start_byte: u64, size_bytes: u64, in_extended: bool ) -> PartitionInfo
{
	PartitionInfo
	{
		path:        disk_path.to_path_buf(),
		number:      0,
		name:        None,
		fs_type:     None,
		start_byte,
		size_bytes,
		is_free:     true,
		in_extended,
	}
}

/// Fallback partition enumeration when neither gptman nor mbrman
/// recognises the device. Reads `/sys/block/<dev>/<dev>*` directories
/// the kernel publishes from its own block-device scan; each subdir
/// has a `start` and `size` file in 512-byte sectors.
///
/// `fs_type` and partition names are left `None` — that data needs
/// `blkid` or a per-fs probe, which is out of scope for the
/// fallback. Deletes / wipes still work because they only need the
/// partition number.
fn partitions_from_sysfs( disk_path: &Path, sector_size: u64, disk_size_bytes: u64 ) -> Vec<PartitionInfo>
{
	let mut parts: Vec<PartitionInfo> = Vec::new();
	let dev_name = match disk_path.file_name().and_then( |s| s.to_str() )
	{
		Some( n ) => n,
		None      => return parts,
	};
	let block_dir = std::path::PathBuf::from( "/sys/block" ).join( dev_name );
	let entries = match std::fs::read_dir( &block_dir )
	{
		Ok( e )  => e,
		Err( _ ) => return parts,
	};
	for entry in entries.flatten()
	{
		let name = entry.file_name();
		let name_s = match name.to_str() { Some( s ) => s, None => continue };
		if !name_s.starts_with( dev_name ) { continue; }
		let suffix  = &name_s[ dev_name.len() .. ];
		let num_str = suffix.trim_start_matches( 'p' );
		let num: u32 = match num_str.parse()
		{
			Ok( n ) if n > 0 => n,
			_                => continue,
		};
		let part_dir = entry.path();
		let start_sectors: u64 = std::fs::read_to_string( part_dir.join( "start" ) )
			.ok().and_then( |s| s.trim().parse().ok() ).unwrap_or( 0 );
		let size_sectors:  u64 = std::fs::read_to_string( part_dir.join( "size" ) )
			.ok().and_then( |s| s.trim().parse().ok() ).unwrap_or( 0 );
		let part_path = std::path::PathBuf::from( format!( "/dev/{}", name_s ) );
		let fs_type   = blkid_fs_type( &part_path );
		parts.push( PartitionInfo
		{
			path:        part_path,
			number:      num,
			name:        None,
			fs_type,
			start_byte:  start_sectors.saturating_mul( sector_size ),
			size_bytes:  size_sectors.saturating_mul( sector_size ),
			is_free:     false,
			in_extended: false,
		} );
	}
	splice_free_spaces( parts, disk_path, disk_size_bytes )
}

/// Probe the filesystem type of `path` with `blkid -o value -s TYPE`.
/// Silent on failure: returns `None` if blkid is missing, refuses to
/// read the device, or the partition has no recognised FS.
pub( crate ) fn blkid_fs_type( path: &Path ) -> Option<String>
{
	let out = std::process::Command::new( "blkid" )
		.args( [ "-o", "value", "-s", "TYPE" ] )
		.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 ) }
}

/// Read `/sys/block/<dev>/removable` and return `true` for "1".
/// `false` when the file is missing, unreadable, or contains
/// anything else (e.g. on platforms where /sys is not mounted).
fn read_removable( disk_path: &std::path::Path ) -> bool
{
	let dev_name = match disk_path.file_name().and_then( |s| s.to_str() )
	{
		Some( n ) => n,
		None      => return false,
	};
	let p = std::path::PathBuf::from( "/sys/block" ).join( dev_name ).join( "removable" );
	std::fs::read_to_string( p ).map( |s| s.trim() == "1" ).unwrap_or( false )
}

/// Classify the bus of a top-level block device.
///
/// First the device-name prefix handles the synthetic / virtual
/// kernel block devices that don't sit under any transport subsystem
/// (`loopN`, `ramN`, `zramN`, `mdN`, `dm-N`, `pmemN`, `nvdimmN`). For
/// everything else we walk the sysfs ancestor chain of
/// `/sys/block/<dev>` and inspect each parent's `subsystem` symlink:
/// the first transport-level subsystem we find wins (`usb`, `mmc`,
/// `nvme`, `virtio`, `ata`, `ide`). Plain `scsi` is the last resort
/// since the kernel routes USB / SATA / SAS through the SCSI
/// mid-layer and is therefore not specific enough on its own.
pub( crate ) fn classify_bus( dev_name: &str ) -> BusKind
{
	if dev_name.starts_with( "loop" ) { return BusKind::Loop; }
	if dev_name.starts_with( "ram" )  { return BusKind::Ram;  }
	if dev_name.starts_with( "zram" ) { return BusKind::Ram;  }
	if dev_name.starts_with( "md" )   { return BusKind::Md;   }
	if dev_name.starts_with( "dm-" )  { return BusKind::Dm;   }
	if dev_name.starts_with( "pmem" ) || dev_name.starts_with( "nvdimm" )
	{
		return BusKind::Pmem;
	}

	let sysfs = std::path::PathBuf::from( "/sys/block" ).join( dev_name );
	let resolved = match std::fs::canonicalize( &sysfs )
	{
		Ok( p )  => p,
		Err( _ ) => return BusKind::Other,
	};

	let mut found_scsi = false;
	let mut cursor: &std::path::Path = resolved.as_path();
	while let Some( parent ) = cursor.parent()
	{
		if parent == std::path::Path::new( "/" ) { break; }
		let link = parent.join( "subsystem" );
		if let Ok( target ) = std::fs::read_link( &link )
		{
			let name = target.file_name().and_then( |s| s.to_str() ).unwrap_or( "" );
			match name
			{
				"usb"    => return BusKind::Usb,
				"mmc"    => return BusKind::Mmc,
				"nvme"   => return BusKind::Nvme,
				"virtio" => return BusKind::Virtio,
				"ata"    => return BusKind::Sata,
				"ide"    => return BusKind::Ide,
				"scsi"   => { found_scsi = true; }
				_        => {}
			}
		}
		cursor = parent;
	}

	if found_scsi { BusKind::Scsi } else { BusKind::Other }
}
