//! Screen-share integration: drive `pixelpass` as a child process.
//!
//! peerspeak owns voice; pixelpass owns pixels. The two are **never** Cargo
//! dependencies of each other — the contract is pixelpass's CLI flags plus its
//! `--output json` stdout event stream, treated as a stable public API. This
//! module spawns a pixelpass *host* (to share our screen) or a *viewer* (to
//! watch a peer's share), scrapes the JSON it needs, and otherwise stays out of
//! the way. Absence of the `pixelpass` binary is a normal, handled state — the
//! UI degrades to a disabled "install pixelpass" control rather than erroring.
//!
//! The only piece that travels between peers is the host's relay **ticket**:
//! peerspeak puts it on the sharer's presence (see `PeerState.sharing`) so the
//! room's existing gossip plane distributes it, and each viewer one-clicks it
//! into a local pixelpass viewer. The ticket *is* the capability, so this is
//! "click to grant the room access" with no ACL to fight.

use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;

use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};

use crate::audio::ownership;

pub mod livesync;

use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};

/// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";

#[cfg(windows)]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
    [dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
}

#[cfg(not(windows))]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
    [dir.join(PIXELPASS_BIN)]
}

/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;

/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;

/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;

/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
const STARTUP_TIMEOUT: Duration = Duration::from_secs(20);

/// One parsed line from pixelpass's `--output json` stdout stream. Mirrors the
/// `event` tags in pixelpass's `src/common/output.rs`. Recognized-but-unused
/// events collapse to [`PixelpassEvent::Other`]; blank or non-JSON lines parse
/// to `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PixelpassEvent {
    /// Host: the relay ticket a viewer needs. Emitted once at host startup.
    Ticket(String),
    /// Viewer: the local player URL is ready to open.
    Connected(String),
    /// Host: a viewer joined; carries the new active count and the cap.
    ViewerJoined { active: u32, max: u32 },
    /// Host: a viewer left.
    ViewerLeft { active: u32, max: u32 },
    /// Host: a viewer was turned away (host full or capture spawn failed).
    Refused(String),
    /// Host: capture pipeline spawned (on first viewer).
    CaptureStarted,
    /// Host: capture pipeline torn down (on last viewer).
    CaptureStopped,
    /// Host (per-app audio): the chosen app's audio is now reaching viewers.
    AppAudioRouted,
    /// Host (per-app audio): the chosen app's last audio stream went away. Under
    /// our `--strict-audio` run this means viewers now hear silence (not the call
    /// echo) until the app produces audio again — we surface it as a warning.
    AppAudioLost,
    /// A recognized event we don't act on (e.g. `host_info`).
    Other,
}

/// What the host's stdout drain forwards to the core over the notice channel.
///
/// `Eof` is **synthesized here**, not parsed: pixelpass has no "I died" event,
/// and a crash can abort across `extern "C"` before any JSON line is written,
/// so the stream ending is the only reliable death signal. A read *error*
/// counts too — either way the event stream is gone and the host must be
/// treated as over.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostNotice {
    /// A parsed pixelpass event line.
    Event(PixelpassEvent),
    /// The host's stdout ended (EOF or read error). Terminal: nothing follows.
    Eof,
}

/// Parse a single stdout line from pixelpass `--output json`. Pure: no I/O.
pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
    let line = line.trim();
    if line.is_empty() {
        return None;
    }
    let v: serde_json::Value = serde_json::from_str(line).ok()?;
    let event = v.get("event")?.as_str()?;
    let ev = match event {
        "ticket" => PixelpassEvent::Ticket(v.get("value")?.as_str()?.to_string()),
        "connected" => PixelpassEvent::Connected(v.get("url")?.as_str()?.to_string()),
        "viewer_joined" => PixelpassEvent::ViewerJoined {
            active: json_u32(&v, "active"),
            max: json_u32(&v, "max"),
        },
        "viewer_left" => PixelpassEvent::ViewerLeft {
            active: json_u32(&v, "active"),
            max: json_u32(&v, "max"),
        },
        "viewer_refused" => PixelpassEvent::Refused(
            v.get("reason")
                .and_then(|r| r.as_str())
                .unwrap_or("")
                .to_string(),
        ),
        "capture" => match v.get("state").and_then(|s| s.as_str()) {
            Some("started") => PixelpassEvent::CaptureStarted,
            Some("stopped") => PixelpassEvent::CaptureStopped,
            _ => PixelpassEvent::Other,
        },
        "app_audio" => match v.get("state").and_then(|s| s.as_str()) {
            Some("routed") => PixelpassEvent::AppAudioRouted,
            Some("lost") => PixelpassEvent::AppAudioLost,
            _ => PixelpassEvent::Other,
        },
        _ => PixelpassEvent::Other,
    };
    Some(ev)
}

fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
    v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
}

