//! Linux evdev-based global hotkey listener.
//!
//! Reads raw input events from `/dev/input/event*` devices. Works on both
//! X11 and Wayland because it operates at the kernel level, bypassing the
//! display server entirely.
//!
//! Key patterns stolen from oddlama/whisper-overlay:
//! - Device hotplug via `notify` watching `/dev/input/`
//! - Retry loop for udev permission propagation on new devices
//! - Per-device async event streams
//!
//! ## Lifecycle
//!
//! Every task this module spawns is owned by a
//! [`crate::supervisor::SupervisorHandle`] living inside the
//! [`EvdevHotkeyListener`]. On `stop()`, the supervisor sends a broadcast
//! shutdown signal and awaits every `JoinHandle` with a bounded timeout,
//! so a reconfigure cannot leave orphaned listeners alive (which would
//! otherwise hold `event_tx` clones forever and emit duplicate events to
//! the frontend). See `supervisor.rs` for the supervisor and
//! `tests/listener_lifecycle.rs` for the regression tests.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use evdev::{AttributeSetRef, Device, InputEventKind, Key};
use notify::{recommended_watcher, EventKind, RecursiveMode, Watcher};
use tokio::sync::{mpsc, watch, Mutex};

use crate::supervisor::SupervisorHandle;
use crate::HotkeyCombo;

/// Events emitted by the hotkey listener.
#[derive(Debug, Clone)]
pub enum HotkeyEvent {
    /// The configured hotkey was pressed.
    Pressed,
    /// The configured hotkey was released (useful for push-to-talk).
    Released,
}

/// Shared map of attached evdev devices. Keyed by path so attach is
/// idempotent. Membership-only marker — the actual `JoinHandle` for each
/// device listener task lives in the supervisor. Insert-before-spawn
/// under one mutex hold closes the TOCTOU window the previous design had.
type TrackedDevices = Arc<Mutex<HashMap<PathBuf, ()>>>;

/// Manages evdev device listeners and hotplug detection.
///
/// All spawned tasks are owned by an internal
/// [`SupervisorHandle`]. On `stop()` (or `Drop`) every task receives a
/// broadcast shutdown signal and is joined with a per-task timeout so a
/// reconfigure cannot leak listeners.
pub struct EvdevHotkeyListener {
    /// Send a new hotkey config to all listener tasks.
    hotkey_tx: watch::Sender<Option<HotkeyCombo>>,
    /// Tracks every spawned task. Cloned into spawn sites so per-device
    /// retry tasks can register their own children.
    supervisor: SupervisorHandle,
    /// Set to `true` once `stop()` has run so `Drop` skips its
    /// best-effort shutdown signal.
    stopped: bool,
}

