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

//! Running-app bookkeeping.
//!
//! The compositor (forge) exposes the live toplevel list via
//! `ext-foreign-toplevel-list-v1` and crustace consumes it through
//! ltk's binding (added in a follow-up stage). Each open/close event
//! arrives keyed by a Wayland-side handle id with a `app_id` string;
//! we translate the Wayland `app_id` to its installed `.desktop`
//! entry, then maintain a refcounted set of "currently running" apps
//! that the dock view reads to decide which icons to decorate with
//! the running-app indicator dot and which (un-pinned) extra
//! icons to append on the right.
//!
//! Refcounting is per-Wayland-handle: a single Firefox window opens
//! one handle; opening a second Firefox window opens another, both
//! map to the same `firefox.desktop` entry, and the entry only drops
//! out of the set once the *last* handle closes. The order in which
//! apps first appear is preserved so the dock's right-hand temporary
//! slot column reads "first opened, first listed", matching what
//! Plasma / GNOME taskbars do.

use std::collections::HashMap;

use crate::launcher::AppEntry;

/// Live state of the toplevel list, translated to `.desktop` ids.
///
/// `entries` is the insertion-ordered list of running app ids — the
/// dock's temporary-slot column iterates this directly. `refcounts`
/// is the per-app live-handle counter; an entry drops out of
/// `entries` only when its count hits zero. `by_handle` is the
/// handle → app id back-reference the `close` path needs because
/// the `closed` Wayland event only carries the handle id, not the
/// `app_id` of the toplevel it is closing.
#[ derive( Default ) ]
pub struct RunningApps
{
	entries:    Vec<String>,
	refcounts:  HashMap<String, u32>,
	by_handle:  HashMap<u32, String>,
	/// Latest Wayland `app_id` per desktop entry — what forge keys
	/// windows by, so the activate IPC sends this, not the basename.
	wayland_id: HashMap<String, String>,
}

impl RunningApps
{
	pub fn new() -> Self { Self::default() }

	/// Register a new Wayland toplevel mapped to `desktop_app_id`. If
	/// it is the first window of that app, the entry is appended to
	/// `entries`; otherwise only the refcount bumps and the existing
	/// position is preserved. Calling `open` twice with the same
	/// `handle_id` overwrites the previous binding silently — that
	/// case should not occur in practice (the protocol guarantees
	/// handle ids are unique) but the silent overwrite keeps the
	/// invariant that one handle counts at most once.
	pub fn open( &mut self, handle_id: u32, desktop_app_id: String, wayland_app_id: String )
	{
		if let Some( previous ) = self.by_handle.insert( handle_id, desktop_app_id.clone() )
		{
			// Out-of-sequence open for an id we are already tracking:
			// drop the old refcount before applying the new one so the
			// total stays consistent. Same logic the `close` path runs.
			if let Some( count ) = self.refcounts.get_mut( &previous )
			{
				*count = count.saturating_sub( 1 );
				if *count == 0
				{
					self.refcounts.remove( &previous );
					self.entries.retain( |e| e != &previous );
					self.wayland_id.remove( &previous );
				}
			}
		}

		let count = self.refcounts.entry( desktop_app_id.clone() ).or_insert( 0 );
		if *count == 0
		{
			self.entries.push( desktop_app_id.clone() );
		}
		*count += 1;
		self.wayland_id.insert( desktop_app_id, wayland_app_id );
	}

	/// Drop the toplevel handle. If its app's refcount hits zero, the
	/// app is removed from `entries` too. Unknown handles (close for
	/// a toplevel we never saw an open for) are a no-op; this
	/// happens at startup if we attach to the protocol mid-life.
	pub fn close( &mut self, handle_id: u32 )
	{
		let Some( desktop_app_id ) = self.by_handle.remove( &handle_id ) else
		{
			return;
		};
		let Some( count ) = self.refcounts.get_mut( &desktop_app_id ) else
		{
			return;
		};
		*count = count.saturating_sub( 1 );
		if *count == 0
		{
			self.refcounts.remove( &desktop_app_id );
			self.entries.retain( |e| e != &desktop_app_id );
			self.wayland_id.remove( &desktop_app_id );
		}
	}

