//! Self-update mechanism.
//!
//! Checks the hosted herdr.dev update manifest for newer versions.
//! Manual `herdr update` downloads and installs the binary.
//! Background checks only surface availability and release notes.
//! Uses `curl` as a subprocess for HTTP — no additional Rust HTTP dependencies.
//! JSON parsing uses serde_json (already in deps for persistence).

use std::collections::BTreeMap;
use std::env;
use std::fs;
#[cfg(not(windows))]
use std::io;
#[cfg(not(windows))]
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(not(windows))]
use std::time::{Duration, Instant};

#[cfg(not(windows))]
use interprocess::local_socket::traits::Stream as _;
use serde::{Deserialize, Deserializer};

const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
const HOMEBREW_FORMULA_API_URL: &str = "https://formulae.brew.sh/api/formula/herdr.json";
const HERDR_UPDATE_COMMAND: &str = "herdr update";
const HOMEBREW_UPDATE_COMMAND: &str = "brew update && brew upgrade herdr";
const MISE_UPDATE_COMMAND: &str = "mise upgrade herdr";
const NIX_UPDATE_COMMAND: &str = "update through Nix";
const MISE_INSTALLS_DIR_ENV: &str = "MISE_INSTALLS_DIR";
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
#[cfg(not(windows))]
const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(not(windows))]
const SERVER_HANDOFF_REQUEST_TIMEOUT: Duration = Duration::from_secs(240);
#[cfg(not(windows))]
const SERVER_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(windows))]
const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
fn fake_release_notes_body(version: &str) -> String {
    let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV)
        .ok()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| DEFAULT_FAKE_UPDATE_NOTES_VERSION.to_string());

    if let Some(notes) = crate::release_notes::load_preview_from_local_changelog(&notes_version) {
        return notes.body;
    }

    format!(
        "### Changed\n- Test update v{version} for local UI validation.\n\n### Notes\n- This is stub release-notes content generated by {FAKE_UPDATE_VERSION_ENV}."
    )
}

// ---------------------------------------------------------------------------
// Version
// ---------------------------------------------------------------------------

/// Parsed semver version for comparison.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
    pub major: u32,
    pub minor: u32,
    pub patch: u32,
}

impl Version {
    pub fn parse(s: &str) -> Option<Self> {
        let s = s.strip_prefix('v').unwrap_or(s);
        let parts: Vec<&str> = s.split('.').collect();
        if parts.len() != 3 {
            return None;
        }
        Some(Self {
            major: parts[0].parse().ok()?,
            minor: parts[1].parse().ok()?,
            patch: parts[2].parse().ok()?,
        })
    }

    pub fn current() -> Self {
        Self::parse(crate::build_info::BASE_VERSION).expect("invalid CARGO_PKG_VERSION")
    }
}

impl std::fmt::Display for Version {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
    }
}

// ---------------------------------------------------------------------------
// Update manifest
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpdateChannel {
    Stable,
    Preview,
}

impl UpdateChannel {
    fn configured() -> Self {
        match crate::config::Config::load().config.update.channel {
            crate::config::UpdateChannelConfig::Stable => Self::Stable,
            crate::config::UpdateChannelConfig::Preview => Self::Preview,
        }
    }

    fn as_str(self) -> &'static str {
        match self {
            Self::Stable => "stable",
            Self::Preview => "preview",
        }
    }
}

#[derive(Debug, Clone)]
struct AssetRef {
    url: String,
    sha256: Option<String>,
    #[cfg(windows)]
    format: Option<String>,
}

impl<'de> Deserialize<'de> for AssetRef {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = serde_json::Value::deserialize(deserializer)?;
        match value {
            serde_json::Value::String(url) if !url.trim().is_empty() => Ok(Self {
                url: url.trim().to_string(),
                sha256: None,
                #[cfg(windows)]
                format: None,
            }),
            serde_json::Value::Object(mut object) => {
                let url = object
                    .remove("url")
                    .and_then(|value| value.as_str().map(str::to_string))
                    .ok_or_else(|| serde::de::Error::custom("asset object is missing url"))?;
                let sha256 = object
                    .remove("sha256")
                    .and_then(|value| value.as_str().map(str::to_string));
                #[cfg(windows)]
                let format = object
                    .remove("format")
                    .and_then(|value| value.as_str().map(str::to_string));
                if url.trim().is_empty() {
                    return Err(serde::de::Error::custom("asset url must not be empty"));
                }
                Ok(Self {
                    url: url.trim().to_string(),
                    sha256: sha256.filter(|value| !value.trim().is_empty()),
                    #[cfg(windows)]
                    format: format.filter(|value| !value.trim().is_empty()),
                })
            }
            _ => Err(serde::de::Error::custom(
                "asset must be a URL string or object with url",
            )),
        }
    }
}

#[cfg(windows)]
impl AssetRef {
    fn package_format(&self) -> Result<String, String> {
        let format = self
            .format
            .clone()
            .unwrap_or_else(|| {
                if self.url.to_ascii_lowercase().ends_with(".zip") {
                    "zip"
                } else {
                    "exe"
                }
                .into()
            })
            .to_ascii_lowercase();
        match format.as_str() {
            "zip" | "exe" => Ok(format),
            _ => Err(format!(
                "update manifest asset has unsupported format '{format}'"
            )),
        }
    }
}

#[derive(Deserialize)]
struct UpdateManifest {
    version: String,
    /// Thin-client protocol spoken by this release, when advertised by the manifest.
    #[cfg(not(windows))]
    protocol: Option<u32>,
    notes: String,
    assets: BTreeMap<String, AssetRef>,
    #[serde(default)]
    sha256: BTreeMap<String, String>,
    announcement: Option<serde_json::Value>,
    #[serde(default, deserialize_with = "deserialize_manifest_releases")]
    releases: BTreeMap<String, serde_json::Value>,
}

fn deserialize_manifest_releases<'de, D>(
    deserializer: D,
) -> Result<BTreeMap<String, serde_json::Value>, D::Error>
where
    D: Deserializer<'de>,
{
    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
    Ok(match value {
        Some(serde_json::Value::Object(object)) => object.into_iter().collect(),
        _ => BTreeMap::new(),
    })
}

#[derive(Deserialize)]
struct ManifestReleaseMetadata {
    notes: String,
    announcement: Option<serde_json::Value>,
}

#[derive(Deserialize)]
struct PreviewManifest {
    channel: String,
    base_version: String,
    build_id: String,
    commit: String,
    built_at: String,
    protocol: u32,
    notes: String,
    assets: BTreeMap<String, AssetRef>,
    #[serde(default)]
    builds: BTreeMap<String, PreviewBuildMetadata>,
}

#[derive(Deserialize)]
struct PreviewBuildMetadata {
    base_version: String,
    commit: String,
    built_at: String,
    protocol: u32,
    assets: BTreeMap<String, AssetRef>,
}

#[derive(Deserialize)]
struct HomebrewFormula {
    versions: HomebrewFormulaVersions,
}

#[derive(Deserialize)]
struct HomebrewFormulaVersions {
    stable: String,
}

impl UpdateManifest {
    #[cfg(all(test, unix))]
    fn download_url_for(&self, os: &str, arch: &str) -> Option<String> {
        self.assets
            .get(&format!("{os}-{arch}"))
            .map(|asset| asset.url.clone())
    }

    fn metadata_for_version(&self, version: &Version) -> Option<ManifestReleaseMetadata> {
        let version = version.to_string();
        if self.version.trim_start_matches('v') == version {
            return Some(ManifestReleaseMetadata {
                notes: self.notes.clone(),
                announcement: self.announcement.clone(),
            });
        }

        self.releases.get(&version).and_then(|release| {
            let metadata =
                serde_json::from_value::<ManifestReleaseMetadata>(release.clone()).ok()?;
            (!metadata.notes_body().is_empty()).then_some(metadata)
        })
    }
}

impl ManifestReleaseMetadata {
    fn notes_body(&self) -> String {
        self.notes.trim().to_string()
    }
}

// ---------------------------------------------------------------------------
// Release info
// ---------------------------------------------------------------------------

/// Information about an available update.
#[derive(Debug, Clone)]
struct ReleaseInfo {
    version: Version,
    identity: String,
    channel: UpdateChannel,
    build_id: Option<String>,
    commit: Option<String>,
    #[cfg(not(windows))]
    target_protocol: Option<u32>,
    download_url: String,
    sha256: Option<String>,
    #[cfg(windows)]
    package_format: String,
    notes_body: String,
}

impl ReleaseInfo {
    fn label(&self) -> &str {
        &self.identity
    }
}

fn fetch_update_manifest() -> Result<UpdateManifest, String> {
    fetch_json_manifest(STABLE_UPDATE_MANIFEST_URL)
}

fn fetch_preview_manifest() -> Result<PreviewManifest, String> {
    fetch_json_manifest(PREVIEW_UPDATE_MANIFEST_URL)
}

fn fetch_json_manifest<T>(url: &str) -> Result<T, String>
where
    T: serde::de::DeserializeOwned,
{
    let output = crate::noninteractive_process::curl_command()
        .args([
            "-sfL",
            "--retry",
            "3",
            "--connect-timeout",
            "10",
            "--max-time",
            "20",
            url,
        ])
        .output()
        .map_err(|e| format!("curl failed: {e}"))?;

    if !output.status.success() {
        return Err("failed to fetch update manifest".into());
    }

    serde_json::from_slice(&output.stdout)
        .map_err(|e| format!("failed to parse update manifest JSON: {e}"))
}

fn handle_manifest_announcement(version: &str, value: Option<&serde_json::Value>) {
    let announcement = match value {
        Some(value) => match serde_json::from_value::<
            crate::product_announcements::ManifestAnnouncement,
        >(value.clone())
        {
            Ok(announcement) => Some(announcement),
            Err(err) => {
                tracing::warn!("skipping invalid product announcement in update manifest: {err}");
                None
            }
        },
        None => None,
    };

    if let Err(err) =
        crate::product_announcements::save_manifest_announcement(version, announcement.as_ref())
    {
        tracing::warn!("failed to save product announcement: {err}");
    }
}

fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<ReleaseInfo>, String> {
    let current = Version::current();
    let latest = Version::parse(&manifest.version)
        .ok_or_else(|| format!("invalid version in update manifest: {}", manifest.version))?;

    if !stable_channel_should_install(&latest, &current, crate::build_info::is_preview()) {
        return Ok(None); // up to date
    }

    let metadata = manifest
        .metadata_for_version(&latest)
        .ok_or_else(|| format!("missing release metadata for v{latest}"))?;
    let notes_body = metadata.notes_body();
    if notes_body.is_empty() {
        return Err("update manifest notes are empty".into());
    }

    let (os, arch) = platform_target();
    let asset_key = format!("{os}-{arch}");
    let asset = manifest
        .assets
        .get(&asset_key)
        .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?;
    let download_url = asset.url.clone();
    let sha256 = asset
        .sha256
        .clone()
        .or_else(|| manifest.sha256.get(&asset_key).cloned())
        .ok_or_else(|| {
            format!("update manifest asset {asset_key} is missing a SHA-256 checksum")
        })?;

    Ok(Some(ReleaseInfo {
        identity: latest.to_string(),
        version: latest,
        channel: UpdateChannel::Stable,
        build_id: None,
        commit: None,
        #[cfg(not(windows))]
        target_protocol: manifest.protocol,
        download_url,
        sha256: Some(sha256),
        #[cfg(windows)]
        package_format: asset.package_format()?,
        notes_body,
    }))
}

fn stable_channel_should_install(
    latest: &Version,
    current: &Version,
    installed_is_preview: bool,
) -> bool {
    installed_is_preview || latest > current
}

fn preview_display_version(base_version: &str, build_id: &str) -> String {
    format!(
        "{}-preview.{}",
        base_version.trim_start_matches('v'),
        build_id
    )
}

fn release_info_from_preview_manifest(
    manifest: &PreviewManifest,
) -> Result<Option<ReleaseInfo>, String> {
    if manifest.channel != "preview" {
        return Err(format!(
            "invalid preview manifest channel: {}",
            manifest.channel
        ));
    }
    let build_id = manifest.build_id.trim();
    if build_id.is_empty() {
        return Err("preview manifest build_id is empty".into());
    }
    if crate::build_info::is_preview()
        && crate::build_info::build_id().is_some_and(|current| current == build_id)
    {
        return Ok(None);
    }

    let version = Version::parse(&manifest.base_version).ok_or_else(|| {
        format!(
            "invalid base_version in preview manifest: {}",
            manifest.base_version
        )
    })?;
    let notes_body = manifest.notes.trim().to_string();
    if notes_body.is_empty() {
        return Err("preview manifest notes are empty".into());
    }
    let (os, arch) = platform_target();
    let asset_key = format!("{os}-{arch}");
    if let Some(archived) = manifest.builds.get(build_id) {
        if archived.base_version != manifest.base_version
            || archived.commit != manifest.commit
            || archived.built_at != manifest.built_at
            || archived.protocol != manifest.protocol
        {
            tracing::warn!(
                build_id,
                "preview manifest archived build metadata differs from top-level metadata"
            );
        }
    }
    let asset = manifest
        .assets
        .get(&asset_key)
        .or_else(|| {
            manifest
                .builds
                .get(build_id)
                .and_then(|build| build.assets.get(&asset_key))
        })
        .ok_or_else(|| format!("no binary for {asset_key} in preview manifest"))?;
    let download_url = asset.url.clone();

    Ok(Some(ReleaseInfo {
        identity: preview_display_version(&manifest.base_version, build_id),
        version,
        channel: UpdateChannel::Preview,
        build_id: Some(build_id.to_string()),
        commit: Some(manifest.commit.clone()),
        #[cfg(not(windows))]
        target_protocol: Some(manifest.protocol),
        download_url,
        sha256: asset.sha256.clone(),
        #[cfg(windows)]
        package_format: asset.package_format()?,
        notes_body,
    }))
}

