//! `pmacs --daemon` subprocess fixture shared across integration tests.
//!
//! `TestDaemon` spawns a real foreground daemon in a tempdir-scoped
//! socket, waits until the socket is reachable, and cleans up on
//! `Drop`. `attach()` / `connect()` produce a `UnixStream` to the
//! daemon's socket; tests handle the protocol handshake themselves.
//!
//! First consumer: M5.5 acceptance suite
//! (`tests/m5_5_acceptance.rs`). Second consumer: M10.11
//! doubled-PTY two-laptop tests (`tests/m10_11_acceptance.rs`).
//!
//! Note: `tests/m5_8_acceptance.rs` builds a different daemon shape
//! (fake-SSH driver, not a real daemon process) and does not consume
//! this module.

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

use tempfile::TempDir;

#[cfg(feature = "crdt")]
use pmacs::cell::CellSize;
#[cfg(feature = "crdt")]
use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION};
use pmacs::protocol::{FrontendCapabilities, Hello};
use pmacs::transport::read_message;
#[cfg(feature = "crdt")]
use pmacs::transport::write_message;

/// A foreground daemon spawned in the test, with cleanup on Drop.
pub struct TestDaemon {
    /// Tempdir holding the socket and lockfile; auto-cleaned on Drop.
    _tempdir: TempDir,
    socket_path: PathBuf,
    process: Child,
}

impl TestDaemon {
    pub fn spawn() -> Self {
        Self::spawn_with_env(&[])
    }

    /// T M10.8 Day 4 — spawn with extra env-var overrides for
    /// instance-capability tests.
    pub fn spawn_with_env(env_vars: &[(&str, &str)]) -> Self {
        let tempdir = TempDir::new().expect("tempdir");
        // tempfile::TempDir creates 0755-mode directories; the daemon
        // requires a 0700-or-stricter parent for the socket. Tighten
        // the tempdir before spawning.
        fs::set_permissions(tempdir.path(), fs::Permissions::from_mode(0o700))
            .expect("chmod tempdir 0700");
        let socket_path = tempdir.path().join("pmacs.sock");
        let mut process = spawn_daemon_process_with_env(&socket_path, env_vars);
        wait_for_socket_or_exit(&socket_path, &mut process, Duration::from_secs(10))
            .expect("daemon socket appeared");
        Self {
            _tempdir: tempdir,
            socket_path,
            process,
        }
    }

    pub fn pid(&self) -> u32 {
        self.process.id()
    }

    pub fn socket_path(&self) -> &Path {
        &self.socket_path
    }

    pub fn connect(&self) -> UnixStream {
        UnixStream::connect(&self.socket_path).expect("connect")
    }

    pub fn is_alive(&mut self) -> bool {
        self.process.try_wait().ok().flatten().is_none()
    }

    pub fn lockfile_path(&self) -> PathBuf {
        let mut s = self.socket_path.as_os_str().to_os_string();
        s.push(".lock");
        PathBuf::from(s)
    }

    /// Block until the daemon's child process exits, returning the
    /// exit status. Used by the SIGTERM test in `m5_5_acceptance.rs`
    /// to confirm a clean shutdown after the signal was sent.
    pub fn wait_for_exit(&mut self) -> std::io::Result<std::process::ExitStatus> {
        self.process.wait()
    }
}

impl Drop for TestDaemon {
    fn drop(&mut self) {
        let _ = self.process.kill();
        let _ = self.process.wait();
    }
}

pub fn spawn_daemon_process(socket_path: &Path) -> Child {
    spawn_daemon_process_with_env(socket_path, &[])
}

/// Spawn a daemon with extra environment-variable overrides.
///
/// T M10.8 Day 4 — used by tests that need to exercise non-default
/// instance capabilities (e.g., the M10.7 mismatch test, which
/// needs `PMACS_INSTANCE_MULTI_FRONTEND=0` so a frontend declaring
/// `multi_frontend: true` hits the capability-mismatch path).
/// Production daemons don't set these vars.
///
/// Stderr is redirected to a socket-adjacent log file so that
/// [`wait_for_socket_or_exit`] can surface daemon panics / startup
/// failures in test error messages instead of leaving operators
/// with an opaque 10s timeout. File-backed stderr avoids the
/// deadlock risk of an undrained pipe for long-running daemon tests.
pub fn spawn_daemon_process_with_env(socket_path: &Path, env_vars: &[(&str, &str)]) -> Child {
    let isolated_home = socket_path.parent().expect("socket has parent");
    let stderr_path = daemon_stderr_path(socket_path);
    let stderr = fs::File::create(&stderr_path).expect("create daemon stderr log");
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_pmacs"));
    cmd.args(["--daemon", "--socket"])
        .arg(socket_path)
        .env("HOME", isolated_home)
        .env("XDG_CONFIG_HOME", isolated_home)
        .stdout(Stdio::null())
        .stderr(Stdio::from(stderr));
    for (key, value) in env_vars {
        cmd.env(key, value);
    }
    cmd.spawn().expect("spawn pmacs --daemon")
}

