// Screen 04 — Disk management.

use std::sync::OnceLock;
use std::time::Instant;

use ltk::
{
	ButtonVariant, Color, Corners, Element,
	button, column, container, dialog, flex, icon_button, pressable, row, scroll,
	separator, slider, spacer, spinner, stack, text,
};

use crate::app::Msg;
use crate::assets;
use crate::flow::Screen;
use crate::i18n::tr;
use crate::state::
{
	disk_pending_wipe, partition_pending_delete, partition_pending_resize, pending_creates_in_slot,
	AddPartitionForm, DiskGroup, DiskIcon, DiskOp, DiskSelection, InstallTarget, ResizeForm, State,
};
use crate::ui::chrome;
use crate::ui::theme as t;

const HIGHLIGHT_ALPHA: f32 = 0.18;
const PENDING_ALPHA:   f32 = 0.55;
/// Floor for a free / residual slot to be worth rendering. Below
/// this, any leftover bytes are silently absorbed (sub-MiB gaps
/// can show up after a `CreatePartition` whose size doesn't divide
/// the free slot evenly, and a ghost row for a sliver smaller than
/// any usable filesystem would just be visual noise).
const MIN_PART_BYTES: u64 = 1024 * 1024;
/// Foreground colour used for queued-but-not-yet-applied
/// `CreatePartition` rows. Materialised partitions read in the
/// theme's primary text colour; deleted-pending ones in a faded
/// grey via [`pending_color`]; create-pending ones in this green so
/// the user can tell at a glance which rows still need an apply.
const PENDING_CREATE_COLOR: Color = Color { r: 0.18, g: 0.62, b: 0.30, a: 1.0 };

#[ derive( Clone, Copy ) ]
struct Metrics
{
	padding:     f32,
	disk_icon:   u32,
	btn_icon:    u32,
	title_size:  f32,
	sub_size:    f32,
}

fn metrics( viewport_h: u32 ) -> Metrics
{
	let h         = viewport_h.max( 320 ) as f32;
	let disk_icon = ( h * 0.03 ).max( 14.0 ) as u32;
	let btn_icon  = ( h * 0.02 ).max( 10.0 ) as u32;
	let title_size = ( disk_icon as f32 ) * 0.55;
	let sub_size   = ( disk_icon as f32 ) * 0.35;
	Metrics
	{
		padding:    0.0,
		disk_icon,
		btn_icon,
		title_size,
		sub_size,
	}
}

pub fn view( state: &State ) -> Element<Msg>
{
	let m = metrics( state.viewport.1 );
	let mut list = column::<Msg>().spacing( t::ITEM_SPACING );
	for ( disk_idx, disk ) in state.disks.iter().enumerate()
	{
		list = list.push( disk_card( disk, disk_idx, state.selection.as_ref(), &state.pending_ops, m ) );
	}

	let cta_msg = state.selection.as_ref().map( |_| Msg::OpenInstallConfirm );

	// Pending changes block the "Continue" CTA — the user must
	// commit or discard before moving on. Once the queue is empty,
	// the wizard's normal "Continue" comes back.
	let footer = if !state.pending_ops.is_empty()
	{
		row::<Msg>()
			.spacing( t::ITEM_SPACING )
			.push( spacer() )
			.push(
				button::<Msg>( tr!( "disk.discard" ).to_string() )
					.variant( ButtonVariant::Tertiary )
					.font_size( t::control_font_size() )
					.on_press( Msg::DiscardOps )
					.height( t::control_height() ),
			)
			.push(
				button::<Msg>( tr!( "disk.apply" ).to_string() )
					.variant( ButtonVariant::Primary )
					.font_size( t::control_font_size() )
					.on_press( Msg::ApplyOps )
					.height( t::control_height() ),
			)
	}
	else
	{
		row::<Msg>()
			.spacing( t::ITEM_SPACING )
			.push( spacer() )
			.push(
				button::<Msg>( tr!( "common.continue" ).to_string() )
					.variant( ButtonVariant::Primary )
					.font_size( t::control_font_size() )
					.on_press_maybe( cta_msg )
					.height( t::control_height() ),
			)
	};

	let body = column::<Msg>()
		.padding( t::PAGE_PADDING_H )
		.spacing( t::SECTION_SPACING )
		.push( chrome::top_bar( Screen::DiskManagement, true, state.mode ) )
		.push( chrome::title_block(
			&tr!( "disk.title" ),
			Some( &tr!( "disk.subtitle" ) ),
		) )
		.push( scroll( list ) )
		.push( footer );

	let page = chrome::page( body );

	if state.applying
	{
		stack::<Msg>().push( page ).push( applying_dialog() ).into()
	}
	else if let Some( msg ) = &state.last_error
	{
		stack::<Msg>().push( page ).push( error_dialog( msg ) ).into()
	}
	else if state.confirm_install
	{
		stack::<Msg>().push( page ).push( install_confirm_dialog( state ) ).into()
	}
	else if state.confirm_apply
	{
		stack::<Msg>().push( page ).push( confirm_dialog( state ) ).into()
	}
	else if let Some( form ) = state.resize_form
	{
		stack::<Msg>().push( page ).push( resize_dialog( form ) ).into()
	}
	else if let Some( form ) = state.add_partition_form
	{
		stack::<Msg>().push( page ).push( add_partition_dialog( form ) ).into()
	}
	else
	{
		page
	}
}

