//! Rust wrapper for the Ledger Speculos device emulator.
//!
//! Spawns the speculos Python binary as a child process, exposes its REST
//! API for reading screen text, and sends button / tap / drag input.
//!
//! ```no_run
//! use speculos::{Speculos, Button, Model};
//!
//! let sim = Speculos::launch(std::path::Path::new("/path/to/app.elf"), Model::NanoX).unwrap();
//! // ... talk to sim.apdu_addr() with your Ledger transport ...
//! sim.press(Button::Right).unwrap();
//! sim.press(Button::Both).unwrap();
//! ```

use std::{
    io::{self, Read, Write},
    net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream},
    path::Path,
    process::{Child, Command, ExitStatus, Stdio},
    sync::{
        atomic::{AtomicUsize, Ordering},
        Arc, Mutex,
    },
    thread::JoinHandle,
    time::{Duration, Instant},
};

use regex::Regex;
use serde::Deserialize;

#[derive(Debug)]
pub enum Error {
    /// I/O error spawning or talking to the child / REST API.
    Io(io::Error),
    /// HTTP transport error (connection refused, timeout, etc).
    Http(Box<ureq::Error>),
    /// Failed to deserialize a REST response body.
    Json(serde_json::Error),
    /// Speculos did not become responsive within the launch timeout.
    LaunchTimeout,
    /// `reset()` POST sent, but `/events` did not become responsive again
    /// within the timeout.
    ResetTimeout,
    /// The speculos child process exited before becoming ready.
    SpeculosExited {
        status: ExitStatus,
        /// Captured stderr from the speculos child, lossily decoded.
        stderr: String,
    },
    /// `wait_for` did not see a matching event within the timeout.
    WaitTimeout,
    /// `press()` was called on a touchscreen model.
    TouchscreenOnly,
    /// `tap()` / `drag()` / `press_at()` / `release_at()` / `move_to()` was
    /// called on a button-only model.
    ButtonsOnly,
    /// `screenshot()` returned a body that is not a PNG.
    ScreenshotNotPng,
    /// A required environment variable is not set.
    EnvMissing(String),
    /// An environment variable (or model string) could not be parsed.
    EnvInvalid { var: String, reason: String },
}

impl From<io::Error> for Error {
    fn from(e: io::Error) -> Self {
        Error::Io(e)
    }
}

impl From<ureq::Error> for Error {
    fn from(e: ureq::Error) -> Self {
        Error::Http(Box::new(e))
    }
}

impl From<serde_json::Error> for Error {
    fn from(e: serde_json::Error) -> Self {
        Error::Json(e)
    }
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Error::Io(e) => write!(f, "speculos io: {e}"),
            Error::Http(e) => write!(f, "speculos http: {e}"),
            Error::Json(e) => write!(f, "speculos json: {e}"),
            Error::LaunchTimeout => write!(f, "speculos launch timeout"),
            Error::ResetTimeout => write!(f, "speculos reset timeout"),
            Error::SpeculosExited { status, stderr } => {
                if stderr.is_empty() {
                    write!(f, "speculos process exited before ready: {status}")
                } else {
                    write!(
                        f,
                        "speculos process exited before ready: {status}\nstderr:\n{stderr}"
                    )
                }
            }
            Error::WaitTimeout => write!(f, "speculos wait_for timeout"),
            Error::TouchscreenOnly => write!(f, "press() not supported on touchscreen models"),
            Error::ButtonsOnly => write!(
                f,
                "tap/drag/press_at/release_at/move_to not supported on button models"
            ),
            Error::ScreenshotNotPng => write!(f, "speculos screenshot: response is not a PNG"),
            Error::EnvMissing(v) => write!(f, "speculos env: missing {v}"),
            Error::EnvInvalid { var, reason } => {
                write!(f, "speculos env: invalid {var}: {reason}")
            }
        }
    }
}

impl std::error::Error for Error {}

pub type Result<T> = std::result::Result<T, Error>;

/// Ledger device families supported by Speculos.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Model {
    /// Nano X (buttons).
    NanoX,
    /// Nano S Plus (buttons).
    NanoSP,
    /// Stax (touchscreen, 400x672).
    Stax,
    /// Flex (touchscreen, 480x600).
    Flex,
}

