//! The system-wide shortcut that raises QuickSearch and focuses the search
//! box.
//!
//! It must be registered with the operating system — an egui shortcut gets
//! no key events while the window is minimised, behind something else, or
//! unfocused. Two ways, chosen by what the session is:
//!
//! * **Windows and X11** let an application claim a key for itself
//!   (`RegisterHotKey`, `XGrabKey`), which `global-hotkey` wraps. The key is
//!   exactly the one that was asked for, or the registration fails.
//! * **Wayland** does not, on purpose, so the shortcut goes through the XDG
//!   desktop portal instead and the *desktop* owns the binding. See
//!   [`portal`].
//!
//! Held in a thread-local global rather than a field: the registration is
//! process-wide, `GlobalHotKeyEvent::set_event_handler` is a set-once
//! global, and on Windows `GlobalHotKeyManager` owns a hidden message
//! window, so it is not `Send` and must stay on the winit event-loop thread.
//!
//! Every entry point is inert until [`init`] runs, so the headless UI tests
//! never touch an OS registration.

mod binding;
#[cfg(all(unix, not(target_os = "macos")))]
mod portal;
mod raise;

pub use binding::{parse_setting, Binding};
pub use raise::raise;

use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};

use global_hotkey::hotkey::HotKey;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};

/// Set from whichever thread the shortcut arrives on, consumed by the UI
/// thread in [`take_fired`]. A flag rather than a queue: two presses before
/// the app can redraw mean the same thing as one.
static FIRED: AtomicBool = AtomicBool::new(false);

thread_local! {
    /// UI-thread only. See the module docs for why it is not a field.
    static REGISTRY: RefCell<Option<Registry>> = const { RefCell::new(None) };
}

/// What the Settings tab says about the shortcut.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
    /// The setting is empty: no shortcut, by choice.
    Disabled,
    /// Registered with the display server, exactly as asked.
    Active,
    /// Asked for, and the desktop has not answered yet.
    Pending,
    /// Wayland: registered, described in the desktop's own words because the
    /// desktop, not the setting, decides the key.
    PortalBound(String),
    /// It is not going to work, and this says why.
    Error(String),
}

struct Registry {
    backend: Backend,
    /// The status of everything except the portal, which reports its own
    /// asynchronously; see [`status`].
    status: Status,
}

enum Backend {
    /// Nothing registered: no shortcut set, or the backend never started.
    Idle,
    /// Windows and X11.
    Grab {
        manager: GlobalHotKeyManager,
        /// The registration currently held, to be released before the next.
        registered: Option<HotKey>,
    },
    #[cfg(all(unix, not(target_os = "macos")))]
    Portal(portal::Portal),
}

/// Start the shortcut and register `setting`.
///
/// Must be called on the thread running the event loop, and only from there:
/// on Windows `GlobalHotKeyManager` creates a hidden window whose messages
/// that loop is what dispatches. In practice that means eframe's app-creation
/// closure, which runs on the main thread with the loop already going.
pub fn init(ctx: &egui::Context, setting: &str) {
    // Press only: the crate reports the release as a second event, and
    // acting on both means every press does its work twice.
    let repaint = ctx.clone();
    GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| {
        if event.state == HotKeyState::Pressed {
            fire(&repaint);
        }
    }));

    let backend = match choose_backend(ctx) {
        Ok(backend) => backend,
        Err(message) => {
            quicksearch_core::log_warn!("global shortcut: {}", message);
            REGISTRY.with_borrow_mut(|slot| {
                *slot = Some(Registry {
                    backend: Backend::Idle,
                    status: Status::Error(message),
                });
            });
            return;
        }
    };
    REGISTRY.with_borrow_mut(|slot| {
        *slot = Some(Registry {
            backend,
            status: Status::Disabled,
        })
    });
    apply(setting);
}

/// Register `setting`, releasing whatever was registered before. Empty means
/// no shortcut. An unparseable or refused shortcut is reported through
/// [`status`], never by failing: a shortcut is not worth blocking a config
/// the user has already applied.
pub fn apply(setting: &str) {
    REGISTRY.with_borrow_mut(|slot| {
        let Some(registry) = slot.as_mut() else {
            return;
        };
        let wanted = match parse_setting(setting) {
            Ok(binding) => binding,
            Err(e) => {
                registry.status = Status::Error(format!("{:?} is not a shortcut: {}", setting, e));
                // Releasing cannot fail in a way worth a second message.
                let _ = registry.backend.register(None);
                return;
            }
        };
        registry.status = match registry.backend.register(wanted) {
            Ok(()) if wanted.is_some() => Status::Active,
            Ok(()) => Status::Disabled,
            Err(e) => Status::Error(e),
        };
        // Logged because a shortcut that quietly does nothing is impossible
        // to tell apart from one that was never asked for.
        match (&registry.status, wanted) {
            (Status::Active, Some(binding)) => {
                quicksearch_core::log_info!("global shortcut: {} registered", binding)
            }
            (Status::Error(why), _) => quicksearch_core::log_warn!("global shortcut: {}", why),
            _ => {}
        }
    });
}

