//! S2 exit gate: a pixelpass host that dies mid-share must be torn down —
//! reaped, pulled off presence, `ScreenShareStopped` emitted **before** the
//! explanatory error — and a host stopped *deliberately* must NOT produce that
//! error when its stdout EOF arrives late (the staleness gate).
//!
//! Drives the real core loop end to end through `CoreController`, with the
//! pixelpass override pointed at fake shell scripts: one that emits a ticket
//! and dies, one that emits a ticket and lives until signalled. This is the
//! only harness that reaches the core's fault handler — the command loop has
//! no unit seam — so these two halves are what kill the "forwarder drops the
//! Eof" and "handler ignores the generation" mutants.
//!
//! Live: joins a real (solo) room, so it needs a working audio backend and
//! network access for the endpoint bind.
//! `cargo test --test screenshare_host_fault -- --ignored`

#![cfg(unix)]

use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::Duration;

use peerspeak::core::CoreController;
use peerspeak::core::messages::{CoreCommand, UiEvent};

const EVENT_TIMEOUT: Duration = Duration::from_secs(20);
/// How long to listen for events that must NOT arrive. Comfortably past the
/// fake host's exit plus the drain/forwarder hop, so a stale fault that WOULD
/// be mishandled has arrived by the end of it.
const QUIET_WINDOW: Duration = Duration::from_secs(3);

/// Removes the fake-pixelpass dir even when an assertion panics mid-test
/// (a plain trailing `remove_dir_all` never runs on an unwind).
struct TempDir(PathBuf);

impl Drop for TempDir {
    fn drop(&mut self) {
        std::fs::remove_dir_all(&self.0).ok();
    }
}

fn write_fake_pixelpass(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
    let path = dir.join(name);
    std::fs::write(&path, body).expect("write fake pixelpass");
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
        .expect("chmod fake pixelpass");
    path
}

/// Skip events until `pick` matches, panicking after [`EVENT_TIMEOUT`].
/// Unrelated events (identity, presence, chat plumbing) flow on this channel
/// too, so gates scan rather than assert exact sequences.
async fn wait_for<T>(
    rx: &mut tokio::sync::mpsc::Receiver<UiEvent>,
    what: &str,
    mut pick: impl FnMut(&UiEvent) -> Option<T>,
) -> T {
    let deadline = tokio::time::Instant::now() + EVENT_TIMEOUT;
    loop {
        let ev = tokio::time::timeout_at(deadline, rx.recv())
            .await
            .unwrap_or_else(|_| panic!("timed out waiting for {what}"))
            .unwrap_or_else(|| panic!("ui channel closed waiting for {what}"));
        if let Some(v) = pick(&ev) {
            return v;
        }
    }
}

