// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! Blocking D-Bus client to NetworkManager (`org.freedesktop.NetworkManager` on
//! the system bus): Wi-Fi radio state, access-point scan, connect / forget,
//! hotspot and VPN queries. Every entry point degrades gracefully when the
//! daemon is unreachable.

use std::collections::{ HashMap, HashSet };
use std::time::Duration;

use zbus::blocking::{ Connection, Proxy };
use zbus::zvariant::{ ObjectPath, OwnedObjectPath, OwnedValue, Value };

const DEST:           &str = "org.freedesktop.NetworkManager";
const NM_PATH:        &str = "/org/freedesktop/NetworkManager";
const NM_IFACE:       &str = "org.freedesktop.NetworkManager";
const SETTINGS_PATH:  &str = "/org/freedesktop/NetworkManager/Settings";
const SETTINGS_IFACE: &str = "org.freedesktop.NetworkManager.Settings";
const CONN_IFACE:     &str = "org.freedesktop.NetworkManager.Settings.Connection";
const DEVICE_IFACE:   &str = "org.freedesktop.NetworkManager.Device";
const WIRELESS_IFACE: &str = "org.freedesktop.NetworkManager.Device.Wireless";
const AP_IFACE:       &str = "org.freedesktop.NetworkManager.AccessPoint";
const ACTIVE_IFACE:   &str = "org.freedesktop.NetworkManager.Connection.Active";

const DEVICE_TYPE_WIFI:        u32 = 2;
const ACTIVE_STATE_ACTIVATING: u32 = 1;
const ACTIVE_STATE_ACTIVATED:  u32 = 2;
const AP_FLAGS_PRIVACY:        u32 = 0x1;
// OWE ("Enhanced Open") advertises RSN but needs no password — treat as open.
// 0x400 is SAE (WPA3-Personal, password-based); it must stay out of this mask.
const RSN_KEY_MGMT_OWE:        u32 = 0x800;
const RSN_KEY_MGMT_OWE_TM:     u32 = 0x1000;

type Settings = HashMap<String, HashMap<String, OwnedValue>>;

pub struct WifiNetwork
{
	pub ssid:      String,
	pub signal:    u8,
	pub connected: bool,
	pub secured:   bool,
	pub known:     bool,
}

fn conn() -> Option<Connection>
{
	Connection::system().ok()
}

fn proxy<'a>( c: &'a Connection, path: &'a str, iface: &'a str ) -> Option<Proxy<'a>>
{
	Proxy::new( c, DEST, path, iface ).ok()
}

fn root_path() -> ObjectPath<'static>
{
	ObjectPath::from_static_str_unchecked( "/" )
}

fn wifi_device( c: &Connection ) -> Option<OwnedObjectPath>
{
	let p = proxy( c, NM_PATH, NM_IFACE )?;
	let devices: Vec<OwnedObjectPath> = p.call( "GetDevices", &() ).ok()?;
	for d in devices
	{
		let device_type = proxy( c, d.as_str(), DEVICE_IFACE )
			.and_then( |dp| dp.get_property::<u32>( "DeviceType" ).ok() )
			.unwrap_or( 0 );
		if device_type == DEVICE_TYPE_WIFI
		{
			return Some( d );
		}
	}
	None
}

pub const CONNECTIVITY_PORTAL: u32 = 2;

/// NM connectivity state: 0 unknown, 1 none, 2 portal, 3 limited, 4 full.
/// Reads the cached property; NM refreshes it on its own probe interval.
pub fn connectivity() -> u32
{
	let Some( c ) = conn() else { return 0 };
	let Some( p ) = proxy( &c, NM_PATH, NM_IFACE ) else { return 0 };
	p.get_property::<u32>( "Connectivity" ).unwrap_or( 0 )
}

/// Force a fresh connectivity probe. Blocks while NM performs the HTTP
/// check — call from a worker thread.
pub fn check_connectivity() -> u32
{
	let Some( c ) = conn() else { return 0 };
	let Some( p ) = proxy( &c, NM_PATH, NM_IFACE ) else { return 0 };
	p.call::<_, _, u32>( "CheckConnectivity", &() )
		.ok()
		.unwrap_or_else( connectivity )
}

pub fn wifi_enabled() -> bool
{
	let Some( c ) = conn() else { return false };
	let Some( p ) = proxy( &c, NM_PATH, NM_IFACE ) else { return false };
	p.get_property::<bool>( "WirelessEnabled" ).unwrap_or( false )
}

