// Network discovery — `nmcli`-backed WiFi scan.
//
// One public entry point ([`scan_wifi_networks`]) shells out to
// `nmcli` with terse output, parses the resulting field-per-colon
// rows into [`crate::state::WifiNetwork`] values, deduplicates by
// SSID (keeping the strongest signal when several APs broadcast the
// same name), and returns an empty `Vec` when `nmcli` is missing,
// non-zero, or yields no usable rows.

use std::collections::HashSet;
use std::process::Command;

use crate::state::WifiNetwork;

/// Probe the host for visible Wi-Fi networks.
///
/// Returns the strongest AP per SSID, sorted by signal descending.
/// `SECURITY` from `nmcli` decides whether the network is locked —
/// any non-empty value other than `--` means it expects a key
/// (WPA*, WEP, 802.1X, …). The 0–100 % signal `nmcli` reports is
/// quantised to the 0–4 bar scale the wireless screen renders.
pub fn scan_wifi_networks() -> Vec<WifiNetwork>
{
	// SSID goes LAST so any literal colons inside it stay together
	// in `splitn( 4, ':' )`'s tail field — nmcli's terse output
	// escapes them as `\:`, which `unescape_field` then folds back.
	let output = Command::new( "nmcli" )
		.args( [
			"-t",
			"-f", "SECURITY,ACTIVE,SIGNAL,SSID",
			"dev", "wifi", "list", "--rescan", "yes",
		] )
		.output();

	let text = match output
	{
		Ok( out ) if out.status.success() =>
		{
			String::from_utf8_lossy( &out.stdout ).into_owned()
		}
		Ok( out ) =>
		{
			eprintln!( "network::scan_wifi_networks: nmcli exit {}: {}",
				out.status, String::from_utf8_lossy( &out.stderr ).trim() );
			return Vec::new();
		}
		Err( e ) =>
		{
			eprintln!( "network::scan_wifi_networks: spawn nmcli: {e}" );
			return Vec::new();
		}
	};

	let mut nets: Vec<WifiNetwork> = text.lines()
		.filter_map( parse_line )
		.collect();

	// Strongest first, then drop duplicate SSIDs.
	nets.sort_by( |a, b| b.signal.cmp( &a.signal ) );
	let mut seen: HashSet<String> = HashSet::new();
	nets.retain( |n| seen.insert( n.ssid.clone() ) );
	nets
}

fn parse_line( line: &str ) -> Option<WifiNetwork>
{
	let parts: Vec<&str> = line.splitn( 4, ':' ).collect();
	if parts.len() < 4 { return None; }

	let security = unescape_field( parts[ 0 ] );
	let signal:  u8 = parts[ 2 ].parse().unwrap_or( 0 );
	let ssid       = unescape_field( parts[ 3 ] );
	if ssid.is_empty() { return None; }

	let locked = !security.is_empty() && security != "--";
	let bars   = ( ( signal as u32 ).saturating_add( 12 ) / 25 ).min( 4 ) as u8;

	Some( WifiNetwork { ssid, locked, signal: bars } )
}

/// Fold `nmcli -t`'s backslash escapes (`\:` → `:`, `\\` → `\`)
/// back into their literal characters. Anything else after a
/// backslash is passed through verbatim — nmcli only escapes the
/// two delimiter characters.
fn unescape_field( s: &str ) -> String
{
	let mut out = String::with_capacity( s.len() );
	let mut chars = s.chars();
	while let Some( c ) = chars.next()
	{
		if c == '\\'
		{
			if let Some( next ) = chars.next()
			{
				out.push( next );
				continue;
			}
		}
		out.push( c );
	}
	out
}

/// Check whether NetworkManager believes the system has full
/// internet connectivity right now. Reads the *cached* connectivity
/// value (`nmcli networking connectivity`, no `check` argument) so
/// the call is fast — typically tens of milliseconds — and safe to
/// poll at 1 Hz from the UI thread without freezing it.
///
/// Returns `true` only for `full`. `limited` (link-local only),
/// `portal` (captive portal), `none` and `unknown` all map to
/// `false` because the installer needs working DNS + outbound HTTP
/// to fetch packages, and any of the non-`full` states would block
/// that.
pub fn is_online() -> bool
{
	let out = match Command::new( "nmcli" )
		.args( [ "-t", "networking", "connectivity" ] )
		.output()
	{
		Ok( o )  => o,
		Err( _ ) => return false,
	};
	if !out.status.success() { return false; }
	String::from_utf8_lossy( &out.stdout ).trim() == "full"
}