#[tokio::test]
#[ignore = "live: joins a real solo room (audio backend + network bind)"]
async fn a_dead_host_is_torn_down_and_a_clean_stop_stays_clean() {
    let dir_guard =
        TempDir(std::env::temp_dir().join(format!("peerspeak-hostfault-{}", std::process::id())));
    let dir = dir_guard.0.clone();
    std::fs::create_dir_all(&dir).unwrap();

    // Half 1's host: emits its ticket, then dies on its own — the S2 defect
    // scenario. Plain `sleep` (no exec) so the shell itself exits and closes
    // stdout with no orphan holding the pipe.
    let dying_host = write_fake_pixelpass(
        &dir,
        "pixelpass-dies",
        "#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-dies\"}'\nsleep 1\n",
    );
    // Half 2's host: lives until signalled. `exec` so the SIGINT from Stop
    // Share hits the sleep itself — the process dies AND its stdout closes,
    // which is exactly what makes the late Eof arrive and exercise the
    // staleness gate rather than vacuously never sending a fault.
    let living_host = write_fake_pixelpass(
        &dir,
        "pixelpass-lives",
        "#!/bin/sh\necho '{\"event\":\"ticket\",\"value\":\"fake-ticket-lives\"}'\nexec sleep 600\n",
    );

    let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
    let controller = CoreController::new(ui_tx);

    assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
        dying_host.to_string_lossy().into_owned()
    ))));
    assert!(controller.send(CoreCommand::Join {
        name: "host-fault-gate".into(),
        ticket: "create".into(),
        room_name: "s2".into(),
        input_device: None,
        output_device: None,
        echo_cancellation: false,
        avatar: Default::default(),
    }));
    wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
        UiEvent::RoomJoined { .. } => Some(()),
        UiEvent::Error(e) => panic!("join failed: {e}"),
        _ => None,
    })
    .await;

    // ── Half 1: the host dies mid-share ─────────────────────────────────────
    assert!(controller.send(CoreCommand::StartScreenShare {
        audio_app: None,
        settings: Default::default(),
        quality: Default::default(),
    }));
    wait_for(
        &mut ui_rx,
        "ScreenShareStarted (dying host)",
        |ev| match ev {
            UiEvent::ScreenShareStarted => Some(()),
            UiEvent::Error(e) => panic!("share start failed: {e}"),
            _ => None,
        },
    )
    .await;

    // The fake host exits ~1s in. The contract: ScreenShareStopped FIRST (it
    // clears the UI's sharing state), the explanatory error only after.
    wait_for(&mut ui_rx, "ScreenShareStopped after host death", |ev| {
        match ev {
            UiEvent::ScreenShareStopped => Some(()),
            // An error arriving first is the exact ordering defect S2 fixes:
            // the UI would show "sharing" next to the explanation.
            UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
            _ => None,
        }
    })
    .await;
    let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
        UiEvent::Error(e) => Some(e.clone()),
        _ => None,
    })
    .await;
    assert!(
        err.contains("unexpectedly"),
        "the error should say the share ended unexpectedly, got: {err}"
    );

    // ── Half 2: a deliberate stop must stay clean ───────────────────────────
    assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
        living_host.to_string_lossy().into_owned()
    ))));
    assert!(controller.send(CoreCommand::StartScreenShare {
        audio_app: None,
        settings: Default::default(),
        quality: Default::default(),
    }));
    wait_for(
        &mut ui_rx,
        "ScreenShareStarted (living host)",
        |ev| match ev {
            UiEvent::ScreenShareStarted => Some(()),
            UiEvent::Error(e) => panic!("second share start failed: {e}"),
            _ => None,
        },
    )
    .await;

    assert!(controller.send(CoreCommand::StopScreenShare));
    wait_for(
        &mut ui_rx,
        "ScreenShareStopped after Stop Share",
        |ev| match ev {
            UiEvent::ScreenShareStopped => Some(()),
            UiEvent::Error(e) => panic!("clean stop produced an error: {e}"),
            _ => None,
        },
    )
    .await;

    // The stopped host's stdout EOF is arriving about now as a *stale* fault
    // (its generation was retired when Stop Share cleared the share). Without
    // the staleness gate the handler would emit a second ScreenShareStopped
    // and a spurious "ended unexpectedly" error — listen long enough for that
    // mishandling to have shown up, and require silence.
    let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
    while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
        match ev {
            UiEvent::ScreenShareStopped => {
                panic!("stale host fault re-emitted ScreenShareStopped after a clean stop")
            }
            UiEvent::Error(e) if e.contains("unexpectedly") => {
                panic!("stale host fault surfaced as an error after a clean stop: {e}")
            }
            _ => {}
        }
    }

    // ── Half 3: a failed room switch while sharing must not cry "crash" ─────
    // Join tears the old session down (killing the host, deliberately) BEFORE
    // it validates the ticket, so an invalid ticket exits the Join arm early.
    // The share must be retired at the teardown itself — left advertised, the
    // killed host's EOF passes the staleness gate and a spurious "ended
    // unexpectedly" lands on top of the ticket error (Gemini review, P2-1).
    assert!(controller.send(CoreCommand::StartScreenShare {
        audio_app: None,
        settings: Default::default(),
        quality: Default::default(),
    }));
    wait_for(
        &mut ui_rx,
        "ScreenShareStarted (before failed switch)",
        |ev| match ev {
            UiEvent::ScreenShareStarted => Some(()),
            UiEvent::Error(e) => panic!("third share start failed: {e}"),
            _ => None,
        },
    )
    .await;
    assert!(controller.send(CoreCommand::Join {
        name: "host-fault-gate".into(),
        ticket: "definitely-not-a-ticket".into(),
        room_name: "s2".into(),
        input_device: None,
        output_device: None,
        echo_cancellation: false,
        avatar: Default::default(),
    }));
    wait_for(&mut ui_rx, "the invalid-ticket error", |ev| match ev {
        UiEvent::Error(e) if e.contains("invalid room ticket") => Some(()),
        UiEvent::Error(e) => panic!("unexpected error before the ticket error: {e}"),
        _ => None,
    })
    .await;
    // The deliberately-killed host's EOF is arriving about now; it must be
    // dropped as stale, not reported as a crash.
    let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
    while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
        match ev {
            UiEvent::ScreenShareStopped => {
                panic!("failed room switch re-emitted ScreenShareStopped for the torn-down share")
            }
            UiEvent::Error(e) if e.contains("unexpectedly") => {
                panic!("deliberate teardown during a failed room switch reported as a crash: {e}")
            }
            _ => {}
        }
    }
}

