//! [`PortablePtyAdapter`] — local [`PtyPort`] implementation over the
//! `portable-pty` crate (ARCHITECTURE §5, L3).
//!
//! # Design
//!
//! `portable-pty` is a blocking, thread-oriented API: a master PTY gives a
//! `Box<dyn Read>` reader and a `Box<dyn Write>` writer, and the child process is
//! waited on a thread. We bridge that to the domain [`PtyPort`] as follows:
//!
//! - [`PtyHandle`] only carries a [`SessionId`]; the *real* OS handles (master
//!   PTY, writer, child) live in this adapter's registry keyed by that id. The
//!   domain never sees an OS handle (ARCHITECTURE §4).
//! - On [`spawn`](PtyPort::spawn) we open a PTY pair, spawn the command in the
//!   slave, then start **one reader thread** that pumps bytes from the master
//!   into a shared [`Broadcast`] hub. The hub does two things with every chunk:
//!   it appends to a bounded **scrollback ring buffer** (~100 KB, most recent
//!   bytes) and it fans the chunk out to every *currently subscribed* receiver.
//! - [`subscribe_output`](PtyPort::subscribe_output) registers a fresh
//!   subscriber and returns its receiver wrapped as the domain's blocking
//!   [`OutputStream`] iterator. It is **re-subscribable**: after a view tears
//!   down (navigation / layout change) a new view can re-attach to the *same*
//!   live PTY by subscribing again and repainting the scrollback first — no
//!   re-spawn. [`scrollback`](PtyPort::scrollback) returns that retained buffer.
//! - [`write`](PtyPort::write) / [`resize`](PtyPort::resize) act on the stored
//!   writer / master. [`kill`](PtyPort::kill) terminates the child, joins the
//!   reader thread, and returns the [`ExitStatus`].
//!
//! # Cross-platform note (spike, ARCHITECTURE §13.1)
//!
//! `portable-pty` abstracts ConPTY on Windows, but exit-code/signal semantics
//! differ: a Unix process killed by a signal reports `code: None` here, while
//! ConPTY surfaces a numeric code. Resize uses the same `PtySize` everywhere.
//! Points to validate on Windows: child `kill()` actually tears down ConPTY, and
//! the reader thread observes EOF promptly on exit. The code below avoids any
//! Unix-only assumption (no raw fds, no signals) so it should port as-is.

use std::collections::HashMap;
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;

use async_trait::async_trait;
use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty};

use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::terminal::PtySize;
use domain::SessionId;

/// Size of each read buffer pumped from the master PTY.
const READ_BUF: usize = 8 * 1024;

/// Maximum number of bytes retained in a session's scrollback ring buffer
/// (~100 KB, "the most recent output"). When the buffer would exceed this, the
/// oldest bytes are dropped so a re-attaching view repaints recent history.
const SCROLLBACK_CAP: usize = 100 * 1024;

type SharedChild = Arc<Mutex<Option<Box<dyn Child + Send + Sync>>>>;
type SharedExitStatus = Arc<Mutex<Option<ExitStatus>>>;

/// The shared output hub of one PTY: a bounded scrollback ring buffer plus the
/// **single** currently-subscribed receiver. The reader thread feeds both; a
/// view subscribes on (re-)attach, and each new subscription supersedes the
/// previous one ([`Broadcast::subscribe`]) so a PTY is never fanned to two live
/// consumers at once.
///
/// A subscriber is a [`Sender`]; a send failing (receiver dropped because the
/// view detached) prunes it on the next chunk. The `Vec` is retained (rather
/// than an `Option`) only to keep the prune-on-send logic uniform.
#[derive(Default)]
struct Broadcast {
    /// Bounded ring buffer of the most recent output bytes.
    scrollback: VecDeque<u8>,
    /// Live subscribers; pruned lazily when their receiver is gone.
    subscribers: Vec<Sender<Vec<u8>>>,
    /// Set once the PTY hit EOF (process exited) — no more output will ever come.
    eof: bool,
}