pub fn set_wifi_enabled( on: bool )
{
	let Some( c ) = conn() else { return };
	let Some( p ) = proxy( &c, NM_PATH, NM_IFACE ) else { return };
	let _ = p.set_property( "WirelessEnabled", on );
}

/// Request a fresh scan and return the visible networks, one entry per SSID
/// (strongest access point wins), connected first, then by signal strength.
/// Blocks up to ~4 s waiting for the scan to land — call from a worker thread.
pub fn wifi_networks() -> Vec<WifiNetwork>
{
	let Some( c ) = conn() else { return Vec::new() };
	let Some( dev ) = wifi_device( &c ) else { return Vec::new() };
	let Some( w ) = proxy( &c, dev.as_str(), WIRELESS_IFACE ) else { return Vec::new() };

	let last_scan = w.get_property::<i64>( "LastScan" ).unwrap_or( -1 );
	// The radio may still be settling right after being enabled; retry the
	// scan request briefly instead of giving up with an empty cached list.
	let mut requested = false;
	for _ in 0..5
	{
		if w.call::<_, _, ()>( "RequestScan", &( HashMap::<&str, Value>::new(), ) ).is_ok()
		{
			requested = true;
			break;
		}
		std::thread::sleep( Duration::from_millis( 500 ) );
	}
	if requested
	{
		for _ in 0..10
		{
			std::thread::sleep( Duration::from_millis( 400 ) );
			if w.get_property::<i64>( "LastScan" ).unwrap_or( -1 ) != last_scan { break; }
		}
	}

	let active_ap = w.get_property::<OwnedObjectPath>( "ActiveAccessPoint" )
		.map( |p| p.to_string() ).unwrap_or_default();
	let aps: Vec<OwnedObjectPath> = w.call( "GetAllAccessPoints", &() ).unwrap_or_default();
	let known = known_ssids( &c );

	let mut best: HashMap<String, WifiNetwork> = HashMap::new();
	for ap in aps
	{
		let Some( p ) = proxy( &c, ap.as_str(), AP_IFACE ) else { continue };
		let ssid_bytes = p.get_property::<Vec<u8>>( "Ssid" ).unwrap_or_default();
		let ssid = String::from_utf8_lossy( &ssid_bytes ).trim().to_string();
		if ssid.is_empty() { continue; }
		let flags = p.get_property::<u32>( "Flags" ).unwrap_or( 0 );
		let wpa   = p.get_property::<u32>( "WpaFlags" ).unwrap_or( 0 );
		let rsn   = p.get_property::<u32>( "RsnFlags" ).unwrap_or( 0 );
		let owe   = rsn & ( RSN_KEY_MGMT_OWE | RSN_KEY_MGMT_OWE_TM ) != 0;
		// Hidden OWE companion BSSes of a transition-mode network (both
		// OWE and OWE-TM set, or the hostapd `_owetm_` naming) — phones
		// hide them; users join through the visible open SSID.
		if ( rsn & RSN_KEY_MGMT_OWE != 0 && rsn & RSN_KEY_MGMT_OWE_TM != 0 )
			|| ssid.starts_with( "_owetm_" )
		{
			continue;
		}
		let net = WifiNetwork
		{
			connected: ap.as_str() == active_ap,
			secured:   !owe && ( flags & AP_FLAGS_PRIVACY != 0 || wpa != 0 || rsn != 0 ),
			known:     known.contains( &ssid ),
			signal:    p.get_property::<u8>( "Strength" ).unwrap_or( 0 ),
			ssid:      ssid.clone(),
		};
		match best.get( &ssid )
		{
			Some( prev ) if prev.connected || ( !net.connected && prev.signal >= net.signal ) => {}
			_ => { best.insert( ssid, net ); }
		}
	}

	let mut nets: Vec<WifiNetwork> = best.into_values().collect();
	nets.sort_by( |a, b| b.connected.cmp( &a.connected ).then( b.signal.cmp( &a.signal ) ) );
	nets
}

pub fn connected_wifi_ssid() -> Option<String>
{
	let c = conn()?;
	let dev = wifi_device( &c )?;
	let w = proxy( &c, dev.as_str(), WIRELESS_IFACE )?;
	let ap = w.get_property::<OwnedObjectPath>( "ActiveAccessPoint" ).ok()?;
	if ap.as_str() == "/" { return None; }
	let p = proxy( &c, ap.as_str(), AP_IFACE )?;
	let bytes = p.get_property::<Vec<u8>>( "Ssid" ).ok()?;
	let ssid = String::from_utf8_lossy( &bytes ).trim().to_string();
	if ssid.is_empty() { None } else { Some( ssid ) }
}

