//! In-container binary self-update — fetch a new `huskies` binary, atomically
//! replace the on-disk executable, drain CRDT persistence, and re-exec.

use crate::slog;
use std::path::{Path, PathBuf};

// ── Binary fetch ─────────────────────────────────────────────────────────────

/// Download a binary from `source_url` and atomically replace `target_path`.
///
/// Writes to a sibling `.tmp` file first, then renames so the replacement is
/// atomic on the same filesystem.  Sets the execute bit before renaming so the
/// file is runnable the moment it appears at the target location.
pub async fn fetch_and_replace_binary(source_url: &str, target_path: &Path) -> Result<(), String> {
    slog!("[upgrade] Fetching binary from {source_url}");

    let resp = reqwest::get(source_url)
        .await
        .map_err(|e| format!("Failed to fetch binary from {source_url}: {e}"))?;

    if !resp.status().is_success() {
        return Err(format!(
            "Binary fetch returned HTTP {}: {source_url}",
            resp.status()
        ));
    }

    let bytes = resp
        .bytes()
        .await
        .map_err(|e| format!("Failed to read binary response body: {e}"))?;

    if bytes.is_empty() {
        return Err("Binary fetch returned an empty body".to_string());
    }

    // Write to a sibling temp file so the rename is atomic on the same FS.
    let tmp_path = sibling_tmp_path(target_path)?;
    std::fs::write(&tmp_path, &bytes)
        .map_err(|e| format!("Failed to write tmp binary to {}: {e}", tmp_path.display()))?;

    set_executable(&tmp_path)?;

    std::fs::rename(&tmp_path, target_path).map_err(|e| {
        format!(
            "Failed to rename {} → {}: {e}",
            tmp_path.display(),
            target_path.display()
        )
    })?;

    slog!(
        "[upgrade] Binary replaced at {} ({} bytes)",
        target_path.display(),
        bytes.len()
    );
    Ok(())
}

// ── Full server upgrade (called from the running process) ─────────────────

/// Fetch a new binary, atomically replace the current executable, drain CRDT
/// persistence, and exit so Docker restarts the container with the new binary
/// (the entrypoint launches `huskies` from PATH, which resolves to the
/// replaced file).
///
/// On failure it returns `Err(message)` so the caller can report the error
/// while keeping the original server running.
pub async fn upgrade_and_reexec(source_url: &str, project_root: &Path) -> Result<String, String> {
    let target = resolve_target_path();

    fetch_and_replace_binary(source_url, &target).await?;

    crate::rebuild::drain_and_exit(project_root, "upgrade").await
}

/// Verify the upgrade target can actually be replaced, without downloading
/// anything: create and remove the same sibling tmp file the real swap uses.
///
/// Called by the `/api/upgrade` handler BEFORE returning 202 so that a sled
/// that cannot write its own binary (e.g. a container from an old image where
/// the install dir is root-owned) fails the upgrade loudly and immediately
/// instead of accepting the request and failing in the background.
pub fn preflight_target_writable() -> Result<(), String> {
    let target = resolve_target_path();
    let tmp = sibling_tmp_path(&target)?;
    std::fs::write(&tmp, b"").map_err(|e| {
        format!(
            "Upgrade target `{}` is not replaceable by this process: {e}. \
             The container likely predates the /opt/huskies/bin layout — \
             run `project-rebuild` on it once to migrate.",
            target.display()
        )
    })?;
    let _ = std::fs::remove_file(&tmp);
    Ok(())
}

// ── Helpers ───────────────────────────────────────────────────────────────

/// Resolve the path to replace with the new binary.
///
/// Inside project containers the binary is installed at
/// `/opt/huskies/bin/huskies` (a huskies-owned directory, so the atomic
/// tmp-write + rename works without root) and `/usr/local/bin/huskies` is a
/// symlink to it.  That path is preferred over `current_exe()` because
/// `current_exe()` can point at a stale location — e.g.
/// `/workspace/target/release/huskies` after a historical in-container
/// rebuild — which the entrypoint would never launch after a restart.
///
/// Outside containers (no `/opt/huskies`), falls back to `current_exe()`.
pub fn resolve_target_path() -> PathBuf {
    let container_path = PathBuf::from("/opt/huskies/bin/huskies");
    if container_path.exists() {
        return container_path;
    }
    std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/huskies"))
}