impl Broadcast {
    /// Appends a chunk to the scrollback (trimming to [`SCROLLBACK_CAP`]) and
    /// fans it out to every live subscriber, dropping any that have gone away.
    fn push(&mut self, chunk: &[u8]) {
        self.scrollback.extend(chunk.iter().copied());
        let overflow = self.scrollback.len().saturating_sub(SCROLLBACK_CAP);
        if overflow > 0 {
            self.scrollback.drain(0..overflow);
        }
        self.subscribers
            .retain(|tx| tx.send(chunk.to_vec()).is_ok());
    }

    /// Registers a new subscriber, returning its receiver.
    ///
    /// **Single live consumer.** A PTY session is shown by exactly one view at a
    /// time; a re-attach supersedes the previous view. So a new subscription
    /// **drops every prior subscriber**, which ends the previous attach's pump
    /// thread (its receiver closes) instead of leaving it alive to fan the *same*
    /// bytes a second time — the root cause of on-resize output duplication.
    ///
    /// If the PTY already hit EOF, the returned stream is immediately closed
    /// (its sender is dropped) so a late re-attach to a finished session doesn't
    /// block forever waiting for output that will never come.
    fn subscribe(&mut self) -> Receiver<Vec<u8>> {
        let (tx, rx) = mpsc::channel();
        self.subscribers.clear();
        if !self.eof {
            self.subscribers.push(tx);
        }
        rx
    }

    /// Returns the retained scrollback as a contiguous byte vector.
    fn snapshot(&self) -> Vec<u8> {
        self.scrollback.iter().copied().collect()
    }

    /// Drops every subscriber's sender so their output streams end (EOF). Called
    /// by the reader thread when the PTY hits EOF (process exit). The scrollback
    /// is preserved so a late re-attach can still repaint the final output.
    fn close_subscribers(&mut self) {
        self.eof = true;
        self.subscribers.clear();
    }
}

/// A live PTY owned by the adapter.
struct LivePty {
    /// Master side — used for resize.
    master: Box<dyn MasterPty + Send>,
    /// Writer into the PTY (child stdin).
    writer: Box<dyn Write + Send>,
    /// The spawned child process, retained until wait/try_wait/kill observes its
    /// exit status.
    child: SharedChild,
    /// Memorized exit status. Kept while the PTY session is registered so
    /// wait/try_wait/kill are idempotent relative to each other.
    exit_status: SharedExitStatus,
    /// Shared scrollback + subscriber hub, fed by the reader thread.
    output: Arc<Mutex<Broadcast>>,
    /// Handle of the reader thread, joined on kill.
    reader: Option<JoinHandle<()>>,
}

/// Local PTY adapter backed by `portable-pty`'s native PTY system.
///
/// Thread-safe: the registry of live PTYs is behind a [`Mutex`]; the adapter is
/// cloneable-as-`Arc` and injected as `Arc<dyn PtyPort>` at the composition root.
#[derive(Default)]
pub struct PortablePtyAdapter {
    sessions: Mutex<HashMap<SessionId, LivePty>>,
    /// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all
    /// (the default, zero behaviour change). When set **and** a [`SpawnSpec`]
    /// carries a [`SandboxPlan`], the plan is enforced on the child at spawn
    /// (see [`spawn_command_sandboxed`]).
    sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
}

impl PortablePtyAdapter {
    /// Creates an empty adapter (no OS sandbox).
    #[must_use]
    pub fn new() -> Self {
        Self {
            sessions: Mutex::new(HashMap::new()),
            sandbox_enforcer: None,
        }
    }

    /// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With
    /// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on
    /// the spawned child. Without it (the default), no spawn is ever sandboxed —
    /// `new()`'s behaviour is unchanged.
    #[must_use]
    pub fn with_sandbox_enforcer(mut self, enforcer: Arc<dyn SandboxEnforcer>) -> Self {
        self.sandbox_enforcer = Some(enforcer);
        self
    }
}

/// Maps the domain [`PtySize`] to the `portable-pty` one.
fn to_pty_size(size: PtySize) -> portable_pty::PtySize {
    portable_pty::PtySize {
        rows: size.rows,
        cols: size.cols,
        pixel_width: 0,
        pixel_height: 0,
    }
}