	/// `true` if `handle_id` is currently tracked (open seen, close not
	/// yet fired). Used by the toplevel handler to ignore spurious app_id
	/// resets that some apps (e.g. foot) send right before closing.
	pub fn has_handle( &self, handle_id: u32 ) -> bool
	{
		self.by_handle.contains_key( &handle_id )
	}

	/// True if at least one Wayland toplevel of `desktop_app_id` is
	/// currently mapped. The dock's pinned-icon loop uses this to
	/// decide whether to draw the indicator dot.
	pub fn is_running( &self, desktop_app_id: &str ) -> bool
	{
		self.refcounts.contains_key( desktop_app_id )
	}

	/// Wayland `app_id` of the most recent toplevel registered under
	/// `desktop_app_id`. `None` when the app is not running.
	pub fn wayland_id_for( &self, desktop_app_id: &str ) -> Option<&str>
	{
		self.wayland_id.get( desktop_app_id ).map( String::as_str )
	}

	/// All running app ids in the order they first appeared. Used by
	/// the dock's temporary-slot column to render an entry per
	/// running un-pinned app, in "first opened, first listed" order.
	pub fn in_order( &self ) -> &[ String ]
	{
		&self.entries
	}
}

/// Resolve a Wayland `app_id` against the installed `.desktop`
/// catalogue. Returns the matching `AppEntry`, or `None` if no
/// installed app claims this toplevel (anonymous / unmatched windows
/// fall through here and never enter the dock).
///
/// Three pass match:
///
/// 1. **`StartupWMClass`** — the canonical desktop-entry → Wayland
///    binding. Required for legacy apps where the binary name (the
///    Wayland `app_id`) does not equal the desktop file basename
///    (Firefox: binary `firefox`, file `firefox-esr.desktop`).
///    Case-insensitive equality.
/// 2. **Filename basename** — covers reverse-DNS GNOME-style apps
///    that emit a Wayland `app_id` equal to their `.desktop`
///    basename (e.g. `org.gnome.Calculator`).
/// 3. **Normalized fallback** — strips non-alphanumerics and
///    lower-cases both sides before comparing the Wayland id
///    against the basename stem and the entry's display `name`.
///    Catches Electron apps that emit a humanised `app_id` like
///    `"Proton Mail"` while the file on disk is `proton-mail.desktop`.
///
/// First match wins; ties (two `.desktop` files claiming the same
/// `StartupWMClass`, which is invalid per the spec but does happen
/// in practice) resolve by scan order — which is alphabetical name
/// thanks to `scan_applications`.
pub fn match_app_id<'a>(
	wayland_app_id: &str,
	apps:           &'a [ AppEntry ],
) -> Option<&'a AppEntry>
{
	for app in apps
	{
		if let Some( ref wm_class ) = app.startup_wm_class
		{
			if wm_class.eq_ignore_ascii_case( wayland_app_id )
			{
				return Some( app );
			}
		}
	}

	for app in apps
	{
		let stem = app.app_id.strip_suffix( ".desktop" ).unwrap_or( &app.app_id );
		if stem.eq_ignore_ascii_case( wayland_app_id )
		{
			return Some( app );
		}
	}

	let needle = normalize_app_id( wayland_app_id );
	if needle.is_empty()
	{
		return None;
	}
	for app in apps
	{
		let stem = app.app_id.strip_suffix( ".desktop" ).unwrap_or( &app.app_id );
		if normalize_app_id( stem ) == needle
		{
			return Some( app );
		}
		if normalize_app_id( &app.name ) == needle
		{
			return Some( app );
		}
	}

	None
}

fn normalize_app_id( s: &str ) -> String
{
	s.chars()
		.filter( |c| c.is_ascii_alphanumeric() )
		.flat_map( |c| c.to_lowercase() )
		.collect()
}

#[ cfg( test ) ]
mod tests
{
	use super::*;
	use std::path::PathBuf;

