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

//! `org.freedesktop.Notifications` interface implementation.
//!
//! Serves the four spec methods (`GetCapabilities`, `Notify`,
//! `CloseNotification`, `GetServerInformation`) and declares the two
//! signals (`NotificationClosed`, `ActionInvoked`). Internal state
//! tracks the next `u32` id and the live notifications keyed by id
//! so future iterations (toast renderer, expiry timer, action click
//! routing) can plug into a stable data model.
//!
//! The actual painting is intentionally absent right now — the
//! daemon logs each notification it receives and assigns it a fresh
//! id. Replacing the log with an LTK toast surface is the next
//! milestone.

use std::collections::HashMap;
use std::sync::{ Arc, Mutex };

use ltk::ChannelSender;
use zbus::interface;
use zbus::object_server::SignalContext;
use zbus::zvariant::Value;

use crate::app::Msg;

/// Slot for the LTK-side message sender. `set_channel_sender` on the
/// `App` writes into it; D-Bus handlers read it to push `Msg::Show /
/// Msg::Dismiss` into the LTK main loop. Wrapped in `Arc<Mutex<…>>`
/// because the D-Bus interface runs on a separate tokio thread and
/// the LTK main thread fills the slot asynchronously at startup.
pub type SenderSlot = Arc<Mutex<Option<ChannelSender<Msg>>>>;

/// Reason payload of the `NotificationClosed` signal, per the spec.
#[ allow( dead_code ) ]
pub mod close_reason
{
	/// The notification expired (timeout reached).
	pub const EXPIRED:      u32 = 1;
	/// The user explicitly dismissed it (clicked the close button).
	pub const DISMISSED:    u32 = 2;
	/// A client called `CloseNotification` for this id.
	pub const CLOSED_BY_CALL: u32 = 3;
	/// Any other reason (server policy, replaced by another notification, …).
	pub const UNDEFINED:    u32 = 4;
}

/// Visual flavour assigned to a notification. Determines the toast
/// background gradient + icon-circle gradient and gives apps a way to
/// communicate intent ("this is an error" / "this just succeeded") on
/// top of the spec's coarse three-level urgency.
///
/// Crustace-specific clients can set `x-crustace-type` in the hints map with
/// one of the lowercase variant names (`info`, `warning`, `error`,
/// `success`, `default`) to pin the type directly. Spec-only clients
/// get a heuristic mapping from `urgency` + `category`.
#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum NotificationType
{
	Default,
	Info,
	Warning,
	Error,
	Success,
}

/// Snapshot of a single live notification — the fields we keep around
/// once `Notify` has returned. The toast renderer consumes this to
/// draw + lay out; the expiry timer reads `expire_timeout` to
/// schedule auto-dismiss; the action dispatcher looks up by id before
/// emitting `ActionInvoked`.
///
/// `app_name`, `app_icon`, `actions` and `urgency` are captured from the
/// spec but not yet consumed by the current toast renderer (no app
/// icon/name header, no action buttons, no urgency branch). They are
/// kept so the data is already in hand when the renderer grows those
/// affordances; `allow(dead_code)` marks the gap as intentional.
#[ derive( Clone ) ]
#[ allow( dead_code ) ]
pub struct LiveNotification
{
	pub app_name:        String,
	pub app_icon:        String,
	pub summary:         String,
	pub body:            String,
	pub actions:         Vec<( String, String )>,
	pub urgency:         u8,
	pub expire_timeout:  i32,
	pub kind:            NotificationType,
	/// Wall-clock time the `Notify` call landed on the daemon. Drives
	/// the `HH:MM` stamp on the right of each toast's header row.
	pub created_at:      chrono::DateTime<chrono::Local>,
}

pub struct State
{
	pub next_id: u32,
	pub live:    HashMap<u32, LiveNotification>,
}

impl State
{
	pub fn new() -> Self
	{
		Self { next_id: 1, live: HashMap::new() }
	}
}

/// Shared notifications state. The D-Bus interface holds it to track
/// active notifications + assign ids; a sibling tokio task on the
/// same runtime also holds it so it can prune entries when LTK
/// surfaces dismiss events back through the bidirectional channel.
pub type SharedState = Arc<Mutex<State>>;

pub struct NotificationsServer
{
	state:  SharedState,
	sender: SenderSlot,
}

impl NotificationsServer
{
	pub fn new( state: SharedState, sender: SenderSlot ) -> Self
	{
		Self { state, sender }
	}

	/// Push a message into the LTK main loop if it has already started
	/// and registered its channel sender. Silently drops the message
	/// when LTK is not yet up — happens during the brief window between
	/// `serve_dbus` claiming the bus name and `ltk::run` calling
	/// `set_channel_sender`. Notifications sent in that window are
	/// rare and losing them does not violate the spec (we just don't
	/// paint a toast for them).
	fn send_to_ui( &self, msg: Msg )
	{
		if let Ok( guard ) = self.sender.lock()
		{
			if let Some( s ) = guard.as_ref()
			{
				if let Err( e ) = s.send( msg )
				{
					tracing::warn!( error = ?e, "channel send failed" );
				}
			}
		}
	}
}