fn sibling_tmp_path(target: &Path) -> Result<PathBuf, String> {
    let parent = target
        .parent()
        .ok_or_else(|| format!("Cannot determine parent dir of {}", target.display()))?;
    Ok(parent.join(".huskies_upgrade.tmp"))
}

#[cfg(unix)]
fn set_executable(path: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;
    let meta =
        std::fs::metadata(path).map_err(|e| format!("Cannot stat {}: {e}", path.display()))?;
    let mut perms = meta.permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms)
        .map_err(|e| format!("Cannot chmod {}: {e}", path.display()))
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<(), String> {
    Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────

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

    /// Start a tiny HTTP server in the background that serves `content` at `/`.
    async fn serve_bytes(content: Vec<u8>) -> (u16, tokio::task::JoinHandle<()>) {
        use std::sync::Arc;

        let content = Arc::new(content);
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();

        let handle = tokio::spawn(async move {
            loop {
                let Ok((mut stream, _)) = listener.accept().await else {
                    break;
                };
                let content = Arc::clone(&content);
                tokio::spawn(async move {
                    use tokio::io::AsyncWriteExt;
                    // Drain the HTTP request (ignore it).
                    let mut buf = [0u8; 4096];
                    let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut buf).await;
                    // Write a minimal HTTP/1.1 200 response.
                    let header = format!(
                        "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\n\r\n",
                        content.len()
                    );
                    let _ = stream.write_all(header.as_bytes()).await;
                    let _ = stream.write_all(&content).await;
                });
            }
        });

        (port, handle)
    }

    #[tokio::test]
    async fn fetch_and_replace_binary_downloads_and_replaces() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("huskies");
        std::fs::write(&target, b"old binary").unwrap();

        let content = b"new binary content v0.99.0".to_vec();
        let (port, _srv) = serve_bytes(content.clone()).await;

        let url = format!("http://127.0.0.1:{port}/huskies");
        fetch_and_replace_binary(&url, &target).await.unwrap();

        let on_disk = std::fs::read(&target).unwrap();
        assert_eq!(
            on_disk, content,
            "target must contain the downloaded content"
        );
    }

    #[tokio::test]
    async fn fetch_and_replace_binary_sets_executable_bit() {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;

            let dir = tempfile::tempdir().unwrap();
            let target = dir.path().join("huskies");
            std::fs::write(&target, b"old").unwrap();

            let (port, _srv) = serve_bytes(b"#!/bin/sh\nexit 0".to_vec()).await;
            let url = format!("http://127.0.0.1:{port}/huskies");
            fetch_and_replace_binary(&url, &target).await.unwrap();

            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
            assert!(mode & 0o111 != 0, "binary must be executable after upgrade");
        }
    }

    #[tokio::test]
    async fn fetch_and_replace_binary_empty_body_is_error() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("huskies");
        std::fs::write(&target, b"old").unwrap();

        let (port, _srv) = serve_bytes(vec![]).await;
        let url = format!("http://127.0.0.1:{port}/huskies");
        let err = fetch_and_replace_binary(&url, &target).await.unwrap_err();
        assert!(
            err.contains("empty"),
            "expected empty-body error, got: {err}"
        );

        // Original must be untouched (rename never happened).
        let on_disk = std::fs::read(&target).unwrap();
        assert_eq!(on_disk, b"old");
    }

    #[tokio::test]
    async fn fetch_and_replace_binary_unreachable_url_is_error() {
        let dir = tempfile::tempdir().unwrap();
        let target = dir.path().join("huskies");
        let err = fetch_and_replace_binary("http://127.0.0.1:1/huskies", &target)
            .await
            .unwrap_err();
        assert!(!err.is_empty(), "expected a non-empty error");
    }

    #[tokio::test]
    async fn persisted_ops_count_does_not_decrease_after_flush() {
        // Initialise an in-process CRDT, flush it, and verify the persisted
        // count is stable (AC 5 — no ops lost across upgrade).
        crate::crdt_state::init_for_test();

        let before = crate::crdt_state::dump_crdt_state(None).persisted_ops_count;
        crate::crdt_state::flush_persistence(std::time::Duration::from_millis(200)).await;
        let after = crate::crdt_state::dump_crdt_state(None).persisted_ops_count;

        assert!(
            after >= before,
            "persisted_ops_count must not decrease after flush: before={before} after={after}"
        );
    }
}