/// S2 presence gate: a host fault must pull the share ticket off PRESENCE —
/// what remote peers actually see — and must do it BEFORE the reap wait, not
/// after. Nothing on the sharer's own `UiEvent` channel can witness either
/// half (presence is only observable from another node), so this test runs a
/// real second core as an OBSERVER and asserts the sharer's `PeerState.sharing`
/// goes `Some` → `None` on fault.
///
/// The observer runs in a SEPARATE PROCESS (`presence_probe_helper`, this same
/// test binary re-invoked): two in-process cores would load the same
/// `identity.key` and collapse into one node id, and swapping `XDG_CONFIG_HOME`
/// between spawns in-process races other threads' getenv.
///
/// The fake host is a WEDGE — it closes stdout (the fault) but ignores SIGINT
/// and lives until the SIGKILL fallback — so `stop_host` burns the full 2 s
/// grace and TIME becomes the discriminator, exactly like the SIGINT gate:
/// with presence-removal-first the observer sees the ticket clear ~1 s after
/// it appeared (the wedge's pre-fault lifetime); with the old
/// reap-then-presence ordering, only after ~3 s. The bound also makes the
/// "presence removal deleted" mutant fail by timeout instead of passing
/// vacuously.
///
/// Live: two real solo-room cores (audio backend + network bind each).
#[tokio::test]
#[ignore = "live: two real cores in one room (audio backend + network bind), observer subprocess"]
async fn a_host_fault_pulls_the_ticket_off_presence_within_the_grace() {
    /// Mirrors `core::teardown::STOP_GRACE` (private): the wait the wedge
    /// forces before the SIGKILL fallback reaps it.
    const STOP_GRACE_MS: u128 = 2000;

    let dir_guard = TempDir(
        std::env::temp_dir().join(format!("peerspeak-presence-gate-{}", std::process::id())),
    );
    let dir = dir_guard.0.clone();
    std::fs::create_dir_all(&dir).unwrap();

    // Emits its ticket, shares for ~1 s, then closes stdout (the fault) while
    // staying alive and ignoring SIGINT, so the reap must wait out the grace.
    // The trailing sleep is NOT exec'd on purpose: it forks after stdout is
    // closed, so it holds no pipe (the vacuous-staleness trap doesn't apply),
    // and it merely idles out after the SIGKILL reaps the shell.
    //
    // The fake ticket must pass `screenshare::sanitize_ticket` (`endpoint` +
    // alphanumerics): the OBSERVER's gossip ingest sanitizes peer-advertised
    // tickets, and a garbage one is nulled to `sharing: None` there — the
    // probe would never see the share appear and the gate would go vacuous.
    let wedged_host = write_fake_pixelpass(
        &dir,
        "pixelpass-wedges",
        "#!/bin/sh\ntrap '' INT\n\
         echo '{\"event\":\"ticket\",\"value\":\"endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm\"}'\n\
         sleep 1\nexec 1>&-\nsleep 30\n",
    );

    let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
    let controller = CoreController::new(ui_tx);

    assert!(controller.send(CoreCommand::SetPixelpassPath(Some(
        wedged_host.to_string_lossy().into_owned()
    ))));
    assert!(controller.send(CoreCommand::Join {
        name: "presence-gate".into(),
        ticket: "create".into(),
        room_name: "s2-presence".into(),
        input_device: None,
        output_device: None,
        echo_cancellation: false,
        avatar: Default::default(),
    }));
    let room_ticket = wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
        UiEvent::RoomJoined { ticket, .. } => Some(ticket.clone()),
        UiEvent::Error(e) => panic!("join failed: {e}"),
        _ => None,
    })
    .await;

    // The observer, in its own process with its own config dir (fresh
    // identity). It prints `PROBE …` lines this test parses.
    let probe_config = dir.join("probe-config");
    std::fs::create_dir_all(&probe_config).unwrap();
    let probe = tokio::process::Command::new(std::env::current_exe().unwrap())
        .kill_on_drop(true)
        .args([
            "presence_probe_helper",
            "--exact",
            "--ignored",
            "--nocapture",
        ])
        .env("PEERSPEAK_PROBE_TICKET", &room_ticket)
        .env("XDG_CONFIG_HOME", &probe_config)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("spawn the presence probe");

    // Only share once the probe is in the room, so it witnesses the ticket
    // APPEARING before the fault clears it (otherwise `Some` → `None` could
    // both predate its join and the gate would go vacuous).
    wait_for(&mut ui_rx, "the probe's PeerJoined", |ev| match ev {
        UiEvent::PeerJoined { .. } => Some(()),
        UiEvent::Error(e) => panic!("waiting for the probe: {e}"),
        _ => None,
    })
    .await;

    assert!(controller.send(CoreCommand::StartScreenShare {
        audio_app: None,
        settings: Default::default(),
        quality: Default::default(),
    }));
    wait_for(
        &mut ui_rx,
        "ScreenShareStarted (wedged host)",
        |ev| match ev {
            UiEvent::ScreenShareStarted => Some(()),
            UiEvent::Error(e) => panic!("share start failed: {e}"),
            _ => None,
        },
    )
    .await;

    // Sharer-side contract, unchanged by the reorder: Stopped first, the
    // explanatory error only after.
    wait_for(
        &mut ui_rx,
        "ScreenShareStopped after the wedge faults",
        |ev| match ev {
            UiEvent::ScreenShareStopped => Some(()),
            UiEvent::Error(e) => panic!("error arrived before ScreenShareStopped: {e}"),
            _ => None,
        },
    )
    .await;
    let err = wait_for(&mut ui_rx, "the host-death error", |ev| match ev {
        UiEvent::Error(e) => Some(e.clone()),
        _ => None,
    })
    .await;
    assert!(
        err.contains("unexpectedly"),
        "the error should say the share ended unexpectedly, got: {err}"
    );

    let out = tokio::time::timeout(Duration::from_secs(60), probe.wait_with_output())
        .await
        .expect("probe process outlived its budget")
        .expect("probe process wait");
    let stdout = String::from_utf8_lossy(&out.stdout);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        out.status.success(),
        "probe failed ({}).\nstdout:\n{stdout}\nstderr:\n{stderr}",
        out.status
    );
    let cleared_ms: u128 = stdout
        .lines()
        .find_map(|l| l.strip_prefix("PROBE sharing-cleared "))
        .unwrap_or_else(|| {
            panic!("probe never saw the ticket clear from presence.\nstdout:\n{stdout}")
        })
        .trim()
        .parse()
        .expect("probe delta should be integer millis");
    // Presence-removal-first: ~1000 ms (the wedge's pre-fault lifetime).
    // Reap-then-presence: ~3000 ms (lifetime + the full stop grace). The
    // grace itself splits them with ~1 s of jitter headroom on each side.
    assert!(
        cleared_ms < STOP_GRACE_MS,
        "presence kept advertising the dead share for {cleared_ms} ms after it appeared — \
         at or past the wedge lifetime + stop grace, i.e. the ticket was only removed \
         AFTER the reap wait instead of before it"
    );

    assert!(controller.send(CoreCommand::Leave));
}