/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
/// captures only that app's audio instead of the whole desktop sink monitor
/// (which contains our own call playout → the viewer would hear themselves
/// echoed back, backlog A23).
///
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
/// without it, pixelpass falls back to the whole-desktop loopback before the
/// app's first stream routes and again if the app's audio later stops — both of
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
///
/// The name is passed in the single-token `--app=<name>` form so a value that
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
/// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (our own enumeration / the user's pick), not peer-supplied, but is still
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
pub fn host_args(
    audio_app: Option<&str>,
    settings: &ScreenShareSettings,
    quality: ShareQuality,
) -> Vec<String> {
    let mut args = vec![
        "--host".to_string(),
        "--output".to_string(),
        "json".to_string(),
    ];
    if let Some(name) = audio_app.and_then(sanitize_app_name) {
        args.push(format!("--app={name}"));
        args.push("--strict-audio".to_string());
    }
    if quality != ShareQuality::Auto {
        args.push(format!("--quality={}", pixelpass_quality(quality)));
    }
    if let Some(height) = settings.max_height {
        args.push(format!("--max-height={height}"));
    }
    if let Some(mbps) = settings.bitrate_mbps {
        args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
    }
    if let Some(fps) = settings.framerate {
        args.push(format!("--framerate={fps}"));
    }
    if settings.force_software_encode {
        args.push("--no-hwencode".to_string());
    }
    if let Some(max) = settings.max_viewers {
        args.push(format!("--max-viewers={max}"));
    }
    args.extend(split_extra_args(&settings.extra_host_args));
    args
}

fn pixelpass_quality(quality: ShareQuality) -> &'static str {
    match quality {
        ShareQuality::Auto => "auto",
        ShareQuality::Low => "low",
        ShareQuality::Medium => "medium",
        ShareQuality::High => "high",
        ShareQuality::Source => "source",
    }
}

/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
/// not depend on a shell lexer, so quoted values are not interpreted here.
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
    raw.split_whitespace().map(str::to_string)
}

/// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters
/// (newlines etc.) that have no place in a real `application.name`. `None` means
/// "no valid app selected" — the caller then shares the whole desktop audio.
pub fn sanitize_app_name(name: &str) -> Option<String> {
    let name = name.trim();
    let ok =
        !name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
    ok.then(|| name.to_string())
}

/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
/// failure: empty list → "All system audio" only.
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);

/// Enumerate the apps currently sending audio to a sink, deduplicated by
/// `application.name`. Mirrors how pixelpass itself builds its interactive
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
/// the ones `--app` matches against. Returns an empty list on any error (pactl
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
/// — a normal, handled state that leaves the picker showing only "All system
/// audio".
pub async fn list_audio_apps() -> Vec<String> {
    let run = Command::new("pactl")
        .args(["-f", "json", "list", "sink-inputs"])
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        // On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
        // the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
        // `pactl` can't linger/accumulate across picker opens (audit P3).
        .kill_on_drop(true)
        .output();
    match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
        Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
        _ => Vec::new(),
    }
}

/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
/// picker (safe), never a stalled core loop.
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);

/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
/// share always appends `--strict-audio`, and an **older** pixelpass would have
/// clap reject the unknown flag → the host spawn hard-fails and the share is
/// broken (audit P2, version skew). When unsupported the picker degrades to
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
/// would reintroduce the call echo (A23).
///
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
pub async fn supports_strict_audio(bin: &Path) -> bool {
    let run = Command::new(bin)
        .arg("--help")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true)
        .output();
    match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
        Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
        _ => false,
    }
}

/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
/// flag token rather than a whole line, since clap may wrap/realign help text.
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
    String::from_utf8_lossy(help_stdout).contains("--strict-audio")
}

/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
/// offers names that will actually survive [`host_args`]; otherwise a name that
/// parses here but fails sanitization later would be selectable yet silently
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
    let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
        return Vec::new();
    };
    let mut names: Vec<String> = entries
        .into_iter()
        .filter_map(|e| e.properties.application_name)
        .filter_map(|n| sanitize_app_name(&n))
        .collect();
    names.sort_unstable();
    names.dedup();
    names
}

#[derive(serde::Deserialize)]
struct SinkInput {
    properties: SinkInputProperties,
}

#[derive(serde::Deserialize)]
struct SinkInputProperties {
    #[serde(rename = "application.name")]
    application_name: Option<String>,
}

/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
/// rides gossip presence, which is untrusted and spoofable), so flags come first
/// and the ticket is passed as a positional **after a `--` end-of-options
/// guard**. That guard means a ticket beginning with `-`/`--` can never be
/// reinterpreted as a pixelpass flag — closing the argument-injection vector
/// (security finding S3). Pure: no I/O.
pub fn viewer_args(ticket: &str) -> Vec<String> {
    vec![
        "--output".to_string(),
        "json".to_string(),
        "--".to_string(),
        ticket.to_string(),
    ]
}

/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
pub fn sanitize_ticket(ticket: String) -> Option<String> {
    let ticket = ticket.trim();
    let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
    let valid_shape = ticket.starts_with("endpoint")
        && ticket.len() > "endpoint".len()
        && ticket.bytes().all(|b| b.is_ascii_alphanumeric());
    (valid_len && valid_shape).then(|| ticket.to_string())
}

/// Resolve the pixelpass binary: an explicit config override (used only if it
/// points at an existing file), otherwise the first `pixelpass` found on
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An
/// override that doesn't resolve falls through to the `$PATH` search rather than
/// failing outright.
pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
    if let Some(p) = config_override {
        let p = p.trim();
        if !p.is_empty() {
            let pb = PathBuf::from(p);
            if pb.is_file() {
                return Some(pb);
            }
            // Override set but missing — fall through to the $PATH search.
        }
    }
    let path_var = std::env::var_os("PATH")?;
    std::env::split_paths(&path_var)
        .flat_map(|dir| pixelpass_path_candidates(&dir))
        .find(|c| c.is_file())
}

/// Whether pixelpass is available to shell out to.
pub fn is_available(config_override: Option<&str>) -> bool {
    pixelpass_path(config_override).is_some()
}