impl Model {
    /// Value passed to speculos's `--model` flag.
    pub fn as_str(self) -> &'static str {
        match self {
            Model::NanoX => "nanox",
            Model::NanoSP => "nanosp",
            Model::Stax => "stax",
            Model::Flex => "flex",
        }
    }

    /// Whether the model has hardware buttons (vs a touchscreen).
    pub fn is_buttons(self) -> bool {
        matches!(self, Model::NanoX | Model::NanoSP)
    }

    /// Screen size in pixels (width, height).
    pub fn screen_size(self) -> (u32, u32) {
        match self {
            Model::NanoX | Model::NanoSP => (128, 64),
            Model::Stax => (400, 672),
            Model::Flex => (480, 600),
        }
    }
}

impl std::str::FromStr for Model {
    type Err = Error;
    /// Parse `nanox` / `nanosp` / `stax` / `flex` (case-insensitive).
    fn from_str(s: &str) -> Result<Self> {
        match s.to_ascii_lowercase().as_str() {
            "nanox" => Ok(Model::NanoX),
            "nanosp" | "nanosplus" | "nanos+" => Ok(Model::NanoSP),
            "stax" => Ok(Model::Stax),
            "flex" => Ok(Model::Flex),
            _ => Err(Error::EnvInvalid {
                var: "model".into(),
                reason: format!("unknown: {s}"),
            }),
        }
    }
}

/// Hardware buttons on Nano X / Nano S Plus.
#[derive(Copy, Clone, Debug)]
pub enum Button {
    Left,
    Right,
    /// Both buttons pressed simultaneously (the affirmative action on Nano).
    Both,
}

/// Optional spawn-time configuration for [`Speculos::launch_with`].
/// Every field defaults to "let speculos pick / use the default".
#[derive(Default, Clone, Debug)]
pub struct SpawnOptions {
    /// Override the speculos seed. Passed verbatim to `--seed`: a BIP39
    /// mnemonic, or `hex:<hex>` for a raw seed. `None` means use the
    /// default mnemonic baked into speculos ("glory promote mansion idle
    /// axis ...").
    pub seed: Option<String>,
    /// Bind APDU socket to this address. `None` picks a free ephemeral port.
    pub apdu: Option<SocketAddr>,
    /// Bind REST API to this address. `None` picks a free ephemeral port.
    pub api: Option<SocketAddr>,
}

/// One screen text element returned by Speculos `/events`.
#[derive(Debug, Clone, Deserialize)]
pub struct ScreenEvent {
    pub text: String,
    #[serde(default)]
    pub x: i32,
    #[serde(default)]
    pub y: i32,
    #[serde(default)]
    pub w: i32,
    #[serde(default)]
    pub h: i32,
}

#[derive(Deserialize)]
struct EventsResponse {
    events: Vec<ScreenEvent>,
}

/// Speculos process handle and REST client.
///
/// Construct with [`Speculos::launch`] (free ports), [`Speculos::launch_on`]
/// (specific addresses), or [`Speculos::attach`] (already-running process).
/// Drop kills the child if we own it.
#[derive(Debug)]
pub struct Speculos {
    child: Option<Child>,
    apdu: SocketAddr,
    api: SocketAddr,
    cursor: AtomicUsize,
    model: Model,
    agent: ureq::Agent,
    /// Joins the stderr-drain thread on drop. The thread reads to EOF, so
    /// once the child is killed and its stderr pipe closes the read returns
    /// 0 and the thread exits cleanly.
    stderr_thread: Option<JoinHandle<()>>,
}

/// Cap on the stderr capture so a long-lived simulator doesn't accumulate
/// unbounded log output in memory. Keeps the head (the part most useful
/// for diagnosing a launch failure); later output is silently discarded.
const STDERR_CAPTURE_CAP: usize = 64 * 1024;

impl Speculos {
    /// Spawn speculos picking two free ephemeral TCP ports automatically.
    /// Uses the default speculos seed.
    pub fn launch(elf: &Path, model: Model) -> Result<Self> {
        Self::launch_with(elf, model, SpawnOptions::default())
    }

    /// Spawn speculos with caller-specified addresses. Uses the default seed.
    pub fn launch_on(elf: &Path, model: Model, apdu: SocketAddr, api: SocketAddr) -> Result<Self> {
        Self::launch_with(
            elf,
            model,
            SpawnOptions {
                apdu: Some(apdu),
                api: Some(api),
                seed: None,
            },
        )
    }