fn disk_card(
	disk:        &DiskGroup,
	disk_idx:    usize,
	selection:   Option<&DiskSelection>,
	pending_ops: &[ DiskOp ],
	m:           Metrics,
) -> Element<Msg>
{
	let palette  = ltk::theme_palette();
	let disk_sel = matches!( selection, Some( DiskSelection::Disk( p ) ) if *p == disk.device_path );
	let wipe_pending = disk_pending_wipe( pending_ops, disk_idx );

	let header_inner = row::<Msg>()
		.spacing( 8.0 )
		.padding( m.padding )
		.push( disk_icon( disk.icon, m.disk_icon ) )
		.push( flex(
			column::<Msg>()
				.spacing( 1.0 )
				.push( text( disk.label.clone() )
					.size( m.title_size )
					.color( pending_color( palette.text_primary, wipe_pending ) )
					.weight( 700 ) )
				.push( text( format_size( disk.size_bytes ) )
					.size( m.sub_size )
					.color( pending_color( palette.text_secondary, wipe_pending ) ) ),
		) );

	let header: Element<Msg> = pressable::<Msg>( header_inner )
		.on_press( Msg::SelectDisk( disk_idx ) )
		.into();

	let mut body = column::<Msg>()
		.spacing( 0.0 )
		.push( highlight_wrap( header, disk_sel ) );

	if !disk.partitions.is_empty()
	{
		body = body.push( separator().pad_v( 0.0 ) );
	}

	for ( part_idx, part ) in disk.partitions.iter().enumerate()
	{
		let part_sel_specific = match selection
		{
			Some( DiskSelection::Partition( p, n ) ) =>
				*p == disk.device_path && !part.is_free && *n == part.number,
			Some( DiskSelection::FreeSpace( p, s ) ) =>
				*p == disk.device_path && part.is_free && *s == part.start_byte,
			_ => false,
		};
		let slot_sel = disk_sel || part_sel_specific;
		if part.is_free
		{
			let creates = pending_creates_in_slot( pending_ops, disk_idx, part.start_byte, part.size_bytes );
			if creates.is_empty()
			{
				body = body.push( highlight_wrap(
					free_space_row( disk_idx, part_idx, part, m ),
					slot_sel,
				) );
				continue;
			}

			// One or more queued creates land inside this free slot.
			// Walk the slot left-to-right, emitting a ghost partition
			// row for each create and a residual free-space row for
			// any gap between them or trailing the last one. The
			// user keeps the same single `SelectTarget( disk_idx,
			// part_idx )` selection on every residual chunk because
			// they all belong to the same original free slot — once
			// the queue is applied and the disk is rediscovered, the
			// new layout takes over and selection is re-validated by
			// `selection::still_valid`.
			let slot_end = part.start_byte.saturating_add( part.size_bytes );
			let mut cursor = part.start_byte;
			for ( c_start, c_size ) in &creates
			{
				if *c_start > cursor
				{
					let gap = c_start - cursor;
					if gap >= MIN_PART_BYTES
					{
						body = body.push( highlight_wrap(
							residual_free_row( disk_idx, part_idx, cursor, gap, m ),
							slot_sel,
						) );
					}
				}
				body = body.push( ghost_create_row( *c_size, m ) );
				cursor = c_start.saturating_add( *c_size );
			}
			if cursor < slot_end
			{
				let trailing = slot_end - cursor;
				if trailing >= MIN_PART_BYTES
				{
					body = body.push( highlight_wrap(
						residual_free_row( disk_idx, part_idx, cursor, trailing, m ),
						slot_sel,
					) );
				}
			}
			continue;
		}
		let part_pending = partition_pending_delete( pending_ops, disk_idx, part.number );
		let resize_to   = partition_pending_resize( pending_ops, disk_idx, part.number );
		body = body.push( highlight_wrap(
			partition_row( disk_idx, part_idx, part, part_pending, resize_to, m ),
			slot_sel,
		) );
	}

	container::<Msg>( body )
		.background( palette.surface )
		.radius( Corners::all( t::CARD_RADIUS ) )
		.into()
}