/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
/// for its startup ticket, and return the live child plus the ticket. When
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. The drain
/// forwards every parsed event over `notices` and — the part no share may opt
/// out of — a terminal [`HostNotice::Eof`] when the stream ends, which is the
/// caller's only reliable signal that the host died. We do
/// not pass encode/viewer overrides unless the local settings explicitly ask for
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host(
    bin: &Path,
    audio_app: Option<&str>,
    settings: &ScreenShareSettings,
    quality: ShareQuality,
    notices: tokio::sync::mpsc::UnboundedSender<HostNotice>,
) -> std::io::Result<(Child, String)> {
    let args = host_args(audio_app, settings, quality);
    // Log the exact argv we hand pixelpass so a field log can confirm which
    // encode/quality flags (e.g. --bitrate) actually reached the host — these
    // are local flags with no ticket/secret, so logging them verbatim is safe.
    crate::log_msg(&format!(
        "pixelpass host spawn: {} {}",
        bin.display(),
        args.join(" ")
    ));
    let mut child = Command::new(bin)
        .args(&args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        // Capture stderr (not null): pixelpass prints its startup precondition
        // failures there — a missing GStreamer plugin / `pactl`, each with an
        // actionable "Install hint: sudo apt install ..." line. If the host dies
        // before its ticket we fold that tail into our error so the user sees
        // *what to install* instead of a dead-end "exited before a ticket". On
        // the success path we drain it in the background so the pipe can't fill.
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()?;

    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
    let stderr = child.stderr.take();
    let mut lines = BufReader::new(stdout).lines();

    let ticket = match read_until(&mut lines, |e| match e {
        PixelpassEvent::Ticket(t) => Some(t),
        _ => None,
    })
    .await
    {
        Ok(Some(t)) => t,
        Ok(None) => {
            let _ = child.kill().await;
            let detail = read_stderr_tail(stderr).await;
            return Err(std::io::Error::other(format!(
                "pixelpass host exited before emitting a ticket{detail}"
            )));
        }
        Err(e) => {
            let _ = child.kill().await;
            return Err(e);
        }
    };

    if let Some(stderr) = stderr {
        drain_stderr_in_background(stderr);
    }
    drain_in_background(lines, "host", Some(notices));
    Ok((child, ticket))
}

/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
/// string when stderr was already taken or carried nothing useful.
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
    use tokio::io::AsyncReadExt;
    let Some(mut stderr) = stderr else {
        return String::new();
    };
    let mut buf = Vec::new();
    let _ = stderr.read_to_end(&mut buf).await;
    pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
}

/// Discard a running pixelpass child's stderr in the background so its pipe
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
    use tokio::io::AsyncReadExt;
    tokio::spawn(async move {
        let mut buf = [0u8; 4096];
        while let Ok(n) = stderr.read(&mut buf).await {
            if n == 0 {
                break;
            }
        }
    });
}

/// Extract a human-useful tail from a failed pixelpass child's stderr to append
/// to our error. pixelpass writes actionable startup errors there (a missing
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
/// line), which is exactly what a freshly-installed host needs to see. The
/// decorative host banner (box-drawing) is dropped — it only prints on the
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
/// string when there's nothing worth surfacing (so callers can append blindly).
pub fn pixelpass_failure_detail(stderr: &str) -> String {
    let useful: Vec<&str> = stderr
        .lines()
        .map(str::trim_end)
        .filter(|l| !l.trim().is_empty())
        .filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
        .collect();
    if useful.is_empty() {
        return String::new();
    }
    // The anyhow error and its install hint are the *last* lines printed, so
    // keep the tail rather than the head.
    const MAX_LINES: usize = 12;
    let start = useful.len().saturating_sub(MAX_LINES);
    format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
}

/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
/// stream in a local player (mpv/VLC in the configured order, then fallback).
/// Returns the live viewer child so the caller can kill it on room-leave; it also
/// self-exits when the player window closes (its tunnel ends).
pub async fn spawn_viewer(
    bin: &Path,
    ticket: &str,
    settings: &ScreenShareSettings,
) -> std::io::Result<Child> {
    let mut child = Command::new(bin)
        .args(viewer_args(ticket))
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true)
        .spawn()?;

    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| std::io::Error::other("pixelpass viewer stdout missing"))?;
    let mut lines = BufReader::new(stdout).lines();

    let url = match read_until(&mut lines, |e| match e {
        PixelpassEvent::Connected(u) => Some(u),
        _ => None,
    })
    .await
    {
        Ok(Some(u)) => u,
        Ok(None) => {
            let _ = child.kill().await;
            return Err(std::io::Error::other(
                "pixelpass viewer exited before connecting",
            ));
        }
        Err(e) => {
            let _ = child.kill().await;
            return Err(e);
        }
    };

    if let Err(e) = launch_player(&url, settings) {
        let _ = child.kill().await;
        return Err(e);
    }

    drain_in_background(lines, "viewer", None);
    Ok(child)
}