/// Whether the shortcut was pressed since this was last asked, clearing it.
pub fn take_fired() -> bool {
    FIRED.swap(false, Ordering::SeqCst)
}

/// What to tell the user about the shortcut right now.
pub fn status() -> Status {
    REGISTRY.with_borrow(|slot| match slot.as_ref() {
        None => Status::Disabled,
        // The portal answers on its own schedule, so it keeps its own status
        // and this one is stale the moment a bind is sent.
        #[cfg(all(unix, not(target_os = "macos")))]
        Some(Registry {
            backend: Backend::Portal(portal),
            ..
        }) => portal.status(),
        Some(registry) => registry.status.clone(),
    })
}

/// Record a press and wake the UI: without the repaint an idle or minimised
/// window would leave the flag unread until something else asked for a frame.
fn fire(ctx: &egui::Context) {
    FIRED.store(true, Ordering::SeqCst);
    ctx.request_repaint();
}

impl Backend {
    /// Hold `wanted` and nothing else. `None` releases without registering.
    fn register(&mut self, wanted: Option<Binding>) -> Result<(), String> {
        match self {
            Backend::Idle => Ok(()),
            Backend::Grab {
                manager,
                registered,
            } => {
                if let Some(old) = registered.take() {
                    // A failed unregister leaves a key claimed that nothing
                    // listens for; worth reporting, not worth refusing the
                    // new binding over.
                    if let Err(e) = manager.unregister(old) {
                        quicksearch_core::log_warn!("releasing the old global shortcut: {}", e);
                    }
                }
                let Some(binding) = wanted else {
                    return Ok(());
                };
                // Infallible in practice: a test holds `Binding`'s tokens to
                // being parseable.
                let hotkey: HotKey = binding
                    .to_string()
                    .parse()
                    .map_err(|e| format!("{} is not a usable shortcut: {}", binding, e))?;
                manager.register(hotkey).map_err(|e| match e {
                    global_hotkey::Error::AlreadyRegistered(_) => {
                        format!("another application is already using {}", binding)
                    }
                    other => format!("{} could not be registered: {}", binding, other),
                })?;
                *registered = Some(hotkey);
                Ok(())
            }
            #[cfg(all(unix, not(target_os = "macos")))]
            Backend::Portal(portal) => {
                portal.bind(wanted.map(|b| b.portal_trigger()));
                Ok(())
            }
        }
    }
}

/// Wayland refuses key grabs by design, so a session with a Wayland display
/// gets the portal and everything else gets a grab. No falling back from one
/// to the other: an X11 grab made from inside a Wayland session succeeds and
/// then only ever fires while an XWayland window has focus, which looks like
/// a broken shortcut rather than an unavailable one.
#[cfg(all(unix, not(target_os = "macos")))]
fn choose_backend(ctx: &egui::Context) -> Result<Backend, String> {
    if std::env::var_os("WAYLAND_DISPLAY").is_some() {
        return Ok(Backend::Portal(portal::Portal::new(ctx)));
    }
    grab_backend()
}

#[cfg(not(all(unix, not(target_os = "macos"))))]
fn choose_backend(_ctx: &egui::Context) -> Result<Backend, String> {
    grab_backend()
}

fn grab_backend() -> Result<Backend, String> {
    GlobalHotKeyManager::new()
        .map(|manager| Backend::Grab {
            manager,
            registered: None,
        })
        .map_err(|e| format!("global shortcuts are unavailable: {}", e))
}

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

    /// Nothing may touch an OS registration before `init`, so that the
    /// headless UI tests can render the Settings row.
    #[test]
    fn an_uninitialised_registry_is_inert() {
        apply("Ctrl+Shift+F");
        assert_eq!(status(), Status::Disabled);
        assert!(!take_fired());
    }

    #[test]
    fn a_press_is_reported_once() {
        FIRED.store(true, Ordering::SeqCst);
        assert!(take_fired());
        assert!(!take_fired(), "the flag is consumed");
    }

    /// `Idle` stands in for a backend that never started; it must accept
    /// every call rather than panic, since `apply` runs on every config save.
    #[test]
    fn an_idle_backend_accepts_everything() {
        let mut backend = Backend::Idle;
        assert_eq!(backend.register(None), Ok(()));
        assert_eq!(
            backend.register(Some("Ctrl+Shift+F".parse().unwrap())),
            Ok(())
        );
    }
}
