//! `release` gateway chat command — build the sled binary and publish it.
//!
//! Usage (gateway mode only): `{bot} release`
//!
//! Builds a Linux binary inside the registered sled that carries the huskies
//! source tree (found by marker files, conventionally the `huskies` project),
//! then copies it into the gateway's artifact store (`~/.huskies/artifacts/`)
//! together with a `.hash` sidecar recording the source git commit.  After a
//! successful `release`, `upgrade all` distributes the artifact to the fleet.
//!
//! The build uses a dedicated `CARGO_TARGET_DIR` (`target/sled-release` on the
//! bind mount) so container release builds never clobber host builds in
//! `target/release`, and the incremental cache survives container replacement.

use crate::service::gateway::config::ProjectEntry;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;

/// Cargo target dir (container path) for release builds inside the sled.
const SLED_TARGET_DIR: &str = "/workspace/target/sled-release";

/// Parse a `release` command from a raw message body.
///
/// Returns `true` when the stripped message is exactly `release`.
pub fn extract_release_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
    let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());
    trimmed.eq_ignore_ascii_case("release")
}

/// Find the registered project that carries the huskies source tree.
///
/// A project qualifies when its `host_path` contains both a workspace
/// `Cargo.toml` and a `server/` directory.  Returns `(name, host_path)` for
/// the first match in name order.
pub fn find_builder_project(
    projects: &BTreeMap<String, ProjectEntry>,
) -> Option<(String, PathBuf)> {
    for (name, entry) in projects {
        let Some(ref host_path) = entry.host_path else {
            continue;
        };
        let root = PathBuf::from(host_path);
        if root.join("Cargo.toml").exists() && root.join("server").is_dir() {
            return Some((name.clone(), root));
        }
    }
    None
}