/// Spawns the child **under an OS sandbox** (lot LP4-1).
///
/// ## Why a dedicated thread instead of a `pre_exec` hook
///
/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its
/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its
/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no
/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's
/// `pre_exec` directly.
///
/// Instead we lean on a guaranteed kernel property: **a Landlock domain is
/// inherited across `fork` and preserved across `execve`**. We therefore restrict a
/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from
/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from
/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is
/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits
/// the restricted thread's domain. The throwaway thread then dies, taking its
/// (irreversible) restriction with it, leaving IdeA's other threads untouched.
///
/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel
/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported /
/// Degraded)` proceeds (best-effort).
///
/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is
/// required of the slave; the resulting `Child` is `Send` and returned to the
/// caller.
fn spawn_command_sandboxed(
    slave: Box<dyn SlavePty + Send>,
    cmd: CommandBuilder,
    enforcer: Arc<dyn SandboxEnforcer>,
    plan: SandboxPlan,
) -> Result<Box<dyn Child + Send + Sync>, PtyError> {
    let join = std::thread::spawn(move || -> Result<Box<dyn Child + Send + Sync>, PtyError> {
        // Restrict THIS thread; the forked child inherits the domain.
        if let Err(e) = enforcer.enforce(&plan) {
            return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}")));
        }
        let child = slave
            .spawn_command(cmd)
            .map_err(|e| PtyError::Spawn(e.to_string()))?;
        // The slave is held by the child; drop our copy so EOF propagates on exit.
        drop(slave);
        Ok(child)
    });
    join.join()
        .map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))?
}

/// Builds the `portable-pty` command from a domain [`SpawnSpec`].
fn to_command(spec: &SpawnSpec) -> CommandBuilder {
    let mut cmd = CommandBuilder::new(&spec.command);
    cmd.args(&spec.args);
    cmd.cwd(spec.cwd.as_str());
    for (k, v) in &spec.env {
        cmd.env(k, v);
    }
    cmd
}

fn portable_status(status: &portable_pty::ExitStatus) -> ExitStatus {
    ExitStatus {
        code: exit_code(status),
    }
}

fn try_wait_shared(
    child: &SharedChild,
    exit_status: &SharedExitStatus,
) -> Result<Option<ExitStatus>, PtyError> {
    if let Some(status) = *exit_status
        .lock()
        .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
    {
        return Ok(Some(status));
    }

    let observed = {
        let mut child_guard = child
            .lock()
            .map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?;
        let Some(child) = child_guard.as_mut() else {
            return Ok(*exit_status
                .lock()
                .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?);
        };

        match child.try_wait().map_err(|e| PtyError::Io(e.to_string()))? {
            Some(status) => {
                let status = portable_status(&status);
                *child_guard = None;
                Some(status)
            }
            None => None,
        }
    };

    if let Some(status) = observed {
        *exit_status
            .lock()
            .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status);
        Ok(Some(status))
    } else {
        Ok(None)
    }
}

fn kill_shared(
    child: &SharedChild,
    exit_status: &SharedExitStatus,
) -> Result<ExitStatus, PtyError> {
    if let Some(status) = *exit_status
        .lock()
        .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
    {
        return Ok(status);
    }

    let status = {
        let mut child_guard = child
            .lock()
            .map_err(|_| PtyError::Io("pty child lock poisoned".to_owned()))?;
        let Some(child) = child_guard.as_mut() else {
            return exit_status
                .lock()
                .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))?
                .ok_or_else(|| PtyError::Io("pty child missing exit status".to_owned()));
        };

        let _ = child.kill();
        let raw = child.wait().map_err(|e| PtyError::Io(e.to_string()))?;
        let status = portable_status(&raw);
        *child_guard = None;
        status
    };

    *exit_status
        .lock()
        .map_err(|_| PtyError::Io("pty exit status poisoned".to_owned()))? = Some(status);
    Ok(status)
}