impl EvdevHotkeyListener {
    /// Start the hotkey listener. Returns the listener handle and a receiver
    /// for hotkey events.
    ///
    /// The listener spawns:
    /// 1. One async task per input device that has the target key
    /// 2. A watcher task that detects new devices via inotify on `/dev/input/`
    pub async fn start(combo: HotkeyCombo, event_tx: mpsc::Sender<HotkeyEvent>) -> Self {
        let (hotkey_tx, hotkey_rx) = watch::channel(Some(combo));
        let tracked: TrackedDevices = Arc::new(Mutex::new(HashMap::new()));
        let supervisor = SupervisorHandle::new();

        // Spawn initial scanner. Walks /dev/input once and attaches every
        // matching device. After it completes the hotplug watcher (below)
        // is responsible for keeping the attachment set in sync.
        {
            let hotkey_rx = hotkey_rx.clone();
            let event_tx = event_tx.clone();
            let tracked = tracked.clone();
            let supervisor_inner = supervisor.clone();
            let mut shutdown_rx = supervisor.subscribe();
            let scanner_handle = tokio::spawn(async move {
                tokio::select! {
                    _ = scan_and_attach(
                        &hotkey_rx,
                        &event_tx,
                        &tracked,
                        &supervisor_inner,
                    ) => {}
                    _ = shutdown_rx.recv() => {
                        tracing::debug!(
                            target: "lumotia_hotkey",
                            "scanner received shutdown signal mid-scan"
                        );
                    }
                }
            });
            supervisor.register("scanner", scanner_handle).await;
        }

        // Spawn hotplug watcher. Hands the supervisor handle through so
        // it can register retry tasks it spawns.
        {
            let hotkey_rx = hotkey_rx.clone();
            let event_tx = event_tx.clone();
            let tracked = tracked.clone();
            let supervisor_inner = supervisor.clone();
            let mut shutdown_rx = supervisor.subscribe();
            let hotplug_handle = tokio::spawn(async move {
                run_hotplug_watcher(
                    hotkey_rx,
                    event_tx,
                    tracked,
                    supervisor_inner,
                    &mut shutdown_rx,
                )
                .await;
            });
            supervisor.register("hotplug", hotplug_handle).await;
        }

        let task_count = supervisor.task_count().await;
        tracing::info!(
            target: "lumotia_hotkey",
            task_count = task_count,
            "supervisor started"
        );

        Self {
            hotkey_tx,
            supervisor,
            stopped: false,
        }
    }

    /// Update the hotkey combination. All device listeners pick up the
    /// change via the watch channel.
    pub fn set_hotkey(&self, combo: HotkeyCombo) {
        let _ = self.hotkey_tx.send(Some(combo));
    }

    /// Stop all listeners and clean up.
    ///
    /// Consumes the listener so it cannot be reused. Awaits every
    /// supervised task with a per-task timeout (see
    /// [`SupervisorHandle::shutdown`]); a stuck task is logged and
    /// detached rather than blocking the caller indefinitely.
    pub async fn stop(mut self) {
        // Signal None first so device listeners exit their loop cleanly
        // without waiting for the broadcast subscription select arm.
        let _ = self.hotkey_tx.send(None);
        self.supervisor.shutdown().await;
        self.stopped = true;
    }
}

/// Best-effort shutdown on drop. Async drop isn't available in stable
/// Rust, so we only fire the broadcast — we cannot await JoinHandles
/// here. Tasks subscribed to the broadcast see the signal and exit
/// cooperatively; their JoinHandles detach but the runtime reclaims them
/// once they finish. The intended path is always explicit `stop().await`
/// before drop.
impl Drop for EvdevHotkeyListener {
    fn drop(&mut self) {
        if !self.stopped {
            self.supervisor.signal_shutdown_nonblocking();
            let _ = self.hotkey_tx.send(None);
        }
    }
}