fn free_space_row( disk_idx: usize, part_idx: usize, part: &InstallTarget, m: Metrics ) -> Element<Msg>
{
	let palette = ltk::theme_palette();
	let labels = column::<Msg>()
		.spacing( 1.0 )
		.push( text( part.label.clone() )
			.size( m.title_size )
			.color( palette.text_primary ) )
		.push( text( format_size( part.size_bytes ) )
			.size( m.sub_size )
			.color( palette.text_secondary ) );

	let body = row::<Msg>()
		.spacing( 8.0 )
		.padding( m.padding )
		.push( flex( labels ) )
		.push( add_partition_button(
			Msg::OpenAddPartition( disk_idx, part.start_byte, part.size_bytes ),
			m.btn_icon,
		) );

	pressable::<Msg>( body )
		.on_press( Msg::SelectTarget( disk_idx, part_idx ) )
		.into()
}

/// Free-space row for the residual portion of a slot whose first
/// chunk(s) are consumed by queued [`DiskOp::CreatePartition`]
/// entries. Same shape as [`free_space_row`] (still selectable as
/// install target — selection points back at the original free
/// slot, which `selection::still_valid` re-checks after apply) but
/// the displayed size and the "+" button's anchor are the start /
/// size of the leftover region, not the original slot's.
fn residual_free_row( disk_idx: usize, part_idx: usize, start_byte: u64, size_bytes: u64, m: Metrics ) -> Element<Msg>
{
	let palette = ltk::theme_palette();
	let labels = column::<Msg>()
		.spacing( 1.0 )
		.push( text( tr!( "disk.free_space" ).to_string() )
			.size( m.title_size )
			.color( palette.text_primary ) )
		.push( text( format_size( size_bytes ) )
			.size( m.sub_size )
			.color( palette.text_secondary ) );

	let body = row::<Msg>()
		.spacing( 8.0 )
		.padding( m.padding )
		.push( flex( labels ) )
		.push( add_partition_button(
			Msg::OpenAddPartition( disk_idx, start_byte, size_bytes ),
			m.btn_icon,
		) );

	pressable::<Msg>( body )
		.on_press( Msg::SelectTarget( disk_idx, part_idx ) )
		.into()
}

/// Read-only "ghost" partition row drawn in [`PENDING_CREATE_COLOR`]
/// for a queued but not-yet-applied [`DiskOp::CreatePartition`].
/// Carries the same visual weight as a real partition row so the
/// user can see the future layout — minus the resize / trash icon
/// buttons (no FS exists yet) and minus any `Pressable`, since the
/// new partition has no GPT/MBR number to anchor a `SelectTarget`
/// against until the queue is applied.
fn ghost_create_row( size_bytes: u64, m: Metrics ) -> Element<Msg>
{
	let labels = column::<Msg>()
		.spacing( 1.0 )
		.push( text( tr!( "disk.partition_new" ).to_string() )
			.size( m.title_size )
			.color( PENDING_CREATE_COLOR )
			.weight( 700 ) )
		.push( text( format_size( size_bytes ) )
			.size( m.sub_size )
			.color( PENDING_CREATE_COLOR ) );

	row::<Msg>()
		.spacing( 8.0 )
		.padding( m.padding )
		.push( flex( labels ) )
		.into()
}

