//! Auto-insert-at-cursor.
//!
//! `copy_to_clipboard` puts the transcript on the user's clipboard. That is
//! fine when the user wants to choose where it lands. It is friction when
//! they were about to paste into the already-focused window — which is
//! almost always, for a dictation tool.
//!
//! This module adds the follow-on step: send the platform's Ctrl+V / Cmd+V
//! keystroke to the focused window so the transcript lands where the cursor
//! already is. Each platform uses a different primitive (wtype / xdotool /
//! ydotool / osascript / SendKeys), so we probe and fall back.
//!
//! NOTE: focus must already be on the target window when the keystroke
//! fires. The global hotkey flow preserves this naturally; clicking Lumotia's
//! window does not. The frontend surfaces this caveat next to the toggle.

use std::process::Command;
use std::time::Duration;

use arboard::Clipboard;
use serde::Serialize;
use tauri::Manager;

use crate::commands::security::{ensure_main_window, ensure_window_in_set};

/// Windows allowed to invoke `paste_text_replacing`. The
/// `transcription-preview` window's paste-to-foreground flow legitimately
/// uses this command; mirror the secondary-windows capability grant in
/// `src-tauri/capabilities/secondary-windows.json`. The mirror invariant
/// is pinned by
/// `commands::security::tests_capability_mirror::allowlists_match_capability_jsons`.
pub(crate) const PASTE_REPLACING_ALLOWED_WINDOWS: &[&str] = &["main", "transcription-preview"];

/// Refuse-to-paste limit. Matches `commands::clipboard::MAX_CLIPBOARD_BYTES`
/// (1 MiB) so paste and copy surfaces share a single rejection rule.
/// Synthesising a 50 MiB Ctrl+V keystroke into the foreground app would
/// also be a denial-of-service against the target (LibreOffice / Notes
/// can hang or crash on multi-megabyte paste payloads).
pub(crate) const MAX_PASTE_BYTES: usize = crate::commands::clipboard::MAX_CLIPBOARD_BYTES;

/// Window after the paste keystroke at which we restore the user's
/// prior clipboard content. 300 ms is enough for even a slow Wayland
/// compositor to have fully delivered the synthesised Ctrl+V to the
/// focused app; longer risks the user manually pasting again and
/// stomping themselves. See Handy #921.
const CLIPBOARD_RESTORE_MS: u64 = 300;

/// Compositor settle time after hiding the preview overlay before firing
/// the paste keystroke. Empirically ~80ms is enough on KWin + Mutter
/// Wayland for focus to return to the previously-focused app; shorter
/// risks the keystroke still landing on the (now-invisible) overlay.
const PREVIEW_HIDE_SETTLE_MS: u64 = 80;

/// Gap between the undo keystroke and the follow-up paste in the
/// replace flow (brief item #17). Most GUI apps process undo
/// instantly, but a short beat keeps the paste from racing the undo
/// on slower-to-redraw targets (Electron apps, LibreOffice).
const UNDO_PASTE_GAP_MS: u64 = 60;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PasteOutcome {
    /// The backend that executed the paste, if any (`"wtype"`, `"xdotool"`,
    /// `"ydotool"`, `"osascript"`, `"sendkeys"`).
    pub backend: Option<String>,
    pub pasted: bool,
    pub copied: bool,
    /// Diagnostic message when either step failed. Present even on success
    /// if the paste fell back to copy-only so the frontend can toast it.
    pub message: Option<String>,
}