/// Check the hosted update manifest for the latest release. Returns release info if newer.
fn first_windows_stable_is_pending(
    manifest: &UpdateManifest,
    is_windows: bool,
    installed_is_preview: bool,
) -> bool {
    is_windows && installed_is_preview && !manifest.assets.contains_key("windows-x86_64")
}

fn check_latest() -> Result<Option<ReleaseInfo>, String> {
    let channel = UpdateChannel::configured();
    if channel == UpdateChannel::Preview {
        return release_info_from_preview_manifest(&fetch_preview_manifest()?);
    }

    let manifest = fetch_update_manifest()?;
    if first_windows_stable_is_pending(&manifest, cfg!(windows), crate::build_info::is_preview()) {
        tracing::info!("waiting for the first stable Windows release");
        return Ok(None);
    }
    let release = release_info_from_manifest(&manifest)?;
    if let Some(release) = &release {
        if let Some(metadata) = manifest.metadata_for_version(&release.version) {
            handle_manifest_announcement(
                &release.version.to_string(),
                metadata.announcement.as_ref(),
            );
        }
    }
    Ok(release)
}

fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result<Version, String> {
    let formula: HomebrewFormula = serde_json::from_slice(input)
        .map_err(|e| format!("failed to parse Homebrew formula JSON: {e}"))?;
    Version::parse(&formula.versions.stable).ok_or_else(|| {
        format!(
            "invalid stable version in Homebrew formula JSON: {}",
            formula.versions.stable
        )
    })
}

fn homebrew_update_from_formula_json(
    input: &[u8],
    current: &Version,
) -> Result<Option<Version>, String> {
    let latest = parse_homebrew_formula_stable_version(input)?;
    if &latest <= current {
        return Ok(None);
    }

    Ok(Some(latest))
}

fn check_homebrew_latest() -> Result<Option<Version>, String> {
    let current = Version::current();

    let output = crate::noninteractive_process::curl_command()
        .args([
            "-sfL",
            "--retry",
            "2",
            "--connect-timeout",
            "5",
            "--max-time",
            "10",
            HOMEBREW_FORMULA_API_URL,
        ])
        .output()
        .map_err(|e| format!("curl failed: {e}"))?;

    if !output.status.success() {
        return Err("failed to fetch Homebrew formula JSON".into());
    }

    homebrew_update_from_formula_json(&output.stdout, &current)
}

// ---------------------------------------------------------------------------
// Download + install
// ---------------------------------------------------------------------------

#[cfg(not(windows))]
struct DownloadedUpdate {
    current_exe: PathBuf,
    tmp_path: Option<PathBuf>,
}

#[cfg(not(windows))]
impl Drop for DownloadedUpdate {
    fn drop(&mut self) {
        if let Some(tmp_path) = self.tmp_path.take() {
            let _ = fs::remove_file(tmp_path);
        }
    }
}

/// Download a release to a prepared executable temp file without touching the running server.
#[cfg(not(windows))]
fn download_update(release: &ReleaseInfo) -> Result<DownloadedUpdate, String> {
    let current_exe = env::current_exe().map_err(|e| format!("can't find current binary: {e}"))?;

    let parent = current_exe.parent().ok_or("can't find binary directory")?;

    // Check write permissions early
    let test_path = parent.join(".herdr-write-test");
    if let Err(e) = fs::write(&test_path, b"") {
        let _ = fs::remove_file(&test_path);
        return Err(format!(
            "install directory not writable: {} ({}). Try running with appropriate permissions.",
            parent.display(),
            e
        ));
    }
    let _ = fs::remove_file(&test_path);

    // Unique temp file (avoids races with concurrent instances)
    let tmp_path = parent.join(format!(".herdr-update-{}.tmp", std::process::id()));

    // Download the exact asset URL (pinned to the release we checked)
    let status = crate::noninteractive_process::curl_command()
        .args(["-sfL", "--max-time", "120", "-o"])
        .arg(&tmp_path)
        .arg(&release.download_url)
        .status()
        .map_err(|e| format!("download failed: {e}"))?;

    if !status.success() {
        let _ = fs::remove_file(&tmp_path);
        return Err("download failed".into());
    }

    if let Some(expected) = &release.sha256 {
        if let Err(e) = crate::checksum::verify_sha256(&tmp_path, expected) {
            let _ = fs::remove_file(&tmp_path);
            return Err(format!(
                "downloaded update checksum verification failed: {e}"
            ));
        }
        tracing::info!(sha256 = %expected, "downloaded update checksum verified");
    }

    // Make executable
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755)) {
            let _ = fs::remove_file(&tmp_path);
            return Err(format!("chmod failed: {e}"));
        }
    }

    Ok(DownloadedUpdate {
        current_exe,
        tmp_path: Some(tmp_path),
    })
}

#[cfg(not(windows))]
fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> {
    let tmp_path = update
        .tmp_path
        .take()
        .ok_or("downloaded update temp file is missing")?;

    // Atomic replace — rename over the current binary.
    // On Linux, the running process keeps its fd to the old inode.
    // Next launch picks up the new file.
    if let Err(e) = fs::rename(&tmp_path, &update.current_exe) {
        let _ = fs::remove_file(&tmp_path);
        return Err(format!("failed to replace binary: {e}"));
    }

    Ok(())
}

#[cfg(windows)]
const WINDOWS_INSTALLER: &str = include_str!("../website/install.ps1");

#[cfg(windows)]
struct DownloadedWindowsUpdate {
    package_path: PathBuf,
    installer_path: PathBuf,
}

#[cfg(windows)]
impl Drop for DownloadedWindowsUpdate {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.package_path);
        let _ = fs::remove_file(&self.installer_path);
    }
}

#[cfg(windows)]
fn download_windows_update(release: &ReleaseInfo) -> Result<DownloadedWindowsUpdate, String> {
    let expected_sha256 = release
        .sha256
        .as_deref()
        .ok_or("Windows update asset is missing a SHA-256 checksum")?;
    let stem = format!("herdr-update-{}", std::process::id());
    let update = DownloadedWindowsUpdate {
        package_path: env::temp_dir().join(format!("{stem}.{}", release.package_format)),
        installer_path: env::temp_dir().join(format!("{stem}.ps1")),
    };
    fs::write(&update.installer_path, WINDOWS_INSTALLER)
        .map_err(|err| format!("failed to prepare Windows installer: {err}"))?;

    let status = crate::noninteractive_process::curl_command()
        .args(["-sfL", "--max-time", "120", "-o"])
        .arg(&update.package_path)
        .arg(&release.download_url)
        .status()
        .map_err(|err| format!("download failed: {err}"))?;
    if !status.success() {
        return Err("download failed".into());
    }
    crate::checksum::verify_sha256(&update.package_path, expected_sha256)
        .map_err(|err| format!("downloaded update checksum verification failed: {err}"))?;
    tracing::info!(sha256 = %expected_sha256, "downloaded update checksum verified");

    Ok(update)
}

#[cfg(windows)]
fn install_windows_update_with_installer(
    release: &ReleaseInfo,
    update: &DownloadedWindowsUpdate,
) -> Result<(), String> {
    let expected_sha256 = release
        .sha256
        .as_deref()
        .ok_or("Windows update asset is missing a SHA-256 checksum")?;
    let mut command = Command::new("powershell");
    command
        .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
        .arg(&update.installer_path)
        .args(["-Channel", release.channel.as_str(), "-LocalPackagePath"])
        .arg(&update.package_path)
        .args([
            "-LocalPackageFormat",
            &release.package_format,
            "-LocalPackageIdentity",
            release.label(),
            "-LocalPackageSha256",
            expected_sha256,
        ])
        // Drop any inherited PSModulePath. When herdr is launched from
        // PowerShell 7, its Core module paths come first and Windows
        // PowerShell 5.1 (this `powershell`) fails to autoload cmdlets like
        // Get-FileHash. Removing it lets 5.1 compute its own default path.
        // See PowerShell/PowerShell#8635.
        .env_remove("PSModulePath");
    let status = command
        .status()
        .map_err(|err| format!("failed to run Windows installer: {err}"))?;

    if !status.success() {
        return Err(format!("Windows installer failed with status {status}"));
    }

    Ok(())
}

#[cfg(windows)]
fn windows_installed_herdr_exe_path() -> Result<PathBuf, String> {
    if let Some(install_dir) = env::var_os("HERDR_INSTALL_DIR").filter(|value| !value.is_empty()) {
        return Ok(PathBuf::from(install_dir).join("herdr.exe"));
    }

    let local_app_data = env::var_os("LOCALAPPDATA")
        .ok_or("LOCALAPPDATA is not set; cannot locate Herdr install")?;
    Ok(PathBuf::from(local_app_data)
        .join("Programs")
        .join("Herdr")
        .join("bin")
        .join("herdr.exe"))
}

// ---------------------------------------------------------------------------
// Upgrade flow helpers
// ---------------------------------------------------------------------------

fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool {
    herdr_env == Some(crate::HERDR_ENV_VALUE)
}

fn running_inside_herdr() -> bool {
    running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref())
}

#[cfg(not(windows))]
fn client_protocol_server_is_running_at(socket_path: &Path) -> bool {
    if !socket_path.exists() {
        return false;
    }

    crate::ipc::connect_local_stream(socket_path).is_ok()
}

#[cfg(not(windows))]
fn client_protocol_server_is_running() -> bool {
    client_protocol_server_is_running_at(&crate::server::socket_paths::client_socket_path())
}

#[cfg(not(windows))]
fn version_label(version: Option<&str>) -> &str {
    version.unwrap_or("unknown")
}

#[cfg(not(windows))]
fn update_requires_server_restart(
    server: &crate::api::RuntimeStatus,
    release: &ReleaseInfo,
) -> bool {
    match (server.protocol, release.target_protocol) {
        (Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol,
        _ => true,
    }
}

#[cfg(not(windows))]
fn server_supports_live_handoff(server: &crate::api::RuntimeStatus) -> bool {
    server
        .capabilities
        .as_ref()
        .is_some_and(|capabilities| capabilities.live_handoff)
}

#[cfg(not(windows))]
fn parse_stop_old_servers_after_update_response(input: &str, default_yes: bool) -> Option<bool> {
    let trimmed = input.trim().to_ascii_lowercase();
    match trimmed.as_str() {
        "" => Some(default_yes),
        "y" | "yes" => Some(true),
        "n" | "no" => Some(false),
        _ => None,
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(windows))]
struct RunningServerUpdatePlan {
    target: RunningUpdateTarget,
    server: crate::api::RuntimeStatus,
    requires_server_restart: bool,
}

#[cfg(not(windows))]
impl RunningServerUpdatePlan {
    fn label(&self) -> &str {
        &self.target.label
    }

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

    fn stop_command(&self) -> String {
        self.target.stop_command.clone()
    }

    fn attach_command(&self) -> Option<String> {
        self.target.attach_command.clone()
    }

    fn target_noun(&self) -> &'static str {
        if self.target.attach_command.is_some() {
            "session"
        } else {
            "server"
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(windows))]
struct RunningServerUpdateDecision {
    plan: RunningServerUpdatePlan,
    action: RunningServerUpdateAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(windows))]
enum RunningServerUpdateAction {
    None,
    LiveHandoff,
    StopOldServer,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(windows))]
enum RunningServerUpdateOutcome {
    RestartDeferred,
    Stopped,
    LiveHandoffComplete,
    FailedHandoffOldServerKept,
    FailedHandoffOldServerStopped,
    FailedHandoffNoServer,
    FailedHandoffUnknown,
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(windows))]
struct RunningSessionUpdateOutcome {
    session_label: String,
    target_noun: &'static str,
    stop_command: String,
    attach_command: Option<String>,
    server_version: Option<String>,
    outcome: RunningServerUpdateOutcome,
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(windows))]
enum FailedHandoffServerState {
    UpdatedServerRunning,
    OldServerRunning(crate::api::RuntimeStatus),
    NoServerResponding,
    Unknown(String),
}

#[cfg(not(windows))]
fn plan_running_server_updates(
    release: &ReleaseInfo,
) -> Result<Vec<RunningServerUpdatePlan>, String> {
    let targets = running_update_targets()?;
    let mut plans = Vec::new();

    for target in targets {
        let server = match crate::api::read_runtime_status_at(
            &target.socket_path,
            SERVER_STOP_RESPONSE_TIMEOUT,
        )
        .map_err(|err| {
            format!(
                "failed to read status for herdr target {} at {}: {err}. stop it with `{}` and run `herdr update` again",
                target.label,
                target.socket_path.display(),
                target.stop_command
            )
        })? {
            Some(server) => server,
            None if target.must_be_running => {
                return Err(format!(
                        "herdr target {} looked running, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again",
                    target.label,
                    target.socket_path.display(),
                    target.stop_command
                ));
            }
            None if client_protocol_server_is_running_at(&target.client_socket_path) => {
                return Err(format!(
                    "herdr target {} has a client socket, but its status API did not respond at {}. stop it with `{}` and run `herdr update` again",
                    target.label,
                    target.socket_path.display(),
                    target.stop_command
                ));
            }
            None => continue,
        };

        plans.push(RunningServerUpdatePlan {
            requires_server_restart: update_requires_server_restart(&server, release),
            server,
            target,
        });
    }

    if plans.is_empty() && target_client_protocol_server_is_running()? {
        return Err(format!(
            "a herdr server is listening, but its status API is unavailable; try `{}`, or stop the old server process manually, then run `herdr update` again",
            crate::session::local_stop_command()
        ));
    }

    Ok(plans)
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(windows))]
struct RunningUpdateTarget {
    name: Option<String>,
    label: String,
    stop_command: String,
    attach_command: Option<String>,
    socket_path: PathBuf,
    client_socket_path: PathBuf,
    must_be_running: bool,
}

