//! Real telephony backend talking to ModemManager over D-Bus.
//!
//! The backend struct lives on the UI thread and exposes the
//! synchronous `CallBackend` trait. All zbus + signal handling work
//! happens on a dedicated worker thread that runs a single-threaded
//! `smol` executor; the two sides communicate through:
//!
//!   ─ a `async_channel` for UI → worker commands (Dial, Accept, …)
//!   ─ a `std::sync::mpsc` for worker → UI events (drained by
//!     `poll_events` from LTK's `poll_external` tick)
//!
//! Call ids are allocated from a shared atomic counter so both sides
//! can mint them without coordination.

use std::collections::{ HashMap, HashSet };
use std::sync::Arc;
use std::sync::atomic::{ AtomicU64, Ordering };
use std::sync::mpsc as std_mpsc;
use std::thread;
use std::time::Duration;

use futures_util::stream::StreamExt;
use smol::{ LocalExecutor, Task };
use zbus::Connection;
use zbus::zvariant::{ OwnedObjectPath, Value };

use super::{ CallBackend, CallError, CallEvent };
use crate::types::{ CallId, CallState, EndReason, PhoneNumber };

// ── ModemManager constants ────────────────────────────────────────

mod mm
{
	pub const STATE_DIALING:     i32 = 1;
	pub const STATE_RINGING_OUT: i32 = 2;
	pub const STATE_RINGING_IN:  i32 = 3;
	pub const STATE_ACTIVE:      i32 = 4;
	pub const STATE_TERMINATED:  i32 = 7;

	pub const REASON_UNKNOWN:            u32 = 0;
	pub const REASON_REFUSED_OR_BUSY:    u32 = 5;
	pub const REASON_ERROR:              u32 = 6;
	pub const REASON_AUDIO_SETUP_FAILED: u32 = 7;

	pub const DIRECTION_INCOMING: i32 = 1;
}

// ── D-Bus proxies ────────────────────────────────────────────────

#[ zbus::proxy(
	interface = "org.freedesktop.ModemManager1.Modem.Voice",
	default_service = "org.freedesktop.ModemManager1"
) ]
trait Voice
{
	fn create_call( &self, properties: HashMap<&str, Value<'_>> ) -> zbus::Result<OwnedObjectPath>;
	fn list_calls( &self ) -> zbus::Result<Vec<OwnedObjectPath>>;

	#[ zbus( signal ) ]
	fn call_added( &self, path: OwnedObjectPath ) -> zbus::Result<()>;

	#[ zbus( signal ) ]
	fn call_deleted( &self, path: OwnedObjectPath ) -> zbus::Result<()>;
}

#[ zbus::proxy(
	interface = "org.freedesktop.ModemManager1.Call",
	default_service = "org.freedesktop.ModemManager1"
) ]
trait Call
{
	fn start( &self ) -> zbus::Result<()>;
	fn accept( &self ) -> zbus::Result<()>;
	fn hangup( &self ) -> zbus::Result<()>;
	fn send_dtmf( &self, dtmf: &str ) -> zbus::Result<()>;

	#[ zbus( property ) ]
	fn number( &self ) -> zbus::Result<String>;
	#[ zbus( property ) ]
	fn state( &self ) -> zbus::Result<i32>;
	#[ zbus( property ) ]
	fn direction( &self ) -> zbus::Result<i32>;

	#[ zbus( signal, name = "StateChanged" ) ]
	fn state_signal( &self, old: i32, new: i32, reason: u32 ) -> zbus::Result<()>;
}

// ── Channel payloads ─────────────────────────────────────────────

#[ derive( Debug ) ]
enum Cmd
{
	Dial    { id: CallId, number: PhoneNumber },
	Accept  { id: CallId },
	Reject  { id: CallId },
	Hangup  { id: CallId },
	Dtmf    { id: CallId, tone: char },
}

#[ derive( Debug ) ]
enum Internal
{
	Cmd( Cmd ),
	CallAdded( OwnedObjectPath ),
	CallDeleted( OwnedObjectPath ),
	CallStateChanged
	{
		path:   OwnedObjectPath,
		old:    i32,
		new:    i32,
		reason: u32,
	},
}

// ── Public backend ───────────────────────────────────────────────