/// Copy `text` to the clipboard, then trigger a paste keystroke in the
/// focused window. Returns a structured result so the frontend can surface
/// partial success (clipboard set, paste failed).
///
/// Wayland compositor quirk: if Lumotia's always-on-top preview overlay is
/// visible when the keystroke fires, KWin / Mutter may resolve the keystroke
/// against the overlay (even with `focused: false` set at build time) and
/// the paste lands inside Lumotia instead of the previously-focused app. We hide
/// the preview window and give the compositor a beat to re-focus the real
/// target before dispatching. Matches OpenWhispr's PR #246 fix on GNOME.
#[tauri::command]
pub async fn paste_text(
    window: tauri::WebviewWindow,
    app: tauri::AppHandle,
    text: String,
) -> Result<PasteOutcome, String> {
    ensure_main_window(&window)?;
    if text.len() > MAX_PASTE_BYTES {
        return Err(format!(
            "Paste payload too large ({} bytes; limit {} bytes).",
            text.len(),
            MAX_PASTE_BYTES
        ));
    }
    let mut outcome = PasteOutcome {
        backend: None,
        pasted: false,
        copied: false,
        message: None,
    };

    let prior_clipboard = snapshot_clipboard_text();

    match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
        Ok(()) => outcome.copied = true,
        Err(err) => {
            outcome.message = Some(format!("clipboard: {err}"));
            return Ok(outcome);
        }
    }

    // Brief item #10: if the focused window is a known terminal
    // emulator, skip the synthesised Ctrl+V — it duplicates the
    // keystroke through the PTY, which Kitty / Alacritty / Windows
    // Terminal all demonstrably mishandle (Handy #692). Clipboard-only
    // paste with a "Terminal detected" message lets the user finish
    // with a manual right-click paste or Ctrl+Shift+V.
    if let Some(term) = detect_focused_terminal() {
        outcome.message = Some(format!(
            "Terminal detected ({term}) — transcript is on your clipboard. \
             Use Ctrl+Shift+V (or right-click → paste) to insert."
        ));
        // Prior clipboard snapshot is intentionally NOT restored here:
        // the user's path to recover the transcript is their own paste,
        // which needs the transcript on the clipboard. The restore
        // task below only runs when `outcome.pasted` is true.
        return Ok(outcome);
    }

    hide_preview_overlay_for_paste(&app).await;

    match trigger_paste_keystroke() {
        Ok(backend) => {
            outcome.backend = Some(backend);
            outcome.pasted = true;
        }
        Err(err) => outcome.message = Some(err),
    }

    if outcome.pasted {
        schedule_clipboard_restore(prior_clipboard, text);
    }

    Ok(outcome)
}

/// Snapshot the clipboard's current text, or `None` when the clipboard
/// holds non-text content (images, files, empty). Shared between
/// `paste_text` and `paste_text_replacing` so both halves of the
/// dictation flow obey the "never silently clobber the user's
/// clipboard" contract from Handy #921 (brief item #3).
fn snapshot_clipboard_text() -> Option<String> {
    match Clipboard::new() {
        Ok(mut cb) => cb.get_text().ok(),
        Err(_) => None,
    }
}

/// Fire-and-forget: restore `prior` to the clipboard after
/// `CLIPBOARD_RESTORE_MS`, but only if the clipboard still holds the
/// `transcript` we set (i.e., the user hasn't copied something new in
/// the window). No-op when `prior` is `None` — nothing safe to
/// restore.
fn schedule_clipboard_restore(prior: Option<String>, transcript: String) {
    let Some(prior) = prior else {
        return;
    };
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(CLIPBOARD_RESTORE_MS)).await;
        restore_prior_clipboard(&prior, &transcript);
    });
}

/// Write `prior` back to the clipboard only if the current clipboard
/// content is still the transcript we just set — i.e. the user hasn't
/// already copied something new themselves in the last 300 ms. This
/// avoids stomping a deliberate Cmd+C that happened right after the
/// paste.
fn restore_prior_clipboard(prior: &str, transcript: &str) {
    let mut cb = match Clipboard::new() {
        Ok(cb) => cb,
        Err(_) => return,
    };

    if should_restore(cb.get_text().ok().as_deref(), transcript) {
        let _ = cb.set_text(prior.to_string());
    }
}