#[cfg(not(windows))]
fn running_update_targets() -> Result<Vec<RunningUpdateTarget>, String> {
    if crate::session::explicit_session_requested() {
        return Ok(vec![RunningUpdateTarget {
            name: crate::session::active_name(),
            label: crate::session::active_name()
                .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()),
            stop_command: crate::session::local_stop_command(),
            attach_command: Some(crate::session::local_attach_command()),
            socket_path: crate::api::socket_path(),
            client_socket_path: crate::server::socket_paths::client_socket_path(),
            must_be_running: false,
        }]);
    }

    if let Some(socket_path) = std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR) {
        let socket_path = PathBuf::from(socket_path);
        return Ok(vec![RunningUpdateTarget {
            name: None,
            label: socket_path.display().to_string(),
            stop_command: format!(
                "{}={} herdr server stop",
                crate::api::SOCKET_PATH_ENV_VAR,
                socket_path.display()
            ),
            attach_command: None,
            client_socket_path: crate::server::socket_paths::client_socket_path_from_overrides(
                Some(&socket_path.to_string_lossy()),
                None,
            ),
            socket_path,
            must_be_running: false,
        }]);
    }

    let sessions = crate::session::list_sessions()
        .map_err(|err| format!("failed to list herdr sessions: {err}"))?;
    Ok(sessions
        .into_iter()
        .map(|session| RunningUpdateTarget {
            name: if session.default {
                None
            } else {
                Some(session.name.clone())
            },
            stop_command: crate::session::stop_command_for(if session.default {
                None
            } else {
                Some(&session.name)
            }),
            attach_command: Some(if session.default {
                "herdr".to_string()
            } else {
                format!("herdr session attach {}", session.name)
            }),
            label: session.name.clone(),
            client_socket_path: crate::session::client_socket_path_for(if session.default {
                None
            } else {
                Some(&session.name)
            }),
            socket_path: PathBuf::from(session.socket_path),
            must_be_running: session.running,
        })
        .collect())
}

#[cfg(not(windows))]
fn target_client_protocol_server_is_running() -> Result<bool, String> {
    if crate::session::explicit_session_requested()
        || std::env::var_os(crate::api::SOCKET_PATH_ENV_VAR).is_some()
    {
        return Ok(client_protocol_server_is_running());
    }

    let sessions = crate::session::list_sessions()
        .map_err(|err| format!("failed to list herdr sessions: {err}"))?;
    Ok(sessions.into_iter().any(|session| {
        let client_socket = crate::session::client_socket_path_for(if session.default {
            None
        } else {
            Some(&session.name)
        });
        client_protocol_server_is_running_at(&client_socket)
    }))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct SelfUpdateOptions {
    pub(crate) live_handoff: bool,
}

pub(crate) fn parse_self_update_args(args: &[String]) -> Result<SelfUpdateOptions, String> {
    let mut options = SelfUpdateOptions::default();
    for arg in args {
        match arg.as_str() {
            "--handoff" => options.live_handoff = true,
            "--help" | "-h" => {
                return Err("usage: herdr update [--handoff]".to_string());
            }
            _ => return Err(format!("unknown update option: {arg}")),
        }
    }
    Ok(options)
}

#[cfg(not(windows))]
fn prompt_to_stop_old_servers_before_update(
    plans: &[RunningServerUpdatePlan],
    release: &ReleaseInfo,
) -> Result<bool, String> {
    if !io::stdin().is_terminal() {
        return Err(
            "one or more Herdr sessions must stop for this update. Stop running Herdr sessions when ready, then run `herdr update` again from an interactive terminal."
                .to_string(),
        );
    }

    eprintln!(
        "Running sessions that must stop to use {}:",
        release.label()
    );
    for plan in plans {
        eprintln!(
            "  {}: server v{}",
            plan.label(),
            version_label(plan.server.version.as_deref())
        );
    }
    eprintln!();
    eprintln!("If you choose no, these sessions keep using the old server until you stop them.");
    eprintln!("Stop the old server after installing? Stopping exits pane processes.");

    loop {
        eprint!("stop after installing? [y/N] ");
        io::stderr()
            .flush()
            .map_err(|e| format!("failed to flush prompt: {e}"))?;

        let mut input = String::new();
        let read = io::stdin()
            .read_line(&mut input)
            .map_err(|e| format!("failed to read prompt response: {e}"))?;
        if read == 0 {
            return Ok(false);
        }

        match input.trim().to_ascii_lowercase().as_str() {
            "y" | "yes" => return Ok(true),
            "" | "n" | "no" => return Ok(false),
            _ => eprintln!("please answer y or n"),
        }
    }
}

#[cfg(not(windows))]
fn confirm_running_server_update_action(
    plans: Vec<RunningServerUpdatePlan>,
    release: &ReleaseInfo,
    options: SelfUpdateOptions,
) -> Result<Vec<RunningServerUpdateDecision>, String> {
    if plans.is_empty() {
        return Ok(Vec::new());
    }

    print_running_session_update_summary(&plans, release, options);

    if !options.live_handoff {
        return Ok(plans
            .into_iter()
            .map(|plan| RunningServerUpdateDecision {
                plan,
                action: RunningServerUpdateAction::None,
            })
            .collect());
    }

    let handoff_unsupported_requiring_update: Vec<&RunningServerUpdatePlan> = plans
        .iter()
        .filter(|plan| !server_supports_live_handoff(&plan.server) && plan.requires_server_restart)
        .collect();

    let stop_unsupported = if handoff_unsupported_requiring_update.is_empty() {
        false
    } else {
        let owned: Vec<RunningServerUpdatePlan> = handoff_unsupported_requiring_update
            .iter()
            .map(|plan| (*plan).clone())
            .collect();
        prompt_to_stop_old_servers_before_update(&owned, release)?
    };

    let mut decisions = Vec::new();
    for plan in plans {
        let action = if server_supports_live_handoff(&plan.server) {
            RunningServerUpdateAction::LiveHandoff
        } else if !server_supports_live_handoff(&plan.server)
            && plan.requires_server_restart
            && stop_unsupported
        {
            RunningServerUpdateAction::StopOldServer
        } else {
            RunningServerUpdateAction::None
        };
        decisions.push(RunningServerUpdateDecision { plan, action });
    }

    Ok(decisions)
}

#[cfg(not(windows))]
fn target_group_nouns(plans: &[&RunningServerUpdatePlan]) -> (&'static str, &'static str) {
    let all_sessions = plans.iter().all(|plan| plan.target_noun() == "session");
    let all_servers = plans.iter().all(|plan| plan.target_noun() == "server");
    if all_sessions {
        ("session", "sessions")
    } else if all_servers {
        ("server", "servers")
    } else {
        ("target", "targets")
    }
}

#[cfg(not(windows))]
fn prompt_to_complete_plain_update(
    decisions: &[RunningServerUpdateDecision],
    release: &ReleaseInfo,
) -> Result<bool, String> {
    if decisions.is_empty() {
        return Ok(true);
    }

    if !io::stdin().is_terminal() {
        return Ok(false);
    }

    let plans: Vec<&RunningServerUpdatePlan> =
        decisions.iter().map(|decision| &decision.plan).collect();
    let (singular, plural) = target_group_nouns(&plans);
    let noun = if plans.len() == 1 { singular } else { plural };
    eprintln!(
        "To complete the update, Herdr must stop {} running {}.",
        plans.len(),
        noun
    );
    eprintln!("This stops active pane processes, including shells, dev servers, and tests.");
    for plan in plans {
        eprintln!(
            "  {} {}: server v{}",
            plan.target_noun(),
            plan.label(),
            version_label(plan.server.version.as_deref())
        );
    }

    loop {
        eprint!(
            "Stop running {} and install {} now? [y/N] ",
            noun,
            release.label()
        );
        io::stderr()
            .flush()
            .map_err(|e| format!("failed to flush prompt: {e}"))?;

        let mut input = String::new();
        let read = io::stdin()
            .read_line(&mut input)
            .map_err(|e| format!("failed to read prompt response: {e}"))?;
        if read == 0 {
            return Ok(false);
        }

        if let Some(answer) = parse_stop_old_servers_after_update_response(&input, false) {
            return Ok(answer);
        }
        eprintln!("please answer y or n");
    }
}

#[cfg(not(windows))]
fn mark_plain_update_stop_decisions(
    decisions: Vec<RunningServerUpdateDecision>,
) -> Vec<RunningServerUpdateDecision> {
    decisions
        .into_iter()
        .map(|mut decision| {
            decision.action = RunningServerUpdateAction::StopOldServer;
            decision
        })
        .collect()
}

#[cfg(not(windows))]
fn print_running_session_update_summary(
    plans: &[RunningServerUpdatePlan],
    release: &ReleaseInfo,
    options: SelfUpdateOptions,
) {
    eprintln!("running herdr targets:");
    for plan in plans {
        if options.live_handoff {
            let capability = if server_supports_live_handoff(&plan.server) {
                "handoff supported"
            } else {
                "too old for handoff"
            };
            eprintln!(
                "  {}: server v{} ({})",
                plan.label(),
                version_label(plan.server.version.as_deref()),
                capability
            );
        } else {
            eprintln!(
                "  {}: server v{}",
                plan.label(),
                version_label(plan.server.version.as_deref())
            );
        }
    }
    eprintln!("  update: {}", release.label());
    eprintln!();
}

#[cfg(not(windows))]
fn live_handoff_running_server_for_update(
    plan: &RunningServerUpdatePlan,
    release: &ReleaseInfo,
    updated_exe: &Path,
) -> Result<(), String> {
    eprintln!(
        "asking {} {} to hand off live panes to the updated server...",
        plan.target_noun(),
        plan.label()
    );
    live_handoff_server_via_api_for_update_at(plan.socket_path(), updated_exe, release)?;
    wait_for_server_handoff_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT, release)?;
    eprintln!(
        "live handoff complete for {} {}; pane processes should still be running.",
        plan.target_noun(),
        plan.label()
    );
    Ok(())
}

#[cfg(not(windows))]
fn runtime_matches_release(status: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool {
    let protocol_matches = release
        .target_protocol
        .is_none_or(|protocol| status.protocol == Some(protocol));
    let version_matches = status.version.as_deref() == Some(release.label());
    protocol_matches && version_matches
}

#[cfg(not(windows))]
fn classify_failed_live_handoff_state_at(
    socket_path: &Path,
    release: &ReleaseInfo,
) -> FailedHandoffServerState {
    match crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT) {
        Ok(Some(status)) if runtime_matches_release(&status, release) => {
            FailedHandoffServerState::UpdatedServerRunning
        }
        Ok(Some(status)) => FailedHandoffServerState::OldServerRunning(status),
        Ok(None) => FailedHandoffServerState::NoServerResponding,
        Err(err) => FailedHandoffServerState::Unknown(err.to_string()),
    }
}

#[cfg(not(windows))]
fn prompt_to_stop_old_server_after_failed_handoff(
    plan: &RunningServerUpdatePlan,
    release: &ReleaseInfo,
    status: &crate::api::RuntimeStatus,
) -> Result<bool, String> {
    eprintln!(
        "live handoff failed, but {} {} is still running with your panes.",
        plan.target_noun(),
        plan.label()
    );
    eprintln!("  server: v{}", version_label(status.version.as_deref()));
    eprintln!("  installed: {}", release.label());
    eprintln!(
        "you can keep using the old server, or stop it now so the next `herdr` start uses {}.",
        release.label()
    );
    eprintln!("stopping the old server will exit its pane processes.");

    if !io::stdin().is_terminal() {
        eprintln!(
            "not stopping the old server from a non-interactive update; run `{}` when you are ready.",
            plan.stop_command()
        );
        return Ok(false);
    }

    loop {
        eprint!("stop the old server now? [y/N] ");
        io::stderr()
            .flush()
            .map_err(|e| format!("failed to flush prompt: {e}"))?;

        let mut input = String::new();
        let read = io::stdin()
            .read_line(&mut input)
            .map_err(|e| format!("failed to read prompt response: {e}"))?;
        if read == 0 {
            return Ok(false);
        }

        match input.trim().to_ascii_lowercase().as_str() {
            "y" | "yes" => return Ok(true),
            "" | "n" | "no" => return Ok(false),
            _ => eprintln!("please answer y or n"),
        }
    }
}