pub struct ModemManagerBackend
{
	cmd_tx:   async_channel::Sender<Cmd>,
	evt_rx:   std_mpsc::Receiver<CallEvent>,
	next_id:  Arc<AtomicU64>,
	_worker:  thread::JoinHandle<()>,
}

impl ModemManagerBackend
{
	pub fn new() -> Self
	{
		let next_id = Arc::new( AtomicU64::new( 1 ) );
		let next_id_w = next_id.clone();

		let ( cmd_tx, cmd_rx ) = async_channel::unbounded::<Cmd>();
		let ( evt_tx, evt_rx ) = std_mpsc::channel::<CallEvent>();

		let worker = thread::Builder::new()
			.name( "eydos-calls-modem".into() )
			.spawn( move ||
			{
				let ex = LocalExecutor::new();
				smol::block_on( ex.run( worker_main( &ex, cmd_rx, evt_tx, next_id_w ) ) );
			} )
			.expect( "failed to spawn modem worker thread" );

		Self
		{
			cmd_tx,
			evt_rx,
			next_id,
			_worker: worker,
		}
	}

	fn alloc_id( &self ) -> CallId
	{
		CallId( self.next_id.fetch_add( 1, Ordering::Relaxed ) )
	}

	fn send( &self, cmd: Cmd ) -> Result<(), CallError>
	{
		self.cmd_tx
			.try_send( cmd )
			.map_err( |e| CallError::Backend( format!( "worker channel: {}", e ) ) )
	}
}

impl Default for ModemManagerBackend
{
	fn default() -> Self
	{
		Self::new()
	}
}

impl CallBackend for ModemManagerBackend
{
	fn dial( &mut self, number: &PhoneNumber ) -> Result<CallId, CallError>
	{
		if number.as_str().is_empty()
		{
			return Err( CallError::InvalidNumber );
		}
		let id = self.alloc_id();
		self.send( Cmd::Dial { id, number: number.clone() } )?;
		Ok( id )
	}

	fn accept( &mut self, id: CallId ) -> Result<(), CallError>
	{
		self.send( Cmd::Accept { id } )
	}

	fn reject( &mut self, id: CallId ) -> Result<(), CallError>
	{
		self.send( Cmd::Reject { id } )
	}

	fn hangup( &mut self, id: CallId ) -> Result<(), CallError>
	{
		self.send( Cmd::Hangup { id } )
	}

	fn send_dtmf( &mut self, id: CallId, tone: char ) -> Result<(), CallError>
	{
		self.send( Cmd::Dtmf { id, tone } )
	}

	fn set_mute( &mut self, _id: CallId, _muted: bool ) -> Result<(), CallError>
	{
		// ModemManager has no mute on the Call interface — local mute
		// is the audio module's responsibility. Acknowledge silently.
		Ok( () )
	}

	fn poll_events( &mut self ) -> Vec<CallEvent>
	{
		let mut events = Vec::new();
		while let Ok( ev ) = self.evt_rx.try_recv()
		{
			events.push( ev );
		}
		events
	}
}

// ── Worker thread ────────────────────────────────────────────────

struct Session
{
	path_to_id:      HashMap<OwnedObjectPath, CallId>,
	id_to_path:      HashMap<CallId, OwnedObjectPath>,
	directions:      HashMap<OwnedObjectPath, i32>,
	locally_hung_up: HashSet<OwnedObjectPath>,
	listeners:       HashMap<OwnedObjectPath, Task<()>>,
	next_id:         Arc<AtomicU64>,
}

async fn worker_main(
	ex: &LocalExecutor<'_>,
	cmd_rx: async_channel::Receiver<Cmd>,
	evt_tx: std_mpsc::Sender<CallEvent>,
	next_id: Arc<AtomicU64>,
)
{
	loop
	{
		match try_session( ex, &cmd_rx, &evt_tx, &next_id ).await
		{
			Ok( () ) => return,
			Err( e ) =>
			{
				eprintln!( "eydos-calls modem session error: {e}" );
				let _ = evt_tx.send( CallEvent::BackendUnavailable );
				smol::Timer::after( Duration::from_secs( 2 ) ).await;
			}
		}
	}
}