    /// Spawn speculos with the given options. Free ephemeral TCP ports are
    /// chosen for any address left as `None`. Drop kills the child.
    ///
    /// If any port is auto-picked, retries up to 3 times on launch failure,
    /// re-picking ports each attempt. Covers the TOCTOU window where another
    /// process can grab a port between [`pick_free_port`] and speculos's bind.
    pub fn launch_with(elf: &Path, model: Model, opts: SpawnOptions) -> Result<Self> {
        if !elf.exists() {
            return Err(Error::Io(io::Error::new(
                io::ErrorKind::NotFound,
                format!("speculos elf not found: {}", elf.display()),
            )));
        }
        const MAX_ATTEMPTS: u32 = 3;
        let can_retry = opts.apdu.is_none() || opts.api.is_none();

        let mut last_err = None;
        for attempt in 1..=MAX_ATTEMPTS {
            let apdu = match opts.apdu {
                Some(a) => a,
                None => pick_free_port()?,
            };
            let api = match opts.api {
                Some(a) => a,
                None => pick_free_port()?,
            };

            match Self::spawn_and_wait(elf, model, &opts, apdu, api) {
                Ok(s) => return Ok(s),
                Err(e) => {
                    last_err = Some(e);
                    if !can_retry {
                        break;
                    }
                    if attempt < MAX_ATTEMPTS {
                        std::thread::sleep(Duration::from_millis(200));
                    }
                }
            }
        }
        Err(last_err.expect("loop runs at least once"))
    }

    fn spawn_and_wait(
        elf: &Path,
        model: Model,
        opts: &SpawnOptions,
        apdu: SocketAddr,
        api: SocketAddr,
    ) -> Result<Self> {
        let bin = std::env::var("SPECULOS_BIN").unwrap_or_else(|_| "speculos".into());
        let mut cmd = Command::new(&bin);
        cmd.arg("--model")
            .arg(model.as_str())
            .arg("--display")
            .arg("headless")
            .arg("--apdu-port")
            .arg(apdu.port().to_string())
            .arg("--api-port")
            .arg(api.port().to_string());
        if let Some(seed) = &opts.seed {
            cmd.arg("--seed").arg(seed);
        }
        cmd.arg(elf).stdout(Stdio::null()).stderr(Stdio::piped());

        let mut child = cmd.spawn()?;

        // Drain speculos's stderr in a background thread. Speculos is very
        // chatty on stderr (every APDU is logged); without an active drainer
        // the pipe buffer fills around 64 KB and speculos blocks on its
        // stderr write, stalling APDU processing. The "sign_tx never shows
        // Review transaction" failure mode for multi-frame PSBTs traces back
        // to exactly this. The thread keeps a bounded head capture so that a
        // launch-time exit can still surface stderr in the error message.
        let capture = Arc::new(Mutex::new(Vec::<u8>::new()));
        let stderr_thread = child.stderr.take().map(|mut s| {
            let capture = capture.clone();
            std::thread::spawn(move || {
                let mut buf = [0u8; 4096];
                loop {
                    match s.read(&mut buf) {
                        Ok(0) | Err(_) => break,
                        Ok(n) => {
                            if let Ok(mut c) = capture.lock() {
                                let room = STDERR_CAPTURE_CAP.saturating_sub(c.len());
                                if room > 0 {
                                    c.extend_from_slice(&buf[..n.min(room)]);
                                }
                            }
                        }
                    }
                }
            })
        });

        let agent = ureq::AgentBuilder::new()
            .timeout_connect(Duration::from_millis(500))
            .timeout_read(Duration::from_secs(5))
            .build();

        if let Err(e) = poll_until_ready(
            &agent,
            apdu,
            api,
            Some(&mut child),
            &capture,
            Duration::from_secs(30),
        ) {
            let _ = child.kill();
            let _ = child.wait();
            if let Some(t) = stderr_thread {
                let _ = t.join();
            }
            return Err(e);
        }

        Ok(Self {
            child: Some(child),
            apdu,
            api,
            cursor: AtomicUsize::new(0),
            model,
            agent,
            stderr_thread,
        })
    }

