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

//! `crustace-notifier` — notification daemon for the Crustace desktop
//! environment.
//!
//! Claims the well-known D-Bus name `org.freedesktop.Notifications`
//! and serves the [freedesktop notification spec][spec]. Both legacy
//! apps (talking to the spec directly) and Flatpak apps (via the
//! portal's notification impl forwarding) land here.
//!
//! The runtime is split across two threads:
//!
//! * a tokio worker thread that hosts the D-Bus serving (zbus needs
//!   an async runtime), and
//! * the main thread, where LTK owns the Wayland event loop and the
//!   toast surface (Wayland clients keep their event loop on main).
//!
//! Communication is one-way: D-Bus handlers push [`app::Msg`] values
//! through an [`ltk::ChannelSender`] kept in a shared slot. LTK fills
//! the slot at startup via `App::set_channel_sender`; D-Bus handlers
//! read it lazily.
//!
//! [spec]: https://specifications.freedesktop.org/notification-spec/latest/

mod app;
mod channels;
mod notifications;

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

use anyhow::Context;
use tracing_subscriber::EnvFilter;
use zbus::fdo::{ DBusProxy, RequestNameFlags };
use zbus::names::WellKnownName;

const SERVICE_NAME: &str = "org.freedesktop.Notifications";
const SERVICE_PATH: &str = "/org/freedesktop/Notifications";

fn main() -> anyhow::Result<()>
{
	tracing_subscriber::fmt()
		.with_env_filter(
			EnvFilter::try_from_default_env()
				.unwrap_or_else( | _ | EnvFilter::new( "info" ) )
				.add_directive( "usvg=error".parse().unwrap() )
				.add_directive( "resvg=error".parse().unwrap() ),
		)
		.init();

	let sender_slot: notifications::SenderSlot = Arc::new( Mutex::new( None ) );
	let state:       notifications::SharedState = Arc::new( Mutex::new( notifications::State::new() ) );

	// Bidirectional channel: LTK pushes DismissEvents back to the
	// D-Bus thread so it can emit NotificationClosed for every
	// dismiss path (auto-expire / user click), not just explicit
	// `CloseNotification` calls.
	let ( dismiss_tx, dismiss_rx ) = tokio::sync::mpsc::unbounded_channel::<app::DismissEvent>();

	let server = notifications::NotificationsServer::new( Arc::clone( &state ), Arc::clone( &sender_slot ) );
	let channels_server = notifications::ChannelsServer::new( Arc::clone( &state ) );

	let rt = tokio::runtime::Builder::new_multi_thread()
		.enable_all()
		.build()
		.context( "build tokio runtime" )?;

	let _dbus_thread = std::thread::Builder::new()
		.name( "crustace-notifier-dbus".into() )
		.spawn( move ||
		{
			rt.block_on( async move
			{
				match serve_dbus( server, channels_server ).await
				{
					Ok( connection ) =>
					{
						// Spawn the dismiss-event drain on the same
						// runtime so it shares the D-Bus connection.
						tokio::spawn( drain_dismiss_events( connection, state, dismiss_rx ) );
						// Park forever — the connection stays alive
						// until the process exits.
						std::future::pending::<()>().await;
					}
					Err( e ) =>
					{
						tracing::error!( error = ?e, "D-Bus serve failed" );
					}
				}
			} );
		} )
		.context( "spawn D-Bus thread" )?;

	// Hand control of the main thread to LTK. `ltk::run` blocks until
	// the surface is closed; the tokio thread is orphaned and reaped
	// by the OS at process exit.
	ltk::run( app::ToastApp::new( sender_slot, dismiss_tx ) );
	Ok(())
}

/// Drain dismiss events the LTK side pushes back through the channel.
/// For every event: prune the local state map (so it doesn't grow
/// unbounded) and emit `NotificationClosed` so D-Bus clients see the
/// dismiss event regardless of where it originated.
async fn drain_dismiss_events(
	connection:  zbus::Connection,
	state:       notifications::SharedState,
	mut rx:      tokio::sync::mpsc::UnboundedReceiver<app::DismissEvent>,
)
{
	while let Some( ev ) = rx.recv().await
	{
		// Prune state.live so the map doesn't accumulate stale ids.
		if let Ok( mut guard ) = state.lock()
		{
			guard.live.remove( &ev.id );
		}
		if let Err( e ) = notifications::emit_notification_closed( &connection, SERVICE_PATH, ev.id, ev.reason ).await
		{
			tracing::warn!( error = ?e, id = ev.id, reason = ev.reason, "emit NotificationClosed failed" );
		}
	}
}

async fn serve_dbus(
	server:          notifications::NotificationsServer,
	channels_server: notifications::ChannelsServer,
) -> anyhow::Result<zbus::Connection>
{
	// Build the connection + register the service object at its path
	// *before* claiming the name. Bus-name acquisition runs separately
	// so we can request `REPLACE_EXISTING | ALLOW_REPLACEMENT` —
	// without that flag pair we cannot take over from gnome-shell
	// (which on some setups sets `ALLOW_REPLACEMENT` and on others
	// does not; the call below logs the resulting state either way).
	let connection = zbus::connection::Builder::session()
		.context( "open session bus" )?
		.serve_at( SERVICE_PATH, server )
		.context( "register Notifications service at path" )?
		.serve_at( SERVICE_PATH, channels_server )
		.context( "register Channels interface at path" )?
		.build()
		.await
		.context( "build connection" )?;

	let well_known: WellKnownName = SERVICE_NAME.try_into()
		.context( "well-known name is malformed (compile-time bug)" )?;
	let flags = RequestNameFlags::ReplaceExisting | RequestNameFlags::AllowReplacement;
	let dbus = DBusProxy::new( &connection ).await.context( "open fdo DBus proxy" )?;
	let reply = dbus.request_name( well_known, flags ).await.context( "RequestName" )?;
	use zbus::fdo::RequestNameReply::*;
	match reply
	{
		PrimaryOwner =>
		{
			tracing::info!( name = SERVICE_NAME, "primary owner of bus name" );
		}
		InQueue =>
		{
			tracing::warn!(
				name = SERVICE_NAME,
				"queued behind another owner — another notifier holds the name and didn't allow replacement",
			);
		}
		Exists =>
		{
			anyhow::bail!( "bus name `{SERVICE_NAME}` is already owned and no replacement was permitted" );
		}
		AlreadyOwner =>
		{
			tracing::info!( name = SERVICE_NAME, "already the owner" );
		}
	}

	tracing::info!( name = SERVICE_NAME, path = SERVICE_PATH, "crustace-notifier D-Bus online" );

	// Return the connection to the caller; keeping it parked-alive is
	// the caller's responsibility now (the dismiss drain task lives on
	// the same runtime and shares this connection).
	Ok( connection )
}
