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

//! Notification channels — a general Crustace concept layered over the
//! freedesktop notification spec.
//!
//! A channel is a per-source category, keyed by `(app_id, channel_id)`. The
//! source app declares metadata (name, description, group, a default
//! importance); the *user* owns the policy (an importance override, where
//! `0` blocks the channel entirely). The effective importance a client sees
//! is the user override when set, else the app's default. App metadata is
//! re-declared on every launch and is not persisted; only the user policy is.
//!
//! The importance scale matches Android's so an Android compat layer can pass
//! it straight through, but the concept is not Android-specific — any app can
//! register channels and any notification can be tagged with one (the
//! `x-crustace-channel` hint on `Notify`).

use std::collections::{ BTreeMap, HashMap };
use std::path::PathBuf;

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

/// Importance scale (matches Android). `0` blocks the channel; `4` maps to
/// critical urgency.
pub const IMPORTANCE_BLOCKED: i32 = 0;
pub const IMPORTANCE_MIN:     i32 = 1;
pub const IMPORTANCE_LOW:     i32 = 2;
pub const IMPORTANCE_DEFAULT: i32 = 3;
pub const IMPORTANCE_HIGH:    i32 = 4;

fn clamp_importance( i: i32 ) -> i32
{
	i.clamp( IMPORTANCE_BLOCKED, IMPORTANCE_HIGH )
}

/// Map an effective importance to a freedesktop urgency (0 low, 1 normal,
/// 2 critical). `BLOCKED` never reaches here (the notification is dropped).
pub fn urgency_from_importance( importance: i32 ) -> u8
{
	match importance
	{
		i if i >= IMPORTANCE_HIGH    => 2,
		i if i >= IMPORTANCE_LOW     => 1,
		_                            => 0,
	}
}

/// App-declared metadata. Volatile: re-registered on every app launch.
#[ derive( Clone, Default ) ]
pub struct ChannelMeta
{
	pub name:               String,
	pub description:        String,
	pub group:              String,
	pub default_importance: i32,
}

#[ derive( Clone, Default ) ]
pub struct GroupMeta
{
	pub name: String,
}

#[ derive( Clone, Default ) ]
struct Channel
{
	meta:       ChannelMeta,
	/// User importance override; `None` follows `meta.default_importance`.
	importance: Option<i32>,
}

/// The registry: app metadata (volatile) plus user policy (persisted), keyed
/// by `(app_id, channel_id)` and `(app_id, group_id)`.
pub struct ChannelRegistry
{
	channels: HashMap<( String, String ), Channel>,
	groups:   HashMap<( String, String ), GroupMeta>,
	path:     PathBuf,
}

/// `~/.config/crustace-notifier/channels.toml` (honouring `XDG_CONFIG_HOME`).
pub fn default_path() -> PathBuf
{
	let base = std::env::var_os( "XDG_CONFIG_HOME" )
		.map( PathBuf::from )
		.filter( | p | !p.as_os_str().is_empty() )
		.or_else( || std::env::var_os( "HOME" ).map( | h | PathBuf::from( h ).join( ".config" ) ) )
		.unwrap_or_else( || PathBuf::from( "." ) );
	base.join( "crustace-notifier" ).join( "channels.toml" )
}

fn ov_str( s: &str ) -> OwnedValue
{
	OwnedValue::try_from( Value::from( s.to_string() ) ).expect( "string is a valid value" )
}

fn ov_i32( i: i32 ) -> OwnedValue
{
	OwnedValue::try_from( Value::from( i ) ).expect( "i32 is a valid value" )
}

fn ov_bool( b: bool ) -> OwnedValue
{
	OwnedValue::try_from( Value::from( b ) ).expect( "bool is a valid value" )
}