/// Synchronously bring up a Wi-Fi connection to `ssid` and report
/// whether the radio actually associated.
///
/// Build-and-activate (rather than the simpler
/// `nmcli device wifi connect <ssid> password <pwd>`) because the
/// short form delegates `key-mgmt` detection to the live scan cache
/// and can fail with `802-11-wireless-security.key-mgmt: property
/// is missing` when the cache never captured the AP's security
/// flags or has gone stale between scan and connect. The explicit
/// `connection add` pins `key-mgmt = wpa-psk`, `psk = <pwd>` and
/// `psk-flags = 0` directly into the new profile, so NetworkManager
/// has every field it needs *and* the PSK is stored as a system
/// secret rather than agent-owned — without `psk-flags = 0` the
/// activation step refuses to read the PSK from the profile and
/// asks for a secret agent (which the installer does not run),
/// failing with "Secrets were required, but not provided".
///
/// The flow is:
///
/// 1. Best-effort `connection delete id <ssid>` to drop any leftover
///    profile from a previous attempt — the next `connection add`
///    would otherwise refuse to create a duplicate `con-name`.
///    Errors here (no such profile) are expected and silently
///    ignored.
/// 2. `connection add` — creates the profile.
/// 3. `connection up <ssid>` — activates it.
///
/// `-w 30` caps the wait at 30 seconds for steps 2 and 3, bounding
/// how long the UI thread is frozen. Open networks (no `password`)
/// skip the security-section args; everything else uses `wpa-psk`
/// (covers WPA2-PSK and WPA2/WPA3-mixed; pure WPA3-SAE networks
/// would need `sae`, but those are still rare and the error path
/// lets the user retry / fall back to cellular).
///
/// Property names use the `wifi-sec.*` short alias instead of the
/// canonical `802-11-wireless-security.*` form: the long form needs
/// the `--` separator to start the property block, and some nmcli
/// versions silently mis-parse the trailing pairs after `--` such
/// that the PSK never lands in the profile.
///
/// Returns `Ok( () )` only when both steps exit 0; otherwise
/// returns the trimmed stderr / stdout so the caller can surface
/// it inline in the password dialog.
pub fn connect_wifi( ssid: &str, password: Option<&str>, hidden: bool ) -> Result<(), String>
{
	let _ = Command::new( "nmcli" )
		.args( [ "-w", "5", "connection", "delete", "id", ssid ] )
		.output();

	let mut add = Command::new( "nmcli" );
	add.args( [
		"-w", "30",
		"connection", "add",
		"type", "wifi",
		"con-name", ssid,
		"ifname", "*",
		"ssid", ssid,
	] );
	// Manually-entered networks may not be broadcasting; `hidden yes`
	// makes NetworkManager probe for the SSID instead of requiring it
	// in the scan cache.
	if hidden
	{
		add.args( [ "wifi.hidden", "yes" ] );
	}
	if let Some( p ) = password
	{
		add.args( [
			"wifi-sec.key-mgmt", "wpa-psk",
			"wifi-sec.psk",       p,
			"wifi-sec.psk-flags", "0",
		] );
	}
	let out = add.output()
		.map_err( |e| format!( "spawn nmcli: {e}" ) )?;
	if !out.status.success()
	{
		return Err( nmcli_error( "connection add", &out ) );
	}

	let out = Command::new( "nmcli" )
		.args( [ "-w", "30", "connection", "up", ssid ] )
		.output()
		.map_err( |e| format!( "spawn nmcli: {e}" ) )?;
	if out.status.success()
	{
		return Ok( () );
	}
	Err( nmcli_error( "connection up", &out ) )
}

fn nmcli_error( phase: &str, out: &std::process::Output ) -> String
{
	let stderr = String::from_utf8_lossy( &out.stderr ).trim().to_string();
	let stdout = String::from_utf8_lossy( &out.stdout ).trim().to_string();
	if !stderr.is_empty() { stderr }
	else if !stdout.is_empty() { stdout }
	else { format!( "nmcli {phase}: exit {}", out.status ) }
}

#[ cfg( test ) ]
mod tests
{
	use super::*;

	#[ test ]
	fn parse_line_open_network()
	{
		let n = parse_line( ":yes:80:CafeWifi" ).unwrap();
		assert_eq!( n.ssid,   "CafeWifi" );
		assert_eq!( n.locked, false );
		assert_eq!( n.signal, 4 );
	}

	#[ test ]
	fn parse_line_wpa2_network()
	{
		let n = parse_line( "WPA2:no:55:Casa_Principal_5G" ).unwrap();
		assert_eq!( n.ssid,   "Casa_Principal_5G" );
		assert!( n.locked );
		assert_eq!( n.signal, 2 );
	}

	#[ test ]
	fn parse_line_dash_security_is_open()
	{
		let n = parse_line( "--:no:30:Open_AP" ).unwrap();
		assert!( !n.locked );
	}

	#[ test ]
	fn parse_line_drops_empty_ssid()
	{
		assert!( parse_line( "WPA2:no:50:" ).is_none() );
	}

	#[ test ]
	fn parse_line_handles_escaped_colon_in_ssid()
	{
		// nmcli emits literal colons inside an SSID as `\:`.
		let n = parse_line( "WPA2:no:60:home\\:net" ).unwrap();
		assert_eq!( n.ssid, "home:net" );
	}

	#[ test ]
	fn parse_line_signal_bars_quantisation()
	{
		// 0–12 → 0; 13–37 → 1; 38–62 → 2; 63–87 → 3; 88–100 → 4
		assert_eq!( parse_line( "--:no:5:a"   ).unwrap().signal, 0 );
		assert_eq!( parse_line( "--:no:25:a"  ).unwrap().signal, 1 );
		assert_eq!( parse_line( "--:no:50:a"  ).unwrap().signal, 2 );
		assert_eq!( parse_line( "--:no:75:a"  ).unwrap().signal, 3 );
		assert_eq!( parse_line( "--:no:100:a" ).unwrap().signal, 4 );
	}

	#[ test ]
	fn parse_line_rejects_short_lines()
	{
		assert!( parse_line( "WPA2:no:60" ).is_none() );
		assert!( parse_line( "" ).is_none() );
	}

	#[ test ]
	fn unescape_field_passes_through_normal_strings()
	{
		assert_eq!( unescape_field( "plain" ), "plain" );
	}

	#[ test ]
	fn unescape_field_unfolds_colon_and_backslash()
	{
		assert_eq!( unescape_field( "a\\:b" ),  "a:b" );
		assert_eq!( unescape_field( "a\\\\b" ), "a\\b" );
	}
}