/// Connect to a network by SSID. Reuses the saved profile when one exists;
/// otherwise creates one (WPA-PSK when a password is given, open otherwise).
/// Blocks until the activation settles, up to ~30 s — call from a worker
/// thread. A failed new profile is deleted so a wrong password is not saved.
pub fn connect( ssid: &str, password: Option<&str> ) -> bool
{
	let Some( c ) = conn() else { return false };
	let Some( dev ) = wifi_device( &c ) else { return false };
	let Some( nm ) = proxy( &c, NM_PATH, NM_IFACE ) else { return false };

	// Activating against the concrete AP lets NM autocomplete the security
	// settings (plain open vs OWE) that the profile does not spell out.
	let specific = access_point_for_ssid( &c, &dev, ssid )
		.unwrap_or_else( || root_path().into() );

	let saved = saved_connections( &c ).into_iter()
		.find( |( _, s )| setting_ssid( s ).as_deref() == Some( ssid ) );

	if let Some( ( path, _ ) ) = saved
	{
		let active: zbus::Result<OwnedObjectPath> =
			nm.call( "ActivateConnection", &( &path, &dev, &specific ) );
		return active.map( |a| wait_activated( &c, a.as_str() ) ).unwrap_or( false );
	}

	let mut connection: HashMap<&str, Value> = HashMap::new();
	connection.insert( "id",   Value::from( ssid ) );
	connection.insert( "type", Value::from( "802-11-wireless" ) );

	let mut wireless: HashMap<&str, Value> = HashMap::new();
	wireless.insert( "ssid", Value::from( ssid.as_bytes().to_vec() ) );
	wireless.insert( "mode", Value::from( "infrastructure" ) );

	let mut settings: HashMap<&str, HashMap<&str, Value>> = HashMap::new();
	if let Some( pw ) = password
	{
		let mut security: HashMap<&str, Value> = HashMap::new();
		security.insert( "key-mgmt", Value::from( "wpa-psk" ) );
		security.insert( "psk",      Value::from( pw ) );
		settings.insert( "802-11-wireless-security", security );
		wireless.insert( "security", Value::from( "802-11-wireless-security" ) );
	}
	settings.insert( "connection",     connection );
	settings.insert( "802-11-wireless", wireless );

	let added: zbus::Result<( OwnedObjectPath, OwnedObjectPath )> =
		nm.call( "AddAndActivateConnection", &( settings, &dev, &specific ) );
	match added
	{
		Ok( ( conn_path, active ) ) =>
		{
			if wait_activated( &c, active.as_str() ) { return true; }
			delete_connection( &c, conn_path.as_str() );
			false
		}
		Err( _ ) => false,
	}
}

/// Delete every saved profile matching the SSID. Deleting the active profile
/// also disconnects it.
pub fn forget( ssid: &str )
{
	let Some( c ) = conn() else { return };
	for ( path, s ) in saved_connections( &c )
	{
		if setting_ssid( &s ).as_deref() == Some( ssid )
		{
			delete_connection( &c, path.as_str() );
		}
	}
}

pub fn hotspot_active() -> bool
{
	let Some( c ) = conn() else { return false };
	active_ap_mode_connection( &c ).is_some()
}

pub fn set_hotspot_enabled( on: bool )
{
	let Some( c ) = conn() else { return };
	let Some( nm ) = proxy( &c, NM_PATH, NM_IFACE ) else { return };
	if on
	{
		let Some( dev ) = wifi_device( &c ) else { return };
		for ( path, s ) in saved_connections( &c )
		{
			if setting_str( &s, "802-11-wireless", "mode" ).as_deref() == Some( "ap" )
			{
				let _ = nm.call::<_, _, OwnedObjectPath>(
					"ActivateConnection", &( &path, &dev, root_path() ) );
				return;
			}
		}
	} else if let Some( active ) = active_ap_mode_connection( &c )
	{
		let _ = nm.call::<_, _, ()>( "DeactivateConnection", &( &active, ) );
	}
}

pub fn vpn_connections() -> Vec<String>
{
	let Some( c ) = conn() else { return Vec::new() };
	saved_connections( &c ).into_iter()
		.filter_map( |( _, s )|
		{
			let typ = setting_str( &s, "connection", "type" )?;
			if typ == "vpn" || typ == "wireguard"
			{
				setting_str( &s, "connection", "id" )
			} else {
				None
			}
		})
		.collect()
}