    /// Spawn speculos for `model` with the elf path read from the
    /// `SPECULOS_ELF` environment variable. Drop kills the child.
    ///
    /// Optional `SPECULOS_APDU`, `SPECULOS_API`, and `SPECULOS_SEED` are
    /// honored as in [`Self::from_env`]; ports are auto-assigned (free
    /// ephemeral) when the env vars are unset.
    ///
    /// Convenience over [`launch`] for callers that already source the
    /// elf via env (e.g. `tests/speculos/run.sh`) and want to pick the
    /// model in code rather than from `SPECULOS_MODEL`.
    pub fn launch_model(model: Model) -> Result<Self> {
        let (elf, opts) = options_from_env()?;
        Self::launch_with(Path::new(&elf), model, opts)
    }

    /// Attach to an already-running speculos. `Drop` will not kill the child.
    pub fn attach(model: Model, apdu: SocketAddr, api: SocketAddr) -> Result<Self> {
        let agent = ureq::AgentBuilder::new()
            .timeout_connect(Duration::from_millis(500))
            .timeout_read(Duration::from_secs(5))
            .build();
        let empty = Arc::new(Mutex::new(Vec::new()));
        poll_until_ready(&agent, apdu, api, None, &empty, Duration::from_secs(5))?;
        Ok(Self {
            child: None,
            apdu,
            api,
            cursor: AtomicUsize::new(0),
            model,
            agent,
            stderr_thread: None,
        })
    }

    /// Read SPECULOS_ELF + SPECULOS_MODEL (and optional SPECULOS_APDU,
    /// SPECULOS_API, SPECULOS_SEED) from the environment and dispatch
    /// to [`launch_with`].
    ///
    /// SPECULOS_SEED is forwarded verbatim to speculos's `--seed` flag.
    /// Use a BIP39 mnemonic, or `hex:<hex>` for a raw seed.
    pub fn from_env() -> Result<Self> {
        let model_str = std::env::var("SPECULOS_MODEL")
            .map_err(|_| Error::EnvMissing("SPECULOS_MODEL".into()))?;
        let model: Model = model_str.parse().map_err(|e| match e {
            Error::EnvInvalid { reason, .. } => Error::EnvInvalid {
                var: "SPECULOS_MODEL".into(),
                reason,
            },
            other => other,
        })?;
        let (elf, opts) = options_from_env()?;
        Self::launch_with(Path::new(&elf), model, opts)
    }

    pub fn apdu_addr(&self) -> SocketAddr {
        self.apdu
    }
    pub fn api_addr(&self) -> SocketAddr {
        self.api
    }
    pub fn model(&self) -> Model {
        self.model
    }

    /// Capture the current display as a PNG byte buffer.
    ///
    /// Returned image dimensions match [`Model::screen_size`] for the
    /// running model (e.g. 128x64 for Nano X / Nano S Plus, 400x672 for
    /// Stax, 480x600 for Flex). Useful for golden-image regression tests
    /// and for snapshotting a failing screen during debug.
    pub fn screenshot(&self) -> Result<Vec<u8>> {
        let mut buf = Vec::new();
        self.agent
            .get(&format!("http://{}/screenshot", self.api))
            .call()?
            .into_reader()
            .read_to_end(&mut buf)?;
        // PNG magic: 89 50 4E 47 0D 0A 1A 0A. Speculos wraps the screen as a
        // PNG; anything else (HTML error page, JSON) means the endpoint
        // misbehaved and we'd otherwise silently write garbage to disk.
        if !buf.starts_with(b"\x89PNG\r\n\x1a\n") {
            return Err(Error::ScreenshotNotPng);
        }
        Ok(buf)
    }

    /// Capture the current display and write it to `path` (PNG).
    /// Convenience over [`screenshot`]; truncates `path` if it exists.
    pub fn screenshot_to_file(&self, path: &Path) -> Result<()> {
        let bytes = self.screenshot()?;
        let mut f = std::fs::File::create(path)?;
        f.write_all(&bytes)?;
        Ok(())
    }

    /// Full event log since launch (does not advance the cursor).
    pub fn events(&self) -> Result<Vec<ScreenEvent>> {
        let body: EventsResponse = self
            .agent
            .get(&format!("http://{}/events", self.api))
            .call()?
            .into_json()?;
        Ok(body.events)
    }