fn partition_row(
	disk_idx:  usize,
	part_idx:  usize,
	part:      &InstallTarget,
	pending:   bool,
	resize_to: Option<u64>,
	m:         Metrics,
) -> Element<Msg>
{
	let palette = ltk::theme_palette();

	let size_text = match resize_to
	{
		Some( new_bytes ) => format!( "{} → {}", format_size( part.size_bytes ), format_size( new_bytes ) ),
		None              => format_size( part.size_bytes ),
	};

	let labels = column::<Msg>()
		.spacing( 1.0 )
		.push( text( part.label.clone() )
			.size( m.title_size )
			.color( pending_color( palette.text_primary, pending ) ) )
		.push( text( size_text )
			.size( m.sub_size )
			.color( pending_color( palette.text_secondary, pending ) ) );

	let kind = crate::fs::parse( part.fs_type.as_deref() );

	let mut body = row::<Msg>()
		.spacing( 8.0 )
		.padding( m.padding )
		.push( flex( labels ) );
	// Resize is offered when there's a way to safely set the new
	// size. That's either (a) a known FS we can shrink with the
	// host's userspace tools, or (b) a partition with no recognised
	// filesystem at all — in (b) there is nothing inside to preserve,
	// so the apply step just rewrites the table entry without
	// touching contents.
	let resizable = ( crate::fs::can_shrink( kind ) && crate::fs::tools_available( kind ) )
		|| kind == crate::fs::FsKind::Unknown;
	if resizable
	{
		body = body.push( resize_button( Msg::OpenResize( disk_idx, part_idx ), m.btn_icon ) );
	}
	body = body.push( trash_button( Msg::DeletePartition( disk_idx, part_idx ), m.btn_icon ) );

	pressable::<Msg>( body )
		.on_press( Msg::SelectTarget( disk_idx, part_idx ) )
		.into()
}

fn highlight_wrap( child: Element<Msg>, selected: bool ) -> Element<Msg>
{
	if !selected { return child; }
	let a = ltk::theme_palette().accent;
	let bg = Color { r: a.r, g: a.g, b: a.b, a: HIGHLIGHT_ALPHA };
	container::<Msg>( child )
		.background( bg )
		.radius( Corners::all( 0.0 ) )
		.into()
}

fn pending_color( base: Color, pending: bool ) -> Color
{
	if pending { Color { r: base.r, g: base.g, b: base.b, a: base.a * PENDING_ALPHA } }
	else       { base }
}

fn trash_button( on_press: Msg, size: u32 ) -> Element<Msg>
{
	icon_glyph_button( "actions/delete", on_press, size )
}

fn resize_button( on_press: Msg, size: u32 ) -> Element<Msg>
{
	icon_glyph_button( "navigation/full-screen-expand", on_press, size )
}

fn add_partition_button( on_press: Msg, size: u32 ) -> Element<Msg>
{
	icon_glyph_button( "actions/add", on_press, size )
}

fn icon_glyph_button( name: &str, on_press: Msg, size: u32 ) -> Element<Msg>
{
	let ( rgba, w, h ) = match ltk::theme_icon_rgba( name, size )
	{
		Some( v ) => v,
		None      => return spacer().into(),
	};
	icon_button( rgba, w, h )
		.icon_size( size as f32 )
		.on_press( on_press )
		.into()
}

fn disk_icon( kind: DiskIcon, size: u32 ) -> Element<Msg>
{
	let name = match kind
	{
		DiskIcon::HardDisk  => "hardware/harddisk",
		DiskIcon::HardDrive => "hardware/harddrive",
		DiskIcon::Chip      => "hardware/chip",
		DiskIcon::Usb       => "hardware/usb",
		DiskIcon::SdCard    => "hardware/sdcard",
	};
	assets::theme_icon::<Msg>( name, size )
		.unwrap_or_else( || spacer().into() )
}

// ── Confirmation overlay ─────────────────────────────────────────────────────

fn confirm_dialog( state: &State ) -> Element<Msg>
{
	let palette = ltk::theme_palette();

	let mut ops_list = column::<Msg>().spacing( 4.0 );
	for op in &state.pending_ops
	{
		ops_list = ops_list.push( text( describe_op( state, *op ) )
			.size( t::SMALL_SIZE )
			.color( palette.text_primary )
			.wrap( true ) );
	}

	dialog::<Msg>()
		.title( tr!( "disk.confirm_title" ).to_string() )
		.subtitle( tr!( "disk.confirm_subtitle" ).to_string() )
		.body( ops_list )
		.cancel( Msg::CancelApply )
		.action(
			button::<Msg>( tr!( "disk.confirm_no" ).to_string() )
				.variant( ButtonVariant::Tertiary )
				.font_size( t::control_font_size() )
				.on_press( Msg::CancelApply )
				.height( t::control_height() ),
		)
		.action(
			button::<Msg>( tr!( "disk.confirm_yes" ).to_string() )
				.variant( ButtonVariant::Primary )
				.font_size( t::control_font_size() )
				.on_press( Msg::ConfirmApply )
				.height( t::control_height() ),
		)
		.into()
}