async fn try_session(
	ex: &LocalExecutor<'_>,
	cmd_rx: &async_channel::Receiver<Cmd>,
	evt_tx: &std_mpsc::Sender<CallEvent>,
	next_id: &Arc<AtomicU64>,
) -> zbus::Result<()>
{
	let conn = Connection::system().await?;
	let voice_path = find_voice_modem( &conn ).await?;

	let _ = evt_tx.send( CallEvent::BackendAvailable );

	let ( int_tx, int_rx ) = async_channel::unbounded::<Internal>();

	// Forward UI commands into the internal stream so the dispatch
	// loop only has to listen on a single receiver.
	let _cmd_fwd = ex.spawn( {
		let cmd_rx = cmd_rx.clone();
		let int_tx = int_tx.clone();
		async move
		{
			while let Ok( c ) = cmd_rx.recv().await
			{
				if int_tx.send( Internal::Cmd( c ) ).await.is_err()
				{
					break;
				}
			}
		}
	} );

	// Fan in CallAdded / CallDeleted signals. Each task owns its own
	// Connection clone so the proxy can live for the task's lifetime.
	let _added_fwd = ex.spawn( {
		let conn = conn.clone();
		let voice_path = voice_path.clone();
		let int_tx = int_tx.clone();
		async move
		{
			let Some( voice ) = build_voice_proxy( &conn, voice_path ).await else { return; };
			if let Ok( mut s ) = voice.receive_call_added().await
			{
				while let Some( sig ) = s.next().await
				{
					if let Ok( args ) = sig.args()
					{
						let _ = int_tx.send( Internal::CallAdded( args.path ) ).await;
					}
				}
			}
		}
	} );
	let _deleted_fwd = ex.spawn( {
		let conn = conn.clone();
		let voice_path = voice_path.clone();
		let int_tx = int_tx.clone();
		async move
		{
			let Some( voice ) = build_voice_proxy( &conn, voice_path ).await else { return; };
			if let Ok( mut s ) = voice.receive_call_deleted().await
			{
				while let Some( sig ) = s.next().await
				{
					if let Ok( args ) = sig.args()
					{
						let _ = int_tx.send( Internal::CallDeleted( args.path ) ).await;
					}
				}
			}
		}
	} );

	let mut session = Session
	{
		path_to_id:      HashMap::new(),
		id_to_path:      HashMap::new(),
		directions:      HashMap::new(),
		locally_hung_up: HashSet::new(),
		listeners:       HashMap::new(),
		next_id:         next_id.clone(),
	};

	// Pick up calls that were already in flight when we connected.
	if let Some( voice ) = build_voice_proxy( &conn, voice_path.clone() ).await
	{
		if let Ok( initial ) = voice.list_calls().await
		{
			for path in initial
			{
				let _ = int_tx.send( Internal::CallAdded( path ) ).await;
			}
		}
	}

	while let Ok( int ) = int_rx.recv().await
	{
		match int
		{
			Internal::Cmd( cmd ) =>
			{
				handle_cmd( &mut session, &conn, &voice_path, ex, &int_tx, evt_tx, cmd ).await;
			}
			Internal::CallAdded( path ) =>
			{
				let _ = on_call_added( &mut session, &conn, ex, &int_tx, evt_tx, path, None ).await;
			}
			Internal::CallDeleted( path ) =>
			{
				on_call_deleted( &mut session, evt_tx, path );
			}
			Internal::CallStateChanged { path, old: _, new, reason } =>
			{
				on_state_changed( &mut session, evt_tx, path, new, reason );
			}
		}
	}

	Ok( () )
}

async fn find_voice_modem( conn: &Connection ) -> zbus::Result<OwnedObjectPath>
{
	let om = zbus::fdo::ObjectManagerProxy::builder( conn )
		.destination( "org.freedesktop.ModemManager1" )?
		.path( "/org/freedesktop/ModemManager1" )?
		.build()
		.await?;
	let objects = om.get_managed_objects().await?;

	for ( path, ifaces ) in objects
	{
		if ifaces.contains_key( "org.freedesktop.ModemManager1.Modem.Voice" )
		{
			return Ok( path );
		}
	}
	Err( zbus::Error::Failure( "no modem with Voice interface available".into() ) )
}