// ─── Internals ───────────────────────────────────────────────────────

fn access_point_for_ssid( c: &Connection, dev: &OwnedObjectPath, ssid: &str ) -> Option<OwnedObjectPath>
{
	let w = proxy( c, dev.as_str(), WIRELESS_IFACE )?;
	let aps: Vec<OwnedObjectPath> = w.call( "GetAllAccessPoints", &() ).ok()?;
	let mut best: Option<( u8, OwnedObjectPath )> = None;
	for ap in aps
	{
		let Some( p ) = proxy( c, ap.as_str(), AP_IFACE ) else { continue };
		let bytes = p.get_property::<Vec<u8>>( "Ssid" ).unwrap_or_default();
		if String::from_utf8_lossy( &bytes ).trim() != ssid { continue; }
		let strength = p.get_property::<u8>( "Strength" ).unwrap_or( 0 );
		if best.as_ref().map( |( s, _ )| strength > *s ).unwrap_or( true )
		{
			best = Some( ( strength, ap.clone() ) );
		}
	}
	best.map( |( _, ap )| ap )
}

fn saved_connections( c: &Connection ) -> Vec<( OwnedObjectPath, Settings )>
{
	let Some( p ) = proxy( c, SETTINGS_PATH, SETTINGS_IFACE ) else { return Vec::new() };
	let paths: Vec<OwnedObjectPath> = p.call( "ListConnections", &() ).unwrap_or_default();
	paths.into_iter().filter_map( |path|
	{
		let cp = proxy( c, path.as_str(), CONN_IFACE )?;
		let s: Settings = cp.call( "GetSettings", &() ).ok()?;
		Some( ( path.clone(), s ) )
	}).collect()
}

fn known_ssids( c: &Connection ) -> HashSet<String>
{
	saved_connections( c ).iter()
		.filter_map( |( _, s )| setting_ssid( s ) )
		.collect()
}

fn active_ap_mode_connection( c: &Connection ) -> Option<OwnedObjectPath>
{
	let nm = proxy( c, NM_PATH, NM_IFACE )?;
	let actives: Vec<OwnedObjectPath> = nm.get_property( "ActiveConnections" ).ok()?;
	for a in actives
	{
		if is_ap_mode_active( c, a.as_str() )
		{
			return Some( a );
		}
	}
	None
}

fn is_ap_mode_active( c: &Connection, active_path: &str ) -> bool
{
	let Some( ap ) = proxy( c, active_path, ACTIVE_IFACE ) else { return false };
	let Ok( conn_path ) = ap.get_property::<OwnedObjectPath>( "Connection" ) else { return false };
	let Some( cp ) = proxy( c, conn_path.as_str(), CONN_IFACE ) else { return false };
	let Ok( s ) = cp.call::<_, _, Settings>( "GetSettings", &() ) else { return false };
	setting_str( &s, "802-11-wireless", "mode" ).as_deref() == Some( "ap" )
}

fn wait_activated( c: &Connection, active_path: &str ) -> bool
{
	for _ in 0..60
	{
		let Some( p ) = proxy( c, active_path, ACTIVE_IFACE ) else { return false };
		match p.get_property::<u32>( "State" )
		{
			Ok( ACTIVE_STATE_ACTIVATED )  => return true,
			Ok( ACTIVE_STATE_ACTIVATING ) => {}
			_ => return false,
		}
		std::thread::sleep( Duration::from_millis( 500 ) );
	}
	false
}

fn delete_connection( c: &Connection, path: &str )
{
	if let Some( p ) = proxy( c, path, CONN_IFACE )
	{
		let _ = p.call::<_, _, ()>( "Delete", &() );
	}
}

fn setting_str( s: &Settings, group: &str, key: &str ) -> Option<String>
{
	s.get( group )?.get( key )
		.and_then( |v| <&str>::try_from( &**v ).ok() )
		.map( str::to_string )
}

fn setting_ssid( s: &Settings ) -> Option<String>
{
	let v = s.get( "802-11-wireless" )?.get( "ssid" )?;
	let Value::Array( a ) = &**v else { return None };
	let mut bytes = Vec::with_capacity( a.len() );
	for item in a.iter()
	{
		bytes.push( u8::try_from( item ).ok()? );
	}
	let ssid = String::from_utf8_lossy( &bytes ).trim().to_string();
	if ssid.is_empty() { None } else { Some( ssid ) }
}