/// Build the sled binary in the builder container and publish it to the
/// artifact store.
///
/// Streams progress via `send_phase`.  On success the artifact and its
/// `.hash` sidecar are in place and the reply names the commit; on failure
/// the reply carries the build error tail and nothing is published.
pub async fn handle_release<F, Fut>(
    projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
    send_phase: F,
) -> String
where
    F: Fn(String) -> Fut,
    Fut: Future<Output = ()>,
{
    // ── Locate the builder sled ──────────────────────────────────────────────
    let (builder_name, source_root) = {
        let projects = projects_store.read().await;
        match find_builder_project(&projects) {
            Some(found) => found,
            None => {
                return "No registered project carries the huskies source tree \
                        (looked for `Cargo.toml` + `server/` under each project's \
                        host_path). Cannot build a release."
                    .to_string();
            }
        }
    };
    let container_name = format!("huskies-{builder_name}");

    // ── Record the source commit before building ────────────────────────────
    let git_hash = match tokio::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .current_dir(&source_root)
        .output()
        .await
    {
        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
        _ => {
            return format!(
                "Cannot read git HEAD in `{}` — is it a git checkout?",
                source_root.display()
            );
        }
    };

    // ── Build inside the sled container ──────────────────────────────────────
    send_phase(format!(
        "[1/2] building {git_hash} in `{container_name}`\u{2026} (a few minutes)"
    ))
    .await;

    let build = tokio::process::Command::new("docker")
        .args([
            "exec",
            "-w",
            "/workspace",
            "-e",
            &format!("CARGO_TARGET_DIR={SLED_TARGET_DIR}"),
            &container_name,
            "cargo",
            "build",
            "--release",
            "-p",
            "huskies",
        ])
        .output()
        .await;

    let build = match build {
        Ok(out) => out,
        Err(e) => return format!("Release failed at **[1/2] build**: docker exec failed: {e}"),
    };
    if !build.status.success() {
        let stderr = String::from_utf8_lossy(&build.stderr);
        let tail: Vec<&str> = stderr.lines().rev().take(30).collect();
        let tail: Vec<&str> = tail.into_iter().rev().collect();
        return format!(
            "Release failed at **[1/2] build** (commit {git_hash}):\n```\n{}\n```",
            tail.join("\n")
        );
    }

    // ── Publish to the artifact store ────────────────────────────────────────
    send_phase("[2/2] publishing artifact\u{2026}".to_string()).await;

    // The container's /workspace is the host source_root bind mount, so the
    // built binary is directly readable on the host.
    let built = source_root
        .join("target/sled-release/release")
        .join("huskies");
    if !built.exists() {
        return format!(
            "Release failed at **[2/2] publish**: build succeeded but no binary at `{}`.",
            built.display()
        );
    }

    let artifacts = crate::http::artifacts_dir();
    if let Err(e) = std::fs::create_dir_all(&artifacts) {
        return format!(
            "Release failed at **[2/2] publish**: cannot create `{}`: {e}",
            artifacts.display()
        );
    }

    let artifact_path = artifacts.join(crate::http::SLED_ARTIFACT_NAME);
    // Write to a sibling tmp file then rename so a concurrent download from
    // /api/artifacts never sees a half-written binary.
    let tmp_path = artifacts.join(".publish.tmp");
    if let Err(e) = std::fs::copy(&built, &tmp_path) {
        return format!("Release failed at **[2/2] publish**: copy failed: {e}");
    }
    if let Err(e) = std::fs::rename(&tmp_path, &artifact_path) {
        return format!("Release failed at **[2/2] publish**: rename failed: {e}");
    }

    let hash_path = artifacts.join(format!("{}.hash", crate::http::SLED_ARTIFACT_NAME));
    if let Err(e) = std::fs::write(&hash_path, &git_hash) {
        return format!(
            "Artifact published but writing the hash sidecar failed: {e}. \
             `upgrade all` will skip convergence verification."
        );
    }

    let size_mb = std::fs::metadata(&artifact_path)
        .map(|m| m.len() / (1024 * 1024))
        .unwrap_or(0);

    format!(
        "Release **{git_hash}** published ({size_mb} MB at `{}`).\n\
         Say `upgrade all` to roll it out to the fleet.",
        artifact_path.display()
    )
}

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

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

    fn entry(host_path: Option<&str>) -> ProjectEntry {
        ProjectEntry {
            url: Some("http://127.0.0.1:3101".into()),
            auth_token: None,
            ssh_port: None,
            host_path: host_path.map(String::from),
            expected_node_id: None,
        }
    }

    // ── extract_release_command ───────────────────────────────────────────────

    #[test]
    fn extract_release_basic() {
        assert!(extract_release_command(
            "Timmy release",
            "Timmy",
            "@timmy:home"
        ));
        assert!(extract_release_command(
            "@timmy: RELEASE",
            "Timmy",
            "@timmy:home"
        ));
    }

    #[test]
    fn extract_release_rejects_other_commands() {
        assert!(!extract_release_command(
            "Timmy status",
            "Timmy",
            "@timmy:home"
        ));
        assert!(!extract_release_command(
            "Timmy release the hounds",
            "Timmy",
            "@timmy:home"
        ));
    }

    // ── find_builder_project ─────────────────────────────────────────────────

    #[test]
    fn find_builder_matches_huskies_source_markers() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("Cargo.toml"), "[workspace]").unwrap();
        std::fs::create_dir_all(dir.path().join("server")).unwrap();

        let mut projects = BTreeMap::new();
        projects.insert("other".to_string(), entry(Some("/nonexistent/xyz")));
        projects.insert(
            "huskies".to_string(),
            entry(Some(dir.path().to_str().unwrap())),
        );

        let found = find_builder_project(&projects);
        assert_eq!(found.map(|(n, _)| n), Some("huskies".to_string()));
    }

    #[test]
    fn find_builder_none_when_no_source_tree() {
        let dir = tempfile::tempdir().unwrap(); // no Cargo.toml, no server/
        let mut projects = BTreeMap::new();
        projects.insert("app".to_string(), entry(Some(dir.path().to_str().unwrap())));
        projects.insert("no-path".to_string(), entry(None));

        assert!(find_builder_project(&projects).is_none());
    }

    // ── handle_release validation ────────────────────────────────────────────

    #[tokio::test]
    async fn release_without_builder_project_reports_error() {
        let store: Arc<RwLock<BTreeMap<String, ProjectEntry>>> =
            Arc::new(RwLock::new(BTreeMap::new()));
        let msg = handle_release(&store, |_m| async {}).await;
        assert!(
            msg.contains("No registered project"),
            "should explain no builder found: {msg}"
        );
    }
}