#[async_trait]
impl PtyPort for PortablePtyAdapter {
    async fn spawn(&self, spec: SpawnSpec, size: PtySize) -> Result<PtyHandle, PtyError> {
        let pty_system = NativePtySystem::default();
        let pair = pty_system
            .openpty(to_pty_size(size))
            .map_err(|e| PtyError::Spawn(e.to_string()))?;

        let cmd = to_command(&spec);
        // Move the slave out of the pair (the master stays usable) so it can be
        // either spawned directly or handed to the sandboxed-spawn thread.
        let slave = pair.slave;
        let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) {
            // Sandboxed spawn: a plan AND an enforcer are present.
            (Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?,
            // No plan or no enforcer ⇒ plain spawn (the default path, unchanged).
            _ => {
                let child = slave
                    .spawn_command(cmd)
                    .map_err(|e| PtyError::Spawn(e.to_string()))?;
                // The slave is held by the child; drop our copy so EOF propagates.
                drop(slave);
                child
            }
        };

        let writer = pair
            .master
            .take_writer()
            .map_err(|e| PtyError::Io(e.to_string()))?;
        let mut reader = pair
            .master
            .try_clone_reader()
            .map_err(|e| PtyError::Io(e.to_string()))?;

        // One reader thread per PTY pumps bytes into the broadcast hub until EOF.
        // The hub retains a scrollback ring buffer AND fans bytes out to every
        // current subscriber, so views can detach/re-attach without re-spawning.
        let output: Arc<Mutex<Broadcast>> = Arc::new(Mutex::new(Broadcast::default()));
        let output_for_reader = Arc::clone(&output);
        let reader_handle = std::thread::spawn(move || {
            let mut buf = [0u8; READ_BUF];
            loop {
                match reader.read(&mut buf) {
                    Ok(0) => break,
                    Ok(n) => {
                        if let Ok(mut hub) = output_for_reader.lock() {
                            hub.push(&buf[..n]);
                        }
                    }
                    Err(_) => break,
                }
            }
            // EOF (process exited): end every attached stream by dropping its
            // sender, while preserving the scrollback for any late re-attach.
            if let Ok(mut hub) = output_for_reader.lock() {
                hub.close_subscribers();
            }
        });

        // The PTY layer owns the handle identity: it mints a fresh session id and
        // the caller (the `OpenTerminal` use case) adopts it as the
        // `TerminalSession.id`. This keeps the OS handle out of the domain while
        // giving everyone a single, agreed-upon id (ARCHITECTURE §4).
        let handle = PtyHandle {
            session_id: SessionId::new_random(),
        };
        let live = LivePty {
            master: pair.master,
            writer,
            child: Arc::new(Mutex::new(Some(child))),
            exit_status: Arc::new(Mutex::new(None)),
            output,
            reader: Some(reader_handle),
        };
        self.sessions
            .lock()
            .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?
            .insert(handle.session_id, live);
        Ok(handle)
    }

    fn write(&self, handle: &PtyHandle, data: &[u8]) -> Result<(), PtyError> {
        let mut map = self
            .sessions
            .lock()
            .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
        let live = map.get_mut(&handle.session_id).ok_or(PtyError::NotFound)?;
        live.writer
            .write_all(data)
            .map_err(|e| PtyError::Io(e.to_string()))?;
        live.writer.flush().map_err(|e| PtyError::Io(e.to_string()))
    }

    fn resize(&self, handle: &PtyHandle, size: PtySize) -> Result<(), PtyError> {
        let map = self
            .sessions
            .lock()
            .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
        let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
        live.master
            .resize(to_pty_size(size))
            .map_err(|e| PtyError::Io(e.to_string()))
    }

    fn subscribe_output(&self, handle: &PtyHandle) -> Result<OutputStream, PtyError> {
        let map = self
            .sessions
            .lock()
            .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
        let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
        let rx = live
            .output
            .lock()
            .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
            .subscribe();
        Ok(Box::new(rx.into_iter()))
    }

    fn scrollback(&self, handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
        let map = self
            .sessions
            .lock()
            .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
        let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
        let snapshot = live
            .output
            .lock()
            .map_err(|_| PtyError::Io("pty output hub poisoned".to_owned()))?
            .snapshot();
        Ok(snapshot)
    }

    async fn wait(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        loop {
            if let Some(status) = self.try_wait(handle)? {
                return Ok(status);
            }
            tokio::time::sleep(Duration::from_millis(20)).await;
        }
    }