#[cfg(not(windows))]
fn recover_failed_live_handoff_for_update(
    plan: &RunningServerUpdatePlan,
    release: &ReleaseInfo,
    error: &str,
) -> Result<RunningServerUpdateOutcome, String> {
    eprintln!(
        "live handoff failed for {} {}: {error}",
        plan.target_noun(),
        plan.label()
    );

    match classify_failed_live_handoff_state_at(plan.socket_path(), release) {
        FailedHandoffServerState::UpdatedServerRunning => {
            eprintln!(
                "the updated server is running for {} {}.",
                plan.target_noun(),
                plan.label()
            );
            Ok(RunningServerUpdateOutcome::LiveHandoffComplete)
        }
        FailedHandoffServerState::OldServerRunning(status) => {
            if prompt_to_stop_old_server_after_failed_handoff(plan, release, &status)? {
                stop_running_server_for_update(plan)?;
                Ok(RunningServerUpdateOutcome::FailedHandoffOldServerStopped)
            } else {
                Ok(RunningServerUpdateOutcome::FailedHandoffOldServerKept)
            }
        }
        FailedHandoffServerState::NoServerResponding => {
            if let Some(command) = plan.attach_command() {
                eprintln!(
                    "no herdr server is responding for session {}. the binary was updated; run `{command}` to start {}.",
                    plan.label(),
                    release.label()
                );
            } else {
                eprintln!(
                    "no herdr server is responding at {}. the binary was updated; restart with the same socket override to use {}.",
                    plan.socket_path().display(),
                    release.label()
                );
            }
            Ok(RunningServerUpdateOutcome::FailedHandoffNoServer)
        }
        FailedHandoffServerState::Unknown(status_error) => {
            eprintln!(
                "herdr could not determine server state for {} {} after the failed handoff: {status_error}",
                plan.target_noun(),
                plan.label()
            );
            eprintln!("{}", reconnect_or_stop_guidance(plan));
            Ok(RunningServerUpdateOutcome::FailedHandoffUnknown)
        }
    }
}

#[cfg(not(windows))]
fn reconnect_or_stop_guidance(plan: &RunningServerUpdatePlan) -> String {
    if let Some(command) = plan.attach_command() {
        format!(
            "if `{command}` does not reconnect cleanly, stop the old server with `{}` and run `{command}` again.",
            plan.stop_command()
        )
    } else {
        format!(
            "if reconnecting with the same socket override does not work, stop the old server with `{}`.",
            plan.stop_command()
        )
    }
}

#[cfg(not(windows))]
fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
    use crate::api::schema::{EmptyParams, Method};

    send_server_update_method_at(
        socket_path,
        timeout,
        "update:server:stop",
        Method::ServerStop(EmptyParams::default()),
        "server stop",
    )
}

#[cfg(not(windows))]
fn send_server_update_method_at(
    socket_path: &Path,
    timeout: Duration,
    request_id: &str,
    method: crate::api::schema::Method,
    error_prefix: &str,
) -> Result<(), String> {
    use crate::api::schema::Request;

    let request = Request {
        id: request_id.into(),
        method,
    };

    let mut stream = crate::ipc::connect_local_stream(socket_path)
        .map_err(|e| format!("failed to connect to running server: {e}"))?;
    stream
        .set_send_timeout(Some(timeout))
        .map_err(|e| format!("failed to set {error_prefix} write timeout: {e}"))?;
    stream
        .set_recv_timeout(Some(timeout))
        .map_err(|e| format!("failed to set {error_prefix} read timeout: {e}"))?;
    stream
        .write_all(
            serde_json::to_string(&request)
                .map_err(|e| e.to_string())?
                .as_bytes(),
        )
        .map_err(|e| format!("failed to send {error_prefix} request: {e}"))?;
    stream
        .write_all(b"\n")
        .map_err(|e| format!("failed to finish {error_prefix} request: {e}"))?;
    stream
        .flush()
        .map_err(|e| format!("failed to flush {error_prefix} request: {e}"))?;

    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    let read = reader
        .read_line(&mut line)
        .map_err(|e| format!("failed to read {error_prefix} response: {e}"))?;
    if read == 0 || line.trim().is_empty() {
        return Err(format!("empty {error_prefix} response"));
    }
    let response: serde_json::Value =
        serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?;
    if let Some(error) = response.get("error") {
        return Err(format!("{error_prefix} failed: {error}"));
    }

    Ok(())
}

#[cfg(all(test, unix))]
fn live_handoff_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
    use crate::api::schema::{Method, ServerLiveHandoffParams};

    let params = ServerLiveHandoffParams::default();

    send_server_update_method_at(
        socket_path,
        timeout,
        "update:server:live-handoff",
        Method::ServerLiveHandoff(params),
        "server live handoff",
    )
}

#[cfg(not(windows))]
fn live_handoff_server_via_api_for_release_at(
    socket_path: &Path,
    timeout: Duration,
    updated_exe: &Path,
    release: &ReleaseInfo,
) -> Result<(), String> {
    use crate::api::schema::{Method, ServerLiveHandoffParams};

    let params = ServerLiveHandoffParams {
        import_exe: Some(updated_exe.display().to_string()),
        expected_protocol: release.target_protocol,
        expected_version: Some(release.label().to_string()),
    };

    send_server_update_method_at(
        socket_path,
        timeout,
        "update:server:live-handoff",
        Method::ServerLiveHandoff(params),
        "server live handoff",
    )
}

#[cfg(not(windows))]
fn live_handoff_server_via_api_for_update_at(
    socket_path: &Path,
    updated_exe: &Path,
    release: &ReleaseInfo,
) -> Result<(), String> {
    live_handoff_server_via_api_for_release_at(
        socket_path,
        SERVER_HANDOFF_REQUEST_TIMEOUT,
        updated_exe,
        release,
    )
}

#[cfg(not(windows))]
fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
    if !socket_path.exists() {
        return Ok(true);
    }

    match crate::ipc::connect_local_stream(socket_path) {
        Ok(_) => Ok(false),
        Err(err)
            if matches!(
                err.kind(),
                io::ErrorKind::ConnectionRefused
                    | io::ErrorKind::NotFound
                    | io::ErrorKind::TimedOut
            ) =>
        {
            Ok(true)
        }
        Err(err) => Err(format!(
            "failed to confirm whether the old server stopped on {}: {err}",
            socket_path.display()
        )),
    }
}

#[cfg(not(windows))]
fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
    let deadline = Instant::now() + timeout;
    loop {
        if server_shutdown_confirmed_at(socket_path)? {
            return Ok(());
        }
        if Instant::now() >= deadline {
            return Err(format!(
                "shutdown was requested, but the old server is still responding on {} after {} seconds",
                socket_path.display(),
                timeout.as_secs()
            ));
        }
        std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL);
    }
}

#[cfg(not(windows))]
fn stop_running_server_for_update(plan: &RunningServerUpdatePlan) -> Result<(), String> {
    eprintln!("stopping herdr {} {}...", plan.target_noun(), plan.label());
    stop_server_via_api_at(plan.socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)?;
    wait_for_server_shutdown_at(plan.socket_path(), SERVER_HANDOFF_CONFIRM_TIMEOUT)?;
    Ok(())
}

#[cfg(not(windows))]
fn wait_for_server_handoff_at(
    socket_path: &Path,
    timeout: Duration,
    release: &ReleaseInfo,
) -> Result<(), String> {
    wait_for_running_server_protocol_at(
        socket_path,
        timeout,
        release.target_protocol,
        Some(release.label()),
    )
}

#[cfg(not(windows))]
fn wait_for_running_server_protocol_at(
    socket_path: &Path,
    timeout: Duration,
    expected_protocol: Option<u32>,
    expected_version: Option<&str>,
) -> Result<(), String> {
    let deadline = Instant::now() + timeout;
    loop {
        if let Some(status) =
            crate::api::read_runtime_status_at(socket_path, SERVER_STOP_RESPONSE_TIMEOUT)
                .map_err(|e| format!("failed to read server status after handoff: {e}"))?
        {
            let protocol_matches =
                expected_protocol.is_none_or(|protocol| status.protocol == Some(protocol));
            let version_matches =
                expected_version.is_none_or(|version| status.version.as_deref() == Some(version));
            if protocol_matches && version_matches {
                return Ok(());
            }
        }
        if Instant::now() >= deadline {
            return Err(format!(
                "live handoff was requested, but no compatible server responded on {} after {} seconds",
                socket_path.display(),
                timeout.as_secs()
            ));
        }
        std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL);
    }
}

#[cfg(not(windows))]
fn apply_running_session_update_decisions(
    release: &ReleaseInfo,
    updated_exe: &Path,
    decisions: Vec<RunningServerUpdateDecision>,
) -> Result<Vec<RunningSessionUpdateOutcome>, String> {
    let mut outcomes = Vec::new();

    for decision in decisions {
        let outcome = match decision.action {
            RunningServerUpdateAction::None => RunningServerUpdateOutcome::RestartDeferred,
            RunningServerUpdateAction::StopOldServer => {
                stop_running_server_for_update(&decision.plan)?;
                RunningServerUpdateOutcome::Stopped
            }
            RunningServerUpdateAction::LiveHandoff => {
                match live_handoff_running_server_for_update(&decision.plan, release, updated_exe) {
                    Ok(()) => RunningServerUpdateOutcome::LiveHandoffComplete,
                    Err(err) => {
                        recover_failed_live_handoff_for_update(&decision.plan, release, &err)?
                    }
                }
            }
        };

        let stop_command = decision.plan.stop_command();
        outcomes.push(RunningSessionUpdateOutcome {
            session_label: decision.plan.label().to_string(),
            target_noun: decision.plan.target_noun(),
            stop_command,
            attach_command: decision.plan.attach_command(),
            server_version: decision.plan.server.version.clone(),
            outcome,
        });
    }

    Ok(outcomes)
}