/// Hotplug watcher loop. Listens for inotify events on `/dev/input/`
/// and dispatches a retry task per new device path. Cooperatively
/// shuts down on broadcast.
async fn run_hotplug_watcher(
    hotkey_rx: watch::Receiver<Option<HotkeyCombo>>,
    event_tx: mpsc::Sender<HotkeyEvent>,
    tracked: TrackedDevices,
    supervisor: SupervisorHandle,
    shutdown_rx: &mut tokio::sync::broadcast::Receiver<()>,
) {
    let (notify_tx, mut notify_rx) = mpsc::channel::<PathBuf>(32);

    // notify watcher runs on a blocking thread internally.
    // If inotify itself is unavailable (rare: minimal containers,
    // some BSDs misconfigured as Linux) we degrade to "no
    // hotplug detection" rather than panicking the task — the
    // initial scan_and_attach pass above still picks up all
    // devices that exist at startup.
    let _watcher = {
        let notify_tx = notify_tx.clone();
        let watcher = recommended_watcher(move |res: Result<notify::Event, _>| {
            if let Ok(event) = res {
                if matches!(event.kind, EventKind::Create(_)) {
                    for path in event.paths {
                        if is_event_device(&path) {
                            let _ = notify_tx.blocking_send(path);
                        }
                    }
                }
            }
        });
        match watcher {
            Ok(mut w) => match w.watch(Path::new("/dev/input"), RecursiveMode::NonRecursive) {
                Ok(()) => Some(w),
                Err(e) => {
                    tracing::warn!(
                        error = %e,
                        "cannot watch /dev/input; hotplug detection disabled, \
                         devices present at startup still work"
                    );
                    None
                }
            },
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "cannot create inotify watcher; hotplug detection disabled"
                );
                None
            }
        }
    };

    loop {
        tokio::select! {
            Some(path) = notify_rx.recv() => {
                // Retry opening with backoff — udev permissions propagate
                // asynchronously after device creation (whisper-overlay pattern).
                // The retry task subscribes to the broadcast so it exits
                // promptly on stop() even if it's mid-backoff.
                let hotkey_rx = hotkey_rx.clone();
                let event_tx = event_tx.clone();
                let tracked = tracked.clone();
                let supervisor_inner = supervisor.clone();
                let mut retry_shutdown_rx = supervisor.subscribe();
                let retry_handle = tokio::spawn(async move {
                    for attempt in 0..5 {
                        if attempt > 0 {
                            tokio::select! {
                                _ = tokio::time::sleep(
                                    std::time::Duration::from_secs(1)
                                ) => {}
                                _ = retry_shutdown_rx.recv() => return,
                            }
                        }
                        if try_attach_device(
                            &path, &hotkey_rx, &event_tx, &tracked, &supervisor_inner,
                        ).await {
                            break;
                        }
                    }
                });
                supervisor.register("hotplug-retry", retry_handle).await;
            }
            _ = shutdown_rx.recv() => break,
        }
    }
}

/// Check whether the user has access to evdev devices.
pub fn check_access() -> Result<(), String> {
    let input_dir = Path::new("/dev/input");
    if !input_dir.exists() {
        return Err("/dev/input does not exist".to_string());
    }

    // Try to open any event device
    let entries =
        std::fs::read_dir(input_dir).map_err(|e| format!("Cannot read /dev/input: {e}"))?;

    for entry in entries.flatten() {
        let path = entry.path();
        if is_event_device(&path) {
            match Device::open(&path) {
                Ok(_) => return Ok(()),
                Err(e) => {
                    if e.kind() == std::io::ErrorKind::PermissionDenied {
                        return Err(format!(
                            "Permission denied reading {}. \
                             Add your user to the 'input' group: \
                             sudo usermod -aG input $USER \
                             (then log out and back in)",
                            path.display()
                        ));
                    }
                }
            }
        }
    }

    Err("No input devices found in /dev/input".to_string())
}

/// Scan all `/dev/input/event*` devices and attach listeners to any
/// that support the target key.
async fn scan_and_attach(
    hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
    event_tx: &mpsc::Sender<HotkeyEvent>,
    tracked: &TrackedDevices,
    supervisor: &SupervisorHandle,
) {
    let input_dir = Path::new("/dev/input");
    let entries = match std::fs::read_dir(input_dir) {
        Ok(e) => e,
        Err(e) => {
            tracing::error!(error = %e, "cannot read /dev/input");
            return;
        }
    };

    for entry in entries.flatten() {
        let path = entry.path();
        if is_event_device(&path) {
            try_attach_device(&path, hotkey_rx, event_tx, tracked, supervisor).await;
        }
    }
}