    fn try_wait(&self, handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
        let (child, exit_status) = {
            let map = self
                .sessions
                .lock()
                .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
            let live = map.get(&handle.session_id).ok_or(PtyError::NotFound)?;
            (Arc::clone(&live.child), Arc::clone(&live.exit_status))
        };
        try_wait_shared(&child, &exit_status)
    }

    async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
        // Remove from the registry so the writer/master drop and the child is
        // fully owned here while we tear it down.
        let mut live = {
            let mut map = self
                .sessions
                .lock()
                .map_err(|_| PtyError::Io("pty registry poisoned".to_owned()))?;
            map.remove(&handle.session_id).ok_or(PtyError::NotFound)?
        };

        // Ask the child to terminate, unless wait/try_wait already memorized its
        // natural exit status.
        let status = kill_shared(&live.child, &live.exit_status)?;

        // Dropping master/writer closes the PTY; the reader thread then sees EOF.
        // Dropping the broadcast hub drops every subscriber's sender, so any
        // still-attached view's output stream ends cleanly too.
        if let Some(reader) = live.reader.take() {
            let _ = reader.join();
        }

        Ok(status)
    }
}

/// Extracts a portable exit code. `portable-pty`'s `ExitStatus` exposes
/// `exit_code(): u32`; `0` is success. We surface it as `i32`.
fn exit_code(status: &portable_pty::ExitStatus) -> Option<i32> {
    Some(status.exit_code() as i32)
}

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

    /// Regression: a re-attach (new subscribe) must end the previous subscriber's
    /// stream so only ONE consumer is fed. Two live subscribers would each pump
    /// the same bytes to the bridge → the on-resize output duplication.
    #[test]
    fn subscribe_supersedes_the_previous_subscriber() {
        let mut hub = Broadcast::default();
        let first = hub.subscribe();
        let second = hub.subscribe();

        hub.push(b"hello");

        // The superseded receiver's stream is closed (sender dropped) and gets
        // nothing; only the current subscriber receives the chunk.
        assert!(first.recv().is_err(), "old subscriber must be disconnected");
        assert_eq!(second.recv().unwrap(), b"hello".to_vec());
    }

    #[test]
    fn subscribe_after_eof_yields_a_closed_stream() {
        let mut hub = Broadcast::default();
        hub.close_subscribers();
        let rx = hub.subscribe();
        assert!(rx.recv().is_err(), "no output will ever come after EOF");
    }

    #[test]
    fn snapshot_retains_scrollback_across_resubscribe() {
        let mut hub = Broadcast::default();
        let _first = hub.subscribe();
        hub.push(b"abc");
        // A re-attach drops the old subscriber but the scrollback is preserved.
        let _second = hub.subscribe();
        assert_eq!(hub.snapshot(), b"abc".to_vec());
    }
}