/// Read JSON event lines until `pick` returns `Some(value)`. Returns `Ok(None)`
/// on EOF (child exited first) and `Err` on an I/O error or the startup timeout.
async fn read_until<R, T>(
    lines: &mut tokio::io::Lines<BufReader<R>>,
    mut pick: impl FnMut(PixelpassEvent) -> Option<T>,
) -> std::io::Result<Option<T>>
where
    R: tokio::io::AsyncRead + Unpin,
{
    loop {
        match tokio::time::timeout(STARTUP_TIMEOUT, lines.next_line()).await {
            Ok(Ok(Some(line))) => {
                if let Some(ev) = parse_pixelpass_event(&line)
                    && let Some(v) = pick(ev)
                {
                    return Ok(Some(v));
                }
            }
            Ok(Ok(None)) => return Ok(None),
            Ok(Err(e)) => return Err(e),
            Err(_) => {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::TimedOut,
                    "timed out waiting for pixelpass startup event",
                ));
            }
        }
    }
}

/// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
/// parsed event is also forwarded to the caller (the core), and when the stream
/// ends — EOF or read error, i.e. the child exited or its event stream broke —
/// a final [`HostNotice::Eof`] is sent so the caller learns the child is gone
/// (a host that dies must not stay advertised as sharing). A send failure
/// (receiver dropped) just stops forwarding, draining continues.
fn drain_in_background<R>(
    mut lines: tokio::io::Lines<BufReader<R>>,
    role: &'static str,
    notices: Option<tokio::sync::mpsc::UnboundedSender<HostNotice>>,
) where
    R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
    tokio::spawn(async move {
        while let Ok(Some(line)) = lines.next_line().await {
            if let Some(ev) = parse_pixelpass_event(&line) {
                crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
                if let Some(tx) = &notices {
                    let _ = tx.send(HostNotice::Event(ev));
                }
            }
        }
        if let Some(tx) = &notices {
            crate::log_msg(&format!("pixelpass {role}: stdout ended"));
            let _ = tx.send(HostNotice::Eof);
        }
    });
}

fn event_for_log(ev: &PixelpassEvent) -> String {
    match ev {
        PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
        PixelpassEvent::Connected(_) => "connected".to_string(),
        PixelpassEvent::ViewerJoined { active, max } => {
            format!("viewer_joined active={active} max={max}")
        }
        PixelpassEvent::ViewerLeft { active, max } => {
            format!("viewer_left active={active} max={max}")
        }
        PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
        PixelpassEvent::CaptureStarted => "capture_started".to_string(),
        PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
        PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
        PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
        PixelpassEvent::Other => "other".to_string(),
    }
}

/// Open the viewer stream URL in a media player, then fall back to vlc. The
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
    // One socket per viewer launch, so overlapping shares can't collide on it.
    // Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
    // speak, and an unusable socket path on the argv would help nobody.
    #[cfg(unix)]
    let ipc_socket = Some(livesync::socket_path(
        &livesync::socket_dir(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos() as u64)
            .unwrap_or(0),
    ));
    #[cfg(not(unix))]
    let ipc_socket: Option<PathBuf> = None;

    let mpv_args = mpv_args(settings, ipc_socket.as_deref());
    let vlc_args = vlc_args(settings);
    let first = match settings.player {
        SharePlayer::Mpv => ("mpv", &mpv_args),
        SharePlayer::Vlc => ("vlc", &vlc_args),
    };
    let second = match settings.player {
        SharePlayer::Mpv => ("vlc", &vlc_args),
        SharePlayer::Vlc => ("mpv", &mpv_args),
    };

    let (launched, child) = match spawn_player(first.0, first.1, url) {
        Ok(c) => (first.0, c),
        Err(_) => (
            second.0,
            spawn_player(second.0, second.1, url).map_err(|_| {
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "no media player found — install mpv or vlc to watch screen shares",
                )
            })?,
        ),
    };

    // Only when the socket actually reached the argv: mpv (VLC has no
    // equivalent IPC) in the Low latency posture. The driver ends by itself when
    // the player exits, so it needs no shutdown path.
    #[cfg(unix)]
    if launched == "mpv"
        && settings.buffering == ShareBuffering::LowLatency
        && let Some(socket) = ipc_socket
    {
        tokio::spawn(livesync::drive(socket));
    }
    #[cfg(not(unix))]
    let _ = launched;

    tokio::spawn(async move {
        let mut child = child;
        let _ = child.wait().await;
    });
    Ok(())
}

/// Build the argv for an mpv viewer.
///
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
/// fight on every poll.
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
    let mut args = Vec::new();
    match settings.buffering {
        ShareBuffering::LowLatency => {
            args.push("--profile=low-latency".to_string());
            // Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
            // lossy link turns every retransmission into buffered latency that
            // nothing trims back. `--untimed` does NOT fix that (measured
            // marginally worse: it only unpaces *presentation*, while audio
            // still drains at 1x, so the backlog never shrinks) — the viewer
            // instead drains it by playing slightly fast, see `livesync`.
            args.push("--audio-buffer=0.2".to_string());
            args.push("--demuxer-readahead-secs=0.5".to_string());
        }
        ShareBuffering::Smooth => {
            args.push("--cache=yes".to_string());
            args.push("--demuxer-readahead-secs=2".to_string());
        }
    }
    // The byte cap is what bounds how far behind a viewer can silently fall:
    // a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
    // measured on the netem rig) and call it a buffer. Low latency therefore
    // gets a tighter ceiling than the user's Smooth-oriented setting, so the
    // catch-up has less to claw back after a bad patch of link.
    let cache_mb = match settings.buffering {
        ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
        ShareBuffering::Smooth => settings.cache_mb,
    };
    args.push(format!("--demuxer-max-bytes={cache_mb}M"));
    if settings.hardware_decode {
        args.push("--hwdec=auto".to_string());
    }
    if let Some(socket) = ipc_socket
        && settings.buffering == ShareBuffering::LowLatency
    {
        args.push(format!("--input-ipc-server={}", socket.display()));
    }
    // Extra args stay last so a user override wins over everything above.
    args.extend(split_extra_args(&settings.extra_mpv_args));
    args
}