/// Try to open a device and start listening if it supports the target key.
/// Returns true if the device was successfully attached.
///
/// Insert-into-tracked-then-spawn-then-release-mutex makes attachment
/// atomic against concurrent hotplug + scan; the previous design's
/// remove-after-task-exits window allowed double-attaches.
async fn try_attach_device(
    path: &Path,
    hotkey_rx: &watch::Receiver<Option<HotkeyCombo>>,
    event_tx: &mpsc::Sender<HotkeyEvent>,
    tracked: &TrackedDevices,
    supervisor: &SupervisorHandle,
) -> bool {
    // Hold the mutex across the contains-check, the insert, AND the
    // spawn registration. This is the TOCTOU fix for Race-extra: the
    // previous implementation released the mutex before spawning and
    // before removal, leaving windows where concurrent scan + hotplug
    // could double-attach the same device.
    let mut tracked_map = tracked.lock().await;
    if tracked_map.contains_key(path) {
        return true;
    }

    let Some(combo) = hotkey_rx.borrow().clone() else {
        // Listener is unconfigured or shutting down.
        return false;
    };

    let device = match Device::open(path) {
        Ok(d) => d,
        Err(e) => {
            tracing::debug!(path = %path.display(), error = %e, "cannot open device");
            return false;
        }
    };

    if !device_supports_combo(device.supported_keys(), &combo) {
        return false;
    }

    let device_name = device.name().unwrap_or("unknown").to_string();
    tracing::info!(
        device = %device_name,
        path = %path.display(),
        "attached hotkey listener"
    );

    // Insert BEFORE spawning the listener task so a racing caller (the
    // scanner running concurrently with a hotplug retry, for example)
    // sees the entry and short-circuits.
    tracked_map.insert(path.to_path_buf(), ());

    // Clone everything the spawned task needs before we release the
    // mutex so the release point is a single statement.
    let hotkey_rx_owned = hotkey_rx.clone();
    let event_tx_owned = event_tx.clone();
    let path_owned = path.to_path_buf();
    let tracked_for_cleanup = tracked.clone();
    let mut shutdown_rx = supervisor.subscribe();

    let listener_handle = tokio::spawn(async move {
        let listener_fut = device_listener(device, hotkey_rx_owned, event_tx_owned);
        tokio::select! {
            res = listener_fut => {
                if let Err(e) = res {
                    tracing::warn!(
                        path = %path_owned.display(),
                        error = %e,
                        "device listener ended"
                    );
                }
            }
            _ = shutdown_rx.recv() => {
                tracing::debug!(
                    target: "lumotia_hotkey",
                    path = %path_owned.display(),
                    "device listener received shutdown signal"
                );
            }
        }
        // Remove from tracked set so hotplug can re-attach if reconnected.
        tracked_for_cleanup.lock().await.remove(&path_owned);
    });

    drop(tracked_map);

    // Register with the supervisor. This await is brief — it just locks
    // the supervisor inner Vec and pushes — and happens outside the
    // tracked-map lock.
    supervisor
        .register("device-listener", listener_handle)
        .await;

    true
}