/// Pure decision function: should we restore the prior clipboard?
///
/// - Some(current) == transcript → yes, the clipboard still holds
///   what we wrote, so restore is safe.
/// - Some(current) != transcript → no, user has already copied
///   something else in the 300 ms window; respect it.
/// - None → couldn't read the clipboard (image, no access, etc.);
///   conservative no-op.
fn should_restore(current: Option<&str>, transcript: &str) -> bool {
    matches!(current, Some(text) if text == transcript)
}

/// Replace-flow paste (brief item #17): send the platform's undo
/// keystroke to the previously-focused app, wait a beat so the undo
/// takes effect, then paste `text`. The caller (preview overlay's
/// "Replace with raw" button) is responsible for only invoking this
/// when a paste actually happened moments earlier — we don't probe to
/// verify there's something to undo.
///
/// Returns the same PasteOutcome shape as `paste_text` so the frontend
/// can surface identical partial-success messaging.
#[tauri::command]
pub async fn paste_text_replacing(
    window: tauri::WebviewWindow,
    app: tauri::AppHandle,
    text: String,
) -> Result<PasteOutcome, String> {
    ensure_window_in_set(&window, PASTE_REPLACING_ALLOWED_WINDOWS)?;
    if text.len() > MAX_PASTE_BYTES {
        return Err(format!(
            "Paste payload too large ({} bytes; limit {} bytes).",
            text.len(),
            MAX_PASTE_BYTES
        ));
    }
    let mut outcome = PasteOutcome {
        backend: None,
        pasted: false,
        copied: false,
        message: None,
    };

    // Same clipboard-preservation contract as `paste_text`: snapshot
    // the user's prior clipboard before we write the raw transcript,
    // restore it in the background after the paste keystroke fires.
    // Without this, a successful revert leaves the raw transcript on
    // the clipboard permanently — stomping whatever the user had
    // copied before invoking replace-with-raw.
    let prior_clipboard = snapshot_clipboard_text();

    match Clipboard::new().and_then(|mut cb| cb.set_text(&text)) {
        Ok(()) => outcome.copied = true,
        Err(err) => {
            outcome.message = Some(format!("clipboard: {err}"));
            return Ok(outcome);
        }
    }

    hide_preview_overlay_for_paste(&app).await;

    if let Err(err) = trigger_undo_keystroke() {
        outcome.message = Some(format!("undo: {err}"));
        return Ok(outcome);
    }

    tokio::time::sleep(Duration::from_millis(UNDO_PASTE_GAP_MS)).await;

    match trigger_paste_keystroke() {
        Ok(backend) => {
            outcome.backend = Some(backend);
            outcome.pasted = true;
        }
        Err(err) => outcome.message = Some(err),
    }

    if outcome.pasted {
        schedule_clipboard_restore(prior_clipboard, text);
    }

    Ok(outcome)
}

/// Hide the transcription-preview window if it's currently visible, then
/// sleep a short beat so the compositor can recompute focus. No-ops when
/// the window isn't registered yet (user never enabled the overlay) or
/// isn't currently shown.
async fn hide_preview_overlay_for_paste(app: &tauri::AppHandle) {
    let Some(window) = app.get_webview_window("transcription-preview") else {
        return;
    };
    let visible = window.is_visible().unwrap_or(false);
    if !visible {
        return;
    }
    if window.hide().is_err() {
        return;
    }
    tokio::time::sleep(Duration::from_millis(PREVIEW_HIDE_SETTLE_MS)).await;
}

/// Report which paste backends the OS has available right now. Pure probe —
/// does not paste. Used by Settings to tell the user "install wtype" when
/// nothing is available on their session.
#[tauri::command]
pub fn detect_paste_backends() -> Vec<String> {
    let mut available = Vec::new();

    #[cfg(target_os = "linux")]
    {
        for tool in ["wtype", "xdotool", "ydotool"] {
            if which_on_path(tool) {
                available.push(tool.to_string());
            }
        }
    }
    #[cfg(target_os = "macos")]
    {
        available.push("osascript".to_string());
    }
    #[cfg(target_os = "windows")]
    {
        available.push("sendkeys".to_string());
    }

    available
}

