//! Regression tests for the three concurrency leaks the code-atomiser
//! flagged in the hotkey listener:
//!
//! - **Race-1** — orphaned per-device listener tasks after `stop()`.
//!   Coverage: `listener_stop_drops_internal_senders` — exercises the
//!   only side-effect we can observe through the public API. After
//!   `stop()` every internal device-listener task must drop its
//!   `mpsc::Sender<HotkeyEvent>` clone, which we detect by asserting
//!   that the receiving end's `recv()` returns `None`.
//!
//! - **Race-2** — leaked forwarder task on reconfigure. Coverage:
//!   `reconfigure_does_not_leak_forwarder` — simulates the exact pattern
//!   the Tauri command layer uses (listener + forwarder pair), confirms
//!   that after a reconfigure the OLD forwarder has joined and only the
//!   new one is consuming events.
//!
//! - **Race-extra (TOCTOU)** — see `// TODO(test):` in
//!   `crates/hotkey/src/linux.rs`. Exercising the TOCTOU window
//!   deterministically requires faking the evdev::Device::open path,
//!   which the crate does not currently expose. The fix is closed by
//!   construction (insert-before-spawn under one mutex hold + supervisor
//!   ownership of every spawn handle).

#![cfg(target_os = "linux")]

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use lumotia_hotkey::{EvdevHotkeyListener, HotkeyCombo, HotkeyEvent};
use tokio::sync::mpsc;

fn dummy_combo() -> HotkeyCombo {
    // KEY_R = 19, the default Lumotia hotkey. No device on the CI box is
    // expected to match for actual key events — these tests exercise the
    // lifecycle, not the event-firing path.
    HotkeyCombo {
        ctrl: true,
        shift: true,
        alt: false,
        super_key: false,
        key_code: 19,
        label: "Ctrl+Shift+R".to_string(),
    }
}

/// Race-1 regression. After `stop()`, every device-listener and watcher
/// task spawned by the listener must exit and drop its `event_tx` clone.
/// We detect that by holding the receiving end and observing the
/// channel-closed signal (`recv()` returning `None`).
///
/// If the bug regressed (listeners orphaned), `recv()` would block
/// indefinitely because the leaked tasks still hold sender clones, and
/// the test would time out.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn listener_stop_drops_internal_senders() {
    let (event_tx, mut event_rx) = mpsc::channel::<HotkeyEvent>(64);

    let listener = EvdevHotkeyListener::start(dummy_combo(), event_tx).await;

    // Let the supervisor spin up its tasks. The scanner+hotplug watcher
    // tasks subscribe to broadcast shutdown during construction, so the
    // scheduling order doesn't really matter, but we yield once for
    // robustness.
    tokio::task::yield_now().await;

    let stop_start = Instant::now();
    listener.stop().await;
    let stop_elapsed = stop_start.elapsed();

    // Stop must itself be bounded. The supervisor's per-task timeout is
    // 2 s, and we have at most a handful of internal tasks (scanner,
    // hotplug, plus however many real /dev/input devices the test
    // sandbox exposes — usually zero on CI). Cap total stop time at
    // 10 s to give us a clear failure rather than a hung CI runner.
    assert!(
        stop_elapsed < Duration::from_secs(10),
        "EvdevHotkeyListener::stop() should bound on supervisor timeout; took {stop_elapsed:?}"
    );

    // After stop, every internal sender clone must be dropped, which
    // closes the channel for the receiver. `recv()` returns None on a
    // closed-and-drained channel. We wrap in a timeout so a regressed
    // implementation (leaked listener tasks holding sender clones)
    // surfaces as a clean assertion failure rather than a hung test.
    let recv_result = tokio::time::timeout(Duration::from_secs(5), event_rx.recv()).await;

    match recv_result {
        Ok(None) => {
            // Pass — channel closed, no sender clones leaked.
        }
        Ok(Some(ev)) => panic!(
            "received hotkey event {ev:?} after stop() — listener tasks should have exited \
             and dropped their senders, indicating Race-1 regressed"
        ),
        Err(_) => panic!(
            "timed out waiting for event_rx to close after stop() — internal sender clones \
             were not dropped, indicating Race-1 regressed (orphaned listener tasks)"
        ),
    }
}