/// Observer half of `a_host_fault_pulls_the_ticket_off_presence_within_the_grace`,
/// run BY that test as a subprocess. Standalone (no `PEERSPEAK_PROBE_TICKET` in
/// the env — e.g. a plain `--ignored` sweep) it is a no-op pass.
#[tokio::test]
#[ignore = "helper: spawned by the presence gate as a subprocess; standalone it no-ops"]
async fn presence_probe_helper() {
    let Ok(room_ticket) = std::env::var("PEERSPEAK_PROBE_TICKET") else {
        return;
    };

    let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
    let controller = CoreController::new(ui_tx);
    assert!(controller.send(CoreCommand::Join {
        name: "presence-probe".into(),
        ticket: room_ticket,
        room_name: String::new(),
        input_device: None,
        output_device: None,
        echo_cancellation: false,
        avatar: Default::default(),
    }));
    wait_for(&mut ui_rx, "RoomJoined (probe)", |ev| match ev {
        UiEvent::RoomJoined { .. } => Some(()),
        UiEvent::Error(e) => panic!("probe join failed: {e}"),
        _ => None,
    })
    .await;

    // Watch the sharer's presence: record when its `sharing` ticket appears,
    // report the delta when it clears. Timings on both ends are local-loopback
    // arrival times, so the parent's bound compares like with like.
    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
    let mut seen_at: Option<std::time::Instant> = None;
    loop {
        let ev = tokio::time::timeout_at(deadline, ui_rx.recv())
            .await
            .expect("probe timed out watching for the sharing transition")
            .expect("probe ui channel closed");
        let sharing = match &ev {
            UiEvent::PeerJoined { state, .. } | UiEvent::PeerUpdated { state, .. } => {
                state.sharing.is_some()
            }
            _ => continue,
        };
        match (&seen_at, sharing) {
            (None, true) => {
                seen_at = Some(std::time::Instant::now());
                println!("PROBE sharing-seen");
            }
            (Some(t0), false) => {
                println!("PROBE sharing-cleared {}", t0.elapsed().as_millis());
                break;
            }
            _ => {}
        }
    }
    assert!(controller.send(CoreCommand::Leave));
}

