//! End-to-end macOS integration test — drive Calculator, verify 2+3=5.
//!
//! This test is the capstone proof that CD-02 through CD-06
//! compose into a working loop: enumerate windows, observe with
//! AX tree, click computed points inside the target window,
//! observe again, read the result.
//!
//! Opt-in: requires Screen Recording + Accessibility permissions
//! granted to the test runner and runs only on macOS. Guarded by
//! `#[ignore]` so `cargo test` in CI skips it (CD-13 wires the
//! permissioned runner that flips this on); run locally with:
//!
//! ```sh
//! cargo test -- --ignored macos_calculator
//! ```

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

use std::process::Command;
use std::time::{Duration, Instant};

use car_desktop::models::{ClickRequest, KeyPressRequest, MouseButton, TypeRequest, WindowFilter};
use car_desktop::{default_backend, CarDesktopError};

const CALCULATOR_BUNDLE: &str = "com.apple.calculator";

/// Launch Calculator and wait for its main window to appear. Returns
/// the matched WindowInfo once found, or panics on timeout.
async fn launch_and_find_calculator(
    backend: &dyn car_desktop::DesktopBackend,
) -> car_desktop::models::WindowInfo {
    let _ = Command::new("open")
        .args(["-a", "Calculator"])
        .status()
        .expect("failed to run `open -a Calculator`");

    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        if Instant::now() >= deadline {
            panic!("Calculator window did not appear within 5s");
        }
        let windows = backend
            .list_windows(WindowFilter::by_bundle_id(CALCULATOR_BUNDLE))
            .await
            .expect("list_windows must succeed");
        if let Some(w) = windows.into_iter().find(|w| w.layer == 0) {
            return w;
        }
        tokio::time::sleep(Duration::from_millis(150)).await;
    }
}

/// Close Calculator via AppleScript after the test completes so
/// we don't leave it open on the developer's machine.
fn quit_calculator() {
    let _ = Command::new("osascript")
        .args(["-e", "tell application \"Calculator\" to quit"])
        .status();
}

#[tokio::test]
#[ignore = "requires Screen Recording + Accessibility permissions granted to the test runner"]
async fn calculator_2_plus_3_equals_5() {
    let backend = default_backend();

    // Sanity-preflight — bail with a helpful message if perms are
    // missing, rather than failing mid-click with a cryptic error.
    let snap = backend
        .permissions()
        .await
        .expect("permissions() must not error");
    if !snap.screen_recording || !snap.accessibility {
        panic!(
            "calculator_2_plus_3_equals_5 requires both Screen Recording and \
             Accessibility permissions granted to the test runner. Current: \
             screen_recording={} accessibility={}",
            snap.screen_recording, snap.accessibility
        );
    }

    let window = launch_and_find_calculator(&*backend).await;
    let _cleanup = scopeguard(|| quit_calculator());

    // Focus + let the window settle.
    backend
        .focus_window(window.handle)
        .await
        .expect("focus_window");
    tokio::time::sleep(Duration::from_millis(500)).await;

    // Drive the sequence "2", "+", "3", "=" via keypress (clears any
    // residual state at the start). Using keypresses is more reliable
    // across macOS Calculator revisions than clicking specific AX
    // buttons whose titles have shifted over the years (the Apple
    // app uses "+" vs "plus" vs AXUIElement-localized strings).
    let modifiers = Vec::new();
    // Clear.
    backend
        .keypress(KeyPressRequest {
            window: window.handle,
            key: car_desktop::models::Key::Escape,
            modifiers: modifiers.clone(),
            dry_run: false,
        })
        .await
        .expect("clear");
    tokio::time::sleep(Duration::from_millis(100)).await;

    // Type "2+3" as text — Calculator's main view accepts keyboard
    // input. This is simpler than synthesizing individual keycodes
    // for +, =, and digit keys.
    backend
        .type_text(TypeRequest {
            window: window.handle,
            text: "2+3".into(),
            dry_run: false,
        })
        .await
        .expect("type 2+3");
    tokio::time::sleep(Duration::from_millis(200)).await;

    // Press Return to evaluate (equivalent to "=" in Calculator).
    backend
        .keypress(KeyPressRequest {
            window: window.handle,
            key: car_desktop::models::Key::Return,
            modifiers,
            dry_run: false,
        })
        .await
        .expect("return");
    tokio::time::sleep(Duration::from_millis(300)).await;

    // Observe. The display value lives in an AX element whose
    // role is AXStaticText or AXTextField; we walk the tree and
    // look for the most-recent value-bearing descendant.
    let map = backend
        .observe_window(window.handle)
        .await
        .expect("observe_window");

    let display_value = find_calculator_display_value(&map)
        .expect("Calculator display value not present in AX tree");
    assert_eq!(
        display_value.trim(),
        "5",
        "Calculator display should read 5 after 2+3=; got {display_value:?}"
    );
}

fn find_calculator_display_value(map: &car_desktop::models::UiMap) -> Option<String> {
    // Walk the flat a11y_by_id lookup for anything whose value
    // parses as a number. Calculator's display is typically an
    // AXStaticText with its numeric content in value or name.
    for (_, rec) in map.a11y_by_id.iter() {
        if rec.role != "AXStaticText" && rec.role != "AXTextField" {
            continue;
        }
        let candidate = rec
            .value
            .as_deref()
            .or(rec.name.as_deref())
            .unwrap_or("")
            .trim();
        if candidate.is_empty() {
            continue;
        }
        // Must look like a number (possibly with thousands separator).
        let cleaned = candidate.replace(',', "").replace(' ', "");
        if cleaned.parse::<f64>().is_ok() {
            return Some(cleaned);
        }
    }
    None
}

// Minimal scope-guard for the test cleanup. `scopeguard` crate
// would be cleaner; we're avoiding new deps for a single callsite.
fn scopeguard<F: FnOnce()>(f: F) -> impl Drop {
    struct Guard<F: FnOnce()>(Option<F>);
    impl<F: FnOnce()> Drop for Guard<F> {
        fn drop(&mut self) {
            if let Some(f) = self.0.take() {
                f();
            }
        }
    }
    Guard(Some(f))
}
