// Integration smoke coverage for server runtime helpers.
//
// Scope: exercise public runtime helpers that should be stable even on hosts
// without full gadget/audio plumbing.
// Targets: `server/src/audio.rs`, `server/src/gadget.rs`,
// `server/src/runtime_support.rs`.
// Why: these contracts provide broad safety coverage around startup/recovery
// code paths that are otherwise hard to hit from unit-only tests.

use lesavka_server::audio::{self, ClipTap};
use lesavka_server::gadget::UsbGadget;
use lesavka_server::runtime_support;
use serial_test::serial;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use temp_env::with_var;
use tempfile::{NamedTempFile, tempdir};
use tokio::runtime::Runtime;
use tokio::sync::Mutex;

fn with_isolated_gadget_roots(f: impl FnOnce()) {
    let dir = tempdir().expect("temp gadget roots");
    let sys_root = dir.path().join("sys");
    let cfg_root = dir.path().join("cfg");
    std::fs::create_dir_all(sys_root.join("class/udc")).expect("fake udc root");
    std::fs::create_dir_all(sys_root.join("bus/platform/devices")).expect("fake platform root");
    std::fs::create_dir_all(&cfg_root).expect("fake configfs root");

    let sys_root = sys_root.to_string_lossy().to_string();
    let cfg_root = cfg_root.to_string_lossy().to_string();
    with_var("LESAVKA_GADGET_SYSFS_ROOT", Some(sys_root.as_str()), || {
        with_var("LESAVKA_GADGET_CONFIGFS_ROOT", Some(cfg_root.as_str()), f)
    });
}

fn tmp_files_with_prefix(prefix: &str) -> HashSet<PathBuf> {
    std::fs::read_dir("/tmp")
        .ok()
        .into_iter()
        .flat_map(|iter| iter.flatten())
        .map(|entry| entry.path())
        .filter(|path| {
            path.file_name()
                .and_then(|name| name.to_str())
                .map(|name| name.starts_with(prefix))
                .unwrap_or(false)
        })
        .collect()
}

#[test]
#[serial]
fn clip_tap_writes_rotating_file_when_period_elapses() {
    let tag = "lesavka-cliptap-contract";
    let prefix = format!("{tag}-");
    let before = tmp_files_with_prefix(&prefix);

    let mut tap = ClipTap::new(tag, Duration::from_millis(5));
    tap.feed(b"abc");
    std::thread::sleep(Duration::from_millis(10));
    tap.feed(b"def");
    tap.flush();

    let after = tmp_files_with_prefix(&prefix);
    let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
    assert!(
        !created.is_empty(),
        "expected at least one clip file to be written"
    );

    for path in created {
        let _ = std::fs::remove_file(path);
    }
}

#[test]
#[serial]
fn clip_tap_drop_flushes_pending_audio() {
    let tag = "lesavka-cliptap-drop-contract";
    let prefix = format!("{tag}-");
    let before = tmp_files_with_prefix(&prefix);

    let mut tap = ClipTap::new(tag, Duration::from_secs(60));
    tap.feed(b"pending-bytes");
    drop(tap);

    let after = tmp_files_with_prefix(&prefix);
    let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
    assert!(
        !created.is_empty(),
        "expected drop() to flush buffered clip bytes"
    );
    for path in created {
        let _ = std::fs::remove_file(path);
    }
}

#[test]
#[serial]
fn clip_tap_flush_is_noop_when_buffer_is_empty() {
    let tag = "lesavka-cliptap-empty-contract";
    let prefix = format!("{tag}-");
    let before = tmp_files_with_prefix(&prefix);

    let mut tap = ClipTap::new(tag, Duration::from_secs(60));
    tap.flush();
    drop(tap);

    let after = tmp_files_with_prefix(&prefix);
    let created: Vec<PathBuf> = after.difference(&before).cloned().collect();
    assert!(
        created.is_empty(),
        "empty flush/drop should not create clip artifacts"
    );
}