/// The long-owed Stop Share SIGINT gate (0c half (ii)), against the REAL
/// pixelpass binary: a Stop Share must end the host through the graceful
/// SIGINT path — child exits within [`STOP_GRACE`], no SIGKILL fallback, no
/// "couldn't confirm" warning — because SIGKILL would skip pixelpass's own
/// teardown (it unloads its capture sink on the way out in sink-owning modes).
///
/// The fallback is indistinguishable from success in the event stream (both
/// end in a confirmed reap), so the discriminator is TIME: the fallback path
/// first waits out the full 2 s grace, while a host honouring SIGINT exits in
/// milliseconds. The bound asserts the stop completed inside the grace.
///
/// Live: needs `pixelpass` on `$PATH` plus a real solo room (audio + network).
#[tokio::test]
#[ignore = "live: real pixelpass host + a real solo room (audio backend, network bind)"]
async fn stop_share_ends_the_real_host_via_sigint_within_the_grace() {
    /// Mirrors `core::teardown::STOP_GRACE` (private): the graceful wait
    /// before the SIGKILL fallback.
    const STOP_GRACE: Duration = Duration::from_secs(2);

    let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel(256);
    let controller = CoreController::new(ui_tx);

    // No override: resolve the real binary from $PATH.
    assert!(controller.send(CoreCommand::SetPixelpassPath(None)));
    assert!(controller.send(CoreCommand::Join {
        name: "sigint-gate".into(),
        ticket: "create".into(),
        room_name: "s2".into(),
        input_device: None,
        output_device: None,
        echo_cancellation: false,
        avatar: Default::default(),
    }));
    wait_for(&mut ui_rx, "RoomJoined", |ev| match ev {
        UiEvent::RoomJoined { .. } => Some(()),
        UiEvent::Error(e) => panic!("join failed: {e}"),
        _ => None,
    })
    .await;

    // Whole-desktop share: no viewers ever connect, so the real host sits idle
    // after its ticket (capture starts on first viewer) — exactly the state a
    // Stop Share most often hits.
    assert!(controller.send(CoreCommand::StartScreenShare {
        audio_app: None,
        settings: Default::default(),
        quality: Default::default(),
    }));
    wait_for(
        &mut ui_rx,
        "ScreenShareStarted (real pixelpass)",
        |ev| match ev {
            UiEvent::ScreenShareStarted => Some(()),
            UiEvent::Error(e) => panic!("real pixelpass host failed to start: {e}"),
            _ => None,
        },
    )
    .await;

    let stop_started = std::time::Instant::now();
    assert!(controller.send(CoreCommand::StopScreenShare));
    wait_for(
        &mut ui_rx,
        "ScreenShareStopped (real pixelpass)",
        |ev| match ev {
            UiEvent::ScreenShareStopped => Some(()),
            // An Unconfirmed reap surfaces exactly this way; it means the
            // SIGINT AND the SIGKILL both failed to end the host.
            UiEvent::Error(e) => panic!("stop of the real host was not clean: {e}"),
            _ => None,
        },
    )
    .await;
    let elapsed = stop_started.elapsed();
    assert!(
        elapsed < STOP_GRACE,
        "stop took {elapsed:?} — at or past the {STOP_GRACE:?} grace, i.e. the \
         SIGKILL fallback fired instead of pixelpass honouring SIGINT"
    );

    // And the late stdout EOF from the SIGINTed host must stay silent (same
    // staleness contract the fake-host half pins).
    let deadline = tokio::time::Instant::now() + QUIET_WINDOW;
    while let Ok(Some(ev)) = tokio::time::timeout_at(deadline, ui_rx.recv()).await {
        match ev {
            UiEvent::ScreenShareStopped => {
                panic!("stale fault from the SIGINTed real host re-emitted ScreenShareStopped")
            }
            UiEvent::Error(e) if e.contains("unexpectedly") => {
                panic!("stale fault from the SIGINTed real host surfaced as an error: {e}")
            }
            _ => {}
        }
    }

    assert!(controller.send(CoreCommand::Leave));
}
