//! In-process core lifecycle.
//!
//! The core's HTTP/JSON-RPC server runs as a tokio task inside the Tauri host
//! so its lifetime is tied to the GUI process — there is no sidecar to leak
//! on Cmd+Q.
//!
//! Stale-listener policy (see issue #1130): if something is already listening
//! on the configured port when `ensure_running` runs, we probe `GET /` to see
//! whether it is an OpenHuman core. If it is, we treat it as a stale process
//! left behind by a previous build/dev session and proactively terminate it
//! (graceful signal, then a force-kill that *revalidates* the pid is still
//! the same listener — guards against PID reuse if the original exits inside
//! the grace window) before spawning a fresh embedded server — otherwise the
//! new UI would silently bind to an older RPC implementation. If the listener
//! is something else (or unreachable), we refuse to attach and surface the
//! conflict so it can be diagnosed instead of producing 401s and version
//! drift downstream.
//! Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to opt back into the legacy
//! attach-to-whatever-is-listening behavior (e.g. a manual `openhuman-core
//! run` harness for debugging).

use std::sync::Arc;
use std::sync::LazyLock;

use parking_lot::RwLock;
use tokio::net::TcpStream;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
use tokio_util::sync::CancellationToken;

use crate::process_kill::{kill_pid_force, kill_pid_term};

const CORE_READY_POLL_MS: u64 = 100;
// 60s ceiling. The embedded core boots in ~11s standalone, but inside the
// desktop app it contends with CEF process startup (GPU/renderer/utility
// re-execs) and can intermittently cross a 20s cap on a cold/loaded machine.
// Polling returns the instant the core is ready, so a higher ceiling costs
// nothing in the common fast case — it only avoids spurious startup-timeout
// aborts under contention.
const CORE_READY_ATTEMPTS: usize = 600;
const CORE_READY_TIMEOUT_MS: u64 = CORE_READY_POLL_MS * CORE_READY_ATTEMPTS as u64;

/// Generate a 256-bit cryptographically-random bearer token as a hex string.
///
/// Uses the same encoding as `openhuman_core::core::auth::generate_token`
/// (`hex::encode`) so the token format never silently diverges between the
/// Tauri-side generator and the core-side validator.
pub fn generate_rpc_token() -> String {
    use rand::RngCore as _;
    let mut bytes = [0u8; 32];
    rand::rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}

static CURRENT_RPC_TOKEN: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| RwLock::new(None));