#[test]
#[serial]
fn gadget_queries_handle_missing_controller_paths() {
    let _gadget = UsbGadget::new("lesavka");

    assert!(
        UsbGadget::state("definitely-missing-udc").is_err(),
        "state() should fail for a non-existent UDC controller"
    );

    match UsbGadget::find_controller() {
        Ok(name) => assert!(!name.trim().is_empty()),
        Err(err) => assert!(!err.to_string().trim().is_empty()),
    }

    assert!(
        UsbGadget::wait_state_any("definitely-missing-udc", 0).is_err(),
        "wait_state_any() should fail quickly for a non-existent controller"
    );
}

#[test]
#[serial]
fn runtime_open_voice_with_retry_stays_bounded_for_missing_device() {
    with_var("LESAVKA_MIC_INIT_ATTEMPTS", Some("1"), || {
        with_var("LESAVKA_MIC_INIT_DELAY_MS", Some("1"), || {
            let rt = Runtime::new().expect("create runtime");
            let outcome = rt.block_on(async {
                tokio::time::timeout(
                    Duration::from_secs(3),
                    runtime_support::open_voice_with_retry("hw:DefinitelyMissing,0"),
                )
                .await
            });
            match outcome {
                Ok(Ok(mut voice)) => {
                    voice.finish();
                }
                Ok(Err(err)) => {
                    assert!(!err.to_string().trim().is_empty());
                }
                Err(_) => panic!("open_voice_with_retry timed out"),
            }
        });
    });
}

#[test]
#[serial]
fn runtime_ear_handles_malformed_alsa_source_without_hanging() {
    let rt = Runtime::new().expect("create runtime");
    let outcome = rt.block_on(async {
        tokio::time::timeout(
            Duration::from_secs(3),
            audio::ear("hw:UAC2Gadget,0\" ! malformed-pipeline", 0),
        )
        .await
    });

    match outcome {
        Ok(Ok(stream)) => drop(stream),
        Ok(Err(err)) => assert!(!err.to_string().trim().is_empty()),
        Err(_) => panic!("audio::ear timed out for malformed ALSA source"),
    }
}

#[test]
#[serial]
fn runtime_recover_hid_ignores_non_transport_errors() {
    let rt = Runtime::new().expect("create runtime");
    rt.block_on(async {
        let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
        let ms_tmp = NamedTempFile::new().expect("temp mouse file");

        let kb = tokio::fs::OpenOptions::new()
            .write(true)
            .open(kb_tmp.path())
            .await
            .expect("open temp kb");
        let ms = tokio::fs::OpenOptions::new()
            .write(true)
            .open(ms_tmp.path())
            .await
            .expect("open temp ms");

        let kb = Arc::new(Mutex::new(Some(kb)));
        let ms = Arc::new(Mutex::new(Some(ms)));
        let did_cycle = Arc::new(AtomicBool::new(false));
        let err = std::io::Error::from_raw_os_error(libc::EAGAIN);

        runtime_support::recover_hid_if_needed(
            &err,
            UsbGadget::new("lesavka"),
            kb,
            ms,
            kb_tmp.path().to_string_lossy().to_string(),
            ms_tmp.path().to_string_lossy().to_string(),
            did_cycle.clone(),
        )
        .await;

        assert!(
            !did_cycle.load(Ordering::SeqCst),
            "non-transport errors should not trigger gadget recovery"
        );
    });
}

#[test]
#[serial]
fn runtime_recover_hid_short_circuits_when_cycle_already_in_progress() {
    let rt = Runtime::new().expect("create runtime");
    rt.block_on(async {
        let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
        let ms_tmp = NamedTempFile::new().expect("temp mouse file");

        let kb = tokio::fs::OpenOptions::new()
            .write(true)
            .open(kb_tmp.path())
            .await
            .expect("open temp kb");
        let ms = tokio::fs::OpenOptions::new()
            .write(true)
            .open(ms_tmp.path())
            .await
            .expect("open temp ms");

        let kb = Arc::new(Mutex::new(Some(kb)));
        let ms = Arc::new(Mutex::new(Some(ms)));
        let did_cycle = Arc::new(AtomicBool::new(true));
        let err = std::io::Error::from_raw_os_error(libc::EPIPE);

        runtime_support::recover_hid_if_needed(
            &err,
            UsbGadget::new("lesavka"),
            kb,
            ms,
            kb_tmp.path().to_string_lossy().to_string(),
            ms_tmp.path().to_string_lossy().to_string(),
            did_cycle.clone(),
        )
        .await;

        assert!(
            did_cycle.load(Ordering::SeqCst),
            "existing cycle lock should short-circuit recovery"
        );
    });
}