/// Well-known terminal-emulator window classes / process names. Each
/// entry is matched case-insensitively against whatever the
/// focused-window probe returns on each platform. The list is
/// deliberately conservative — users can always copy + paste manually,
/// so false negatives are cheap; false positives silently break
/// auto-paste on a legitimate GUI, which is expensive.
/// Longest / most specific matches come first so that `classify_terminal`
/// returns the specific name when two needles would both hit
/// (e.g. "windowsterminal" contains "terminal"). Substring match is
/// otherwise blunt enough that a `foot`/`pwsh` match on a non-terminal
/// window would be vanishingly unlikely.
const KNOWN_TERMINAL_CLASSES: &[&str] = &[
    // Windows — specific names first so they win over plain "terminal".
    "windowsterminal",
    "powershell",
    "conhost",
    "console",
    "pwsh",
    "cmd",
    // macOS
    "iterm2",
    "iterm",
    "terminal",
    // Linux
    "gnome-terminal-server",
    "alacritty",
    "konsole",
    "wezterm",
    "kitty",
    "tilix",
    "xterm",
    "urxvt",
    "st-256color",
    "hyper",
    "foot",
];

/// Return `Some(class)` when the focused window matches a known
/// terminal emulator, `None` otherwise. Pure by design: the actual
/// platform probe is isolated in `detect_focused_window_class` so the
/// classification rule is unit-testable.
fn detect_focused_terminal() -> Option<String> {
    let raw = detect_focused_window_class()?;
    classify_terminal(&raw)
}

/// Case-insensitive substring match of the focused window class /
/// process name against `KNOWN_TERMINAL_CLASSES`. Returns the matched
/// needle (for user-facing copy: "Terminal detected (kitty)") when
/// positive.
fn classify_terminal(raw: &str) -> Option<String> {
    let haystack = raw.to_ascii_lowercase();
    for needle in KNOWN_TERMINAL_CLASSES {
        if haystack.contains(needle) {
            return Some((*needle).to_string());
        }
    }
    None
}

/// Probe the focused window's class name / process name. Returns
/// `None` when the probe tool is missing or fails — we fall back to
/// the default auto-paste path rather than guessing wrong and
/// breaking a GUI user's workflow.
fn detect_focused_window_class() -> Option<String> {
    #[cfg(target_os = "linux")]
    {
        detect_focused_window_class_linux()
    }
    #[cfg(target_os = "macos")]
    {
        return detect_focused_window_class_macos();
    }
    #[cfg(target_os = "windows")]
    {
        return detect_focused_window_class_windows();
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        return None;
    }
}

#[cfg(target_os = "linux")]
fn detect_focused_window_class_linux() -> Option<String> {
    // X11: xdotool getactivewindow getwindowclassname
    if let Ok(output) = Command::new("xdotool")
        .args(["getactivewindow", "getwindowclassname"])
        .output()
    {
        if output.status.success() {
            let class = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if !class.is_empty() {
                return Some(class);
            }
        }
    }
    // Wayland: no reliable focused-window probe from an unprivileged
    // client (by design). Return None and let the normal paste path
    // run; users on Kitty-under-Wayland already lean on Ctrl+Shift+V
    // themselves.
    None
}

#[cfg(target_os = "macos")]
fn detect_focused_window_class_macos() -> Option<String> {
    // `osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`
    let output = Command::new("osascript")
        .args([
            "-e",
            "tell application \"System Events\" to get name of first application process whose frontmost is true",
        ])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if name.is_empty() {
        None
    } else {
        Some(name)
    }
}