    /// Events appended since the previous call to `new_events` (advances
    /// the cursor). First call returns the full backlog.
    ///
    /// The cursor is local to this handle; speculos's `/events` endpoint
    /// always returns the full log since launch (or `/reset`), so events
    /// the cursor has skipped past remain accessible via [`Self::events`].
    ///
    /// Not safe to call concurrently from multiple threads on the same
    /// handle: the `/events` fetch and cursor advance are not atomic, so
    /// concurrent callers can observe duplicate events or the cursor
    /// moving backwards. Drive a single `Speculos` from one thread.
    pub fn new_events(&self) -> Result<Vec<ScreenEvent>> {
        let all = self.events()?;
        let prev = self.cursor.swap(all.len(), Ordering::AcqRel);
        if prev > all.len() {
            // /reset can shrink the log; treat as fresh start.
            return Ok(all);
        }
        Ok(all.into_iter().skip(prev).collect())
    }

    /// Block until any new event matches `re`, or `timeout` elapses.
    /// Polls `/events` every ~100 ms. Advances the cursor past the match.
    ///
    /// Consumes all events up to and including the match: any unmatched
    /// events seen during the wait are no longer returned by
    /// [`Self::new_events`], but they remain in the full log returned by
    /// [`Self::events`].
    ///
    /// Inherits the threading constraint of [`Self::new_events`]: drive
    /// a single `Speculos` handle from one thread.
    pub fn wait_for(&self, re: &Regex, timeout: Duration) -> Result<ScreenEvent> {
        self.wait_for_match(timeout, |ev| re.is_match(&ev.text))
    }

    /// Block until any new event's text contains `needle`, or `timeout`
    /// elapses. Substring-match convenience over [`Self::wait_for`] for
    /// callers that don't need a full regex.
    ///
    /// Same threading and cursor-consumption semantics as
    /// [`Self::wait_for`].
    pub fn wait_for_text(&self, needle: &str, timeout: Duration) -> Result<ScreenEvent> {
        self.wait_for_match(timeout, |ev| ev.text.contains(needle))
    }

    fn wait_for_match<F>(&self, timeout: Duration, pred: F) -> Result<ScreenEvent>
    where
        F: Fn(&ScreenEvent) -> bool,
    {
        let deadline = Instant::now() + timeout;
        loop {
            for ev in self.new_events()? {
                if pred(&ev) {
                    return Ok(ev);
                }
            }
            let now = Instant::now();
            if now >= deadline {
                return Err(Error::WaitTimeout);
            }
            // Cap the sleep at the remaining budget so timeouts shorter
            // than 100 ms don't overshoot.
            let remaining = deadline.saturating_duration_since(now);
            std::thread::sleep(remaining.min(Duration::from_millis(100)));
        }
    }

    /// POST /reset: re-launch the embedded Ledger app, clearing in-RAM
    /// state without rebinding ports. Also drains the event cursor so
    /// subsequent `new_events` calls see only post-reset events.
    pub fn reset(&self) -> Result<()> {
        let _ = self
            .agent
            .post(&format!("http://{}/reset", self.api))
            .send_string("");
        // Some speculos builds return the response only after /reset has
        // re-loaded the app; others return immediately. Wait briefly for
        // the API to be responsive again either way.
        let deadline = Instant::now() + Duration::from_secs(5);
        while Instant::now() < deadline {
            if self.events().is_ok() {
                self.cursor.store(0, Ordering::Release);
                // Clear any backlog accumulated during the reset.
                let _ = self.new_events();
                return Ok(());
            }
            std::thread::sleep(Duration::from_millis(50));
        }
        Err(Error::ResetTimeout)
    }

    // ---------------------------------------------------------------------
    // Buttons
    // ---------------------------------------------------------------------

    /// Press-and-release a hardware button (Nano X / Nano S Plus).
    pub fn press(&self, button: Button) -> Result<()> {
        if !self.model.is_buttons() {
            return Err(Error::TouchscreenOnly);
        }
        match button {
            Button::Left => self.button_action("left", "press-and-release"),
            Button::Right => self.button_action("right", "press-and-release"),
            // The "both" composite: press left, press right, release right,
            // release left. Matches what Speculos's --automation rules used.
            Button::Both => {
                self.button_action("left", "press")?;
                self.button_action("right", "press")?;
                self.button_action("right", "release")?;
                self.button_action("left", "release")
            }
        }
    }