/// Poll until the daemon's socket is reachable, the daemon exits,
/// or `deadline` elapses.
///
/// **Diagnostic contract:** if the daemon exits before the socket
/// becomes reachable, or if the deadline expires, the returned
/// error includes the daemon's exit status (when known) and any
/// captured stderr. M10.11's PTY-doubled tests are operator-invoked
/// and a 10s "daemon did not start listening" with no further
/// information was found to be expensive to debug in practice — this
/// helper takes ownership of that diagnostic cost.
pub fn wait_for_socket_or_exit(
    socket: &Path,
    process: &mut Child,
    deadline: Duration,
) -> Result<(), String> {
    // We probe by attempting to *connect*, not by file-exists. A stale
    // socket file from a previous (crashed) daemon can satisfy
    // `exists` without anyone actually listening — only a successful
    // connect proves the new daemon has run through `bind` and
    // `listen`. ECONNREFUSED on a stale socket retries until the new
    // daemon takes over.
    let start = Instant::now();
    while start.elapsed() < deadline {
        if let Ok(mut stream) = UnixStream::connect(socket) {
            // Read the Hello so the daemon's `send_message` succeeds
            // and doesn't log a "send Hello failed" warning to its
            // stderr (which our test runner inherits). After reading
            // we drop without sending AttachRequest; the daemon
            // observes the disconnect and falls back to accept.
            stream
                .set_read_timeout(Some(Duration::from_millis(500)))
                .ok();
            let _ = read_message::<Hello>(&mut stream);
            return Ok(());
        }
        // Daemon may have exited early (panic on bind, missing
        // env, etc.). Surface its exit status + stderr immediately
        // instead of letting the connect-probe burn the full
        // deadline.
        if let Ok(Some(status)) = process.try_wait() {
            let stderr = read_daemon_stderr(socket);
            return Err(format!(
                "daemon exited with {status} before socket appeared; \
                 socket={}\n--- daemon stderr ---\n{stderr}",
                socket.display()
            ));
        }
        thread::sleep(Duration::from_millis(20));
    }
    // Timeout: kill, then capture whatever stderr is available so the
    // operator sees the daemon's last words rather than "no signal."
    let _ = process.kill();
    let _ = process.wait();
    let stderr = read_daemon_stderr(socket);
    Err(format!(
        "daemon did not start listening on {} within {deadline:?}\n\
         --- daemon stderr ---\n{stderr}",
        socket.display()
    ))
}

fn daemon_stderr_path(socket: &Path) -> PathBuf {
    let mut path = socket.as_os_str().to_os_string();
    path.push(".stderr.log");
    PathBuf::from(path)
}

/// Read the daemon's file-backed stderr, best-effort. Used only on
/// failure paths in [`wait_for_socket_or_exit`].
fn read_daemon_stderr(socket: &Path) -> String {
    let path = daemon_stderr_path(socket);
    match fs::read_to_string(&path) {
        Ok(s) if !s.is_empty() => s,
        Ok(_) => String::from("<stderr empty>"),
        Err(e) => format!("<stderr unavailable at {}: {e}>", path.display()),
    }
}

// ---------------------------------------------------------------------------
// Frontend-attach helpers (shared across daemon-driven tests)
// ---------------------------------------------------------------------------

/// Default v0.1 capabilities — no multi-frontend, no CRDT replica.
/// Used by tests that exercise the legacy single-frontend path.
pub fn build_default_caps() -> FrontendCapabilities {
    FrontendCapabilities {
        synchronized_output: true,
        unicode_smp: true,
        true_color: true,
        mouse: true,
        bracketed_paste: true,
        terminal_kind: Some("test".into()),
        multi_frontend: false,
        crdt_replica: false,
        semantic_render: false,
    }
}

/// Caps for a v1.0 multi-frontend + CRDT-replica frontend. CRDT-only
/// because [`build_default_caps`]'s non-multi defaults are kept for
/// the v0.1 path; multi-frontend tests opt in explicitly.
#[cfg(feature = "crdt")]
pub fn multi_frontend_caps() -> FrontendCapabilities {
    FrontendCapabilities {
        multi_frontend: true,
        crdt_replica: true,
        ..build_default_caps()
    }
}

/// Connect, read the daemon's `Hello`, send an `AttachRequest` with
/// multi-frontend + CRDT-replica caps, and return the Hello plus the
/// connected stream. The caller is responsible for any subsequent
/// reads (initial `BufferSnapshot`, `CellDelta`, etc.).
///
/// First consumer: M5.5 acceptance suite's multi-frontend tests
/// (M10.8/M10.9/M10.10 sections). Second consumer: M10.11 synthesis
/// tests and the doubled-PTY observer.
#[cfg(feature = "crdt")]
pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) {
    let mut stream = daemon.connect();
    stream
        .set_read_timeout(Some(Duration::from_secs(5)))
        .unwrap();
    let hello: Hello = read_message(&mut stream).expect("read Hello");
    let req = AttachRequest {
        protocol_version: PROTOCOL_VERSION,
        frontend_capabilities: multi_frontend_caps(),
        initial_size: CellSize::new(24, 80),
    };
    write_message(&mut stream, &req).expect("write AttachRequest");
    (hello, stream)
}