#[cfg(target_os = "windows")]
fn detect_focused_window_class_windows() -> Option<String> {
    // PowerShell one-liner: (Get-Process -Id (Get-Process | Where-Object ...
    // Keeping it simple here — GetForegroundWindow + GetWindowThreadProcessId
    // via PowerShell works without a `windows` crate dep.
    let script = r#"
        $w = Add-Type -MemberDefinition '
            [DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow();
            [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(System.IntPtr hWnd, out int pid);
        ' -Name W -PassThru
        $pid = 0
        [void]$w::GetWindowThreadProcessId($w::GetForegroundWindow(), [ref]$pid)
        (Get-Process -Id $pid).ProcessName
    "#;
    let output = Command::new("powershell")
        .args(["-NoProfile", "-Command", script])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if name.is_empty() {
        None
    } else {
        Some(name)
    }
}

fn trigger_paste_keystroke() -> Result<String, String> {
    #[cfg(target_os = "linux")]
    {
        linux_paste(
            std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
            std::env::var_os("WAYLAND_DISPLAY").is_some(),
        )
    }
    #[cfg(target_os = "macos")]
    {
        macos_paste()
    }
    #[cfg(target_os = "windows")]
    {
        windows_paste()
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        Err("auto-paste not implemented on this platform".into())
    }
}

/// Send Ctrl+Z / Cmd+Z to the focused window. Mirrors the shape of
/// `trigger_paste_keystroke`: reuse the same per-OS backend ordering,
/// just swap the keycode. Used by `paste_text_replacing` for the
/// raw-revert flow (brief item #17).
fn trigger_undo_keystroke() -> Result<String, String> {
    #[cfg(target_os = "linux")]
    {
        linux_undo(
            std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
            std::env::var_os("WAYLAND_DISPLAY").is_some(),
        )
    }
    #[cfg(target_os = "macos")]
    {
        macos_undo()
    }
    #[cfg(target_os = "windows")]
    {
        windows_undo()
    }
    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        Err("undo-keystroke not implemented on this platform".into())
    }
}

#[cfg(target_os = "linux")]
fn linux_paste(
    xdg_session_type: Option<&str>,
    wayland_display_set: bool,
) -> Result<String, String> {
    for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
        match run_linux_tool(tool) {
            Ok(()) => return Ok(tool.to_string()),
            Err(_) => continue,
        }
    }
    Err(
        "No paste backend available. Install wtype (Wayland) or xdotool (X11) to enable \
         auto-insert-at-cursor."
            .into(),
    )
}

#[cfg(target_os = "linux")]
fn pick_linux_backend_order(
    xdg_session_type: Option<&str>,
    wayland_display_set: bool,
) -> &'static [&'static str] {
    let is_wayland = xdg_session_type
        .map(|value| value.eq_ignore_ascii_case("wayland"))
        .unwrap_or(false)
        || wayland_display_set;
    if is_wayland {
        &["wtype", "ydotool", "xdotool"]
    } else {
        &["xdotool", "ydotool", "wtype"]
    }
}