async fn on_call_added(
	session: &mut Session,
	conn: &Connection,
	ex: &LocalExecutor<'_>,
	int_tx: &async_channel::Sender<Internal>,
	evt_tx: &std_mpsc::Sender<CallEvent>,
	path: OwnedObjectPath,
	id_override: Option<CallId>,
) -> zbus::Result<()>
{
	if session.path_to_id.contains_key( &path )
	{
		return Ok( () );
	}

	let call = CallProxy::builder( conn )
		.path( path.clone() )?
		.build()
		.await?;
	let direction = call.direction().await.unwrap_or( 0 );
	let number    = call.number().await.unwrap_or_default();
	let state     = call.state().await.unwrap_or( 0 );

	let id = id_override
		.unwrap_or_else( || CallId( session.next_id.fetch_add( 1, Ordering::Relaxed ) ) );

	session.path_to_id.insert( path.clone(), id );
	session.id_to_path.insert( id, path.clone() );
	session.directions.insert( path.clone(), direction );

	// Only emit Incoming for inbound calls we did not initiate. The
	// UI already created an active_call entry for outgoing dials.
	if id_override.is_none() && direction == mm::DIRECTION_INCOMING
	{
		let _ = evt_tx.send( CallEvent::Incoming
		{
			id,
			number: PhoneNumber::new( number ),
		} );
	}
	if let Some( mapped ) = map_state( state, mm::REASON_UNKNOWN, direction, false )
	{
		let _ = evt_tx.send( CallEvent::StateChanged { id, state: mapped } );
	}

	let listener = ex.spawn( {
		let conn = conn.clone();
		let int_tx = int_tx.clone();
		let path_for_task = path.clone();
		async move
		{
			let call = match CallProxy::builder( &conn ).path( path_for_task.clone() )
			{
				Ok( b ) => match b.build().await
				{
					Ok( c ) => c,
					Err( _ ) => return,
				},
				Err( _ ) => return,
			};
			let mut s = match call.receive_state_signal().await
			{
				Ok( s ) => s,
				Err( _ ) => return,
			};
			while let Some( sig ) = s.next().await
			{
				if let Ok( args ) = sig.args()
				{
					let _ = int_tx.send( Internal::CallStateChanged
					{
						path:   path_for_task.clone(),
						old:    args.old,
						new:    args.new,
						reason: args.reason,
					} ).await;
				}
			}
		}
	} );
	session.listeners.insert( path, listener );

	Ok( () )
}

fn on_call_deleted(
	session: &mut Session,
	evt_tx: &std_mpsc::Sender<CallEvent>,
	path: OwnedObjectPath,
)
{
	if let Some( id ) = session.path_to_id.remove( &path )
	{
		session.id_to_path.remove( &id );
		session.directions.remove( &path );
		session.listeners.remove( &path );
		let was_local = session.locally_hung_up.remove( &path );
		let end = if was_local
		{
			EndReason::LocalHangup
		} else {
			EndReason::Unknown
		};
		let _ = evt_tx.send( CallEvent::StateChanged
		{
			id,
			state: CallState::Ended( end ),
		} );
	}
}

fn on_state_changed(
	session: &mut Session,
	evt_tx: &std_mpsc::Sender<CallEvent>,
	path: OwnedObjectPath,
	new: i32,
	reason: u32,
)
{
	let id = match session.path_to_id.get( &path )
	{
		Some( id ) => *id,
		None       => return,
	};
	let direction = session.directions.get( &path ).copied().unwrap_or( 0 );
	let was_local = session.locally_hung_up.contains( &path );

	if let Some( mapped ) = map_state( new, reason, direction, was_local )
	{
		let _ = evt_tx.send( CallEvent::StateChanged { id, state: mapped } );
	}
}