	fn make_app( app_id: &str, wm_class: Option<&str> ) -> AppEntry
	{
		AppEntry
		{
			app_id:               app_id.to_string(),
			name:                 app_id.to_string(),
			exec:                 String::new(),
			icon:                 None::<PathBuf>,
			description:          None,
			terminal:             false,
			startup_wm_class:     wm_class.map( str::to_string ),
			is_terminal_emulator: false,
		}
	}

	#[ test ]
	fn matches_by_startup_wm_class()
	{
		let apps =
		[
			make_app( "firefox-esr.desktop", Some( "firefox" ) ),
			make_app( "org.gnome.Calculator.desktop", None ),
		];
		assert_eq!( match_app_id( "firefox", &apps ).map( |a| a.app_id.as_str() ), Some( "firefox-esr.desktop" ) );
	}

	#[ test ]
	fn matches_by_basename_when_no_wm_class()
	{
		let apps = [ make_app( "org.gnome.Calculator.desktop", None ) ];
		assert_eq!(
			match_app_id( "org.gnome.Calculator", &apps ).map( |a| a.app_id.as_str() ),
			Some( "org.gnome.Calculator.desktop" )
		);
	}

	#[ test ]
	fn wm_class_wins_over_basename()
	{
		// Two installed apps: one claims `Code` via WMClass, the other
		// has a `.desktop` whose basename happens to be `Code`. The
		// WMClass binding should win.
		let apps =
		[
			make_app( "Code.desktop", None ),
			make_app( "code-oss.desktop", Some( "Code" ) ),
		];
		assert_eq!(
			match_app_id( "Code", &apps ).map( |a| a.app_id.as_str() ),
			Some( "code-oss.desktop" )
		);
	}

	#[ test ]
	fn unmatched_returns_none()
	{
		let apps = [ make_app( "firefox-esr.desktop", Some( "firefox" ) ) ];
		assert!( match_app_id( "totally-unknown-binary", &apps ).is_none() );
	}

	#[ test ]
	fn matches_humanised_app_id_via_normalisation()
	{
		let apps =
		[
			AppEntry
			{
				app_id:               "proton-mail.desktop".to_string(),
				name:                 "Proton Mail".to_string(),
				exec:                 String::new(),
				icon:                 None::<PathBuf>,
				description:          None,
				terminal:             false,
				startup_wm_class:     None,
				is_terminal_emulator: false,
			},
		];
		assert_eq!(
			match_app_id( "Proton Mail", &apps ).map( |a| a.app_id.as_str() ),
			Some( "proton-mail.desktop" )
		);
	}

	#[ test ]
	fn open_close_lifecycle()
	{
		let mut r = RunningApps::new();
		r.open( 1, "firefox.desktop".into(), "firefox".into() );
		assert!( r.is_running( "firefox.desktop" ) );
		assert_eq!( r.wayland_id_for( "firefox.desktop" ), Some( "firefox" ) );
		assert_eq!( r.in_order(), &[ "firefox.desktop" ] );

		r.open( 2, "firefox.desktop".into(), "firefox".into() );  // second window
		assert_eq!( r.in_order(), &[ "firefox.desktop" ] );

		r.close( 1 );
		assert!( r.is_running( "firefox.desktop" ) );

		r.close( 2 );
		assert!( !r.is_running( "firefox.desktop" ) );
		assert!( r.wayland_id_for( "firefox.desktop" ).is_none() );
		assert!( r.in_order().is_empty() );
	}

	#[ test ]
	fn preserves_open_order()
	{
		let mut r = RunningApps::new();
		r.open( 1, "a.desktop".into(), "a".into() );
		r.open( 2, "b.desktop".into(), "b".into() );
		r.open( 3, "c.desktop".into(), "c".into() );
		assert_eq!( r.in_order(), &[ "a.desktop", "b.desktop", "c.desktop" ] );

		r.close( 2 );
		assert_eq!( r.in_order(), &[ "a.desktop", "c.desktop" ] );
	}
}
