//! build.rs — Download the platform-specific prebuilt lazy-mcp binary from the
//! GitLab release assets and expose its path via LAZY_MCP_PREBUILT so that
//! main.rs can embed it with `include_bytes!`.
//!
//! The binary is cached in `$OUT_DIR/lazy-mcp` and reused across rebuilds as
//! long as the version hasn't changed.  No bun, Node.js, or any other build
//! tool is required on the user's machine.
//!
//! Release asset URL pattern (matches release:binaries CI job):
//!   https://gitlab.com/gitlab-org/ai/lazy-mcp/-/releases/v{VERSION}/downloads/
//!     lazy-mcp-{VERSION}-{OS}-{ARCH}.tar.gz
//!     lazy-mcp-{VERSION}-{OS}-{ARCH}.tar.gz.sha256

use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const GITLAB_BASE: &str = "https://gitlab.com/gitlab-org/ai/lazy-mcp/-/releases";

fn main() {
    let version = env::var("CARGO_PKG_VERSION").unwrap();
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    let (os, arch) = target_platform();
    let platform = format!("{os}-{arch}");
    let tarball_name = format!("lazy-mcp-{version}-{platform}.tar.gz");
    let sha256_name = format!("{tarball_name}.sha256");
    let base_url = format!("{GITLAB_BASE}/v{version}/downloads");

    let prebuilt_path = out_dir.join("lazy-mcp");

    // Re-download if the cached binary doesn't match the expected tarball size.
    // OUT_DIR is version-keyed by Cargo, so a version bump always invalidates it.
    // We also record the expected size in a sidecar file to detect corruption.
    let sidecar = out_dir.join("lazy-mcp.size");
    let cached_ok = prebuilt_path.exists() && {
        let recorded = fs::read_to_string(&sidecar)
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok());
        let actual = prebuilt_path.metadata().map(|m| m.len()).ok();
        matches!((recorded, actual), (Some(r), Some(a)) if r == a)
    };

    if !cached_ok {
        let tarball_url = format!("{base_url}/{tarball_name}");
        let sha256_url = format!("{base_url}/{sha256_name}");

        let tarball_path = out_dir.join(&tarball_name);
        let sha256_path = out_dir.join(&sha256_name);

        eprintln!("[lazy-mcp build] Downloading {tarball_name}...");
        fetch_url(&tarball_url, &tarball_path);

        eprintln!("[lazy-mcp build] Downloading {sha256_name}...");
        fetch_url(&sha256_url, &sha256_path);

        verify_sha256(&tarball_path, &sha256_path);

        // Remove any stale binary before extraction so we don't accidentally
        // use a leftover file from a previous (failed or interrupted) build.
        if prebuilt_path.exists() {
            fs::remove_file(&prebuilt_path).unwrap_or_else(|e| {
                panic!(
                    "[lazy-mcp build] Failed to remove stale binary {}: {e}",
                    prebuilt_path.display()
                );
            });
        }

        eprintln!("[lazy-mcp build] Extracting binary...");
        extract_tarball(&tarball_path, &out_dir);

        // Releases >= 2.3 ship the binary as "lazy-mcp" inside the tarball.
        // Older releases (≤ 2.2.x) used "lazy-mcp-{os}-{arch}" — handle both.
        if !prebuilt_path.exists() {
            let legacy_name = format!("lazy-mcp-{platform}");
            let legacy_path = out_dir.join(&legacy_name);
            if legacy_path.exists() {
                fs::rename(&legacy_path, &prebuilt_path).unwrap_or_else(|e| {
                    panic!("[lazy-mcp build] Failed to rename binary: {e}");
                });
            }
        }

        if !prebuilt_path.exists() {
            panic!(
                "[lazy-mcp build] Expected binary at {} after extraction",
                prebuilt_path.display()
            );
        }

        make_executable(&prebuilt_path);

        // Record the size so future runs can detect corruption.
        let size = prebuilt_path.metadata().unwrap().len();
        fs::write(&sidecar, size.to_string()).unwrap_or_else(|e| {
            eprintln!("[lazy-mcp build] Warning: could not write sidecar: {e}")
        });

        eprintln!(
            "[lazy-mcp build] Prebuilt binary ready: {:.1} MB",
            size as f64 / 1_048_576.0
        );
    } else {
        eprintln!(
            "[lazy-mcp build] Using cached binary: {}",
            prebuilt_path.display()
        );
    }

    println!(
        "cargo:rustc-env=LAZY_MCP_PREBUILT={}",
        prebuilt_path.display()
    );

    // Re-run only if Cargo.toml changes (version bump).
    println!("cargo:rerun-if-changed=Cargo.toml");
}

// ── Platform detection ────────────────────────────────────────────────────────

/// Returns (os, arch) matching our release asset naming convention:
///   os:   "linux" | "darwin"
///   arch: "x64"   | "arm64"
fn target_platform() -> (&'static str, &'static str) {
    let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
    let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();

    let os = match target_os.as_str() {
        "linux" => "linux",
        "macos" => "darwin",
        other => panic!(
            "[lazy-mcp build] Unsupported OS '{other}'. \
             Prebuilt binaries are available for linux and darwin only."
        ),
    };

    let arch = match target_arch.as_str() {
        "x86_64" => "x64",
        "aarch64" => "arm64",
        other => panic!(
            "[lazy-mcp build] Unsupported arch '{other}'. \
             Prebuilt binaries are available for x64 and arm64 only."
        ),
    };

    (os, arch)
}