pub fn current_rpc_token() -> Option<String> {
    CURRENT_RPC_TOKEN.read().clone()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortFallbackNotice {
    pub preferred_port: u16,
    pub chosen_port: u16,
}

#[derive(Clone)]
pub struct CoreProcessHandle {
    task: Arc<Mutex<Option<JoinHandle<anyhow::Result<()>>>>>,
    shutdown_token: Arc<Mutex<CancellationToken>>,
    restart_lock: Arc<Mutex<()>>,
    preferred_port: u16,
    active_port: Arc<RwLock<u16>>,
    last_port_fallback: Arc<RwLock<Option<PortFallbackNotice>>>,
    /// Bearer token the embedded server validates on every inbound request.
    ///
    /// Handed to the embedded server **in-memory** (via the `rpc_token`
    /// argument of [`openhuman_core::core::jsonrpc::run_server_embedded_with_ready`])
    /// rather than through `OPENHUMAN_CORE_TOKEN` on the process environment.
    /// Avoiding the env crossing keeps the bearer off `/proc/<pid>/environ`
    /// (Linux) and out of `sysctl KERN_PROCARGS2` / `ps eww -p <pid>` (macOS)
    /// where any same-UID process could otherwise read it without entitlement.
    /// The same value is exposed to the renderer via the `core_rpc_token`
    /// Tauri command so every RPC call can attach `Authorization: Bearer`.
    rpc_token: Arc<String>,
}

impl CoreProcessHandle {
    pub fn new(port: u16) -> Self {
        // CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by
        // ensure_running() only after the embedded server has been spawned
        // with this token handed over via the in-memory `rpc_token` arg of
        // `run_server_embedded_with_ready`. Setting it here would advertise
        // a token that an existing process listening on the port (the
        // harness-attach fast-path) has never seen, causing 401s on every
        // authenticated call.
        let rpc_token = generate_rpc_token();
        Self {
            task: Arc::new(Mutex::new(None)),
            shutdown_token: Arc::new(Mutex::new(CancellationToken::new())),
            restart_lock: Arc::new(Mutex::new(())),
            preferred_port: port,
            active_port: Arc::new(RwLock::new(port)),
            last_port_fallback: Arc::new(RwLock::new(None)),
            rpc_token: Arc::new(rpc_token),
        }
    }

    /// The bearer token the embedded core validates on inbound RPC requests.
    pub fn rpc_token(&self) -> &str {
        &self.rpc_token
    }

    pub fn rpc_url(&self) -> String {
        format!("http://127.0.0.1:{}/rpc", self.port())
    }

    pub fn port(&self) -> u16 {
        *self.active_port.read()
    }

    pub fn take_last_port_fallback_notice(&self) -> Option<PortFallbackNotice> {
        self.last_port_fallback.write().take()
    }

    /// Acquire the restart lock to serialize overlapping restart requests.
    pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
        self.restart_lock.lock().await
    }

    async fn is_rpc_port_open(&self) -> bool {
        is_port_open(self.port()).await
    }

    pub async fn ensure_running(&self) -> Result<(), String> {
        // Idempotent fast path: if we already spawned the embedded server in
        // *this* process and it's still alive on the port, the listener is
        // us — return Ok without identifying or taking over. Without this,
        // a second `start_core_process` call (e.g. HMR re-mounting the boot
        // gate) sees its own port as bound, classifies the listener as
        // "stale OpenHuman", and walks into the SIGTERM/SIGKILL takeover
        // path against itself. (#1130 takeover is meant to recover from
        // *external* leftover binaries, not our own in-process spawn.)
        {
            let guard = self.task.lock().await;
            if let Some(task) = guard.as_ref() {
                if !task.is_finished() && self.is_rpc_port_open().await {
                    log::debug!(
                        "[core] ensure_running: embedded task already running on port {} — no-op",
                        self.port()
                    );
                    return Ok(());
                }
            }
        }

        if is_port_open(self.preferred_port).await {
            // Idempotent fast-path: if we already own a running embedded
            // task, the listener on this port is us — not a stale external
            // process. Without this short-circuit, a second `ensure_running`
            // call (from BootCheckGate re-render, React StrictMode mount, or
            // any double-invoke of `start_core_process`) hits the
            // `identify_listener` path, identifies the listener as
            // OpenHuman, calls `takeover_stale_listener`, and aborts with
            // "stale-listener pid <self> matches the Tauri host pid;
            // refusing to self-terminate". (#1316 introduced the
            // frontend-driven `start_core_process` invoke without
            // hardening `ensure_running` against double-invoke.)
            {
                let guard = self.task.lock().await;
                if let Some(task) = guard.as_ref() {
                    if !task.is_finished() {
                        log::debug!(
                            "[core] ensure_running: embedded task already running on port {}, returning Ok (idempotent)",
                            self.port()
                        );
                        return Ok(());
                    }
                }
            }

            if reuse_existing_listener_enabled() {
                log::warn!(
                    "[core] OPENHUMAN_CORE_REUSE_EXISTING=1 — attaching to whatever is listening on port {} without identification (legacy behavior)",
                    self.preferred_port
                );
                return Ok(());
            }

            match identify_listener(self.preferred_port).await {
                ListenerKind::OpenHuman => {
                    log::warn!(
                        "[core] found stale OpenHuman listener on port {} — taking over (issue #1130)",
                        self.preferred_port
                    );
                    self.takeover_stale_listener().await?;
                    // Fall through to spawn-and-wait below.
                }
                ListenerKind::Unknown { reason } => {
                    if is_expected_port_clash(&reason) {
                        log::warn!(
                            "[core] preferred RPC port {} is occupied by non-OpenHuman listener ({reason}); attempting fallback bind range",
                            self.preferred_port
                        );
                    } else {
                        log::error!(
                            "[core] preferred RPC port {} occupied by unexpected listener ({reason}); attempting fallback bind range",
                            self.preferred_port
                        );
                    }
                }
            }
        }

        for startup_attempt in 0..=1u8 {
            let mut retry_after_takeover = false;
            let shutdown_token = self.fresh_shutdown_token().await;
            let (ready_tx, mut ready_rx) = tokio::sync::oneshot::channel::<
                openhuman_core::core::jsonrpc::EmbeddedReadySignal,
            >();
            let mut received_ready = false;

            {
                let mut guard = self.task.lock().await;
                if guard.is_none() {
                    let port = self.preferred_port;
                    // RPC bearer is handed to the embedded server in-memory
                    // via the `rpc_token` argument of
                    // run_server_embedded_with_ready (see below) — never
                    // through OPENHUMAN_CORE_TOKEN on the process env.
                    // Sidecar-era env-var transport was a leftover from the
                    // PR #1061 cleanup; with the core in-process there is no
                    // child process that needs the env crossing, and
                    // sharing the bearer via env put it within reach of any
                    // same-UID process that could read /proc/<pid>/environ
                    // (Linux) or sysctl KERN_PROCARGS2 / ps eww -p <pid>
                    // (macOS).
                    let token_for_core = self.rpc_token.clone();
                    // Surface the Tauri shell version to the in-process core so
                    // backend-bound HTTP requests can attach `x-tauri-version`
                    // analytics headers alongside `x-core-version`.
                    std::env::set_var("OPENHUMAN_TAURI_VERSION", env!("CARGO_PKG_VERSION"));
                    *self.active_port.write() = port;
                    *self.last_port_fallback.write() = None;

                    // Debug-build only: surface the RPC bearer token at a known
                    // tmpdir path so the e2e test runner (a separate Node process)
                    // can authenticate against the in-process core. Release builds
                    // never write this file. The test harness reads it from
                    // ${tmpdir}/openhuman-e2e-rpc-token.
                    //
                    // Token file is owner-read-write only (mode 0600) on Unix so a
                    // shared dev box doesn't leak the bearer to other local users.
                    #[cfg(debug_assertions)]
                    {
                        use std::io::Write as _;
                        let token_path = std::env::temp_dir().join("openhuman-e2e-rpc-token");
                        let write_result = (|| -> std::io::Result<()> {
                            let mut options = std::fs::OpenOptions::new();
                            options.create(true).write(true).truncate(true);
                            #[cfg(unix)]
                            {
                                use std::os::unix::fs::OpenOptionsExt as _;
                                options.mode(0o600);
                            }
                            let mut file = options.open(&token_path)?;
                            file.write_all(self.rpc_token.as_bytes())?;
                            file.sync_all()?;
                            Ok(())
                        })();
                        if let Err(err) = write_result {
                            log::warn!(
                                "[core] failed to write e2e token file at {}: {err}",
                                token_path.display()
                            );
                        } else {
                            log::debug!(
                                "[core] wrote e2e token file at {} (debug build only)",
                                token_path.display()
                            );
                        }
                    }
                    log::info!(
                        "[core] spawning embedded in-process core server on preferred port {port}"
                    );
                    let task = tokio::spawn(async move {
                        openhuman_core::core::jsonrpc::run_server_embedded_with_ready(
                            None,
                            Some(port),
                            true,
                            shutdown_token,
                            ready_tx,
                            // In-memory bearer handoff: the embedded server
                            // seeds its auth subsystem from this value via
                            // `auth::init_rpc_token_with_value`, so the token
                            // never crosses OPENHUMAN_CORE_TOKEN on the
                            // process env.
                            Some(token_for_core),
                        )
                        .await
                    });
                    *guard = Some(task);
                    // Publish only after the embedded server has been spawned
                    // with the in-memory bearer in scope. Setting this earlier
                    // would advertise a token to the frontend that the server
                    // hadn't loaded yet.
                    *CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
                    log::debug!("[auth] CURRENT_RPC_TOKEN set after embedded spawn");
                }
            }

            // Readiness budget: 600 iterations x 100ms = 60s (CORE_READY_ATTEMPTS).
            // The embedded core's JSON-RPC controller registry has grown over
            // time and earlier 4s/10s/20s budgets started flaking under CI
            // worker load (issue: core_process tests intermittently failing with
            // "core process did not become ready"), especially under
            // cargo-llvm-cov instrumentation where the binary runs ~2x
            // slower, and under desktop CEF startup contention. 60s is still
            // well under any user-visible startup
            // expectation: in normal runs the ready signal arrives in well
            // under 1s and the loop exits immediately; the headroom only
            // matters on heavily loaded instrumented CI workers.
            for _ in 0..CORE_READY_ATTEMPTS {
                if !received_ready {
                    match ready_rx.try_recv() {
                        Ok(ready_signal) => {
                            self.apply_embedded_ready_signal(ready_signal);
                            received_ready = true;
                        }
                        Err(TryRecvError::Empty) => {}
                        Err(TryRecvError::Closed) => {}
                    }
                }

                if received_ready && self.is_rpc_port_open().await {
                    log::info!("[core] core rpc became ready at {}", self.rpc_url());
                    return Ok(());
                }

                let mut guard = self.task.lock().await;
                if let Some(task) = guard.as_ref() {
                    if task.is_finished() {
                        let task = guard.take().expect("checked is_some");
                        drop(guard);
                        return match task.await {
                            Ok(Ok(())) => {
                                Err("in-process core server exited before becoming ready"
                                    .to_string())
                            }
                            Ok(Err(err)) => {
                                if let Some(openhuman_core::openhuman::connectivity::rpc::PickListenPortError::WouldTakeOver { preferred, .. }) = err
                                    .downcast_ref::<openhuman_core::openhuman::connectivity::rpc::PickListenPortError>()
                                {
                                    if startup_attempt == 0 {
                                        log::warn!(
                                            "[core] preferred port {} requested stale-listener takeover from embedded bind path; retrying once",
                                            preferred
                                        );
                                        self.takeover_stale_listener().await?;
                                        retry_after_takeover = true;
                                        break;
                                    }
                                }
                                Err(format!(
                                    "in-process core server exited before becoming ready: {err}"
                                ))
                            }
                            Err(err) => Err(format!(
                                "in-process core server task failed before ready: {err}"
                            )),
                        };
                    }
                }
                tokio::time::sleep(Duration::from_millis(CORE_READY_POLL_MS)).await;
            }

            if retry_after_takeover {
                continue;
            }

            // One last non-sleeping check avoids declaring a timeout when the
            // ready signal arrived during the final poll sleep.
            if !received_ready {
                if let Ok(ready_signal) = ready_rx.try_recv() {
                    self.apply_embedded_ready_signal(ready_signal);
                    received_ready = true;
                }
            }
            if received_ready && self.is_rpc_port_open().await {
                log::info!("[core] core rpc became ready at {}", self.rpc_url());
                return Ok(());
            }

            let port_open = self.is_rpc_port_open().await;
            return Err(self
                .cleanup_startup_timeout(received_ready, port_open, startup_attempt + 1)
                .await);
        }

        let port_open = self.is_rpc_port_open().await;
        Err(self.cleanup_startup_timeout(false, port_open, 2).await)
    }

    async fn cleanup_startup_timeout(
        &self,
        received_ready: bool,
        port_open: bool,
        attempt: u8,
    ) -> String {
        let port = self.port();
        let task_state = {
            let guard = self.task.lock().await;
            match guard.as_ref() {
                None => "missing",
                Some(task) if task.is_finished() => "finished",
                Some(_) => "running",
            }
        };
        log::error!(
            "[core] startup timed out after {CORE_READY_TIMEOUT_MS}ms \
             (port={port}, ready_signal={received_ready}, port_open={port_open}, \
             task_state={task_state}, attempt={attempt}); \
             aborting embedded startup task before retry"
        );
        self.cancel_shutdown_token(" after startup timeout").await;
        self.abort_task(" after startup timeout").await;
        format!(
            "core process did not become ready within {CORE_READY_TIMEOUT_MS}ms \
             (port={port}, ready_signal={received_ready}, port_open={port_open}, \
             task_state={task_state}, attempt={attempt})"
        )
    }

    pub(crate) fn apply_embedded_ready_signal(
        &self,
        ready: openhuman_core::core::jsonrpc::EmbeddedReadySignal,
    ) {
        *self.active_port.write() = ready.port;
        std::env::set_var("OPENHUMAN_CORE_RPC_URL", self.rpc_url());
        if let Some(preferred) = ready.fallback_from {
            let message = format!("port_fallback_engaged: {preferred} -> {}", ready.port);
            log::warn!("[core] {message}");
            sentry::add_breadcrumb(sentry::Breadcrumb {
                category: Some("core.port".to_string()),
                level: sentry::Level::Warning,
                message: Some(message),
                ..Default::default()
            });
            *self.last_port_fallback.write() = Some(PortFallbackNotice {
                preferred_port: preferred,
                chosen_port: ready.port,
            });
        } else {
            *self.last_port_fallback.write() = None;
        }
    }

    /// Identify the OS pid currently bound to our port and terminate it,
    /// then wait for the port to free. Used when the listener has been
    /// fingerprinted as an OpenHuman core (via `GET /`) so killing it is safe.
    async fn takeover_stale_listener(&self) -> Result<(), String> {
        let port = self.preferred_port;
        let pid = match find_pid_on_port(port) {
            Some(pid) => pid,
            None => {
                return Err(format!(
                    "could not determine pid bound to port {} — refusing to take over",
                    port
                ));
            }
        };
        let self_pid = std::process::id();
        if pid == self_pid {
            // Defensive — `ensure_running` checks the port before spawning,
            // so this branch should be unreachable. If it ever hits, killing
            // ourselves would be catastrophic.
            return Err(format!(
                "stale-listener pid {pid} matches the Tauri host pid; refusing to self-terminate"
            ));
        }
        log::warn!(
            "[core] terminating stale OpenHuman process pid={pid} on port {} (issue #1130)",
            port
        );
        if let Err(e) = kill_pid_term(pid) {
            return Err(format!("failed to signal stale openhuman pid {pid}: {e}"));
        }

        // Wait for the graceful exit, then revalidate ownership before any
        // force-kill — between the SIGTERM and a delayed SIGKILL the original
        // pid could have exited and been reused by an unrelated process. If
        // the port is now bound to a different pid (or to nothing), we do
        // NOT escalate to a force-kill against the originally-resolved pid.
        // (CodeRabbit feedback on #1166.)
        const GRACE_MS: u64 = 750;
        tokio::time::sleep(Duration::from_millis(GRACE_MS)).await;

        if is_port_open(port).await {
            match find_pid_on_port(port) {
                Some(current) if current == pid => {
                    log::warn!(
                        "[core] pid {pid} still bound to port {} after SIGTERM — escalating to SIGKILL",
                        port
                    );
                    if let Err(e) = kill_pid_force(pid) {
                        return Err(format!(
                            "failed to force-kill stale openhuman pid {pid}: {e}"
                        ));
                    }
                }
                Some(current) => {
                    return Err(format!(
                        "port {} rebounded to pid {current} after terminating pid {pid}; refusing to kill a different process",
                        port
                    ));
                }
                None => {
                    // Port still showed open in `is_port_open` but pid lookup
                    // returned nothing — likely a transient race with the
                    // listener tearing down. Fall through to the poll loop.
                }
            }
        }

        const POLL_MS: u64 = 100;
        const MAX_WAIT_MS: u64 = 5_000;
        let mut waited_ms: u64 = GRACE_MS;
        while is_port_open(port).await {
            if waited_ms >= MAX_WAIT_MS {
                return Err(format!(
                    "signaled pid {pid} but port {} remained bound after {MAX_WAIT_MS}ms",
                    port
                ));
            }
            tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
            waited_ms += POLL_MS;
        }
        log::info!(
            "[core] stale listener cleared (pid={pid}, port={}) after {waited_ms}ms",
            port
        );
        Ok(())
    }

    /// Restart the embedded core to pick up updated macOS permission grants.
    ///
    /// macOS caches permission state per-process; restarting forces a fresh
    /// read. If something else is bound to the port (e.g. a manual
    /// `openhuman-core run` harness) we surface that instead of looping.
    ///
    /// Issue: <https://github.com/tinyhumansai/openhuman/issues/133>
    pub async fn restart(&self) -> Result<(), String> {
        log::info!("[core] restarting embedded core server for permission refresh");

        let had_managed_task = {
            let guard = self.task.lock().await;
            guard.is_some()
        };

        self.shutdown().await;
        if !had_managed_task {
            log::debug!(
                "[core] restart: no managed embedded task was running; ensure_running will resolve ownership/fallback"
            );
        }

        const POLL_MS: u64 = 50;
        const MAX_WAIT_MS: u64 = 10_000;
        let mut waited_ms: u64 = 0;
        while self.is_rpc_port_open().await {
            if waited_ms >= MAX_WAIT_MS {
                return Err(format!(
                    "Core RPC port {} did not become free after stopping the embedded server.",
                    self.port()
                ));
            }
            tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
            waited_ms += POLL_MS;
        }

        let result = self.ensure_running().await;
        match &result {
            Ok(()) => log::info!("[core] restart: embedded core ready after restart"),
            Err(e) => log::error!("[core] restart: failed to restart embedded core: {e}"),
        }
        result
    }

    /// Lock the task slot, take its handle if any, and abort it. Shared by
    /// `shutdown` (cleanup-on-drop semantics) and `send_terminate_signal`
    /// (cooperative early teardown from `RunEvent::ExitRequested`).
    async fn abort_task(&self, log_context: &str) {
        let mut task_guard = self.task.lock().await;
        if let Some(task) = task_guard.take() {
            log::info!("[core] aborting embedded core server task{log_context}");
            task.abort();
        }
    }

    async fn fresh_shutdown_token(&self) -> CancellationToken {
        let mut guard = self.shutdown_token.lock().await;
        if guard.is_cancelled() {
            log::debug!("[core] resetting embedded core shutdown token for new spawn");
            *guard = CancellationToken::new();
        }
        guard.clone()
    }

    async fn cancel_shutdown_token(&self, log_context: &str) {
        let token = self.shutdown_token.lock().await.clone();
        if token.is_cancelled() {
            log::debug!("[core] embedded core shutdown token already cancelled{log_context}");
        } else {
            log::info!("[core] cancelling embedded core shutdown token{log_context}");
            token.cancel();
        }
    }

    #[cfg(test)]
    async fn shutdown_token_is_cancelled(&self) -> bool {
        self.shutdown_token.lock().await.is_cancelled()
    }

    /// Stop the embedded server task. Safe to call when nothing is running.
    pub async fn shutdown(&self) {
        self.cancel_shutdown_token("").await;
        let task = {
            let mut task_guard = self.task.lock().await;
            task_guard.take()
        };
        let Some(mut task) = task else {
            return;
        };

        match timeout(Duration::from_secs(5), &mut task).await {
            Ok(Ok(Ok(()))) => {
                log::info!("[core] embedded core server task stopped gracefully");
            }
            Ok(Ok(Err(err))) => {
                log::warn!("[core] embedded core server task ended during shutdown: {err}");
            }
            Ok(Err(err)) => {
                log::warn!("[core] embedded core server task join failed during shutdown: {err}");
            }
            Err(_) => {
                log::warn!(
                    "[core] graceful embedded core shutdown timed out; aborting server task"
                );
                task.abort();
                let _ = task.await;
            }
        }
    }

    /// Synchronous-friendly shutdown for `RunEvent::ExitRequested`.
    ///
    /// Aborts the embedded server task so any background tokio tasks the
    /// server spawned stop driving I/O before CEF's teardown runs. Cheap
    /// and non-blocking on the UI thread — `JoinHandle::abort` returns
    /// immediately.
    pub async fn send_terminate_signal(&self) {
        self.cancel_shutdown_token(" on app shutdown").await;
        self.abort_task(" on app shutdown").await;
    }
}