/// Listen for events on a single device. Tracks modifier state and fires
/// hotkey events when the combo matches.
async fn device_listener(
    device: Device,
    mut hotkey_rx: watch::Receiver<Option<HotkeyCombo>>,
    event_tx: mpsc::Sender<HotkeyEvent>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let mut stream = device.into_event_stream()?;

    // Track modifier state
    let mut ctrl_held = false;
    let mut shift_held = false;
    let mut alt_held = false;
    let mut super_held = false;

    loop {
        tokio::select! {
            result = stream.next_event() => {
                let event = result?;

                if let InputEventKind::Key(key) = event.kind() {
                    let pressed = event.value() == 1; // 1 = press, 0 = release, 2 = repeat
                    let released = event.value() == 0;

                    // Update modifier state
                    match key {
                        Key::KEY_LEFTCTRL | Key::KEY_RIGHTCTRL => {
                            ctrl_held = pressed || (!released && ctrl_held);
                        }
                        Key::KEY_LEFTSHIFT | Key::KEY_RIGHTSHIFT => {
                            shift_held = pressed || (!released && shift_held);
                        }
                        Key::KEY_LEFTALT | Key::KEY_RIGHTALT => {
                            alt_held = pressed || (!released && alt_held);
                        }
                        Key::KEY_LEFTMETA | Key::KEY_RIGHTMETA => {
                            super_held = pressed || (!released && super_held);
                        }
                        trigger_key => {
                            let combo = hotkey_rx.borrow().clone();
                            if let Some(ref combo) = combo {
                                let code = trigger_key.code();
                                if code == combo.key_code
                                    && ctrl_held == combo.ctrl
                                    && shift_held == combo.shift
                                    && alt_held == combo.alt
                                    && super_held == combo.super_key
                                {
                                    let to_send = if pressed {
                                        Some(HotkeyEvent::Pressed)
                                    } else if released {
                                        Some(HotkeyEvent::Released)
                                    } else {
                                        None
                                    };
                                    if let Some(event) = to_send {
                                        if event_tx.send(event).await.is_err() {
                                            // Receiver was dropped without an
                                            // explicit None-on-hotkey-rx
                                            // shutdown. Log once and exit so
                                            // the listener doesn't spin
                                            // sending into a closed channel.
                                            tracing::warn!(
                                                "hotkey event channel closed; \
                                                 listener for device exiting"
                                            );
                                            return Ok(());
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
            _ = hotkey_rx.changed() => {
                // Hotkey config changed — if set to None, shut down
                if hotkey_rx.borrow().is_none() {
                    break;
                }
            }
        }
    }

    Ok(())
}

fn is_event_device(path: &Path) -> bool {
    path.file_name()
        .and_then(|n| n.to_str())
        .is_some_and(|n| n.starts_with("event"))
}

/// Return true when the device's reported key set includes the combo's
/// configured trigger key. A device that reports no keys at all (for
/// example a mouse whose `EV_KEY` capability is buttons only) is rejected.
fn device_supports_combo(supported: Option<&AttributeSetRef<Key>>, combo: &HotkeyCombo) -> bool {
    supported.is_some_and(|keys| keys.contains(Key::new(combo.key_code)))
}

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

    fn combo_for(key_code: u16) -> HotkeyCombo {
        HotkeyCombo {
            ctrl: false,
            shift: false,
            alt: false,
            super_key: false,
            key_code,
            label: "test".to_string(),
        }
    }

    const KEY_D: u16 = 32;

    #[test]
    fn attaches_when_device_supports_configured_trigger() {
        let mut keys = AttributeSet::<Key>::new();
        keys.insert(Key::KEY_D);
        assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));
    }

    #[test]
    fn rejects_when_device_lacks_configured_trigger() {
        let mut keys = AttributeSet::<Key>::new();
        keys.insert(Key::KEY_A);
        assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
    }

    #[test]
    fn rejects_when_device_reports_no_keys() {
        assert!(!device_supports_combo(None, &combo_for(KEY_D)));
    }

    // Regression for RB-12: the original filter hard-coded KEY_A || KEY_R
    // and would drop a keyboard bound to any other trigger — for example
    // a user's Ctrl+Shift+D binding on a keyboard that (hypothetically)
    // reports only KEY_D — even though the device clearly supports it.
    #[test]
    fn attaches_for_non_a_non_r_trigger() {
        let mut keys = AttributeSet::<Key>::new();
        keys.insert(Key::KEY_D);
        assert!(device_supports_combo(Some(&keys), &combo_for(KEY_D)));

        // And conversely, a device that only supports KEY_R is correctly
        // rejected when the binding is KEY_D — the old implementation
        // would have incorrectly attached.
        let mut keys = AttributeSet::<Key>::new();
        keys.insert(Key::KEY_R);
        assert!(!device_supports_combo(Some(&keys), &combo_for(KEY_D)));
    }

    // TODO(test): Race-extra (TOCTOU on `tracked`) is hard to exercise
    // without real /dev/input/event* devices + the udev attach race.
    // The new insert-before-spawn + supervisor-owned-handle design
    // closes the window by construction; a deterministic test would need
    // to fake the evdev::Device::open path which the crate doesn't
    // currently expose. See atomiser finding "Race-extra" for context.
}