#[test]
#[serial]
fn runtime_recover_hid_resets_cycle_flag_after_async_recovery_path() {
    with_var("LESAVKA_ALLOW_GADGET_CYCLE", None::<&str>, || {
        let rt = Runtime::new().expect("create runtime");
        rt.block_on(async {
            let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
            let ms_tmp = NamedTempFile::new().expect("temp mouse file");

            let kb = tokio::fs::OpenOptions::new()
                .write(true)
                .open(kb_tmp.path())
                .await
                .expect("open temp kb");
            let ms = tokio::fs::OpenOptions::new()
                .write(true)
                .open(ms_tmp.path())
                .await
                .expect("open temp ms");

            let kb = Arc::new(Mutex::new(Some(kb)));
            let ms = Arc::new(Mutex::new(Some(ms)));
            let did_cycle = Arc::new(AtomicBool::new(false));
            let err = std::io::Error::from_raw_os_error(libc::EPIPE);

            runtime_support::recover_hid_if_needed(
                &err,
                UsbGadget::new("lesavka"),
                kb.clone(),
                ms.clone(),
                kb_tmp.path().to_string_lossy().to_string(),
                ms_tmp.path().to_string_lossy().to_string(),
                did_cycle.clone(),
            )
            .await;

            assert!(
                did_cycle.load(Ordering::SeqCst),
                "transport error should acquire recovery lock"
            );

            tokio::time::sleep(Duration::from_millis(2_300)).await;
            assert!(
                !did_cycle.load(Ordering::SeqCst),
                "recovery task should release lock after cooldown"
            );
        });
    });
}

#[test]
#[serial]
fn runtime_recover_hid_attempts_cycle_when_enabled() {
    with_isolated_gadget_roots(|| {
        with_var("LESAVKA_ALLOW_GADGET_CYCLE", Some("1"), || {
            with_var("LESAVKA_ALLOW_EXTERNAL_UVC_GADGET_CYCLE", Some("1"), || {
                let rt = Runtime::new().expect("create runtime");
                rt.block_on(async {
                    let kb_tmp = NamedTempFile::new().expect("temp keyboard file");
                    let ms_tmp = NamedTempFile::new().expect("temp mouse file");

                    let kb = tokio::fs::OpenOptions::new()
                        .write(true)
                        .open(kb_tmp.path())
                        .await
                        .expect("open temp kb");
                    let ms = tokio::fs::OpenOptions::new()
                        .write(true)
                        .open(ms_tmp.path())
                        .await
                        .expect("open temp ms");

                    let kb = Arc::new(Mutex::new(Some(kb)));
                    let ms = Arc::new(Mutex::new(Some(ms)));
                    let did_cycle = Arc::new(AtomicBool::new(false));
                    let err = std::io::Error::from_raw_os_error(libc::EPIPE);

                    runtime_support::recover_hid_if_needed(
                        &err,
                        UsbGadget::new("lesavka"),
                        kb.clone(),
                        ms.clone(),
                        kb_tmp.path().to_string_lossy().to_string(),
                        ms_tmp.path().to_string_lossy().to_string(),
                        did_cycle.clone(),
                    )
                    .await;

                    assert!(
                        did_cycle.load(Ordering::SeqCst),
                        "transport error should acquire recovery lock before async recovery"
                    );

                    tokio::time::sleep(Duration::from_millis(2_300)).await;
                    assert!(
                        !did_cycle.load(Ordering::SeqCst),
                        "cycle-enabled recovery should eventually clear lock"
                    );
                });
            });
        });
    });
}