/// **End-to-end PTY × Landlock enforcement (lot LP4-3).**
///
/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real
/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose
/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the
/// granted root (must succeed) and **outside** it (must be blocked by the kernel).
/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox →
/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised.
#[cfg(all(test, target_os = "linux"))]
mod sandbox_e2e_tests {
    use super::*;
    use crate::sandbox::LandlockSandbox;
    use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus};
    use domain::terminal::PtySize;
    use domain::Posture;
    use std::path::{Path, PathBuf};
    use std::time::{Duration, Instant};

    /// A unique temp dir for one test (no external tempfile dep), mirroring the
    /// helper used by the Landlock adapter tests.
    fn fresh_dir(tag: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}"));
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    /// Probes whether the running kernel actually enforces Landlock, exactly the way
    /// the launch path would observe it: enforce a tiny RW plan on a throwaway thread
    /// (the restriction is irreversible, so it must not run on the test thread) and
    /// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us
    /// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests.
    fn landlock_is_enforced() -> bool {
        let probe = fresh_dir("probe");
        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: probe.to_string_lossy().into_owned(),
                access: PathAccess::RW,
            }],
            default_posture: Posture::Ask,
        };
        let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan))
            .join()
            .unwrap()
            .expect("enforce must not error under an Ask posture");
        let _ = std::fs::remove_dir_all(&probe);
        !matches!(status, SandboxStatus::Unsupported)
    }

    /// Waits (bounded) for `path` to appear, returning whether it showed up in time.
    fn wait_for(path: &Path, timeout: Duration) -> bool {
        let deadline = Instant::now() + timeout;
        while Instant::now() < deadline {
            if path.exists() {
                return true;
            }
            std::thread::sleep(Duration::from_millis(25));
        }
        path.exists()
    }

    /// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that
    /// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant
    /// path and the granted path must have its out-of-grant write blocked while the
    /// in-grant write lands on disk. The script writes the denied target *first*, so
    /// once the allowed marker exists we know the denied write was already attempted
    /// (and fenced) — making the assertion deterministic without racing the child.
    #[tokio::test]
    async fn pty_spawn_enforces_sandbox_plan_end_to_end() {
        if !landlock_is_enforced() {
            eprintln!(
                "skipping pty_spawn_enforces_sandbox_plan_end_to_end: \
                       Landlock not available/enforced on this kernel"
            );
            return;
        }

        let allowed = fresh_dir("allowed");
        let denied = fresh_dir("denied");
        let allowed_marker = allowed.join("in.txt");
        let denied_marker = denied.join("out.txt");

        // RW on `allowed` only ⇒ the write class is handled and fenced to that root;
        // reads stay globally unrestricted so `sh`/libc still load normally.
        let plan = SandboxPlan {
            allowed: vec![PathGrant {
                abs_root: allowed.to_string_lossy().into_owned(),
                access: PathAccess::RW,
            }],
            default_posture: Posture::Ask,
        };

        // Denied write FIRST, then the allowed write: the allowed marker appearing is
        // a happens-after signal that the denied attempt already ran.
        let script = format!(
            "echo outside > '{}'; echo inside > '{}'",
            denied_marker.display(),
            allowed_marker.display()
        );
        let spec = SpawnSpec {
            command: "sh".to_owned(),
            args: vec!["-c".to_owned(), script],
            cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned())
                .expect("temp dir is absolute"),
            env: vec![],
            context_plan: None,
            sandbox: Some(plan),
        };

        let adapter =
            PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
        let handle = adapter
            .spawn(spec, PtySize { rows: 24, cols: 80 })
            .await
            .expect("sandboxed spawn must succeed (Ask posture never fails closed)");

        // Wait for the in-grant write to land (the child's last action).
        assert!(
            wait_for(&allowed_marker, Duration::from_secs(5)),
            "in-grant write must succeed: {allowed_marker:?} never appeared — \
             the sandbox may have wrongly fenced the granted root"
        );

        // THE GUARANTEE: the out-of-grant write was attempted before the marker and
        // must have been blocked by Landlock, so the file must not exist.
        assert!(
            !denied_marker.exists(),
            "SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \
             the Landlock plan was NOT enforced on the PTY child"
        );

        let _ = adapter.kill(&handle).await;
        let _ = std::fs::remove_dir_all(&allowed);
        let _ = std::fs::remove_dir_all(&denied);
    }

    /// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`]
    /// carrying `sandbox = None`, the out-of-grant write must succeed — proving the
    /// previous test's block comes from the *enforced plan*, not from some ambient PTY
    /// restriction. (Guards against a false-positive where writes fail for unrelated
    /// reasons.) `denied` here is just an ordinary writable temp dir.
    #[tokio::test]
    async fn pty_spawn_without_plan_does_not_sandbox() {
        let denied = fresh_dir("noplan");
        let marker = denied.join("out.txt");
        let script = format!("echo outside > '{}'", marker.display());

        let spec = SpawnSpec {
            command: "sh".to_owned(),
            args: vec!["-c".to_owned(), script],
            cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned())
                .expect("temp dir is absolute"),
            env: vec![],
            context_plan: None,
            sandbox: None,
        };

        let adapter =
            PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer());
        let handle = adapter
            .spawn(spec, PtySize { rows: 24, cols: 80 })
            .await
            .expect("plain spawn must succeed");

        assert!(
            wait_for(&marker, Duration::from_secs(5)),
            "without a sandbox plan the write must succeed: {marker:?} never appeared"
        );

        let _ = adapter.kill(&handle).await;
        let _ = std::fs::remove_dir_all(&denied);
    }
}