/// A non-OpenHuman process holding the core RPC port. Surfaced to the frontend
/// so the user can see what to free, and so the consent-gated force-quit knows
/// which pid the user agreed to terminate.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PortOwner {
    pub pid: u32,
    pub name: String,
}

/// Result returned to the frontend after a port-conflict auto-recovery attempt.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecoveryOutcome {
    pub success: bool,
    pub message: String,
    pub new_port: Option<u16>,
    /// Set when recovery failed because a foreign process still holds the port.
    /// `None` on success or when the owner can't be identified (e.g. a kernel
    /// reservation), in which case the frontend offers guidance, not a kill.
    pub foreign_owner: Option<PortOwner>,
}

impl CoreProcessHandle {
    /// Attempt to recover from a port conflict: reap stale OpenHuman processes,
    /// wait briefly for the port to free, then start the embedded core.
    ///
    /// Called from the `recover_port_conflict` Tauri command when the frontend's
    /// boot-check detects the core is unreachable due to a port conflict.
    pub async fn recover_port_conflict(&self) -> RecoveryOutcome {
        log::debug!(
            "[core_process] recover_port_conflict: starting recovery for port {}",
            self.preferred_port
        );

        tokio::task::spawn_blocking(crate::process_recovery::reap_stale_openhuman_processes)
            .await
            .unwrap_or_else(|e| {
                log::warn!("[core_process] recover_port_conflict: reap task panicked: {e}")
            });
        log::debug!("[core_process] recover_port_conflict: stale process reap complete");

        // Give the OS time to release the port after process termination.
        tokio::time::sleep(Duration::from_millis(500)).await;
        log::debug!("[core_process] recover_port_conflict: post-reap wait complete");

        match self.ensure_running().await {
            Ok(()) => {
                let new_port = self.port();
                log::info!(
                    "[core_process] recover_port_conflict: recovery succeeded, core on port {new_port}"
                );
                RecoveryOutcome {
                    success: true,
                    message: format!("Core recovered on port {new_port}"),
                    new_port: Some(new_port),
                    foreign_owner: None,
                }
            }
            Err(err) => {
                // Reaping stale OpenHuman processes didn't free the port, so a
                // foreign process is holding it. Identify it (name + pid) so the
                // frontend can surface it and offer a consent-gated force-quit.
                let port = self.preferred_port;
                let foreign_owner = tokio::task::spawn_blocking(move || identify_port_owner(port))
                    .await
                    .unwrap_or(None);
                log::warn!(
                    "[core_process] recover_port_conflict: recovery failed: {err}; foreign_owner={foreign_owner:?}"
                );
                RecoveryOutcome {
                    success: false,
                    message: format!("Recovery failed: {err}"),
                    new_port: None,
                    foreign_owner,
                }
            }
        }
    }

