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

//! `org.freedesktop.impl.portal.Notification` backend.
//!
//! When a Flatpak'd app calls `org.freedesktop.portal.Notification`,
//! xdg-desktop-portal forwards the call to this backend — we are the
//! "impl" side of the portal. Our job is to actually deliver / dismiss
//! the notification using the Crustace system notifier.
//!
//! We proxy to the standard freedesktop notification spec
//! (`org.freedesktop.Notifications`), which the Crustace shell exposes
//! the same way every other Linux DE does. This means a single
//! notification daemon serves both Flatpak apps (via the portal) and
//! legacy / system apps (talking to `org.freedesktop.Notifications`
//! directly), with consistent UX. Replacing the proxy with a direct
//! LTK-rendered toast is a follow-up.

use std::collections::HashMap;
use std::sync::Mutex;

use zbus::interface;
use zbus::zvariant::{ OwnedValue, Value };

/// Async client proxy for the freedesktop notification daemon
/// (`org.freedesktop.Notifications`). Generated by zbus from the
/// upstream spec; we use the two methods we need.
#[ zbus::proxy(
	default_service = "org.freedesktop.Notifications",
	default_path    = "/org/freedesktop/Notifications",
	interface       = "org.freedesktop.Notifications",
) ]
trait Notifications
{
	#[ allow( clippy::too_many_arguments ) ]
	fn notify(
		&self,
		app_name:        &str,
		replaces_id:     u32,
		app_icon:        &str,
		summary:         &str,
		body:            &str,
		actions:         &[&str],
		hints:           HashMap<&str, &Value<'_>>,
		expire_timeout:  i32,
	) -> zbus::Result<u32>;

	fn close_notification( &self, id: u32 ) -> zbus::Result<()>;
}

/// Backend instance served at the portal object path.
///
/// Tracks the mapping `(app_id, portal_id) → fdo_id` so that a
/// subsequent `RemoveNotification( app_id, portal_id )` can close the
/// matching freedesktop notification by its uint32 id. The portal
/// spec uses opaque strings for ids; the freedesktop spec uses u32.
/// Bridging the two requires this table.
pub struct NotificationPortal
{
	live: Mutex<HashMap<( String, String ), u32>>,
}

impl NotificationPortal
{
	pub fn new() -> Self
	{
		Self { live: Mutex::new( HashMap::new() ) }
	}
}

#[ interface( name = "org.freedesktop.impl.portal.Notification" ) ]
impl NotificationPortal
{
	/// Show a notification. The portal spec defines the `notification`
	/// dict shape ([title], [body], [icon], [priority], [default-action],
	/// [buttons], …); we map the salient fields onto the freedesktop
	/// `Notify` arguments and call the system notifier. The returned
	/// `u32` is remembered so [`Self::remove_notification`] can withdraw
	/// the toast later.
	async fn add_notification(
		&self,
		#[ zbus( connection ) ]
		conn:          &zbus::Connection,
		app_id:        String,
		id:            String,
		notification:  HashMap<String, OwnedValue>,
	) -> zbus::fdo::Result<()>
	{
		tracing::info!(
			app_id = %app_id,
			id     = %id,
			keys   = ?notification.keys().collect::<Vec<_>>(),
			"AddNotification",
		);

		let title    = string_field( &notification, "title"    ).unwrap_or_default();
		let body     = string_field( &notification, "body"     ).unwrap_or_default();
		let priority = string_field( &notification, "priority" ).unwrap_or_else( || "normal".to_string() );
		// `icon` in the portal spec is a variant containing a `(sv)`
		// tuple; the common case is a themed icon name. Anything more
		// elaborate (file fd, byte buffer) lands in TODO territory.
		let icon     = themed_icon_name( &notification ).unwrap_or_default();

		// freedesktop urgency hint: 0 = low, 1 = normal, 2 = critical.
		let urgency_u8: u8 = match priority.as_str()
		{
			"low"               => 0,
			"high" | "urgent"   => 2,
			_                   => 1,
		};
		let urgency_v = Value::U8( urgency_u8 );
		let mut hints: HashMap<&str, &Value<'_>> = HashMap::new();
		hints.insert( "urgency", &urgency_v );

		let proxy = NotificationsProxy::new( conn ).await
			.map_err( | e | zbus::fdo::Error::Failed( format!( "connect notification daemon: {e}" ) ) )?;

		let fdo_id = proxy.notify(
			&app_id,
			0,           // replaces_id: 0 = new notification
			&icon,
			&title,
			&body,
			&[],         // actions: empty for now; portal-style buttons map later
			hints,
			-1,          // expire_timeout: -1 = let server pick
		).await
			.map_err( | e | zbus::fdo::Error::Failed( format!( "Notify: {e}" ) ) )?;

		if let Ok( mut live ) = self.live.lock()
		{
			live.insert( ( app_id, id ), fdo_id );
		}
		Ok(())
	}

	/// Withdraw a previously-issued notification by its portal id. If
	/// we never saw an `AddNotification` for the same pair the call is
	/// a no-op (matches the portal contract — clients may retry).
	async fn remove_notification(
		&self,
		#[ zbus( connection ) ]
		conn:   &zbus::Connection,
		app_id: String,
		id:     String,
	) -> zbus::fdo::Result<()>
	{
		tracing::info!( app_id = %app_id, id = %id, "RemoveNotification" );
		let fdo_id = self.live.lock().ok().and_then( | mut g | g.remove( &( app_id, id ) ) );
		if let Some( fdo_id ) = fdo_id
		{
			let proxy = NotificationsProxy::new( conn ).await
				.map_err( | e | zbus::fdo::Error::Failed( format!( "connect notification daemon: {e}" ) ) )?;
			proxy.close_notification( fdo_id ).await
				.map_err( | e | zbus::fdo::Error::Failed( format!( "CloseNotification: {e}" ) ) )?;
		}
		Ok(())
	}
}

/// Pull a plain `String` field out of the portal's `a{sv}` dict.
/// Returns `None` when the key is missing or the value is not a string.
fn string_field( dict: &HashMap<String, OwnedValue>, key: &str ) -> Option<String>
{
	let v = dict.get( key )?;
	<&str>::try_from( v ).ok().map( str::to_owned )
}

/// Extract a themed-icon name from the portal `icon` variant. The
/// portal spec wraps the icon in a `(sv)` tuple where the tag is one
/// of `"themed"` / `"bytes"` / `"file-descriptor"`. We only support
/// `"themed"` today; anything else falls through and the freedesktop
/// notifier gets an empty `app_icon` (it will substitute its own
/// fallback).
fn themed_icon_name( dict: &HashMap<String, OwnedValue> ) -> Option<String>
{
	let icon = dict.get( "icon" )?;
	// Some clients pass the icon name as a bare string instead of the
	// `(sv)` tuple; accept that too.
	if let Ok( s ) = <&str>::try_from( icon )
	{
		return Some( s.to_owned() );
	}
	// TODO: parse the `(sv)` variant — pick the first element of the
	// "themed" array. For now bail out so callers fall back to no icon.
	None
}