/// Emit `org.freedesktop.Notifications.NotificationClosed` for the
/// given id + reason. The D-Bus interface impl above declares the
/// signal but the `#[zbus(signal)]` macro only generates the
/// emitter as a free associated function on `NotificationsServer`;
/// this wrapper builds the [`SignalContext`] from a bare
/// [`zbus::Connection`] so background tasks (the auto-dismiss /
/// user-click handler that runs outside of any in-flight D-Bus
/// method) can emit it.
pub async fn emit_notification_closed(
	connection: &zbus::Connection,
	path:       &str,
	id:         u32,
	reason:     u32,
) -> zbus::Result<()>
{
	let ctxt = SignalContext::new( connection, path.to_string() )?;
	NotificationsServer::notification_closed( &ctxt, id, reason ).await
}

#[ interface( name = "org.freedesktop.Notifications" ) ]
impl NotificationsServer
{
	/// Return the capability strings the server supports. Names are
	/// defined by the spec; clients use this to decide whether to send
	/// rich content (e.g. some servers ignore `body` so the client
	/// strips it). Today we advertise the conservative set we *can*
	/// render once the UI lands; we will tighten / widen as features
	/// fill in.
	fn get_capabilities( &self ) -> Vec<String>
	{
		vec!
		[
			"body".into(),
			"actions".into(),
			"persistence".into(),
		]
	}

	/// Identify the server. `( name, vendor, version, spec_version )`.
	/// Clients sometimes branch on `name` for known-quirky servers; we
	/// keep both `name` and `vendor` stable so they can recognise us.
	fn get_server_information( &self ) -> ( String, String, String, String )
	{
		(
			"crustace-notifier".into(),
			"Liberux Labs".into(),
			env!( "CARGO_PKG_VERSION" ).into(),
			"1.2".into(),
		)
	}

	/// Post a notification.
	///
	/// `replaces_id == 0` means "this is a new notification"; any
	/// other value means "replace the existing notification with that
	/// id". The returned `u32` is the id the client uses for later
	/// `CloseNotification` calls and that we will emit in
	/// `NotificationClosed` / `ActionInvoked` signals.
	#[ allow( clippy::too_many_arguments ) ]
	fn notify(
		&self,
		app_name:        String,
		replaces_id:     u32,
		app_icon:        String,
		summary:         String,
		body:            String,
		actions:         Vec<String>,
		hints:           HashMap<String, Value<'_>>,
		expire_timeout:  i32,
	) -> zbus::fdo::Result<u32>
	{
		let urgency  = urgency_from_hints( &hints );
		let category = category_from_hints( &hints );
		let kind     = derive_kind( &hints, urgency, category.as_deref() );

		// `actions` is delivered as a flat list of (id, label) pairs:
		// `[ id0, label0, id1, label1, ... ]`. Coalesce into tuples now so
		// the renderer / action dispatcher don't have to remember the
		// alternation rule.
		let paired_actions: Vec<( String, String )> = actions
			.chunks( 2 )
			.filter_map( | chunk | match chunk
			{
				[ id, label ] => Some( ( id.clone(), label.clone() ) ),
				_             => None,
			} )
			.collect();

		let id;
		let data;
		{
			let mut state = self.state.lock()
				.map_err( | _ | zbus::fdo::Error::Failed( "state lock poisoned".into() ) )?;

			id = if replaces_id != 0 && state.live.contains_key( &replaces_id )
			{
				replaces_id
			} else {
				let next = state.next_id;
				// `u32` wraps after ~4 billion notifications. Skip 0 because
				// the spec reserves it for "new notification" in `replaces_id`.
				state.next_id = next.wrapping_add( 1 );
				if state.next_id == 0 { state.next_id = 1; }
				next
			};

			data = LiveNotification
			{
				app_name:        app_name.clone(),
				app_icon:        app_icon.clone(),
				summary:         summary.clone(),
				body:            body.clone(),
				actions:         paired_actions,
				urgency,
				expire_timeout,
				kind,
				created_at:      chrono::Local::now(),
			};
			state.live.insert( id, data.clone() );
			// `state` (the MutexGuard) drops at the end of this scope,
			// releasing the lock before the `send_to_ui` below so the
			// LTK main thread is not blocked on us if it happens to be
			// reading state for some reason.
		}

		tracing::info!(
			id,
			app_name = %app_name,
			summary  = %summary,
			urgency,
			expire_timeout,
			"Notify",
		);

		// Hand the data to the toast renderer. The renderer owns the
		// auto-dismiss timer; when it expires it will dispatch a
		// `Msg::Dismiss` back to LTK which (in a follow-up) closes
		// the loop by emitting `NotificationClosed`.
		self.send_to_ui( Msg::Show { id, data } );
		Ok( id )
	}