fn describe_op( state: &State, op: DiskOp ) -> String
{
	let disk_label = |idx: usize| -> String
	{
		state.disks.get( idx )
			.map( |d| d.label.clone() )
			.unwrap_or_else( || format!( "disk #{idx}" ) )
	};
	match op
	{
		DiskOp::WipeDisk( d )                => tr!( "disk.op_wipe", disk = disk_label( d ) ).to_string(),
		DiskOp::DeletePartition( d, n )      => tr!( "disk.op_delete", num = n.to_string(), disk = disk_label( d ) ).to_string(),
		DiskOp::ResizePartition( d, n, sz )  => tr!( "disk.op_resize",
			num = n.to_string(),
			disk = disk_label( d ),
			size = format_size( sz ) ).to_string(),
		DiskOp::CreatePartition( d, _, sz )  => tr!( "disk.op_create",
			disk = disk_label( d ),
			size = format_size( sz ) ).to_string(),
	}
}

fn applying_dialog() -> Element<Msg>
{
	let palette = ltk::theme_palette();
	let body = column::<Msg>()
		.spacing( t::ITEM_SPACING )
		.align_center_x( true )
		.push( spinner().size( 48.0 ).stroke_width( 4.0 ).phase( spinner_phase() ) )
		.push( text( tr!( "disk.applying" ).to_string() )
			.size( t::BODY_SIZE )
			.color( palette.text_primary )
			.align_center() );

	// No `cancel(...)` — applying is non-cancellable, so ESC and the
	// (modal) scrim both silently absorb input until the apply
	// finishes and the screen swaps the dialog out.
	dialog::<Msg>().body( body ).into()
}

/// Monotonically-increasing seconds-since-process-start. Drives the
/// spinner's `phase` so the arc actually rotates instead of
/// freezing at angle 0 (ltk's spinner is stateless — without an
/// updating phase it never moves).
fn spinner_phase() -> f32
{
	static START: OnceLock<Instant> = OnceLock::new();
	START.get_or_init( Instant::now ).elapsed().as_secs_f32()
}

fn install_confirm_dialog( state: &State ) -> Element<Msg>
{
	let target = selection_label( state ).unwrap_or_else( || "—".to_string() );
	dialog::<Msg>()
		.title( tr!( "disk.install_confirm_title", target = target ).to_string() )
		.subtitle( tr!( "disk.install_confirm_subtitle" ).to_string() )
		.cancel( Msg::CancelInstallConfirm )
		.action(
			button::<Msg>( tr!( "disk.dialog_cancel" ).to_string() )
				.variant( ButtonVariant::Tertiary )
				.font_size( t::control_font_size() )
				.on_press( Msg::CancelInstallConfirm )
				.height( t::control_height() ),
		)
		.action(
			button::<Msg>( tr!( "disk.dialog_accept" ).to_string() )
				.variant( ButtonVariant::Primary )
				.font_size( t::control_font_size() )
				.on_press( Msg::ConfirmInstall )
				.height( t::control_height() ),
		)
		.into()
}

/// Build a human-readable summary of the user's current selection
/// for the "data on X will be erased" confirmation. Empty when the
/// selection no longer resolves (e.g. after a stale commit).
fn selection_label( state: &State ) -> Option<String>
{
	let sel = state.selection.as_ref()?;
	let disk = state.disks.iter().find( |d| d.device_path == *sel.disk_path() )?;
	match sel
	{
		DiskSelection::Disk( _ )           => Some( disk.label.clone() ),
		DiskSelection::Partition( _, num ) =>
			disk.partitions.iter()
				.find( |p| !p.is_free && p.number == *num )
				.map( |p| format!( "{} — {}", disk.label, p.label ) ),
		DiskSelection::FreeSpace( _, start ) =>
			disk.partitions.iter()
				.find( |p| p.is_free && p.start_byte == *start )
				.map( |p| format!( "{} — {}", disk.label, p.label ) ),
	}
}

fn error_dialog( msg: &str ) -> Element<Msg>
{
	dialog::<Msg>()
		.title( tr!( "disk.error_title" ).to_string() )
		.subtitle( msg.to_string() )
		.cancel( Msg::DismissError )
		.action(
			button::<Msg>( tr!( "disk.error_dismiss" ).to_string() )
				.variant( ButtonVariant::Primary )
				.font_size( t::control_font_size() )
				.on_press( Msg::DismissError )
				.height( t::control_height() ),
		)
		.into()
}