fn meta_str( m: &HashMap<String, Value<'_>>, key: &str ) -> String
{
	m.get( key ).and_then( | v | <&str>::try_from( v ).ok() ).unwrap_or( "" ).to_string()
}

fn meta_i32( m: &HashMap<String, Value<'_>>, key: &str, default: i32 ) -> i32
{
	m.get( key ).and_then( | v | i32::try_from( v ).ok() ).unwrap_or( default )
}

impl ChannelRegistry
{
	/// Load the persisted user policy from `path` (an empty registry when the
	/// file is missing or unparseable).
	pub fn load( path: PathBuf ) -> Self
	{
		let mut reg = Self { channels: HashMap::new(), groups: HashMap::new(), path };
		let Ok( text ) = std::fs::read_to_string( &reg.path ) else { return reg; };
		let parsed: BTreeMap<String, BTreeMap<String, i32>> = match toml::from_str( &text )
		{
			Ok( p )  => p,
			Err( e ) => { tracing::warn!( error = %e, "channels.toml parse failed; starting empty" ); return reg; }
		};
		for ( app_id, chans ) in parsed
		{
			for ( channel_id, importance ) in chans
			{
				reg.channels.insert(
					( app_id.clone(), channel_id ),
					Channel { meta: ChannelMeta::default(), importance: Some( clamp_importance( importance ) ) },
				);
			}
		}
		reg
	}

	/// Persist the user policy — only channels the user has overridden.
	fn save( &self )
	{
		let mut out: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
		for ( ( app_id, channel_id ), ch ) in &self.channels
		{
			if let Some( imp ) = ch.importance
			{
				out.entry( app_id.clone() ).or_default().insert( channel_id.clone(), imp );
			}
		}
		let text = match toml::to_string( &out )
		{
			Ok( t )  => t,
			Err( e ) => { tracing::warn!( error = %e, "serialise channels.toml failed" ); return; }
		};
		if let Some( dir ) = self.path.parent()
		{
			let _ = std::fs::create_dir_all( dir );
		}
		if let Err( e ) = std::fs::write( &self.path, text )
		{
			tracing::warn!( error = %e, path = %self.path.display(), "write channels.toml failed" );
		}
	}

	/// Upsert app-declared metadata, preserving any user policy.
	pub fn register_channel( &mut self, app_id: &str, channel_id: &str, meta: ChannelMeta )
	{
		let entry = self.channels.entry( ( app_id.to_string(), channel_id.to_string() ) ).or_default();
		entry.meta = meta;
	}

	pub fn register_group( &mut self, app_id: &str, group_id: &str, meta: GroupMeta )
	{
		self.groups.insert( ( app_id.to_string(), group_id.to_string() ), meta );
	}

	pub fn delete_channel( &mut self, app_id: &str, channel_id: &str )
	{
		if self.channels.remove( &( app_id.to_string(), channel_id.to_string() ) ).is_some()
		{
			self.save();
		}
	}

	pub fn delete_group( &mut self, app_id: &str, group_id: &str )
	{
		self.groups.remove( &( app_id.to_string(), group_id.to_string() ) );
	}

	/// Set the user importance override. A negative value clears the override
	/// (the channel follows the app default again). Persisted immediately.
	pub fn set_importance( &mut self, app_id: &str, channel_id: &str, importance: i32 )
	{
		let entry = self.channels.entry( ( app_id.to_string(), channel_id.to_string() ) ).or_default();
		entry.importance = if importance < 0 { None } else { Some( clamp_importance( importance ) ) };
		self.save();
	}

	/// Effective importance the source app sees (user override, else app
	/// default). `None` when the channel is unknown.
	pub fn effective_importance( &self, app_id: &str, channel_id: &str ) -> Option<i32>
	{
		self.channels.get( &( app_id.to_string(), channel_id.to_string() ) )
			.map( | ch | ch.importance.unwrap_or( ch.meta.default_importance ) )
	}

	fn channel_dict( &self, app_id: &str, channel_id: &str, ch: &Channel ) -> HashMap<String, OwnedValue>
	{
		let importance = ch.importance.unwrap_or( ch.meta.default_importance );
		let mut d = HashMap::new();
		d.insert( "app_id".into(),             ov_str( app_id ) );
		d.insert( "channel_id".into(),         ov_str( channel_id ) );
		d.insert( "name".into(),               ov_str( &ch.meta.name ) );
		d.insert( "description".into(),        ov_str( &ch.meta.description ) );
		d.insert( "group".into(),              ov_str( &ch.meta.group ) );
		d.insert( "importance".into(),         ov_i32( importance ) );
		d.insert( "default_importance".into(), ov_i32( ch.meta.default_importance ) );
		d.insert( "user_set".into(),           ov_bool( ch.importance.is_some() ) );
		d.insert( "blocked".into(),            ov_bool( importance == IMPORTANCE_BLOCKED ) );
		d
	}

	/// Merged metadata + effective policy for one channel (empty when unknown).
	pub fn get_channel( &self, app_id: &str, channel_id: &str ) -> HashMap<String, OwnedValue>
	{
		match self.channels.get( &( app_id.to_string(), channel_id.to_string() ) )
		{
			Some( ch ) => self.channel_dict( app_id, channel_id, ch ),
			None       => HashMap::new(),
		}
	}

	pub fn list_channels( &self, app_id: &str ) -> Vec<HashMap<String, OwnedValue>>
	{
		self.channels.iter()
			.filter( | ( ( a, _ ), _ ) | a == app_id )
			.map( | ( ( a, c ), ch ) | self.channel_dict( a, c, ch ) )
			.collect()
	}

	pub fn list_groups( &self, app_id: &str ) -> Vec<HashMap<String, OwnedValue>>
	{
		self.groups.iter()
			.filter( | ( ( a, _ ), _ ) | a == app_id )
			.map( | ( ( a, g ), meta ) |
			{
				let mut d = HashMap::new();
				d.insert( "app_id".into(),   ov_str( a ) );
				d.insert( "group_id".into(), ov_str( g ) );
				d.insert( "name".into(),     ov_str( &meta.name ) );
				d
			} )
			.collect()
	}

	/// The set of app ids that have at least one channel — for the settings UI.
	pub fn list_apps( &self ) -> Vec<String>
	{
		let mut apps: Vec<String> = self.channels.keys().map( | ( a, _ ) | a.clone() ).collect();
		apps.sort();
		apps.dedup();
		apps
	}
}

/// Resolve the source app id of a `Notify` call from its hints: the
/// Crustace-specific `x-crustace-app-id`, else the freedesktop `desktop-entry`,
/// else `None` (caller falls back to `app_name`).
pub fn app_id_from_hints( hints: &HashMap<String, Value<'_>> ) -> Option<String>
{
	for key in [ "x-crustace-app-id", "desktop-entry" ]
	{
		if let Some( s ) = hints.get( key ).and_then( | v | <&str>::try_from( v ).ok() )
		{
			if !s.is_empty()
			{
				return Some( s.to_string() );
			}
		}
	}
	None
}

/// Build a [`ChannelMeta`] from a `RegisterChannel` `a{sv}` payload.
pub fn meta_from_dict( m: &HashMap<String, Value<'_>> ) -> ChannelMeta
{
	ChannelMeta
	{
		name:               meta_str( m, "name" ),
		description:        meta_str( m, "description" ),
		group:              meta_str( m, "group" ),
		default_importance: clamp_importance( meta_i32( m, "importance", IMPORTANCE_DEFAULT ) ),
	}
}