#[cfg(not(windows))]
fn print_running_session_update_outcomes(
    outcomes: &[RunningSessionUpdateOutcome],
    release: &ReleaseInfo,
) {
    if outcomes.is_empty() {
        eprintln!("run herdr again.");
        return;
    }

    for outcome in outcomes {
        match outcome.outcome {
            RunningServerUpdateOutcome::LiveHandoffComplete => {
                if let Some(command) = &outcome.attach_command {
                    eprintln!(
                        "session {} was replaced; reconnect clients with `{command}`.",
                        outcome.session_label
                    );
                } else {
                    eprintln!(
                        "server {} was replaced; reconnect using the same socket override.",
                        outcome.session_label
                    );
                }
            }
            RunningServerUpdateOutcome::RestartDeferred => {
                eprintln!(
                    "{} {} kept running.",
                    outcome.target_noun, outcome.session_label
                );
                eprintln!("Stopping exits active pane processes.");
                match &outcome.attach_command {
                    Some(command) => eprintln!(
                        "Run `{}`, then run `{command}` when ready to use {}.",
                        outcome.stop_command,
                        release.label()
                    ),
                    None => eprintln!(
                        "Run `{}`, then restart Herdr with the same socket override when ready to use {}.",
                        outcome.stop_command,
                        release.label()
                    ),
                }
            }
            RunningServerUpdateOutcome::Stopped
            | RunningServerUpdateOutcome::FailedHandoffOldServerStopped
            | RunningServerUpdateOutcome::FailedHandoffNoServer => {
                if let Some(command) = &outcome.attach_command {
                    eprintln!(
                        "session {} is stopped; run `{command}` again.",
                        outcome.session_label
                    );
                } else {
                    eprintln!(
                        "server {} is stopped; restart it with the same socket override.",
                        outcome.session_label
                    );
                }
            }
            RunningServerUpdateOutcome::FailedHandoffOldServerKept => {
                eprintln!(
                    "{} {} is still running server v{}.",
                    outcome.target_noun,
                    outcome.session_label,
                    version_label(outcome.server_version.as_deref())
                );
                eprintln!(
                    "{}",
                    crate::session::restart_after_update_guidance(
                        &outcome.stop_command,
                        outcome.attach_command.as_deref()
                    )
                );
            }
            RunningServerUpdateOutcome::FailedHandoffUnknown => {
                if let Some(command) = &outcome.attach_command {
                    eprintln!(
                        "session {} state is unclear; run `{command}`, or stop the old server with `{}` if reconnect fails.",
                        outcome.session_label, outcome.stop_command
                    );
                } else {
                    eprintln!(
                        "server {} state is unclear; reconnect with the same socket override, or stop it with `{}` if reconnect fails.",
                        outcome.session_label, outcome.stop_command
                    );
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Installation manager detection
// ---------------------------------------------------------------------------

pub(crate) fn update_install_command() -> &'static str {
    if is_homebrew_managed_install() {
        HOMEBREW_UPDATE_COMMAND
    } else if is_mise_managed_install() {
        MISE_UPDATE_COMMAND
    } else if is_nix_managed_install() {
        NIX_UPDATE_COMMAND
    } else {
        HERDR_UPDATE_COMMAND
    }
}

pub(crate) fn update_install_instruction(install_command: &str) -> String {
    match install_command {
        HERDR_UPDATE_COMMAND => {
            "detach, run `herdr update`, then follow its restart guidance".to_string()
        }
        HOMEBREW_UPDATE_COMMAND => {
            "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready".to_string()
        }
        MISE_UPDATE_COMMAND => {
            "detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
                .to_string()
        }
        NIX_UPDATE_COMMAND => {
            "detach, update through Nix, then restart this Herdr session when ready".to_string()
        }
        command => format!("detach, run `{command}`, then restart this Herdr session when ready"),
    }
}

fn is_homebrew_managed_install() -> bool {
    let Ok(current_exe) = env::current_exe() else {
        return false;
    };

    is_homebrew_managed_exe_path_following_links(&current_exe)
}

fn is_nix_managed_install() -> bool {
    let Ok(current_exe) = env::current_exe() else {
        return false;
    };

    is_nix_store_exe_path_following_links(&current_exe)
}

fn is_mise_managed_install() -> bool {
    let Ok(current_exe) = env::current_exe() else {
        return false;
    };

    is_mise_managed_exe_path_following_links(&current_exe)
}

pub(crate) fn preview_channel_rejection_for_current_install() -> Option<&'static str> {
    let Ok(current_exe) = env::current_exe() else {
        return None;
    };

    preview_channel_rejection_for_exe_path(&current_exe)
}

pub(crate) fn package_manager_channel_update_guidance_for_current_install() -> Option<&'static str>
{
    if is_homebrew_managed_install() {
        Some("Use `brew update && brew upgrade herdr` to update Homebrew installs.")
    } else if is_mise_managed_install() {
        Some("Use `mise upgrade herdr` to update mise installs.")
    } else if is_nix_managed_install() {
        Some("Update through Nix to update Nix-managed Herdr installs.")
    } else {
        None
    }
}

fn preview_channel_rejection_for_exe_path(path: &Path) -> Option<&'static str> {
    if is_homebrew_managed_exe_path_following_links(path) {
        Some(
            "preview channel is only available for direct Herdr installs; Homebrew installs update through `brew update && brew upgrade herdr`",
        )
    } else if is_mise_managed_exe_path_following_links(path) {
        Some(
            "preview channel is only available for direct Herdr installs; mise installs update through `mise upgrade herdr`",
        )
    } else if is_nix_store_exe_path_following_links(path) {
        Some("preview channel is only available for direct Herdr installs; Nix installs update through Nix")
    } else {
        None
    }
}

#[cfg(unix)]
pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool {
    is_homebrew_managed_exe_path_following_links(path)
        || is_mise_managed_exe_path_following_links(path)
        || is_nix_store_exe_path_following_links(path)
}

#[cfg(not(unix))]
pub(crate) fn is_package_manager_managed_exe_path(_path: &Path) -> bool {
    false
}

fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool {
    if is_homebrew_managed_exe_path(path) {
        return true;
    }

    path.canonicalize()
        .is_ok_and(|path| is_homebrew_managed_exe_path(&path))
}

fn is_nix_store_exe_path_following_links(path: &Path) -> bool {
    if is_nix_store_exe_path(path) {
        return true;
    }

    path.canonicalize()
        .is_ok_and(|path| is_nix_store_exe_path(&path))
}

fn is_mise_managed_exe_path_following_links(path: &Path) -> bool {
    if is_mise_managed_exe_path(path) {
        return true;
    }

    path.canonicalize()
        .is_ok_and(|path| is_mise_managed_exe_path(&path))
}

fn is_nix_store_exe_path(path: &Path) -> bool {
    path.starts_with("/nix/store")
}

fn is_mise_managed_exe_path(path: &Path) -> bool {
    mise_install_root(path).is_some()
}

fn mise_install_root(path: &Path) -> Option<PathBuf> {
    if let Some(root) = mise_install_root_under_configured_installs_dir(path) {
        return Some(root);
    }

    mise_install_root_under_named_installs_dir(path)
}

fn mise_install_root_under_configured_installs_dir(path: &Path) -> Option<PathBuf> {
    let installs_dir = env::var_os(MISE_INSTALLS_DIR_ENV)
        .map(PathBuf::from)
        .filter(|path| !path.as_os_str().is_empty())?;
    let version_dir = mise_tool_version_dir(path)?;
    let tool_dir = version_dir.parent()?;
    paths_match(tool_dir.parent()?, &installs_dir).then_some(version_dir.to_path_buf())
}

fn mise_install_root_under_named_installs_dir(path: &Path) -> Option<PathBuf> {
    let version_dir = mise_tool_version_dir(path)?;
    let tool_dir = version_dir.parent()?;
    let installs_dir = tool_dir.parent()?;
    if installs_dir.file_name()? != "installs" {
        return None;
    }
    Some(version_dir.to_path_buf())
}

fn mise_tool_version_dir(path: &Path) -> Option<&Path> {
    if path.file_name()? != "herdr" {
        return None;
    }
    let bin_dir = path.parent()?;
    if bin_dir.file_name()? != "bin" {
        return None;
    }
    let version_dir = bin_dir.parent()?;
    let tool_dir = version_dir.parent()?;
    if tool_dir.file_name()? != "herdr" {
        return None;
    }
    Some(version_dir)
}

fn paths_match(left: &Path, right: &Path) -> bool {
    if left == right {
        return true;
    }

    let Ok(left) = left.canonicalize() else {
        return false;
    };
    let Ok(right) = right.canonicalize() else {
        return false;
    };
    left == right
}

fn is_homebrew_managed_exe_path(path: &Path) -> bool {
    homebrew_cellar_keg_root(path).is_some()
}

fn homebrew_cellar_keg_root(path: &Path) -> Option<PathBuf> {
    if path.file_name()? != "herdr" {
        return None;
    }
    let bin_dir = path.parent()?;
    if bin_dir.file_name()? != "bin" {
        return None;
    }
    let version_dir = bin_dir.parent()?;
    let formula_dir = version_dir.parent()?;
    if formula_dir.file_name()? != "herdr" {
        return None;
    }
    let cellar_dir = formula_dir.parent()?;
    if cellar_dir.file_name()? != "Cellar" {
        return None;
    }
    Some(version_dir.to_path_buf())
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Manual self-update command (`herdr update`).
pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
    let channel = UpdateChannel::configured();

    if is_homebrew_managed_install() {
        if channel == UpdateChannel::Preview {
            return Err(
                "self-update is disabled for Homebrew installs; preview is only available for direct Herdr installs".into(),
            );
        }
        return Err(format!(
            "self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`"
        ));
    }

    if is_mise_managed_install() {
        if channel == UpdateChannel::Preview {
            return Err(
                "self-update is disabled for mise installs; preview is only available for direct Herdr installs".into(),
            );
        }
        return Err(format!(
            "self-update is disabled for mise installs; run `{MISE_UPDATE_COMMAND}`"
        ));
    }

    if is_nix_managed_install() {
        if channel == UpdateChannel::Preview {
            return Err(
                "self-update is disabled for Nix installs; preview is only available for direct Herdr installs".into(),
            );
        }
        return Err(
            "self-update is disabled for Nix installs; update with `nix profile upgrade` or update the flake input that provides Herdr".into(),
        );
    }

    if running_inside_herdr() {
        return Err("run `herdr update` outside herdr after detaching from the session".into());
    }

    eprintln!("checking {} channel for updates...", channel.as_str());

    let current = Version::current();

    let release = match check_latest()? {
        Some(r) => r,
        None => {
            eprintln!("already up to date ({})", crate::build_info::version());
            return Ok(current);
        }
    };

    if let Some(commit) = &release.commit {
        tracing::info!(commit = %commit, build_id = ?release.build_id, "selected preview update build");
    }
    if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
        tracing::warn!("failed to save pending release notes: {e}");
    }

    #[cfg(windows)]
    {
        let _ = options;

        eprintln!(
            "installing {} with the Windows installer...",
            release.label()
        );
        if let Some(sha256) = &release.sha256 {
            tracing::debug!(sha256 = %sha256, "selected Windows update asset has checksum");
        }
        eprintln!("downloading {}...", release.label());
        let downloaded_update = download_windows_update(&release)?;
        eprintln!("downloaded {}", release.label());
        install_windows_update_with_installer(&release, &downloaded_update)?;
        let updated_exe = windows_installed_herdr_exe_path()?;
        eprintln!("installed {}", release.label());
        print_outdated_integration_notice_with_updated_binary(&updated_exe);
        eprintln!(
            "Restart any running Herdr sessions to use {}.",
            release.label()
        );
    }

    #[cfg(not(windows))]
    {
        let running_server_plans = plan_running_server_updates(&release)?;
        let server_update_decisions =
            confirm_running_server_update_action(running_server_plans, &release, options)?;

        eprintln!("downloading {}...", release.label());
        let downloaded_update = download_update(&release)?;
        let updated_exe = downloaded_update.current_exe.clone();
        eprintln!("downloaded {}", release.label());
        if !options.live_handoff
            && !prompt_to_complete_plain_update(&server_update_decisions, &release)?
        {
            eprintln!("Herdr was not updated.");
            eprintln!("Stop running Herdr sessions when ready, then run `herdr update` again.");
            return Ok(current);
        }
        install_downloaded_update(downloaded_update)?;
        eprintln!("installed {}", release.label());
        let server_update_decisions = if options.live_handoff {
            server_update_decisions
        } else {
            mark_plain_update_stop_decisions(server_update_decisions)
        };
        let server_update_outcomes = apply_running_session_update_decisions(
            &release,
            &updated_exe,
            server_update_decisions,
        )?;
        print_outdated_integration_notice_with_updated_binary(&updated_exe);

        print_running_session_update_outcomes(&server_update_outcomes, &release);
    }

    Ok(release.version)
}

fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) {
    let status = Command::new(updated_exe)
        .args(["integration", "status", "--outdated-only"])
        .status();

    if !status.is_ok_and(|status| status.success()) {
        crate::integration::print_outdated_update_notice();
    }
}

/// Background update check: only surface availability and release notes.
/// Runs in a background thread at startup.
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
    crate::logging::update_check_started();
    if let Ok(version) = env::var(FAKE_UPDATE_VERSION_ENV) {
        let version = version.trim();
        if !version.is_empty() {
            tracing::info!(
                env = FAKE_UPDATE_VERSION_ENV,
                version,
                "using fake update version for local testing"
            );
            if let Err(e) =
                crate::release_notes::save_pending(version, &fake_release_notes_body(version))
            {
                tracing::warn!("failed to save fake pending release notes: {e}");
            }
            let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
                version: version.to_string(),
                install_command: update_install_command().to_string(),
            });
        }
        return;
    }

    let configured_channel = UpdateChannel::configured();
    if is_homebrew_managed_install() {
        if configured_channel == UpdateChannel::Preview {
            crate::logging::update_check_failed(
                "preview channel is not available for Homebrew installs",
            );
            return;
        }
        auto_update_homebrew(events);
        return;
    }

    if is_mise_managed_install() && configured_channel == UpdateChannel::Preview {
        crate::logging::update_check_failed("preview channel is not available for mise installs");
        return;
    }

    let nix_managed_install = is_nix_managed_install();
    if nix_managed_install && configured_channel == UpdateChannel::Preview {
        crate::logging::update_check_failed("preview channel is not available for Nix installs");
        return;
    }

    let release = match check_latest() {
        Ok(Some(r)) => r,
        Ok(None) => return,
        Err(err) => {
            crate::logging::update_check_failed(&err);
            return;
        }
    };

    crate::logging::update_available(release.label());
    tracing::info!(
        "new {} build available at {}",
        release.channel.as_str(),
        release.download_url
    );

    if let Err(e) = crate::release_notes::save_pending(release.label(), &release.notes_body) {
        tracing::warn!("failed to save pending release notes: {e}");
    }

    tracing::info!(
        "auto-update check: {} available, waiting for explicit install",
        release.label()
    );

    // Notify the TUI — blocking_send is safe from a std::thread
    let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
        version: release.label().to_string(),
        install_command: update_install_command().to_string(),
    });
}

fn auto_update_homebrew(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
    let version = match check_homebrew_latest() {
        Ok(Some(version)) => version,
        Ok(None) => return,
        Err(err) => {
            crate::logging::update_check_failed(&err);
            return;
        }
    };

    crate::logging::update_available(&version.to_string());
    let notes_body = homebrew_release_notes_body(&version);
    if let Err(e) = crate::release_notes::save_pending(&version.to_string(), &notes_body) {
        tracing::warn!("failed to save pending release notes: {e}");
    }

    tracing::info!(
        "auto-update check: v{} available through Homebrew, waiting for explicit install",
        version
    );

    let _ = events.blocking_send(crate::events::AppEvent::UpdateReady {
        version: version.to_string(),
        install_command: HOMEBREW_UPDATE_COMMAND.to_string(),
    });
}

fn homebrew_release_notes_body(version: &Version) -> String {
    let manifest = fetch_update_manifest().ok();
    homebrew_release_notes_body_from_manifest(version, manifest.as_ref())
}

fn homebrew_release_notes_body_from_manifest(
    version: &Version,
    manifest: Option<&UpdateManifest>,
) -> String {
    if let Some(metadata) = manifest.and_then(|manifest| manifest.metadata_for_version(version)) {
        let notes_body = metadata.notes_body();
        if !notes_body.is_empty() {
            handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref());
            return notes_body;
        }
    }

    format!("### Changed\n- v{version} is available through Homebrew.")
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn platform_target() -> (&'static str, &'static str) {
    let os = if cfg!(target_os = "linux") {
        "linux"
    } else if cfg!(target_os = "macos") {
        "macos"
    } else if cfg!(target_os = "windows") {
        "windows"
    } else {
        "unknown"
    };

    let arch = if cfg!(target_arch = "x86_64") {
        "x86_64"
    } else if cfg!(target_arch = "aarch64") {
        "aarch64"
    } else {
        "unknown"
    };

    (os, arch)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(all(test, unix))]
mod tests {
    use super::*;
    use std::os::unix::net::UnixListener;
    use std::sync::{
        atomic::{AtomicBool, Ordering},
        Arc,
    };
    use std::sync::{Mutex, OnceLock};
    use std::thread;