fn resize_dialog( form: ResizeForm ) -> Element<Msg>
{
	let palette = ltk::theme_palette();

	// Track is 0..max_bytes (current size + adjacent free space).
	// Blue fill = `current`. The thumb starts at the partition's
	// existing size so the user can drag both ways.
	let pos = if form.max_bytes == 0 { 0.0 }
		else { ( form.current as f32 ) / ( form.max_bytes as f32 ) };

	let size_line = tr!( "disk.resize_summary",
		size = format_size( form.current ),
		min  = format_size( form.min_bytes ),
		max  = format_size( form.max_bytes ) ).to_string();

	let delta_line = if form.current < form.original
	{
		tr!( "disk.resize_freed",
			size = format_size( form.original - form.current ) ).to_string()
	}
	else if form.current > form.original
	{
		tr!( "disk.resize_added",
			size = format_size( form.current - form.original ) ).to_string()
	}
	else
	{
		String::new()
	};

	let mut body = column::<Msg>().spacing( t::ITEM_SPACING );
	if form.is_unknown_fs
	{
		// No recognised FS inside this partition — `apply` skips the
		// FS-aware shrink and only rewrites the table entry, which
		// silently truncates whatever raw bytes happen to live in
		// the freed tail. Surface that risk in red so the user has a
		// chance to back out before queueing the op.
		body = body.push( text( tr!( "disk.resize_unknown_fs_warning" ).to_string() )
			.size( t::SMALL_SIZE )
			.color( palette.danger )
			.wrap( true ) );
	}
	body = body
		.push( slider( pos ).on_change( Msg::ResizeChange ) )
		.push( text( size_line )
			.size( t::BODY_SIZE )
			.color( palette.text_primary ) )
		.push( text( delta_line )
			.size( t::SMALL_SIZE )
			.color( palette.text_secondary ) );

	dialog::<Msg>()
		.title( tr!( "disk.resize_title" ).to_string() )
		.subtitle( tr!( "disk.resize_subtitle" ).to_string() )
		.body( body )
		.cancel( Msg::CloseResize )
		.action(
			button::<Msg>( tr!( "disk.dialog_cancel" ).to_string() )
				.variant( ButtonVariant::Tertiary )
				.font_size( t::control_font_size() )
				.on_press( Msg::CloseResize )
				.height( t::control_height() ),
		)
		.action(
			button::<Msg>( tr!( "disk.dialog_accept" ).to_string() )
				.variant( ButtonVariant::Primary )
				.font_size( t::control_font_size() )
				.on_press( Msg::ConfirmResize )
				.height( t::control_height() ),
		)
		.into()
}

fn add_partition_dialog( form: AddPartitionForm ) -> Element<Msg>
{
	let palette = ltk::theme_palette();

	// Slider track is 0..max_bytes (the size of the chosen free
	// slot). Default thumb position = max so the user opts into
	// "use all available space" with a single Accept.
	let pos = if form.max_bytes == 0 { 0.0 }
		else { ( form.current as f32 ) / ( form.max_bytes as f32 ) };

	let size_line = tr!( "disk.add_summary",
		size = format_size( form.current ),
		min  = format_size( form.min_bytes ),
		max  = format_size( form.max_bytes ) ).to_string();

	let body = column::<Msg>()
		.spacing( t::ITEM_SPACING )
		.push( slider( pos ).on_change( Msg::AddPartitionChange ) )
		.push( text( size_line )
			.size( t::BODY_SIZE )
			.color( palette.text_primary ) );

	dialog::<Msg>()
		.title( tr!( "disk.add_title" ).to_string() )
		.subtitle( tr!( "disk.add_subtitle" ).to_string() )
		.body( body )
		.cancel( Msg::CloseAddPartition )
		.action(
			button::<Msg>( tr!( "disk.dialog_cancel" ).to_string() )
				.variant( ButtonVariant::Tertiary )
				.font_size( t::control_font_size() )
				.on_press( Msg::CloseAddPartition )
				.height( t::control_height() ),
		)
		.action(
			button::<Msg>( tr!( "disk.dialog_accept" ).to_string() )
				.variant( ButtonVariant::Primary )
				.font_size( t::control_font_size() )
				.on_press( Msg::ConfirmAddPartition )
				.height( t::control_height() ),
		)
		.into()
}

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

fn format_size( bytes: u64 ) -> String
{
	let gb = bytes as f64 / 1_000_000_000.0;
	if gb >= 1.0
	{
		format!( "{:.1} GB", gb )
	}
	else
	{
		let mb = bytes as f64 / 1_000_000.0;
		format!( "{:.0} MB", mb )
	}
}