#[cfg(target_os = "linux")]
fn run_linux_tool(tool: &str) -> Result<(), String> {
    let output = match tool {
        // wtype -M ctrl v -m ctrl  (press ctrl, tap v, release ctrl)
        "wtype" => Command::new("wtype")
            .args(["-M", "ctrl", "v", "-m", "ctrl"])
            .output(),
        "xdotool" => Command::new("xdotool").args(["key", "ctrl+v"]).output(),
        // ydotool linux input keycodes: 29=LEFTCTRL, 47=V. Format is
        // `code:state` pairs. Requires ydotoold running with access to
        // /dev/uinput.
        "ydotool" => Command::new("ydotool")
            .args(["key", "29:1", "47:1", "47:0", "29:0"])
            .output(),
        other => return Err(format!("unknown backend: {other}")),
    }
    .map_err(|e| format!("{tool} unavailable: {e}"))?;

    if output.status.success() {
        Ok(())
    } else {
        Err(format!(
            "{tool} exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(target_os = "linux")]
fn linux_undo(xdg_session_type: Option<&str>, wayland_display_set: bool) -> Result<String, String> {
    for tool in pick_linux_backend_order(xdg_session_type, wayland_display_set) {
        match run_linux_undo_tool(tool) {
            Ok(()) => return Ok(tool.to_string()),
            Err(_) => continue,
        }
    }
    Err("No undo backend available. Install wtype (Wayland) or xdotool (X11).".into())
}

#[cfg(target_os = "linux")]
fn run_linux_undo_tool(tool: &str) -> Result<(), String> {
    let output = match tool {
        // wtype -M ctrl z -m ctrl  (press ctrl, tap z, release ctrl)
        "wtype" => Command::new("wtype")
            .args(["-M", "ctrl", "z", "-m", "ctrl"])
            .output(),
        "xdotool" => Command::new("xdotool").args(["key", "ctrl+z"]).output(),
        // 29 = LEFTCTRL, 44 = Z in linux input keycodes.
        "ydotool" => Command::new("ydotool")
            .args(["key", "29:1", "44:1", "44:0", "29:0"])
            .output(),
        other => return Err(format!("unknown backend: {other}")),
    }
    .map_err(|e| format!("{tool} unavailable: {e}"))?;

    if output.status.success() {
        Ok(())
    } else {
        Err(format!(
            "{tool} exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(target_os = "linux")]
fn which_on_path(tool: &str) -> bool {
    Command::new("sh")
        .args(["-c", &format!("command -v {tool}")])
        .output()
        .map(|output| output.status.success())
        .unwrap_or(false)
}

#[cfg(target_os = "macos")]
fn macos_paste() -> Result<String, String> {
    let output = Command::new("osascript")
        .args([
            "-e",
            "tell application \"System Events\" to keystroke \"v\" using command down",
        ])
        .output()
        .map_err(|e| format!("osascript: {e}"))?;
    if output.status.success() {
        Ok("osascript".into())
    } else {
        Err(format!(
            "osascript exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(target_os = "macos")]
fn macos_undo() -> Result<String, String> {
    let output = Command::new("osascript")
        .args([
            "-e",
            "tell application \"System Events\" to keystroke \"z\" using command down",
        ])
        .output()
        .map_err(|e| format!("osascript: {e}"))?;
    if output.status.success() {
        Ok("osascript".into())
    } else {
        Err(format!(
            "osascript exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(target_os = "windows")]
fn windows_paste() -> Result<String, String> {
    // SendKeys("^v") simulates Ctrl+V in the foreground window. Requires
    // no extra permissions on Windows. A native SendInput call would skip
    // the PowerShell spawn but pulls in the windows crate; cost not worth
    // the complexity until this hot path actually shows up.
    let output = Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            "(New-Object -ComObject WScript.Shell).SendKeys('^v')",
        ])
        .output()
        .map_err(|e| format!("powershell: {e}"))?;
    if output.status.success() {
        Ok("sendkeys".into())
    } else {
        Err(format!(
            "powershell exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(target_os = "windows")]
fn windows_undo() -> Result<String, String> {
    let output = Command::new("powershell")
        .args([
            "-NoProfile",
            "-Command",
            "(New-Object -ComObject WScript.Shell).SendKeys('^z')",
        ])
        .output()
        .map_err(|e| format!("powershell: {e}"))?;
    if output.status.success() {
        Ok("sendkeys".into())
    } else {
        Err(format!(
            "powershell exit {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr).trim()
        ))
    }
}

#[cfg(all(test, target_os = "linux"))]
mod tests_linux {
    use super::pick_linux_backend_order;

    #[test]
    fn wayland_session_prefers_wtype_then_ydotool() {
        assert_eq!(
            pick_linux_backend_order(Some("wayland"), true),
            &["wtype", "ydotool", "xdotool"]
        );
    }

    #[test]
    fn x11_session_prefers_xdotool() {
        assert_eq!(
            pick_linux_backend_order(Some("x11"), false),
            &["xdotool", "ydotool", "wtype"]
        );
    }

    #[test]
    fn wayland_display_env_var_alone_is_enough() {
        assert_eq!(
            pick_linux_backend_order(None, true),
            &["wtype", "ydotool", "xdotool"]
        );
    }

    #[test]
    fn uppercase_wayland_token_still_detects_wayland() {
        assert_eq!(
            pick_linux_backend_order(Some("WAYLAND"), false),
            &["wtype", "ydotool", "xdotool"]
        );
    }
}

#[cfg(test)]
mod tests_clipboard_restore {
    use super::should_restore;

    #[test]
    fn restores_when_clipboard_still_holds_transcript() {
        assert!(should_restore(Some("dictated text"), "dictated text"));
    }

    #[test]
    fn does_not_restore_when_user_copied_something_else() {
        assert!(!should_restore(
            Some("user copied this just now"),
            "dictated text"
        ));
    }

    #[test]
    fn does_not_restore_when_clipboard_unreadable() {
        assert!(!should_restore(None, "dictated text"));
    }

    #[test]
    fn does_not_restore_on_empty_mismatch() {
        assert!(!should_restore(Some(""), "dictated text"));
    }
}

#[cfg(test)]
mod tests_terminal_classification {
    use super::classify_terminal;

    #[test]
    fn kitty_class_is_a_terminal() {
        assert_eq!(classify_terminal("kitty"), Some("kitty".into()));
    }

    #[test]
    fn alacritty_matches_case_insensitively() {
        assert_eq!(classify_terminal("Alacritty"), Some("alacritty".into()));
    }

    #[test]
    fn windows_terminal_matches_collapsed_name() {
        // Windows reports the process name sans space: "WindowsTerminal"
        assert_eq!(
            classify_terminal("WindowsTerminal"),
            Some("windowsterminal".into()),
        );
    }

    #[test]
    fn iterm_matches_iterm2_as_well() {
        assert_eq!(classify_terminal("iTerm2"), Some("iterm2".into()));
    }

    #[test]
    fn firefox_is_not_a_terminal() {
        assert_eq!(classify_terminal("firefox"), None);
    }

    #[test]
    fn empty_class_is_not_a_terminal() {
        assert_eq!(classify_terminal(""), None);
    }

    #[test]
    fn substring_match_survives_xdotool_decorations() {
        // Some X11 window managers report "Alacritty.Alacritty"
        assert_eq!(
            classify_terminal("Alacritty.Alacritty"),
            Some("alacritty".into()),
        );
    }
}

#[cfg(test)]
mod tests_paste_size_cap {
    use super::MAX_PASTE_BYTES;

    /// Pure mirror of the size-cap branch in `paste_text` /
    /// `paste_text_replacing`. Wiring is tested through the command
    /// surface; this asserts the rule itself.
    fn size_check(text: &str) -> Result<(), String> {
        if text.len() > MAX_PASTE_BYTES {
            Err(format!(
                "Paste payload too large ({} bytes; limit {} bytes).",
                text.len(),
                MAX_PASTE_BYTES
            ))
        } else {
            Ok(())
        }
    }

    #[test]
    fn accepts_typical_dictation_payload() {
        // ~500 chars of dictated text: comfortably under the cap.
        let text = "a".repeat(500);
        assert!(size_check(&text).is_ok());
    }

    #[test]
    fn rejects_payload_above_cap() {
        let big = "x".repeat(MAX_PASTE_BYTES + 1);
        let err = size_check(&big).expect_err("size-cap should reject");
        assert!(err.contains("too large"));
    }

    #[test]
    fn paste_cap_matches_clipboard_cap() {
        // Both surfaces must refuse identically — drift between the
        // two would let an attacker copy a 1 MiB-plus payload via one
        // command and synthesise it via the other.
        assert_eq!(
            MAX_PASTE_BYTES,
            crate::commands::clipboard::MAX_CLIPBOARD_BYTES
        );
    }
}