    fn env_lock() -> &'static Mutex<()> {
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
    }

    fn unique_test_socket_path(name: &str) -> std::path::PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::path::PathBuf::from(format!(
            "/tmp/hu-{name}-{}-{nanos}.sock",
            std::process::id()
        ))
    }

    fn spawn_accept_loop(path: &Path) -> (Arc<AtomicBool>, thread::JoinHandle<()>) {
        let listener = UnixListener::bind(path).unwrap();
        listener.set_nonblocking(true).unwrap();
        let running = Arc::new(AtomicBool::new(true));
        let running_thread = Arc::clone(&running);
        let handle = thread::spawn(move || {
            while running_thread.load(Ordering::Relaxed) {
                match listener.accept() {
                    Ok((_stream, _)) => {}
                    Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
                        thread::sleep(Duration::from_millis(10));
                    }
                    Err(_) => break,
                }
            }
        });
        (running, handle)
    }

    fn spawn_status_server_once(
        path: &Path,
        version: &str,
        protocol: u32,
    ) -> thread::JoinHandle<()> {
        let listener = UnixListener::bind(path).unwrap();
        let version = version.to_string();
        thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            assert!(request.contains("\"method\":\"ping\""));
            let response = format!(
                r#"{{"id":"runtime:status","result":{{"type":"pong","version":"{version}","protocol":{protocol},"capabilities":{{"live_handoff":true}}}}}}"#
            );
            stream.write_all(response.as_bytes()).unwrap();
            stream.write_all(b"\n").unwrap();
            stream.flush().unwrap();
        })
    }

    fn fake_release(version: &str, target_protocol: Option<u32>) -> ReleaseInfo {
        ReleaseInfo {
            version: Version::parse(version).unwrap(),
            identity: version.to_string(),
            channel: UpdateChannel::Stable,
            build_id: None,
            commit: None,
            target_protocol,
            download_url: "https://example.com/herdr".to_string(),
            sha256: None,
            notes_body: "### Changed\n- One".to_string(),
        }
    }

    fn set_test_config_home(name: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let short_name: String = name.chars().take(4).collect();
        let dir = PathBuf::from(format!(
            "/tmp/hu-{short_name}-{}-{nanos}",
            std::process::id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        std::env::set_var("XDG_CONFIG_HOME", &dir);
        dir
    }

    #[test]
    fn parse_version_basic() {
        assert_eq!(
            Version::parse("1.2.3"),
            Some(Version {
                major: 1,
                minor: 2,
                patch: 3
            })
        );
    }

    #[test]
    fn parse_version_with_v_prefix() {
        assert_eq!(
            Version::parse("v0.1.0"),
            Some(Version {
                major: 0,
                minor: 1,
                patch: 0
            })
        );
    }

    #[test]
    fn parse_version_invalid() {
        assert_eq!(Version::parse("1.2"), None);
        assert_eq!(Version::parse("abc"), None);
        assert_eq!(Version::parse(""), None);
    }

    #[test]
    fn homebrew_cellar_path_is_detected() {
        let path = Path::new("/opt/homebrew/Cellar/herdr/0.5.9/bin/herdr");

        assert!(is_homebrew_managed_exe_path(path));
        assert_eq!(
            homebrew_cellar_keg_root(path).unwrap(),
            PathBuf::from("/opt/homebrew/Cellar/herdr/0.5.9")
        );
    }

    #[test]
    fn homebrew_linux_cellar_path_is_detected() {
        let path = Path::new("/home/linuxbrew/.linuxbrew/Cellar/herdr/0.5.9/bin/herdr");

        assert!(is_homebrew_managed_exe_path(path));
    }

    #[test]
    fn homebrew_opt_path_requires_canonicalized_cellar_target() {
        let path = Path::new("/opt/homebrew/opt/herdr/bin/herdr");

        assert!(!is_homebrew_managed_exe_path(path));
    }

    #[test]
    fn non_homebrew_path_is_not_detected() {
        let path = Path::new("/usr/local/bin/herdr");

        assert!(!is_homebrew_managed_exe_path(path));
    }

    #[test]
    fn mise_install_path_is_detected() {
        let path = Path::new("/home/user/.local/share/mise/installs/herdr/0.6.6/bin/herdr");

        assert!(is_mise_managed_exe_path(path));
        assert_eq!(
            mise_install_root(path).unwrap(),
            PathBuf::from("/home/user/.local/share/mise/installs/herdr/0.6.6")
        );
    }

    #[test]
    fn mise_alias_install_path_is_detected() {
        let path = Path::new("/home/user/.local/share/mise/installs/herdr/latest/bin/herdr");

        assert!(is_mise_managed_exe_path(path));
    }

    #[test]
    fn mise_custom_installs_dir_path_is_detected() {
        let path = Path::new("/opt/mise-tools/installs/herdr/0.6.6/bin/herdr");

        assert!(is_mise_managed_exe_path(path));
    }

    #[test]
    fn mise_configured_installs_dir_path_is_detected() {
        let _guard = env_lock().lock().unwrap();
        let previous = std::env::var_os(MISE_INSTALLS_DIR_ENV);
        std::env::set_var(MISE_INSTALLS_DIR_ENV, "/opt/mise-tools");
        let path = Path::new("/opt/mise-tools/herdr/0.6.6/bin/herdr");

        assert!(is_mise_managed_exe_path(path));
        assert_eq!(
            mise_install_root(path).unwrap(),
            PathBuf::from("/opt/mise-tools/herdr/0.6.6")
        );

        if let Some(previous) = previous {
            std::env::set_var(MISE_INSTALLS_DIR_ENV, previous);
        } else {
            std::env::remove_var(MISE_INSTALLS_DIR_ENV);
        }
    }

    #[test]
    fn non_mise_install_path_is_not_detected() {
        let path = Path::new("/home/user/.local/bin/herdr");

        assert!(!is_mise_managed_exe_path(path));
    }

    #[test]
    fn package_manager_path_detection_follows_homebrew_symlink() {
        #[cfg(unix)]
        {
            let root = std::env::temp_dir().join(format!(
                "herdr-homebrew-symlink-test-{}",
                std::process::id()
            ));
            let cellar_bin = root.join("Cellar/herdr/0.6.2/bin");
            let opt_bin = root.join("opt/herdr/bin");
            fs::create_dir_all(&cellar_bin).unwrap();
            fs::create_dir_all(&opt_bin).unwrap();
            let cellar_binary = cellar_bin.join("herdr");
            let opt_binary = opt_bin.join("herdr");
            fs::write(&cellar_binary, b"").unwrap();
            std::os::unix::fs::symlink(&cellar_binary, &opt_binary).unwrap();

            assert!(is_package_manager_managed_exe_path(&opt_binary));

            let _ = fs::remove_dir_all(root);
        }
    }

    #[test]
    fn package_manager_path_detection_follows_mise_symlink() {
        #[cfg(unix)]
        {
            let root = std::env::temp_dir()
                .join(format!("herdr-mise-symlink-test-{}", std::process::id()));
            let version_bin = root.join("installs/herdr/0.6.2/bin");
            let latest_bin = root.join("installs/herdr/latest/bin");
            fs::create_dir_all(&version_bin).unwrap();
            fs::create_dir_all(&latest_bin).unwrap();
            let version_binary = version_bin.join("herdr");
            let latest_binary = latest_bin.join("herdr");
            fs::write(&version_binary, b"").unwrap();
            std::os::unix::fs::symlink(&version_binary, &latest_binary).unwrap();

            assert!(is_package_manager_managed_exe_path(&latest_binary));

            let _ = fs::remove_dir_all(root);
        }
    }

    #[test]
    fn nix_store_path_is_detected() {
        let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr");

        assert!(is_nix_store_exe_path(path));
        assert!(is_package_manager_managed_exe_path(path));
    }

    #[test]
    fn preview_channel_is_rejected_for_package_manager_paths() {
        let homebrew = Path::new("/opt/homebrew/Cellar/herdr/0.6.6/bin/herdr");
        let mise = Path::new("/home/user/.local/share/mise/installs/herdr/0.6.6/bin/herdr");
        let nix = Path::new("/nix/store/abc123-herdr-0.6.6/bin/herdr");
        let direct = Path::new("/home/user/.local/bin/herdr");

        assert!(preview_channel_rejection_for_exe_path(homebrew)
            .is_some_and(|message| message.contains("Homebrew")));
        assert!(preview_channel_rejection_for_exe_path(mise)
            .is_some_and(|message| message.contains("mise")));
        assert!(preview_channel_rejection_for_exe_path(nix)
            .is_some_and(|message| message.contains("Nix")));
        assert!(preview_channel_rejection_for_exe_path(direct).is_none());
    }

    #[test]
    fn non_nix_store_path_is_not_detected() {
        let path = Path::new("/usr/local/bin/herdr");

        assert!(!is_nix_store_exe_path(path));
    }

    #[test]
    fn parse_homebrew_formula_stable_version_reads_versions_stable() {
        let version = parse_homebrew_formula_stable_version(
            br#"{"versions":{"stable":"0.5.10","head":"HEAD","bottle":true}}"#,
        )
        .unwrap();

        assert_eq!(version, Version::parse("0.5.10").unwrap());
    }

    #[test]
    fn homebrew_formula_update_uses_formula_stable_not_manifest_latest() {
        let current = Version::parse("0.6.1").unwrap();
        let update = homebrew_update_from_formula_json(
            br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#,
            &current,
        )
        .unwrap();

        assert_eq!(update, Some(Version::parse("0.6.2").unwrap()));
    }

    #[test]
    fn homebrew_formula_update_ignores_versions_that_are_not_newer() {
        let current = Version::parse("0.6.2").unwrap();
        let update = homebrew_update_from_formula_json(
            br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#,
            &current,
        )
        .unwrap();

        assert_eq!(update, None);
    }

    #[test]
    fn homebrew_release_notes_use_package_manager_guidance() {
        let body =
            homebrew_release_notes_body_from_manifest(&Version::parse("0.6.3").unwrap(), None);

        assert_eq!(body, "### Changed\n- v0.6.3 is available through Homebrew.");
    }

    #[test]
    fn homebrew_release_notes_can_use_manifest_metadata() {
        let manifest: UpdateManifest = serde_json::from_str(
            r####"{
                "version": "0.6.3",
                "protocol": 10,
                "notes": "### Fixed\n- Brew notes",
                "assets": {
                    "linux-x86_64": "https://example.com/herdr-linux-x86_64"
                }
            }"####,
        )
        .unwrap();
        let body = homebrew_release_notes_body_from_manifest(
            &Version::parse("0.6.3").unwrap(),
            Some(&manifest),
        );

        assert_eq!(body, "### Fixed\n- Brew notes");
    }

    #[test]
    fn update_install_instruction_distinguishes_install_from_restart() {
        assert_eq!(
            update_install_instruction(HERDR_UPDATE_COMMAND),
            "detach, run `herdr update`, then follow its restart guidance"
        );
        assert_eq!(
            update_install_instruction(HOMEBREW_UPDATE_COMMAND),
            "detach, run `brew update && brew upgrade herdr`, then restart this Herdr session when ready"
        );
        assert_eq!(
            update_install_instruction(MISE_UPDATE_COMMAND),
            "detach, run `mise upgrade herdr`, then restart this Herdr session when ready"
        );
    }

    #[test]
    fn fake_release_notes_default_to_real_large_changelog_section() {
        let _guard = env_lock().lock().unwrap();
        std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);

        let body = fake_release_notes_body("9.4.9");
        assert!(body.contains("### Major Changes"));
        assert!(body.contains("Added tabs within workspaces"));
    }

    #[test]
    fn fake_release_notes_fallback_include_version_and_context() {
        let _guard = env_lock().lock().unwrap();
        std::env::set_var(FAKE_UPDATE_NOTES_VERSION_ENV, "does-not-exist");

        let body = fake_release_notes_body("9.4.9");
        assert!(body.contains("v9.4.9"));
        assert!(body.contains("local UI validation"));
        assert!(body.contains(FAKE_UPDATE_VERSION_ENV));

        std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV);
    }

    #[test]
    fn running_inside_herdr_env_requires_marker() {
        assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE)));
        assert!(!running_inside_herdr_env(None));
        assert!(!running_inside_herdr_env(Some("0")));
    }

    #[test]
    fn self_update_args_gate_live_handoff() {
        assert_eq!(
            parse_self_update_args(&[]).unwrap(),
            SelfUpdateOptions {
                live_handoff: false
            }
        );
        assert_eq!(
            parse_self_update_args(&["--handoff".to_string()]).unwrap(),
            SelfUpdateOptions { live_handoff: true }
        );
        assert_eq!(
            parse_self_update_args(&["--unknown".to_string()]).unwrap_err(),
            "unknown update option: --unknown"
        );
    }

    #[test]
    fn parse_stop_old_servers_after_update_response_uses_prompt_default_for_blank() {
        assert_eq!(
            parse_stop_old_servers_after_update_response("", true),
            Some(true)
        );
        assert_eq!(
            parse_stop_old_servers_after_update_response("\n", false),
            Some(false)
        );
        assert_eq!(
            parse_stop_old_servers_after_update_response("y", false),
            Some(true)
        );
        assert_eq!(
            parse_stop_old_servers_after_update_response("no", true),
            Some(false)
        );
        assert_eq!(
            parse_stop_old_servers_after_update_response("later", true),
            None
        );
    }

    #[test]
    fn update_requires_server_restart_when_target_protocol_differs_or_unknown() {
        let server = crate::api::RuntimeStatus {
            version: Some("0.5.5".to_string()),
            protocol: Some(2),
            capabilities: None,
        };
        let compatible_release = ReleaseInfo {
            version: Version::parse("0.5.6").unwrap(),
            identity: "0.5.6".to_string(),
            channel: UpdateChannel::Stable,
            build_id: None,
            commit: None,
            target_protocol: Some(2),
            download_url: "https://example.com/herdr".to_string(),
            sha256: None,
            notes_body: "### Changed\n- One".to_string(),
        };
        let incompatible_release = ReleaseInfo {
            target_protocol: Some(4),
            ..compatible_release.clone()
        };
        let unknown_release = ReleaseInfo {
            target_protocol: None,
            ..compatible_release.clone()
        };

        assert!(!update_requires_server_restart(
            &server,
            &compatible_release
        ));
        assert!(update_requires_server_restart(
            &server,
            &incompatible_release
        ));
        assert!(update_requires_server_restart(&server, &unknown_release));
    }

    #[test]
    fn plain_update_defers_stop_prompt_until_after_install() {
        assert!(
            !io::stdin().is_terminal(),
            "this test relies on noninteractive test stdin"
        );
        let release = fake_release("9.8.7", Some(77));
        let plan = RunningServerUpdatePlan {
            target: RunningUpdateTarget {
                name: Some("work".to_string()),
                label: "work".to_string(),
                stop_command: "herdr session stop work".to_string(),
                attach_command: Some("herdr session attach work".to_string()),
                socket_path: crate::session::api_socket_path_for(Some("work")),
                client_socket_path: crate::session::client_socket_path_for(Some("work")),
                must_be_running: true,
            },
            requires_server_restart: true,
            server: crate::api::RuntimeStatus {
                version: Some("0.6.2".to_string()),
                protocol: Some(76),
                capabilities: Some(crate::api::schema::ServerCapabilities {
                    live_handoff: true,
                    detached_server_daemon: true,
                }),
            },
        };

        let decisions = confirm_running_server_update_action(
            vec![plan],
            &release,
            SelfUpdateOptions {
                live_handoff: false,
            },
        )
        .unwrap();

        assert_eq!(decisions.len(), 1);
        assert_eq!(decisions[0].action, RunningServerUpdateAction::None);
        assert!(decisions[0].plan.requires_server_restart);
    }

    #[test]
    fn plain_update_targets_all_running_sessions() {
        let _guard = env_lock().lock().unwrap();
        let config_home = set_test_config_home("all-sessions");
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();

        let default_socket = crate::session::api_socket_path_for(None);
        let work_socket = crate::session::api_socket_path_for(Some("work"));
        fs::create_dir_all(default_socket.parent().unwrap()).unwrap();
        fs::create_dir_all(work_socket.parent().unwrap()).unwrap();
        let default_listener = UnixListener::bind(&default_socket).unwrap();
        let work_listener = UnixListener::bind(&work_socket).unwrap();

        let mut targets = running_update_targets().unwrap();
        targets.sort_by(|left, right| left.label.cmp(&right.label));

        drop(default_listener);
        drop(work_listener);
        let _ = fs::remove_dir_all(config_home);
        std::env::remove_var("XDG_CONFIG_HOME");

        assert_eq!(targets.len(), 2);
        assert_eq!(targets[0].label, crate::session::DEFAULT_SESSION_NAME);
        assert_eq!(targets[0].name, None);
        assert_eq!(targets[1].label, "work");
        assert_eq!(targets[1].name.as_deref(), Some("work"));
    }

    #[test]
    fn explicit_session_update_targets_only_that_session() {
        let _guard = env_lock().lock().unwrap();
        let config_home = set_test_config_home("explicit-session");
        std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/ignored-herdr.sock");
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
        let args = vec![
            "herdr".to_string(),
            "--session".to_string(),
            "work".to_string(),
            "update".to_string(),
        ];
        let _ = crate::session::configure_from_args(&args).unwrap();

        let targets = running_update_targets().unwrap();

        let expected_socket = crate::session::api_socket_path_for(Some("work"));
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        std::env::remove_var("XDG_CONFIG_HOME");
        crate::session::clear_explicit_session_for_test();
        let _ = fs::remove_dir_all(config_home);

        assert_eq!(targets.len(), 1);
        assert_eq!(targets[0].label, "work");
        assert_eq!(targets[0].name.as_deref(), Some("work"));
        assert_eq!(targets[0].socket_path, expected_socket);
    }

    #[test]
    fn socket_override_update_targets_socket_not_env_session() {
        let _guard = env_lock().lock().unwrap();
        std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, "/tmp/custom-herdr.sock");
        std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
        crate::session::clear_explicit_session_for_test();

        let targets = running_update_targets().unwrap();

        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();

        assert_eq!(targets.len(), 1);
        assert_eq!(targets[0].name, None);
        assert_eq!(
            targets[0].socket_path,
            PathBuf::from("/tmp/custom-herdr.sock")
        );
        assert!(targets[0]
            .stop_command
            .contains(crate::api::SOCKET_PATH_ENV_VAR));
    }

    #[test]
    fn plain_update_errors_when_named_session_has_client_socket_without_status_api() {
        let _guard = env_lock().lock().unwrap();
        let config_home = set_test_config_home("client-only-session");
        std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR);
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();

        let work_client_socket = crate::session::client_socket_path_for(Some("work"));
        fs::create_dir_all(work_client_socket.parent().unwrap()).unwrap();
        let work_client_listener = UnixListener::bind(&work_client_socket).unwrap();
        let release = fake_release("9.8.7", Some(77));

        let err = plan_running_server_updates(&release).unwrap_err();

        drop(work_client_listener);
        let _ = fs::remove_dir_all(config_home);
        std::env::remove_var("XDG_CONFIG_HOME");

        assert!(
            err.contains("work") && err.contains("status API did not respond"),
            "unexpected error: {err}"
        );
        assert!(
            err.contains("herdr session stop work"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn failed_handoff_classification_detects_updated_server() {
        let socket_path = unique_test_socket_path("handoff-updated-status");
        let handle = spawn_status_server_once(&socket_path, "9.8.7", 77);
        let release = fake_release("9.8.7", Some(77));

        let state = classify_failed_live_handoff_state_at(&socket_path, &release);

        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert_eq!(state, FailedHandoffServerState::UpdatedServerRunning);
    }

    #[test]
    fn failed_handoff_classification_detects_old_server() {
        let socket_path = unique_test_socket_path("handoff-old-status");
        let handle = spawn_status_server_once(&socket_path, "0.6.2", 76);
        let release = fake_release("9.8.7", Some(77));

        let state = classify_failed_live_handoff_state_at(&socket_path, &release);

        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        match state {
            FailedHandoffServerState::OldServerRunning(status) => {
                assert_eq!(status.version.as_deref(), Some("0.6.2"));
                assert_eq!(status.protocol, Some(76));
            }
            other => panic!("unexpected state: {other:?}"),
        }
    }

    #[test]
    fn failed_handoff_classification_detects_missing_server() {
        let socket_path = unique_test_socket_path("handoff-missing-status");
        let release = fake_release("9.8.7", Some(77));

        let state = classify_failed_live_handoff_state_at(&socket_path, &release);

        assert_eq!(state, FailedHandoffServerState::NoServerResponding);
    }

    #[test]
    fn noninteractive_plain_update_does_not_complete_with_running_server() {
        let _guard = env_lock().lock().unwrap();
        assert!(
            !io::stdin().is_terminal(),
            "this test relies on noninteractive test stdin"
        );
        std::env::set_var(crate::session::SESSION_ENV_VAR, "work");
        crate::session::clear_explicit_session_for_test();
        let server = crate::api::RuntimeStatus {
            version: Some("0.5.5".to_string()),
            protocol: Some(2),
            capabilities: None,
        };
        let release = ReleaseInfo {
            version: Version::parse("0.5.6").unwrap(),
            identity: "0.5.6".to_string(),
            channel: UpdateChannel::Stable,
            build_id: None,
            commit: None,
            target_protocol: Some(3),
            download_url: "https://example.com/herdr".to_string(),
            sha256: None,
            notes_body: "### Changed\n- One".to_string(),
        };
        let plan = RunningServerUpdatePlan {
            target: RunningUpdateTarget {
                name: Some("work".to_string()),
                label: "work".to_string(),
                stop_command: "herdr session stop work".to_string(),
                attach_command: Some("herdr session attach work".to_string()),
                socket_path: crate::session::api_socket_path_for(Some("work")),
                client_socket_path: crate::session::client_socket_path_for(Some("work")),
                must_be_running: true,
            },
            requires_server_restart: true,
            server,
        };

        let decisions = confirm_running_server_update_action(
            vec![plan],
            &release,
            SelfUpdateOptions {
                live_handoff: false,
            },
        )
        .unwrap();
        let complete = prompt_to_complete_plain_update(&decisions, &release).unwrap();

        assert!(!complete);
        std::env::remove_var(crate::session::SESSION_ENV_VAR);
        crate::session::clear_explicit_session_for_test();
    }

    #[test]
    fn client_protocol_server_is_running_at_detects_live_socket() {
        let socket_path = unique_test_socket_path("client-live");
        let listener = UnixListener::bind(&socket_path).unwrap();

        assert!(client_protocol_server_is_running_at(&socket_path));

        drop(listener);
        let _ = fs::remove_file(&socket_path);
    }

    #[test]
    fn client_protocol_server_is_running_at_ignores_missing_socket() {
        let socket_path = unique_test_socket_path("client-missing");

        assert!(!client_protocol_server_is_running_at(&socket_path));
    }

    #[test]
    fn stop_server_via_api_accepts_success_response() {
        let socket_path = unique_test_socket_path("stop-ok");
        let listener = UnixListener::bind(&socket_path).unwrap();
        let handle = thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            assert!(
                request.contains("server.stop")
                    || request.contains("ServerStop")
                    || request.contains("server_stop")
                    || request.contains("ServerStop")
            );
            stream
                .write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n")
                .unwrap();
            stream.flush().unwrap();
        });

        let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200));
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(
            result.is_ok(),
            "expected stop request to succeed: {result:?}"
        );
    }

    #[test]
    fn live_handoff_server_via_api_sends_handoff_request() {
        let socket_path = unique_test_socket_path("handoff-ok");
        let listener = UnixListener::bind(&socket_path).unwrap();
        let handle = thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            assert!(request.contains("server.live_handoff"));
            stream
                .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n")
                .unwrap();
            stream.flush().unwrap();
        });

        let result = live_handoff_server_via_api_at(&socket_path, Duration::from_millis(200));
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(
            result.is_ok(),
            "expected handoff request to succeed: {result:?}"
        );
    }

    #[test]
    fn update_live_handoff_request_names_import_binary_and_expected_release() {
        let socket_path = unique_test_socket_path("handoff-update-ok");
        let listener = UnixListener::bind(&socket_path).unwrap();
        let handle = thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = String::new();
            BufReader::new(stream.try_clone().unwrap())
                .read_line(&mut request)
                .unwrap();
            let value: serde_json::Value = serde_json::from_str(&request).unwrap();
            assert_eq!(value["method"], "server.live_handoff");
            assert_eq!(value["params"]["import_exe"], "/tmp/herdr-new");
            assert_eq!(value["params"]["expected_protocol"], 77);
            assert_eq!(value["params"]["expected_version"], "9.8.7");
            stream
                .write_all(b"{\"id\":\"update:server:live-handoff\",\"result\":{}}\n")
                .unwrap();
            stream.flush().unwrap();
        });
        let release = ReleaseInfo {
            version: Version::parse("9.8.7").unwrap(),
            identity: "9.8.7".to_string(),
            channel: UpdateChannel::Stable,
            build_id: None,
            commit: None,
            target_protocol: Some(77),
            download_url: "https://example.com/herdr".to_string(),
            sha256: None,
            notes_body: "### Changed\n- One".to_string(),
        };

        let result = live_handoff_server_via_api_for_release_at(
            &socket_path,
            Duration::from_millis(200),
            Path::new("/tmp/herdr-new"),
            &release,
        );
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(
            result.is_ok(),
            "expected handoff request to succeed: {result:?}"
        );
    }

    #[test]
    fn stop_server_via_api_times_out_when_server_never_replies() {
        let socket_path = unique_test_socket_path("stop-timeout");
        let listener = UnixListener::bind(&socket_path).unwrap();
        let handle = thread::spawn(move || {
            let (_stream, _) = listener.accept().unwrap();
            thread::sleep(Duration::from_millis(200));
        });

        let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err();
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(
            err.contains("failed to read server stop response")
                || err.contains("empty server stop response"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() {
        let socket_path = unique_test_socket_path("shutdown-ok");
        let (running, handle) = spawn_accept_loop(&socket_path);
        let running_for_stop = Arc::clone(&running);
        let stopper = thread::spawn(move || {
            thread::sleep(Duration::from_millis(60));
            running_for_stop.store(false, Ordering::Relaxed);
        });

        let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500));
        running.store(false, Ordering::Relaxed);
        let _ = stopper.join();
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(result.is_ok(), "expected shutdown confirmation: {result:?}");
    }

    #[test]
    fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() {
        let socket_path = unique_test_socket_path("shutdown-timeout");
        let (running, handle) = spawn_accept_loop(&socket_path);

        let err =
            wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err();
        running.store(false, Ordering::Relaxed);
        let _ = handle.join();
        let _ = fs::remove_file(&socket_path);
        assert!(err.contains("still responding"), "unexpected error: {err}");
    }

    #[test]
    fn version_ordering() {
        let v010 = Version::parse("0.1.0").unwrap();
        let v011 = Version::parse("0.1.1").unwrap();
        let v020 = Version::parse("0.2.0").unwrap();
        let v100 = Version::parse("1.0.0").unwrap();

        assert!(v010 < v011);
        assert!(v011 < v020);
        assert!(v020 < v100);
        assert!(v010 == Version::parse("0.1.0").unwrap());
    }

    #[test]
    fn version_display() {
        let v = Version {
            major: 0,
            minor: 1,
            patch: 0,
        };
        assert_eq!(v.to_string(), "0.1.0");
    }

    #[test]
    fn current_version_parses() {
        let v = Version::current();
        assert!(v.major < 100);
    }

    #[test]
    fn platform_target_is_known() {
        let (os, arch) = platform_target();
        assert!(os == "linux" || os == "macos", "os: {os}");
        assert!(arch == "x86_64" || arch == "aarch64", "arch: {arch}");
    }

    #[test]
    fn update_manifest_deserializes() {
        let json = "{\n\
            \"version\": \"0.2.0\",\n\
            \"protocol\": 4,\n\
            \"notes\": \"### Changed\\n- One\",\n\
            \"announcement\": {\n\
                \"id\": \"keymap-v2\",\n\
                \"title\": \"Keymap changes\",\n\
                \"body\": \"### Heads up\\n- Defaults changed\"\n\
            },\n\
            \"assets\": {\n\
                \"linux-x86_64\": \"https://example.com/herdr-linux-x86_64\",\n\
                \"macos-aarch64\": \"https://example.com/herdr-macos-aarch64\"\n\
            }\n\
        }";
        let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
        assert_eq!(manifest.version, "0.2.0");
        assert_eq!(manifest.protocol, Some(4));
        assert_eq!(manifest.assets.len(), 2);
        assert_eq!(
            manifest
                .metadata_for_version(&Version::parse("0.2.0").unwrap())
                .expect("metadata")
                .notes_body(),
            "### Changed\n- One"
        );
        assert_eq!(
            manifest
                .announcement
                .as_ref()
                .and_then(|announcement| announcement.get("id"))
                .and_then(serde_json::Value::as_str),
            Some("keymap-v2")
        );
        assert_eq!(
            manifest.download_url_for("linux", "x86_64").as_deref(),
            Some("https://example.com/herdr-linux-x86_64")
        );
    }

    #[test]
    fn update_manifest_reads_archived_release_metadata() {
        let json = r####"{
            "version": "0.3.0",
            "protocol": 4,
            "notes": "### Changed\n- Three",
            "assets": {
                "linux_x86_64": "https://example.com/unused"
            },
            "releases": {
                "0.2.0": {
                    "notes": "### Changed\n- Two",
                    "announcement": {
                        "id": "two",
                        "title": "Two",
                        "body": "### Two"
                    }
                }
            }
        }"####;
        let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
        let version = Version::parse("0.2.0").unwrap();
        let metadata = manifest.metadata_for_version(&version).expect("metadata");

        assert_eq!(metadata.notes_body(), "### Changed\n- Two");
        assert_eq!(
            metadata
                .announcement
                .as_ref()
                .and_then(|announcement| announcement.get("id"))
                .and_then(serde_json::Value::as_str),
            Some("two")
        );
    }

    #[test]
    fn update_manifest_root_metadata_wins_for_latest_version() {
        let json = r####"{
            "version": "0.3.0",
            "protocol": 4,
            "notes": "### Changed\n- Root",
            "announcement": {
                "id": "root",
                "title": "Root",
                "body": "### Root"
            },
            "assets": {
                "linux_x86_64": "https://example.com/unused"
            },
            "releases": {
                "0.3.0": {
                    "notes": "### Changed\n- Stale",
                    "announcement": {
                        "id": "stale",
                        "title": "Stale",
                        "body": "### Stale"
                    }
                }
            }
        }"####;
        let manifest: UpdateManifest = serde_json::from_str(json).unwrap();
        let version = Version::parse("0.3.0").unwrap();
        let metadata = manifest.metadata_for_version(&version).expect("metadata");

        assert_eq!(metadata.notes_body(), "### Changed\n- Root");
        assert_eq!(
            metadata
                .announcement
                .as_ref()
                .and_then(|announcement| announcement.get("id"))
                .and_then(serde_json::Value::as_str),
            Some("root")
        );
    }

    #[test]
    fn update_manifest_ignores_malformed_releases_container() {
        let json = r####"{
            "version": "0.3.0",
            "protocol": 4,
            "notes": "### Changed\n- Root",
            "assets": {
                "linux_x86_64": "https://example.com/unused"
            },
            "releases": []
        }"####;
        let manifest: UpdateManifest = serde_json::from_str(json).unwrap();

        assert!(manifest.releases.is_empty());
        assert_eq!(
            manifest
                .metadata_for_version(&Version::parse("0.3.0").unwrap())
                .expect("metadata")
                .notes_body(),
            "### Changed\n- Root"
        );
    }

    #[test]
    fn update_manifest_requires_notes_field() {
        let json = r#"{
            "version": "0.2.0",
            "assets": {
                "linux-x86_64": "https://example.com/herdr-linux-x86_64"
            }
        }"#;

        assert!(serde_json::from_str::<UpdateManifest>(json).is_err());
    }

    #[test]
    fn stable_update_requires_asset_checksum() {
        let (os, arch) = platform_target();
        let asset_key = format!("{os}-{arch}");
        let json = format!(
            r####"{{
                "version": "99.99.99",
                "notes": "### Changed\n- One",
                "assets": {{
                    "{asset_key}": "https://example.com/herdr"
                }}
            }}"####
        );
        let manifest: UpdateManifest = serde_json::from_str(&json).unwrap();

        assert!(release_info_from_manifest(&manifest)
            .unwrap_err()
            .contains("missing a SHA-256 checksum"));
    }

    #[test]
    fn invalid_manifest_announcement_does_not_block_release_info() {
        let (os, arch) = platform_target();
        let asset_key = format!("{os}-{arch}");
        let json = format!(
            r####"{{
                "version": "99.99.99",
                "protocol": 4,
                "notes": "### Changed\n- One",
                "announcement": {{
                    "id": 123,
                    "title": "Keymap changes",
                    "body": "### Heads up\n- Defaults changed"
                }},
                "assets": {{
                    "{asset_key}": {{
                        "url": "https://example.com/herdr",
                        "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
                    }}
                }}
            }}"####
        );

        let manifest: UpdateManifest = serde_json::from_str(&json).unwrap();
        handle_manifest_announcement(&manifest.version, manifest.announcement.as_ref());
        let release = release_info_from_manifest(&manifest)
            .unwrap()
            .expect("release info");

        assert_eq!(release.version, Version::parse("99.99.99").unwrap());
        assert_eq!(release.download_url, "https://example.com/herdr");
    }

    #[test]
    fn stable_channel_installs_stable_asset_when_current_binary_is_preview() {
        let latest_stable = Version::parse("0.6.6").unwrap();
        let installed_base = Version::parse("0.6.6").unwrap();
        assert!(stable_channel_should_install(
            &latest_stable,
            &installed_base,
            true
        ));
        assert!(!stable_channel_should_install(
            &latest_stable,
            &installed_base,
            false
        ));
    }

    #[test]
    fn preview_manifest_reports_update_when_build_id_differs() {
        let (os, arch) = platform_target();
        let asset_key = format!("{os}-{arch}");
        let json = format!(
            r####"{{
                "channel": "preview",
                "base_version": "9.9.9",
                "build_id": "2026-06-02-abcdef123456",
                "commit": "abcdef1234567890",
                "built_at": "2026-06-02T03:00:00Z",
                "protocol": 77,
                "notes": "### Fixed\n- One",
                "assets": {{
                    "{asset_key}": {{
                        "url": "https://example.com/herdr-linux-x86_64",
                        "sha256": "deadbeef"
                    }}
                }},
                "builds": {{
                    "2026-06-02-abcdef123456": {{
                        "base_version": "9.9.9",
                        "commit": "abcdef1234567890",
                        "built_at": "2026-06-02T03:00:00Z",
                        "protocol": 77,
                        "assets": {{
                            "{asset_key}": {{
                                "url": "https://example.com/herdr-linux_x86_64",
                                "sha256": "deadbeef"
                            }}
                        }}
                    }}
                }}
            }}"####
        );
        let manifest: PreviewManifest = serde_json::from_str(&json).unwrap();

        let release = release_info_from_preview_manifest(&manifest)
            .unwrap()
            .expect("preview update");

        assert_eq!(release.channel, UpdateChannel::Preview);
        assert_eq!(release.identity, "9.9.9-preview.2026-06-02-abcdef123456");
        assert_eq!(release.target_protocol, Some(77));
        assert_eq!(release.sha256.as_deref(), Some("deadbeef"));
    }

    #[test]
    fn preview_windows_build_waits_for_first_stable_asset() {
        let without_windows: UpdateManifest = serde_json::from_str(
            r#"{"version":"9.9.9","notes":"notes","assets":{},"announcement":null}"#,
        )
        .unwrap();
        assert!(first_windows_stable_is_pending(
            &without_windows,
            true,
            true
        ));
        assert!(!first_windows_stable_is_pending(
            &without_windows,
            true,
            false
        ));
        assert!(!first_windows_stable_is_pending(
            &without_windows,
            false,
            true
        ));

        let with_windows: UpdateManifest = serde_json::from_str(
            r#"{"version":"9.9.9","notes":"notes","assets":{"windows-x86_64":"https://example.com/herdr-windows-x86_64.zip"},"announcement":null}"#,
        )
        .unwrap();
        assert!(!first_windows_stable_is_pending(&with_windows, true, true));
    }

    #[test]
    fn checked_in_website_manifest_matches_update_schema() {
        #[derive(Deserialize)]
        struct LegacyUpdateManifest {
            assets: BTreeMap<String, String>,
        }

        let json = include_str!("../website/latest.json");
        let legacy: LegacyUpdateManifest = serde_json::from_str(json)
            .expect("website/latest.json should keep legacy string asset URLs");
        assert!(legacy.assets.len() >= 4);

        let manifest: UpdateManifest =
            serde_json::from_str(json).expect("website/latest.json should match updater schema");

        assert!(!manifest
            .metadata_for_version(&Version::parse(&manifest.version).unwrap())
            .expect("metadata")
            .notes_body()
            .is_empty());
        // website/latest.json describes the latest released binaries, not the
        // current unreleased checkout. Its protocol is updated by the release
        // flow together with the release assets.
        assert!(manifest.protocol.is_some());
        assert!(manifest.assets.len() >= 4);
        assert!(manifest.releases.contains_key(&manifest.version));

        for target in [
            "linux-x86_64",
            "linux-aarch64",
            "macos-x86_64",
            "macos-aarch64",
        ] {
            let asset = manifest
                .assets
                .get(target)
                .unwrap_or_else(|| panic!("missing asset URL for {target}"));
            let url = &asset.url;
            assert_eq!(
                manifest.sha256.get(target).map(String::len),
                Some(64),
                "missing SHA-256 checksum for {target}"
            );
            assert!(
                url.contains(&format!("/releases/download/v{}/", manifest.version)),
                "unexpected release URL for {target}: {url}"
            );
            assert!(
                url.ends_with(&format!("herdr-{target}")),
                "unexpected asset name for {target}: {url}"
            );
        }

        if let Some(windows) = manifest.assets.get("windows-x86_64") {
            assert!(windows.url.ends_with("/herdr-windows-x86_64.zip"));
            assert_eq!(
                manifest.sha256.get("windows-x86_64").map(String::len),
                Some(64),
                "missing SHA-256 checksum for windows-x86_64"
            );
        }

        for (version, release) in &manifest.releases {
            let assets = release
                .get("assets")
                .and_then(serde_json::Value::as_object)
                .unwrap_or_else(|| panic!("missing assets for release {version}"));
            for target in [
                "linux-x86_64",
                "linux-aarch64",
                "macos-x86_64",
                "macos-aarch64",
            ] {
                let asset = assets
                    .get(target)
                    .cloned()
                    .unwrap_or_else(|| panic!("missing asset URL for {version} {target}"));
                let asset: AssetRef = serde_json::from_value(asset)
                    .unwrap_or_else(|_| panic!("invalid asset for {version} {target}"));
                let url = &asset.url;
                assert!(
                    url.contains(&format!("/releases/download/v{version}/")),
                    "unexpected release URL for {version} {target}: {url}"
                );
                assert!(
                    url.ends_with(&format!("herdr-{target}")),
                    "unexpected asset name for {version} {target}: {url}"
                );
            }
            if let Some(windows) = assets.get("windows-x86_64") {
                let windows: AssetRef = serde_json::from_value(windows.clone())
                    .unwrap_or_else(|_| panic!("invalid Windows asset for release {version}"));
                assert!(windows.url.ends_with("/herdr-windows-x86_64.zip"));
                let checksums = release
                    .get("sha256")
                    .and_then(serde_json::Value::as_object)
                    .unwrap_or_else(|| panic!("missing checksums for release {version}"));
                assert!(checksums
                    .get("windows-x86_64")
                    .and_then(serde_json::Value::as_str)
                    .is_some_and(|value| value.len() == 64));
            }
        }
    }
}