/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
/// that map cleanly onto its option set: the buffering posture (network/live
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
/// them as mpv-only. Pure: no I/O.
///
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
/// `--hwdec=auto`.
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
    let caching_ms = match settings.buffering {
        ShareBuffering::LowLatency => 200,
        ShareBuffering::Smooth => 1500,
    };
    let hw = if settings.hardware_decode {
        "--avcodec-hw=any"
    } else {
        "--avcodec-hw=none"
    };
    vec![
        format!("--network-caching={caching_ms}"),
        format!("--live-caching={caching_ms}"),
        hw.to_string(),
    ]
}

fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
    // Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
    // --avcodec-hw) so a field log can confirm the viewer settings reached the
    // player. The `url` is omitted deliberately — it is the local stream address
    // and is not needed to verify the flags. Logged on each attempt, so a
    // fallback from the preferred player to the other one is visible too.
    crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
    let mut command = Command::new(bin);
    command
        .args(args)
        .arg(url)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .kill_on_drop(false);
    // Ownership tag (plan §5.1): this player is playing the *incoming*
    // screenshare's audio, so it is exactly what must not be fanned back out
    // if this machine also starts sharing. The role is the player binary, so
    // a `pw-dump` during a field test names which one produced the node.
    ownership::tag_child(command.as_std_mut(), bin);
    command.spawn()
}

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

    /// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
    /// actually spawns produces a live node carrying **both** ownership
    /// carriers, tagged with the player's own name as the role.
    ///
    /// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
    /// gate does: the plan requires the tag to be shown "landing on a live
    /// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
    ///
    /// Live: needs PipeWire, `mpv` and `pw-dump`.
    /// `cargo test --lib -- --ignored spawned_player`
    #[tokio::test]
    #[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
    async fn spawned_player_node_carries_both_ownership_carriers() {
        use crate::audio::ownership::live_test;

        let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("silence.wav");
        std::fs::write(&path, live_test::silent_wav(6)).unwrap();

        let mut child = spawn_player(
            "mpv",
            &["--no-video".to_string(), "--really-quiet".to_string()],
            path.to_str().unwrap(),
        )
        .expect("mpv spawns");

        // The role is the player binary, so this also pins that the call site
        // passes `bin` and not a fixed literal.
        let prefix = live_test::expected_prefix("mpv");
        let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
        let _ = child.kill().await;
        std::fs::remove_dir_all(&dir).ok();

        let (name, owned) =
            found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
        assert!(
            name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
            "{name}"
        );
        assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
    }

    #[test]
    fn viewer_args_guard_neutralizes_flag_like_ticket() {
        // A malicious "ticket" that looks like a flag must end up positional,
        // i.e. after the `--` end-of-options guard, never parsed as a flag.
        let args = viewer_args("--malicious-flag");
        assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
        let guard = args
            .iter()
            .position(|a| a == "--")
            .expect("`--` guard present");
        let ticket = args.len() - 1;
        assert!(guard < ticket, "ticket must follow the `--` guard");
        // The real flags are parsed before the guard.
        let out = args.iter().position(|a| a == "--output").unwrap();
        assert!(out < guard, "flags precede the `--` guard");
    }

    #[test]
    fn viewer_args_ordinary_ticket() {
        assert_eq!(
            viewer_args("abc123"),
            vec!["--output", "json", "--", "abc123"]
        );
    }

    #[test]
    fn host_args_without_app_shares_whole_desktop() {
        // No app selected → no --app flag → pixelpass keeps its default
        // (whole-desktop) audio capture.
        let settings = ScreenShareSettings::default();
        assert_eq!(
            host_args(None, &settings, ShareQuality::Auto),
            vec!["--host", "--output", "json"]
        );
    }

    #[test]
    fn host_args_with_app_appends_single_token_flag() {
        // The chosen app rides in the `--app=<name>` single-token form so a
        // name beginning with `-` can never be reparsed as a flag (A23), plus
        // `--strict-audio` so pixelpass never falls back to whole-desktop audio.
        let settings = ScreenShareSettings::default();
        assert_eq!(
            host_args(Some("Firefox"), &settings, ShareQuality::Auto),
            vec![
                "--host",
                "--output",
                "json",
                "--app=Firefox",
                "--strict-audio"
            ]
        );
        // The hyphen-leading name is still bound to --app as a single token;
        // --strict-audio is the trailing flag.
        let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
        assert_eq!(args[3], "--app=-rm -rf");
        assert_eq!(args[4], "--strict-audio");
    }

    #[test]
    fn host_args_blank_or_control_app_is_dropped() {
        // An empty / whitespace / control-laden selection is sanitized away,
        // falling back to whole-desktop capture rather than a broken flag.
        let settings = ScreenShareSettings::default();
        assert_eq!(
            host_args(Some("   "), &settings, ShareQuality::Auto),
            vec!["--host", "--output", "json"]
        );
        assert_eq!(
            host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
            vec!["--host", "--output", "json"]
        );
    }

    #[test]
    fn host_args_apply_screen_share_settings_and_extra_args_last() {
        let settings = ScreenShareSettings {
            bitrate_mbps: Some(5),
            framerate: Some(60),
            max_height: Some(1080),
            max_viewers: Some(4),
            force_software_encode: true,
            extra_host_args: "--relay https://relay.example --verbose".to_string(),
            ..ScreenShareSettings::default()
        };

        assert_eq!(
            host_args(Some("Firefox"), &settings, ShareQuality::High),
            vec![
                "--host",
                "--output",
                "json",
                "--app=Firefox",
                "--strict-audio",
                "--quality=high",
                "--max-height=1080",
                "--bitrate=5000",
                "--framerate=60",
                "--no-hwencode",
                "--max-viewers=4",
                "--relay",
                "https://relay.example",
                "--verbose",
            ]
        );
    }

    #[test]
    fn mpv_args_default_matches_low_latency_software_decode() {
        assert_eq!(
            mpv_args(&ScreenShareSettings::default(), None),
            vec![
                "--profile=low-latency",
                "--audio-buffer=0.2",
                "--demuxer-readahead-secs=0.5",
                "--demuxer-max-bytes=1M",
            ]
        );
    }

    #[test]
    fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
        let args = mpv_args(
            &ScreenShareSettings::default(),
            Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
        );
        assert!(
            args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
            "low latency drains a lagging buffer over mpv IPC: {args:?}"
        );
        // The flag that used to hold this posture at the live edge measured no
        // better than pacing, and cost A/V sync — it must not come back.
        assert!(!args.contains(&"--untimed".to_string()));
    }

    #[test]
    fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
        let settings = ScreenShareSettings {
            buffering: ShareBuffering::Smooth,
            ..ScreenShareSettings::default()
        };
        let args = mpv_args(
            &settings,
            Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
        );
        assert!(
            !args.iter().any(|a| a.starts_with("--input-ipc-server")),
            "catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
        );
    }

    #[test]
    fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
        // The cache is a byte budget, so at a given bitrate it sets the
        // worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
        let generous = ScreenShareSettings {
            cache_mb: 32,
            ..ScreenShareSettings::default()
        };
        assert!(
            mpv_args(&generous, None)
                .contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
            "low latency must bound how far behind the viewer can silently fall"
        );

        let smooth = ScreenShareSettings {
            cache_mb: 32,
            buffering: ShareBuffering::Smooth,
            ..ScreenShareSettings::default()
        };
        assert!(
            mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
            "smooth is the posture where the user asked for a deep buffer"
        );
    }

    #[test]
    fn user_extra_args_still_come_last() {
        let settings = ScreenShareSettings {
            extra_mpv_args: "--no-osc".to_string(),
            ..ScreenShareSettings::default()
        };
        let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
        assert_eq!(
            args.last().map(String::as_str),
            Some("--no-osc"),
            "a user override has to win over everything we add: {args:?}"
        );
    }

    #[test]
    fn mpv_args_smooth_hwdecode_and_extra_args_last() {
        let settings = ScreenShareSettings {
            hardware_decode: true,
            buffering: ShareBuffering::Smooth,
            cache_mb: 16,
            extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
            ..ScreenShareSettings::default()
        };

        assert_eq!(
            mpv_args(&settings, None),
            vec![
                "--cache=yes",
                "--demuxer-readahead-secs=2",
                "--demuxer-max-bytes=16M",
                "--hwdec=auto",
                "--no-osc",
                "--vd-lavc-threads=2",
            ]
        );
    }

    #[test]
    fn vlc_args_default_disables_hardware_decode() {
        // The A-bug fix default (hardware_decode = false) must reach VLC too:
        // VLC hardware-decodes by default, so without an explicit
        // `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
        // the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
        assert_eq!(
            vlc_args(&ScreenShareSettings::default()),
            vec![
                "--network-caching=200",
                "--live-caching=200",
                "--avcodec-hw=none",
            ]
        );
    }

    #[test]
    fn vlc_args_smooth_buffering_and_hwdecode() {
        // Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
        // buffering raises the network/live caches. cache_mb / extra_mpv_args are
        // mpv-only and must NOT leak into the VLC argv.
        let settings = ScreenShareSettings {
            hardware_decode: true,
            buffering: ShareBuffering::Smooth,
            cache_mb: 16,
            extra_mpv_args: "--no-osc".to_string(),
            ..ScreenShareSettings::default()
        };
        assert_eq!(
            vlc_args(&settings),
            vec![
                "--network-caching=1500",
                "--live-caching=1500",
                "--avcodec-hw=any",
            ]
        );
    }

    #[test]
    fn sanitize_app_name_trims_and_rejects_garbage() {
        assert_eq!(
            sanitize_app_name("  Firefox \n"),
            Some("Firefox".to_string())
        );
        assert_eq!(sanitize_app_name(""), None);
        assert_eq!(sanitize_app_name("   "), None);
        assert_eq!(sanitize_app_name("a\tb"), None);
        assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
    }

    #[test]
    fn parse_audio_apps_dedups_and_sorts_by_application_name() {
        let stdout = br#"[
            {"index":1,"properties":{"application.name":"Firefox"}},
            {"index":2,"properties":{"application.name":"mpv"}},
            {"index":3,"properties":{"application.name":"Firefox"}},
            {"index":4,"properties":{"application.name":"  Spotify  "}},
            {"index":5,"properties":{"application.name":""}},
            {"index":6,"properties":{"other":"no name here"}}
        ]"#;
        assert_eq!(
            parse_audio_apps(stdout),
            vec![
                "Firefox".to_string(),
                "Spotify".to_string(),
                "mpv".to_string()
            ]
        );
    }

    #[test]
    fn parse_audio_apps_empty_or_garbage_is_empty() {
        assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
        assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
        assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
    }

    #[test]
    fn parse_audio_apps_drops_names_host_args_would_reject() {
        // Names that parse from pactl but fail `sanitize_app_name` (control chars,
        // overlong) must NOT be offered in the picker — otherwise the user could
        // pick one, `host_args` would silently drop `--app`, and the share would
        // revert to whole-desktop audio (A23 echo) with no signal. The valid name
        // survives; the control-char and overlong ones are filtered out.
        let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
        let stdout = format!(
            r#"[
                {{"index":1,"properties":{{"application.name":"mpv"}}}},
                {{"index":2,"properties":{{"application.name":"bad\nname"}}}},
                {{"index":3,"properties":{{"application.name":"{overlong}"}}}}
            ]"#
        );
        assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
    }

    #[test]
    fn failure_detail_surfaces_install_hint_and_drops_banner() {
        // The real shape of a fresh-host failure: anyhow error + install hint on
        // stderr. We must keep those (so the user knows what to apt install) and
        // drop the decorative banner box-drawing lines.
        let stderr = "\
┌─ PixelPass · host ─────────────────────────────────────────
│  display server : Wayland
└────────────────────────────────────────────────────────────
Error: GStreamer element `vah264enc` not available.
Install hint: sudo apt install gstreamer1.0-plugins-bad
";
        let detail = pixelpass_failure_detail(stderr);
        assert!(detail.starts_with("\n\npixelpass reported:\n"));
        assert!(detail.contains("vah264enc` not available"));
        assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
        assert!(!detail.contains('│'), "banner box-drawing must be dropped");
        assert!(!detail.contains('┌'));
    }

    #[test]
    fn failure_detail_empty_when_nothing_useful() {
        // Blank / banner-only stderr yields an empty string so the caller can
        // append it to the base message unconditionally without trailing noise.
        assert_eq!(pixelpass_failure_detail(""), "");
        assert_eq!(pixelpass_failure_detail("   \n  \n"), "");
        assert_eq!(
            pixelpass_failure_detail("│  display server : Wayland\n│  capture : x\n"),
            ""
        );
    }

    #[test]
    fn failure_detail_keeps_only_the_tail() {
        // A long stderr is truncated to its last lines (where the real error
        // and hint live), not its head.
        let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
        let detail = pixelpass_failure_detail(&body);
        assert!(detail.contains("line 29"));
        assert!(!detail.contains("line 0\n"));
    }

    #[test]
    fn help_probe_detects_strict_audio_flag() {
        // A new pixelpass advertises the flag; an old one doesn't. The probe must
        // match the token even when clap wraps the option onto its own line.
        let new_help = b"Options:\n      --app <APP>\n      --strict-audio\n          With --app, never fall back...";
        assert!(help_mentions_strict_audio(new_help));
        let old_help = b"Options:\n      --app <APP>\n      --output <OUTPUT>\n  -h, --help";
        assert!(!help_mentions_strict_audio(old_help));
        // Garbage / empty output degrades to "unsupported" (safe path).
        assert!(!help_mentions_strict_audio(b""));
        assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
    }

    #[test]
    fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
        let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
        assert_eq!(
            sanitize_ticket(format!("  {ticket}\n")),
            Some(ticket.to_string())
        );
    }

    #[test]
    fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
        assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
        assert_eq!(
            sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))),
            None
        );
        assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
    }

    #[test]
    fn event_log_redacts_ticket_values() {
        let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
        let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
        assert!(log.contains("endpoint"));
        assert!(!log.contains(&ticket["endpoint".len() + 8..]));
    }

    #[test]
    fn parses_ticket() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"ticket","value":"abc123"}"#),
            Some(PixelpassEvent::Ticket("abc123".to_string()))
        );
    }

    #[test]
    fn parses_connected_url() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
            Some(PixelpassEvent::Connected(
                "http://127.0.0.1:5500".to_string()
            ))
        );
    }

    #[test]
    fn parses_viewer_joined_and_left() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"viewer_joined","id":"x","active":2,"max":4}"#),
            Some(PixelpassEvent::ViewerJoined { active: 2, max: 4 })
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"viewer_left","id":"x","active":1,"max":4}"#),
            Some(PixelpassEvent::ViewerLeft { active: 1, max: 4 })
        );
    }

    #[test]
    fn viewer_counts_default_to_zero_when_absent() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"viewer_joined"}"#),
            Some(PixelpassEvent::ViewerJoined { active: 0, max: 0 })
        );
    }

    #[test]
    fn parses_refused_with_and_without_reason() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"viewer_refused","reason":"host is full"}"#),
            Some(PixelpassEvent::Refused("host is full".to_string()))
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"viewer_refused"}"#),
            Some(PixelpassEvent::Refused("".to_string()))
        );
    }

    #[test]
    fn parses_capture_states() {
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"capture","state":"started"}"#),
            Some(PixelpassEvent::CaptureStarted)
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"capture","state":"stopped"}"#),
            Some(PixelpassEvent::CaptureStopped)
        );
        // Unknown capture state is recognized-but-unused, not a parse failure.
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"capture","state":"paused"}"#),
            Some(PixelpassEvent::Other)
        );
    }

    #[test]
    fn parses_app_audio_states() {
        // The wire contract from pixelpass's --strict-audio run (A23): routed =
        // the chosen app's audio is live; lost = it stopped (viewers now silent).
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
            Some(PixelpassEvent::AppAudioRouted)
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
            Some(PixelpassEvent::AppAudioLost)
        );
        // Unknown / missing state is recognized-but-unused, not a parse failure.
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
            Some(PixelpassEvent::Other)
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"app_audio"}"#),
            Some(PixelpassEvent::Other)
        );
    }

    #[test]
    fn recognized_but_unused_event_is_other() {
        assert_eq!(
            parse_pixelpass_event(
                r#"{"event":"host_info","display_server":"wayland","max_viewers":4}"#
            ),
            Some(PixelpassEvent::Other)
        );
        assert_eq!(
            parse_pixelpass_event(r#"{"event":"some_future_event"}"#),
            Some(PixelpassEvent::Other)
        );
    }

    #[test]
    fn blank_and_non_json_lines_are_none() {
        assert_eq!(parse_pixelpass_event(""), None);
        assert_eq!(parse_pixelpass_event("   "), None);
        assert_eq!(parse_pixelpass_event("not json at all"), None);
        // Valid JSON but not an event object.
        assert_eq!(parse_pixelpass_event("[1,2,3]"), None);
        assert_eq!(parse_pixelpass_event(r#"{"no_event":"here"}"#), None);
    }

    #[test]
    fn ticket_without_value_is_none() {
        // A malformed ticket event (missing `value`) must not panic.
        assert_eq!(parse_pixelpass_event(r#"{"event":"ticket"}"#), None);
    }

    #[test]
    fn surrounding_whitespace_is_tolerated() {
        assert_eq!(
            parse_pixelpass_event("  {\"event\":\"ticket\",\"value\":\"t\"}\n"),
            Some(PixelpassEvent::Ticket("t".to_string()))
        );
    }

    #[test]
    fn path_override_to_existing_file_is_used() {
        // A real file (this source file) stands in for a custom binary location.
        let this_file = file!();
        let resolved = pixelpass_path(Some(this_file));
        assert_eq!(resolved.as_deref(), Some(Path::new(this_file)));
        assert!(is_available(Some(this_file)));
    }

    #[test]
    fn empty_override_falls_through() {
        // An empty/whitespace override is ignored (falls through to $PATH); we
        // only assert it doesn't return the empty path as a match.
        assert_ne!(pixelpass_path(Some("   ")).as_deref(), Some(Path::new("")));
    }

    #[test]
    fn pixelpass_path_candidates_are_platform_specific() {
        let dir = Path::new("bin");
        let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
        #[cfg(windows)]
        assert_eq!(
            candidates,
            vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]
        );
        #[cfg(not(windows))]
        assert_eq!(candidates, vec![dir.join("pixelpass")]);
    }

    /// The host-fault contract, clean-exit half: events are forwarded in order
    /// and the stream ending yields exactly one terminal [`HostNotice::Eof`],
    /// after which the drain task drops its sender (the closed channel is what
    /// ends the core's forwarder). A host that dies silently — EOF swallowed —
    /// is the S2 defect: the dead share stays advertised in presence.
    #[tokio::test]
    async fn drain_forwards_events_then_synthesizes_eof_when_stdout_ends() {
        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        let (read_half, mut write_half) = tokio::io::duplex(1024);
        drain_in_background(BufReader::new(read_half).lines(), "test", Some(tx));

        use tokio::io::AsyncWriteExt;
        write_half
            .write_all(b"{\"event\":\"app_audio\",\"state\":\"routed\"}\nnot json\n")
            .await
            .unwrap();
        drop(write_half); // child exited: stdout EOF

        assert_eq!(
            rx.recv().await,
            Some(HostNotice::Event(PixelpassEvent::AppAudioRouted))
        );
        // The non-JSON line is dropped, not forwarded.
        assert_eq!(rx.recv().await, Some(HostNotice::Eof));
        assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
    }

    /// The host-fault contract, broken-stream half: a read *error* (not a tidy
    /// EOF) must synthesize the same terminal `Eof` — the event stream is gone
    /// either way, and only the drain task can tell the core so.
    #[tokio::test]
    async fn drain_synthesizes_eof_on_a_read_error_too() {
        struct BrokenPipe;
        impl tokio::io::AsyncRead for BrokenPipe {
            fn poll_read(
                self: std::pin::Pin<&mut Self>,
                _cx: &mut std::task::Context<'_>,
                _buf: &mut tokio::io::ReadBuf<'_>,
            ) -> std::task::Poll<std::io::Result<()>> {
                std::task::Poll::Ready(Err(std::io::Error::other("stream broke")))
            }
        }

        use tokio::io::AsyncReadExt;
        // One good event line, then the stream breaks mid-read.
        let reader =
            std::io::Cursor::new(b"{\"event\":\"capture\",\"state\":\"started\"}\n".to_vec())
                .chain(BrokenPipe);
        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        drain_in_background(BufReader::new(reader).lines(), "test", Some(tx));

        assert_eq!(
            rx.recv().await,
            Some(HostNotice::Event(PixelpassEvent::CaptureStarted))
        );
        assert_eq!(rx.recv().await, Some(HostNotice::Eof));
        assert_eq!(rx.recv().await, None, "task ended and dropped the sender");
    }
}