    /// Terminate the foreign process the user consented to (the pid surfaced in
    /// the prior [`RecoveryOutcome::foreign_owner`]), then restart the core.
    ///
    /// Re-validates that `expected_pid` still owns the port immediately before
    /// killing — closing the PID-reuse window and refusing if the owner changed,
    /// the port is already free, or the owner is OpenHuman's own process.
    pub async fn force_quit_port_owner(&self, expected_pid: u32) -> RecoveryOutcome {
        let port = self.preferred_port;
        let self_pid = std::process::id();
        log::info!("[core_process] force_quit_port_owner: port {port} expected_pid {expected_pid}");

        let current = tokio::task::spawn_blocking(move || find_pid_on_port(port))
            .await
            .unwrap_or(None);
        let pid = match validate_kill_target(current, expected_pid, self_pid) {
            Ok(pid) => pid,
            Err(reason) => {
                log::warn!("[core_process] force_quit_port_owner: refused: {reason}");
                return RecoveryOutcome {
                    success: false,
                    message: reason,
                    new_port: None,
                    foreign_owner: None,
                };
            }
        };

        // Best-effort: a TERM failure is non-fatal because the escalation below
        // re-validates ownership and force-kills if the process is still there.
        if let Err(err) =
            tokio::task::spawn_blocking(move || crate::process_kill::kill_pid_term(pid))
                .await
                .unwrap_or_else(|e| Err(format!("SIGTERM task panicked: {e}")))
        {
            log::debug!(
                "[core_process] force_quit_port_owner: SIGTERM pid {pid} (best-effort): {err}"
            );
        }
        tokio::time::sleep(Duration::from_millis(500)).await;

        // Re-validate before escalating: a bare `== Some(pid)` would SIGKILL a
        // different process that reused the pid and rebound the port within the
        // grace window, and would skip the self/protected-pid guards.
        let still = tokio::task::spawn_blocking(move || find_pid_on_port(port))
            .await
            .unwrap_or(None);
        if validate_kill_target(still, pid, self_pid).is_ok() {
            if let Err(err) =
                tokio::task::spawn_blocking(move || crate::process_kill::kill_pid_force(pid))
                    .await
                    .unwrap_or_else(|e| Err(format!("force-kill task panicked: {e}")))
            {
                log::warn!("[core_process] force_quit_port_owner: force-kill pid {pid}: {err}");
                return RecoveryOutcome {
                    success: false,
                    message: format!("Could not terminate pid {pid}: {err}"),
                    new_port: None,
                    foreign_owner: None,
                };
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }

        match self.ensure_running().await {
            Ok(()) => {
                let new_port = self.port();
                log::info!(
                    "[core_process] force_quit_port_owner: core recovered on port {new_port}"
                );
                RecoveryOutcome {
                    success: true,
                    message: format!("Core recovered on port {new_port}"),
                    new_port: Some(new_port),
                    foreign_owner: None,
                }
            }
            Err(err) => RecoveryOutcome {
                success: false,
                message: format!("Recovery failed after terminating pid {pid}: {err}"),
                new_port: None,
                foreign_owner: None,
            },
        }
    }
}

pub fn default_core_port() -> u16 {
    std::env::var("OPENHUMAN_CORE_PORT")
        .ok()
        .and_then(|v| v.parse::<u16>().ok())
        .unwrap_or(7788)
}

/// Whether `OPENHUMAN_CORE_REUSE_EXISTING` is set to a truthy value. Opts
/// back into the pre-#1130 behavior of attaching to whatever is listening
/// on the port without identification — useful for manual harnesses.
pub(crate) fn reuse_existing_listener_enabled() -> bool {
    std::env::var("OPENHUMAN_CORE_REUSE_EXISTING")
        .map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
        .unwrap_or(false)
}

async fn is_port_open(port: u16) -> bool {
    matches!(
        timeout(
            Duration::from_millis(150),
            TcpStream::connect(("127.0.0.1", port)),
        )
        .await,
        Ok(Ok(_))
    )
}

/// What is currently listening on the core RPC port.
#[derive(Debug)]
enum ListenerKind {
    /// `GET /` returned a JSON body with `"name": "openhuman"` — i.e. a
    /// stale OpenHuman core process from a previous build/session.
    OpenHuman,
    /// Either the listener didn't speak HTTP, didn't respond, or returned
    /// a body that doesn't identify as openhuman.
    Unknown { reason: String },
}

/// Probe `GET http://127.0.0.1:<port>/` to fingerprint the listener.
/// Unauthenticated — the core's root handler does not require a token.
async fn identify_listener(port: u16) -> ListenerKind {
    let url = format!("http://127.0.0.1:{port}/");
    let client = match reqwest::Client::builder()
        .timeout(Duration::from_millis(750))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            return ListenerKind::Unknown {
                reason: format!("reqwest client build failed: {e}"),
            };
        }
    };
    let resp = match client.get(&url).send().await {
        Ok(r) => r,
        Err(e) => {
            return ListenerKind::Unknown {
                reason: format!("probe GET / failed: {e}"),
            };
        }
    };
    if !resp.status().is_success() {
        return ListenerKind::Unknown {
            reason: format!("probe GET / returned status {}", resp.status()),
        };
    }
    let body = match resp.text().await {
        Ok(b) => b,
        Err(e) => {
            return ListenerKind::Unknown {
                reason: format!("probe GET / body read failed: {e}"),
            };
        }
    };
    if is_openhuman_root_body(&body) {
        log::info!("[core] listener on port {port} identified as openhuman core");
        ListenerKind::OpenHuman
    } else {
        let preview: String = body.chars().take(80).collect();
        ListenerKind::Unknown {
            reason: format!("probe GET / body did not identify as openhuman ({preview:?})"),
        }
    }
}