    fn button_action(&self, side: &str, action: &str) -> Result<()> {
        let url = format!("http://{}/button/{}", self.api, side);
        self.agent
            .post(&url)
            .send_json(serde_json::json!({ "action": action }))?;
        Ok(())
    }

    // ---------------------------------------------------------------------
    // Touchscreen
    // ---------------------------------------------------------------------

    /// Single press-and-release at one point.
    pub fn tap(&self, x: u32, y: u32) -> Result<()> {
        if self.model.is_buttons() {
            return Err(Error::ButtonsOnly);
        }
        self.finger_action("press-and-release", x, y)
    }

    /// Drag/swipe gesture: press at `from`, walk a small straight-line
    /// path of `move` events, release at `to`.
    pub fn drag(&self, from: (u32, u32), to: (u32, u32)) -> Result<()> {
        if self.model.is_buttons() {
            return Err(Error::ButtonsOnly);
        }
        const SEGMENTS: i64 = 8;
        let dx = to.0 as i64 - from.0 as i64;
        let dy = to.1 as i64 - from.1 as i64;
        self.finger_action("press", from.0, from.1)?;
        // Stop one short of SEGMENTS: the release below lands on `to`,
        // so a final move would just duplicate it. Skip segments that
        // collapse onto the previous point (short drags where
        // |delta| < SEGMENTS) to avoid spamming identical `move` events.
        let mut last = (from.0, from.1);
        for i in 1..SEGMENTS {
            let x = (from.0 as i64 + dx * i / SEGMENTS).clamp(0, u32::MAX as i64) as u32;
            let y = (from.1 as i64 + dy * i / SEGMENTS).clamp(0, u32::MAX as i64) as u32;
            if (x, y) == last {
                continue;
            }
            self.finger_action("move", x, y)?;
            last = (x, y);
        }
        self.finger_action("release", to.0, to.1)
    }

    /// Touchscreen primitive: press at `(x, y)`.
    pub fn press_at(&self, x: u32, y: u32) -> Result<()> {
        if self.model.is_buttons() {
            return Err(Error::ButtonsOnly);
        }
        self.finger_action("press", x, y)
    }

    /// Touchscreen primitive: release at `(x, y)`.
    pub fn release_at(&self, x: u32, y: u32) -> Result<()> {
        if self.model.is_buttons() {
            return Err(Error::ButtonsOnly);
        }
        self.finger_action("release", x, y)
    }

    /// Touchscreen primitive: move (while pressed) to `(x, y)`.
    pub fn move_to(&self, x: u32, y: u32) -> Result<()> {
        if self.model.is_buttons() {
            return Err(Error::ButtonsOnly);
        }
        self.finger_action("move", x, y)
    }

    fn finger_action(&self, action: &str, x: u32, y: u32) -> Result<()> {
        let url = format!("http://{}/finger", self.api);
        self.agent
            .post(&url)
            .send_json(serde_json::json!({ "action": action, "x": x, "y": y }))?;
        Ok(())
    }
}

impl Drop for Speculos {
    fn drop(&mut self) {
        if let Some(mut child) = self.child.take() {
            // SIGTERM (well, kill on Windows; on Unix `kill` sends SIGKILL).
            // Good enough for tests; the OS reaps qemu-arm-static via the
            // python process's child handler.
            let _ = child.kill();
            let _ = child.wait();
        }
        // After the child is gone the stderr pipe closes, so the drain
        // thread's read returns 0 and it exits. Join to avoid leaking it.
        if let Some(t) = self.stderr_thread.take() {
            let _ = t.join();
        }
    }
}