	/// Withdraw a notification by id. Idempotent — unknown ids are a
	/// no-op (the spec allows servers to either error or silently
	/// succeed; silent succeed makes life easier for clients that
	/// race against expiry).
	async fn close_notification(
		&self,
		#[ zbus( signal_context ) ]
		ctxt: SignalContext<'_>,
		id:   u32,
	) -> zbus::fdo::Result<()>
	{
		let existed = {
			let mut state = self.state.lock()
				.map_err( | _ | zbus::fdo::Error::Failed( "state lock poisoned".into() ) )?;
			state.live.remove( &id ).is_some()
		};
		tracing::info!( id, existed, "CloseNotification" );
		if existed
		{
			self.send_to_ui( Msg::Dismiss { id, reason: close_reason::CLOSED_BY_CALL } );
			Self::notification_closed( &ctxt, id, close_reason::CLOSED_BY_CALL )
				.await
				.map_err( | e | zbus::fdo::Error::Failed( format!( "emit NotificationClosed: {e}" ) ) )?;
		}
		Ok(())
	}

	/// Signal: a notification was closed.
	///
	/// `reason` follows the spec values in [`close_reason`]. Emitted by
	/// `CloseNotification` calls (above) and, once wired, by the
	/// auto-dismiss timer and the toast widget's close button.
	#[ zbus( signal ) ]
	async fn notification_closed( ctxt: &SignalContext<'_>, id: u32, reason: u32 ) -> zbus::Result<()>;

	/// Signal: the user clicked an action button on a notification.
	///
	/// `action_key` is the action id the client sent in `Notify`'s
	/// `actions` array. The pseudo-action `"default"` represents a
	/// click on the body of the toast itself.
	#[ zbus( signal ) ]
	async fn action_invoked( ctxt: &SignalContext<'_>, id: u32, action_key: &str ) -> zbus::Result<()>;
}

/// Extract the spec-standard `urgency` hint (u8 in [0, 2]) from a
/// notification's hint dictionary. Defaults to `1` (normal) when
/// missing or malformed.
fn urgency_from_hints( hints: &HashMap<String, Value<'_>> ) -> u8
{
	hints.get( "urgency" )
		.and_then( | v | u8::try_from( v ).ok() )
		.filter( | u | *u <= 2 )
		.unwrap_or( 1 )
}

/// Extract the spec-standard `category` hint (string) from a hint
/// dictionary. Returns the raw value so the caller can pattern-match
/// on the freedesktop tokens (`network.connected`, `device.error`,
/// `transfer.complete`, etc.).
fn category_from_hints( hints: &HashMap<String, Value<'_>> ) -> Option<String>
{
	hints.get( "category" )
		.and_then( | v | <&str>::try_from( v ).ok() )
		.map( | s | s.to_string() )
}

/// Pick a [`NotificationType`] for the given notification.
///
/// Resolution order:
///
/// 1. The Crustace-specific `x-crustace-type` hint — clients that know
///    about us can set this verbatim to pin a flavour regardless of urgency.
/// 2. `urgency = 2` (critical) → [`NotificationType::Error`].
/// 3. The freedesktop `category` token — `*.error` → Error,
///    `transfer.complete` / `*.arrived` / `*.received` / `presence.*`
///    → Success, `network.*` → Info.
/// 4. Fallback by urgency: `0` → Default, `1` → Info.
fn derive_kind(
	hints:    &HashMap<String, Value<'_>>,
	urgency:  u8,
	category: Option<&str>,
) -> NotificationType
{
	if let Some( raw ) = hints.get( "x-crustace-type" )
		.and_then( | v | <&str>::try_from( v ).ok() )
	{
		match raw.to_ascii_lowercase().as_str()
		{
			"info"    => return NotificationType::Info,
			"warning" => return NotificationType::Warning,
			"error"   => return NotificationType::Error,
			"success" => return NotificationType::Success,
			"default" => return NotificationType::Default,
			_         => {} // Unknown — fall through to heuristics.
		}
	}

	if urgency == 2
	{
		return NotificationType::Error;
	}

	if let Some( cat ) = category
	{
		if cat.ends_with( ".error" )
		{
			return NotificationType::Error;
		}
		if cat.ends_with( ".arrived" ) || cat.ends_with( ".received" )
			|| cat == "transfer.complete"
			|| cat.starts_with( "presence." )
		{
			return NotificationType::Success;
		}
		if cat.starts_with( "network." ) || cat.starts_with( "transfer" )
		{
			return NotificationType::Info;
		}
	}

	match urgency
	{
		0 => NotificationType::Default,
		_ => NotificationType::Info,
	}
}