/// Pure parse of the root-handler JSON. Public-by-test so the fingerprinting
/// logic stays unit-testable without standing up an HTTP server.
fn is_openhuman_root_body(body: &str) -> bool {
    let value: serde_json::Value = match serde_json::from_str(body) {
        Ok(v) => v,
        Err(_) => return false,
    };
    value
        .get("name")
        .and_then(|v| v.as_str())
        .map(|s| s == "openhuman")
        .unwrap_or(false)
}

/// Returns true when a port conflict is deterministic environment state, not
/// a high-signal unknown squatter worth sending to Sentry at error level.
fn is_expected_port_clash(reason: &str) -> bool {
    let reason = reason.to_ascii_lowercase();
    reason.contains("error sending request for url")
        || reason.contains("connection refused")
        || reason.contains("returned status 404")
        || reason.contains("returned status 200")
        || reason.contains("body did not identify as openhuman")
        || reason.contains("already in use by another process")
        || reason.contains("os error 10013")
        || reason.contains("wsaeacces")
}

#[cfg(unix)]
fn find_pid_on_port(port: u16) -> Option<u32> {
    // Single port-qualified selector: `-iTCP:<port>` ANDs protocol+port. Two
    // separate `-i` flags (`-iTCP -i:<port>`) are ORed by lsof and would match
    // any TCP listener — risking returning an unrelated process as the owner.
    let output = std::process::Command::new("lsof")
        .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_lsof_pid(&String::from_utf8_lossy(&output.stdout))
}