/// Race-2 regression. Simulates the Tauri command layer's
/// listener+forwarder pair. After a reconfigure (stop old, start new),
/// only the NEW forwarder must be alive — the previous implementation
/// leaked one forwarder per reconfigure.
///
/// We assert leak-freedom by:
/// 1. Holding a JoinHandle to each forwarder we spawn.
/// 2. After the reconfigure, asserting the old forwarder's JoinHandle
///    is finished within a bounded timeout.
///
/// The new forwarder's JoinHandle must NOT be finished (it's still
/// receiving from the new listener).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reconfigure_does_not_leak_forwarder() {
    let received_first = Arc::new(AtomicUsize::new(0));
    let received_second = Arc::new(AtomicUsize::new(0));

    // ---- First listener + forwarder ----
    let (event_tx_1, mut event_rx_1) = mpsc::channel::<HotkeyEvent>(64);
    let listener_1 = EvdevHotkeyListener::start(dummy_combo(), event_tx_1).await;
    let counter_1 = received_first.clone();
    let forwarder_1 = tokio::spawn(async move {
        while let Some(_event) = event_rx_1.recv().await {
            counter_1.fetch_add(1, Ordering::SeqCst);
        }
        // Returns when the channel closes (all senders dropped). That's
        // the only clean way for the forwarder to exit, and it must
        // happen on reconfigure for the leak to be fixed.
    });

    tokio::task::yield_now().await;

    // ---- Reconfigure: stop old, start new ----
    // This mirrors `start_evdev_hotkey` in src-tauri/src/commands/hotkey.rs
    // after the fix: stop old listener (which drains every internal task
    // via the supervisor) THEN join the old forwarder (which exits when
    // all sender clones drop) BEFORE installing the new pair.
    listener_1.stop().await;

    // Old forwarder must finish in bounded time. If Race-2 regressed
    // (orphaned listener tasks still holding sender clones), the
    // forwarder would never see `None` from recv() and this timeout
    // would fire.
    let join_result = tokio::time::timeout(Duration::from_secs(5), forwarder_1).await;
    assert!(
        join_result.is_ok(),
        "old forwarder did not join after listener.stop() — Race-2 regressed: \
         orphaned listener tasks are still holding event_tx clones"
    );
    // Verify the inner result (forwarder didn't panic).
    join_result.unwrap().expect("old forwarder panicked");

    // ---- Second listener + forwarder ----
    let (event_tx_2, mut event_rx_2) = mpsc::channel::<HotkeyEvent>(64);
    let listener_2 = EvdevHotkeyListener::start(dummy_combo(), event_tx_2).await;
    let counter_2 = received_second.clone();
    let forwarder_2 = tokio::spawn(async move {
        while let Some(_event) = event_rx_2.recv().await {
            counter_2.fetch_add(1, Ordering::SeqCst);
        }
    });

    tokio::task::yield_now().await;

    // Sanity check: the new forwarder is still running (not yet
    // joined). `is_finished()` returns true only when the task has
    // completed.
    assert!(
        !forwarder_2.is_finished(),
        "new forwarder must still be running after reconfigure — otherwise \
         the new listener's senders were dropped prematurely"
    );

    // ---- Cleanup ----
    listener_2.stop().await;
    let cleanup_join = tokio::time::timeout(Duration::from_secs(5), forwarder_2).await;
    assert!(
        cleanup_join.is_ok(),
        "second forwarder also failed to drain after stop()"
    );

    // We don't actually assert on the counters — these tests run without
    // a matching evdev device, so no Pressed/Released events fire. The
    // leak detection is in the JoinHandle behaviour above, not the event
    // count. The counters exist so the test compiles as a real
    // forwarder pattern matching what commands::hotkey does in
    // production.
    let _ = (
        received_first.load(Ordering::SeqCst),
        received_second.load(Ordering::SeqCst),
    );
}