#[ allow( clippy::too_many_arguments ) ]
async fn handle_cmd(
	session: &mut Session,
	conn: &Connection,
	voice_path: &OwnedObjectPath,
	ex: &LocalExecutor<'_>,
	int_tx: &async_channel::Sender<Internal>,
	evt_tx: &std_mpsc::Sender<CallEvent>,
	cmd: Cmd,
)
{
	match cmd
	{
		Cmd::Dial { id, number } =>
		{
			let Some( voice ) = build_voice_proxy( conn, voice_path.clone() ).await
			else
			{
				eprintln!( "eydos-calls: could not build VoiceProxy" );
				let _ = evt_tx.send( CallEvent::StateChanged
				{
					id,
					state: CallState::Ended( EndReason::NetworkError ),
				} );
				return;
			};

			let mut props: HashMap<&str, Value<'_>> = HashMap::new();
			let value = Value::from( number.as_str().to_string() );
			props.insert( "number", value );

			match voice.create_call( props ).await
			{
				Ok( path ) =>
				{
					if let Err( e ) = on_call_added( session, conn, ex, int_tx, evt_tx, path.clone(), Some( id ) ).await
					{
						eprintln!( "eydos-calls: tracking outgoing call failed: {e}" );
						let _ = evt_tx.send( CallEvent::StateChanged
						{
							id,
							state: CallState::Ended( EndReason::NetworkError ),
						} );
						return;
					}
					if let Some( call ) = build_call_proxy( conn, &path ).await
					{
						if let Err( e ) = call.start().await
						{
							eprintln!( "eydos-calls: Call.Start failed: {e}" );
							let _ = evt_tx.send( CallEvent::StateChanged
							{
								id,
								state: CallState::Ended( EndReason::NetworkError ),
							} );
						}
					}
				}
				Err( e ) =>
				{
					eprintln!( "eydos-calls: Voice.CreateCall failed: {e}" );
					let _ = evt_tx.send( CallEvent::StateChanged
					{
						id,
						state: CallState::Ended( EndReason::NetworkError ),
					} );
				}
			}
		}

		Cmd::Accept { id } =>
		{
			if let Some( path ) = session.id_to_path.get( &id ).cloned()
			{
				if let Some( call ) = build_call_proxy( conn, &path ).await
				{
					if let Err( e ) = call.accept().await
					{
						eprintln!( "eydos-calls: Call.Accept failed: {e}" );
					}
				}
			}
		}

		Cmd::Reject { id } | Cmd::Hangup { id } =>
		{
			if let Some( path ) = session.id_to_path.get( &id ).cloned()
			{
				session.locally_hung_up.insert( path.clone() );
				if let Some( call ) = build_call_proxy( conn, &path ).await
				{
					if let Err( e ) = call.hangup().await
					{
						eprintln!( "eydos-calls: Call.Hangup failed: {e}" );
					}
				}
			}
		}

		Cmd::Dtmf { id, tone } =>
		{
			if let Some( path ) = session.id_to_path.get( &id ).cloned()
			{
				if let Some( call ) = build_call_proxy( conn, &path ).await
				{
					let s = tone.to_string();
					if let Err( e ) = call.send_dtmf( &s ).await
					{
						eprintln!( "eydos-calls: Call.SendDtmf failed: {e}" );
					}
				}
			}
		}
	}
}

async fn build_call_proxy<'a>( conn: &'a Connection, path: &OwnedObjectPath ) -> Option<CallProxy<'a>>
{
	CallProxy::builder( conn )
		.path( path.clone() )
		.ok()?
		.build()
		.await
		.ok()
}

async fn build_voice_proxy<'a>( conn: &'a Connection, path: OwnedObjectPath ) -> Option<VoiceProxy<'a>>
{
	VoiceProxy::builder( conn )
		.path( path )
		.ok()?
		.build()
		.await
		.ok()
}

fn map_state( new: i32, reason: u32, direction: i32, locally_hung_up: bool ) -> Option<CallState>
{
	match new
	{
		mm::STATE_DIALING                              => Some( CallState::Dialing ),
		mm::STATE_RINGING_OUT | mm::STATE_RINGING_IN   => Some( CallState::Ringing ),
		mm::STATE_ACTIVE                               => Some( CallState::Active ),
		mm::STATE_TERMINATED                           =>
		{
			let end = match reason
			{
				mm::REASON_REFUSED_OR_BUSY                              => EndReason::Rejected,
				mm::REASON_ERROR | mm::REASON_AUDIO_SETUP_FAILED        => EndReason::NetworkError,
				_ if locally_hung_up                                    => EndReason::LocalHangup,
				_ if direction == mm::DIRECTION_INCOMING                => EndReason::Missed,
				_                                                       => EndReason::Unknown,
			};
			Some( CallState::Ended( end ) )
		}
		_ => None,
	}
}