#[cfg(windows)]
fn find_pid_on_port(port: u16) -> Option<u32> {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
    let output = std::process::Command::new("netstat")
        .args(["-ano", "-p", "TCP"])
        .creation_flags(CREATE_NO_WINDOW)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_netstat_pid(&String::from_utf8_lossy(&output.stdout), port)
}

/// Pure parse of `lsof -t` output (one pid per line; first wins).
fn parse_lsof_pid(stdout: &str) -> Option<u32> {
    stdout
        .lines()
        .map(str::trim)
        .find(|l| !l.is_empty())
        .and_then(|l| l.parse::<u32>().ok())
}

/// Pure parse of `netstat -ano` output for a LISTENING entry on `port`.
///
/// Skips kernel-protected PIDs 0 (System Idle Process) and 4 (NT Kernel) —
/// `HTTP.sys` and kernel-mode socket reservations occasionally surface as
/// LISTENING under PID 4 even though no user-mode owner exists. Killing
/// those is impossible and would otherwise abort startup recovery; if the
/// "owner" is the kernel, callers should fall back to a port reroute
/// instead of trying to take over.
#[allow(dead_code)] // exercised only on windows builds
fn parse_netstat_pid(stdout: &str, port: u16) -> Option<u32> {
    let needle = format!(":{port}");
    for line in stdout.lines() {
        let trimmed = line.trim();
        if !trimmed.contains("LISTENING") {
            continue;
        }
        let parts: Vec<&str> = trimmed.split_whitespace().collect();
        // Expected: ["TCP", "127.0.0.1:7788", "0.0.0.0:0", "LISTENING", "1234"]
        if parts.len() >= 5 && parts[1].ends_with(&needle) {
            if let Ok(pid) = parts[parts.len() - 1].parse::<u32>() {
                if pid == 0 || pid == 4 {
                    log::warn!(
                        "[core] netstat reports port {port} owned by protected windows pid {pid}; treating as no-owner"
                    );
                    continue;
                }
                return Some(pid);
            }
        }
    }
    None
}