fn poll_until_ready(
    agent: &ureq::Agent,
    apdu: SocketAddr,
    api: SocketAddr,
    mut child: Option<&mut Child>,
    stderr_capture: &Arc<Mutex<Vec<u8>>>,
    timeout: Duration,
) -> Result<()> {
    let deadline = Instant::now() + timeout;
    // 1. APDU port (TCP connect succeeds).
    loop {
        if let Some(c) = child.as_deref_mut() {
            if let Some(status) = c.try_wait()? {
                return Err(Error::SpeculosExited {
                    status,
                    stderr: snapshot_stderr(stderr_capture),
                });
            }
        }
        if Instant::now() > deadline {
            return Err(Error::LaunchTimeout);
        }
        if TcpStream::connect_timeout(&apdu, Duration::from_millis(200)).is_ok() {
            break;
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    // 2. REST API responds to GET /events.
    loop {
        if let Some(c) = child.as_deref_mut() {
            if let Some(status) = c.try_wait()? {
                return Err(Error::SpeculosExited {
                    status,
                    stderr: snapshot_stderr(stderr_capture),
                });
            }
        }
        if Instant::now() > deadline {
            return Err(Error::LaunchTimeout);
        }
        if agent.get(&format!("http://{api}/events")).call().is_ok() {
            return Ok(());
        }
        std::thread::sleep(Duration::from_millis(100));
    }
}

fn snapshot_stderr(capture: &Arc<Mutex<Vec<u8>>>) -> String {
    capture
        .lock()
        .map(|c| String::from_utf8_lossy(&c).into_owned())
        .unwrap_or_default()
}

/// Read `SPECULOS_ELF` (required) plus optional `SPECULOS_APDU`,
/// `SPECULOS_API`, `SPECULOS_SEED`. Returns `(elf_path, options)`.
fn options_from_env() -> Result<(String, SpawnOptions)> {
    let elf =
        std::env::var("SPECULOS_ELF").map_err(|_| Error::EnvMissing("SPECULOS_ELF".into()))?;

    let apdu = match std::env::var("SPECULOS_APDU").ok() {
        Some(a) => Some(a.parse::<SocketAddr>().map_err(|e| Error::EnvInvalid {
            var: "SPECULOS_APDU".into(),
            reason: format!("{a}: {e}"),
        })?),
        None => None,
    };
    let api = match std::env::var("SPECULOS_API").ok() {
        Some(a) => Some(a.parse::<SocketAddr>().map_err(|e| Error::EnvInvalid {
            var: "SPECULOS_API".into(),
            reason: format!("{a}: {e}"),
        })?),
        None => None,
    };
    if apdu.is_some() != api.is_some() {
        return Err(Error::EnvInvalid {
            var: "SPECULOS_APDU/SPECULOS_API".into(),
            reason: "must both be set or both unset".into(),
        });
    }

    let seed = std::env::var("SPECULOS_SEED")
        .ok()
        .filter(|s| !s.is_empty());

    Ok((elf, SpawnOptions { seed, apdu, api }))
}

/// Bind an ephemeral port on localhost, drop the listener, and return the
/// address. Best-effort: the OS can theoretically hand the same port back
/// on a later call, and another process can bind the port in the window
/// between drop and speculos's own bind. `launch_with` retries on launch
/// failure to cover this; in practice the collision is rare enough that
/// we don't try to eliminate the window.
fn pick_free_port() -> Result<SocketAddr> {
    let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0))?;
    let addr = listener.local_addr()?;
    drop(listener);
    Ok(addr)
}

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

    #[test]
    fn model_parsing() {
        assert_eq!("nanox".parse::<Model>().unwrap(), Model::NanoX);
        assert_eq!("NANOSP".parse::<Model>().unwrap(), Model::NanoSP);
        assert_eq!("stax".parse::<Model>().unwrap(), Model::Stax);
        assert_eq!("Flex".parse::<Model>().unwrap(), Model::Flex);
        assert!("nano-x".parse::<Model>().is_err());
    }

    #[test]
    fn screen_sizes() {
        assert_eq!(Model::NanoX.screen_size(), (128, 64));
        assert_eq!(Model::Stax.screen_size(), (400, 672));
        assert_eq!(Model::Flex.screen_size(), (480, 600));
    }

    #[test]
    fn is_buttons() {
        assert!(Model::NanoX.is_buttons());
        assert!(Model::NanoSP.is_buttons());
        assert!(!Model::Stax.is_buttons());
        assert!(!Model::Flex.is_buttons());
    }

    #[test]
    fn pick_free_port_returns_distinct() {
        // Best-effort: the OS could in theory return the same port twice
        // since the first listener is dropped before the second bind.
        // Linux's allocator avoids this in practice and the assertion
        // has not been observed to fail; if it ever does, the launch
        // retry loop covers the real-world case anyway.
        let a = pick_free_port().unwrap();
        let b = pick_free_port().unwrap();
        assert_ne!(a.port(), b.port());
    }
}