// ── File download ─────────────────────────────────────────────────────────────

fn fetch_url(url: &str, dest: &Path) {
    // Try curl first, then wget. Pass dest as OsStr to avoid unwrap on non-UTF8 paths.
    if let Some(curl) = which("curl") {
        let ok = Command::new(curl)
            .args(["-fsSL", "--retry", "3", "-o"])
            .arg(dest)
            .arg(url)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if ok {
            return;
        }
    }

    if let Some(wget) = which("wget") {
        let ok = Command::new(wget)
            .arg("-q")
            .arg("-O")
            .arg(dest)
            .arg(url)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if ok {
            return;
        }
    }

    panic!(
        "[lazy-mcp build] Cannot download {url}: \
         install curl or wget and try again."
    );
}

// ── SHA-256 verification ──────────────────────────────────────────────────────

fn verify_sha256(tarball: &Path, sha256_file: &Path) {
    let expected = fs::read_to_string(sha256_file)
        .unwrap_or_else(|e| panic!("Cannot read {}: {e}", sha256_file.display()));
    // The .sha256 file may contain "HASH  filename" or just "HASH".
    let expected_hash = expected
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_lowercase();

    if expected_hash.is_empty() {
        panic!(
            "[lazy-mcp build] Empty sha256 file: {}",
            sha256_file.display()
        );
    }

    let actual_hash = compute_sha256(tarball);

    if actual_hash != expected_hash {
        panic!(
            "[lazy-mcp build] SHA-256 mismatch for {}:\n  expected: {}\n  actual:   {}",
            tarball.display(),
            expected_hash,
            actual_hash
        );
    }
    eprintln!("[lazy-mcp build] SHA-256 verified ✓");
}

fn compute_sha256(path: &Path) -> String {
    // sha256sum (Linux / GNU coreutils)
    if let Some(cmd) = which("sha256sum") {
        let out = Command::new(cmd)
            .arg(path)
            .output()
            .expect("[lazy-mcp build] Failed to run sha256sum");
        if !out.status.success() {
            panic!(
                "[lazy-mcp build] sha256sum failed:\n{}",
                String::from_utf8_lossy(&out.stderr)
            );
        }
        return String::from_utf8_lossy(&out.stdout)
            .split_whitespace()
            .next()
            .unwrap_or("")
            .to_lowercase()
            .to_string();
    }

    // shasum (macOS / BSD)
    if let Some(cmd) = which("shasum") {
        let out = Command::new(cmd)
            .args(["-a", "256"])
            .arg(path)
            .output()
            .expect("[lazy-mcp build] Failed to run shasum");
        if !out.status.success() {
            panic!(
                "[lazy-mcp build] shasum failed:\n{}",
                String::from_utf8_lossy(&out.stderr)
            );
        }
        return String::from_utf8_lossy(&out.stdout)
            .split_whitespace()
            .next()
            .unwrap_or("")
            .to_lowercase()
            .to_string();
    }

    panic!("[lazy-mcp build] Cannot compute SHA-256: install sha256sum or shasum.");
}

// ── Tarball extraction ────────────────────────────────────────────────────────

fn extract_tarball(tarball: &Path, dest: &Path) {
    // The tarball contains a single file named "lazy-mcp" (or "lazy-mcp-{os}-{arch}" in ≤ 2.2.x).
    // Pass paths as OsStr args to avoid unwrap on non-UTF8 paths.
    if let Some(tar) = which("tar") {
        let ok = Command::new(tar)
            .arg("-xzf")
            .arg(tarball)
            .arg("-C")
            .arg(dest)
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if ok {
            return;
        }
    }

    // Fallback: Python3 with safe extraction (rejects path traversal).
    if let Some(py) = which("python3") {
        // tarfile.extractall() is vulnerable to path traversal with crafted
        // archives. Use a filter that rejects any member whose resolved path
        // escapes the destination directory.
        let dest_str = dest.to_string_lossy();
        let tar_str = tarball.to_string_lossy();
        let script = format!(
            r#"
import tarfile, os, sys
dest = os.path.realpath(r'{dest}')
with tarfile.open(r'{tar}') as t:
    for m in t.getmembers():
        target = os.path.realpath(os.path.join(dest, m.name))
        if not target.startswith(dest + os.sep) and target != dest:
            sys.exit(f'path traversal detected: {{m.name}}')
    t.extractall(dest)
"#,
            dest = dest_str,
            tar = tar_str
        );
        let ok = Command::new(py)
            .args(["-c", &script])
            .status()
            .map(|s| s.success())
            .unwrap_or(false);
        if ok {
            return;
        }
    }

    panic!(
        "[lazy-mcp build] Cannot extract {}: install tar or python3.",
        tarball.display()
    );
}

// ── Utilities ─────────────────────────────────────────────────────────────────

fn which(exe: &str) -> Option<PathBuf> {
    env::var_os("PATH").and_then(|paths| {
        env::split_paths(&paths).find_map(|dir| {
            let candidate = dir.join(exe);
            if candidate.is_file() {
                Some(candidate)
            } else {
                None
            }
        })
    })
}

fn make_executable(path: &Path) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if let Ok(meta) = fs::metadata(path) {
            let mut perms = meta.permissions();
            perms.set_mode(0o755);
            fs::set_permissions(path, perms).ok();
        }
    }
    let _ = path; // suppress unused warning on non-unix
}