/// Identify the process currently holding `port`, resolving its pid (via
/// [`find_pid_on_port`]) to a human-readable name. Returns `None` when the
/// port is free or owned by an unidentifiable (e.g. kernel-reserved) holder.
fn identify_port_owner(port: u16) -> Option<PortOwner> {
    let pid = find_pid_on_port(port)?;
    // Empty when the name can't be resolved — the frontend already shows the
    // pid, so a `"PID <n>"` fallback here would render as "PID n (PID n)".
    let name = process_name(pid).unwrap_or_default();
    Some(PortOwner { pid, name })
}

#[cfg(windows)]
fn process_name(pid: u32) -> Option<String> {
    use std::os::windows::process::CommandExt;
    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
    let output = std::process::Command::new("tasklist")
        .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
        .creation_flags(CREATE_NO_WINDOW)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_tasklist_name(&String::from_utf8_lossy(&output.stdout))
}

#[cfg(unix)]
fn process_name(pid: u32) -> Option<String> {
    let output = std::process::Command::new("ps")
        .args(["-p", &pid.to_string(), "-o", "comm="])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_ps_comm(&String::from_utf8_lossy(&output.stdout))
}

/// Pure parse of `tasklist /FO CSV /NH`: the image name is the first quoted
/// CSV field (`"chrome.exe","1234",...`). The "no tasks" sentinel and any
/// non-CSV line yield `None`.
#[allow(dead_code)] // exercised only on windows builds
fn parse_tasklist_name(stdout: &str) -> Option<String> {
    let line = stdout.lines().map(str::trim).find(|l| !l.is_empty())?;
    let name = line.strip_prefix('"')?.split('"').next()?.trim();
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

/// Pure parse of `ps -p <pid> -o comm=`: a single line, reduced to its
/// trailing path component so a full executable path collapses to a file name.
#[allow(dead_code)] // exercised only on unix builds
fn parse_ps_comm(stdout: &str) -> Option<String> {
    let line = stdout.lines().map(str::trim).find(|l| !l.is_empty())?;
    let name = line.rsplit(['/', '\\']).next().unwrap_or(line).trim();
    if name.is_empty() {
        None
    } else {
        Some(name.to_string())
    }
}

/// Decide whether the consent-gated force-quit may proceed. `current_owner` is
/// the pid `find_pid_on_port` reports right before the kill; `expected_pid` is
/// the owner the user was shown and agreed to terminate. Returns the pid to
/// kill, or the reason the kill must be refused.
fn validate_kill_target(
    current_owner: Option<u32>,
    expected_pid: u32,
    self_pid: u32,
) -> Result<u32, String> {
    match current_owner {
        None => Err("the port is no longer held by any process".to_string()),
        Some(pid) if pid == self_pid => Err(format!(
            "refusing to terminate OpenHuman's own process (pid {pid})"
        )),
        // Defense-in-depth: low/kernel pids are never legitimate port owners and
        // must never be signalled, even if one slipped past `find_pid_on_port`.
        // 0 — `kill(0, sig)` broadcasts to the caller's whole process group on
        // Unix; 1 — init/launchd; 4 — Windows NT kernel.
        Some(pid) if pid <= 1 || pid == 4 => {
            Err(format!("refusing to terminate protected pid {pid}"))
        }
        Some(pid) if pid != expected_pid => Err(format!(
            "port owner changed (was pid {expected_pid}, now pid {pid}); not terminating"
        )),
        Some(pid) => Ok(pid),
    }
}

#[cfg(test)]
#[path = "core_process_tests.rs"]
mod tests;
