//! `msb install` command — create an executable alias for `msb run`.

use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use clap::Args;

use crate::ui;

use super::common::{
    validate_mount_dir_spec, validate_mount_disk_spec, validate_mount_file_spec,
    validate_mount_named_spec, validate_volume_spec,
};

//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

/// Marker comment used to identify msb-generated alias scripts.
pub(super) const MARKER: &str = "# generated by msb install";

//--------------------------------------------------------------------------------------------------
// Types
//--------------------------------------------------------------------------------------------------

/// Install a sandbox as a system command in ~/.microsandbox/bin.
#[derive(Debug, Args)]
pub struct InstallArgs {
    /// Image to install (e.g. python, ubuntu).
    #[arg(required_unless_present = "list")]
    pub image: Option<String>,

    /// Command name for the alias (defaults to image name).
    #[arg(short, long)]
    pub name: Option<String>,

    /// Number of virtual CPUs to allocate.
    #[arg(short = 'c', long)]
    pub cpus: Option<u8>,

    /// Amount of memory to allocate (e.g. 512M, 1G).
    #[arg(short, long)]
    pub memory: Option<String>,

    /// Mount a host path or named volume into the sandbox (`SOURCE:DEST[:OPTIONS]`).
    #[arg(short, long)]
    pub volume: Vec<String>,

    /// Explicitly mount a host directory into the sandbox (`SOURCE:DEST[:OPTIONS]`).
    #[arg(long = "mount-dir", value_name = "SOURCE:DEST[:OPTIONS]")]
    pub mount_dir: Vec<String>,

    /// Explicitly mount a host file into the sandbox (`SOURCE:DEST[:OPTIONS]`).
    #[arg(long = "mount-file", value_name = "SOURCE:DEST[:OPTIONS]")]
    pub mount_file: Vec<String>,

    /// Explicitly mount a disk image into the sandbox (`SOURCE:DEST[:OPTIONS]`).
    #[arg(long = "mount-disk", value_name = "SOURCE:DEST[:OPTIONS]")]
    pub mount_disk: Vec<String>,

    /// Explicitly mount a named volume into the sandbox (`NAME:DEST[:OPTIONS]`).
    #[arg(long = "mount-named", value_name = "NAME:DEST[:OPTIONS]")]
    pub mount_named: Vec<String>,

    /// Set the default working directory for commands.
    #[arg(short, long)]
    pub workdir: Option<String>,

    /// Shell to use for interactive sessions (default: /bin/sh).
    #[arg(long)]
    pub shell: Option<String>,

    /// Set an environment variable (KEY=value).
    #[arg(short, long)]
    pub env: Vec<String>,

    /// Overwrite an existing alias with the same name.
    #[arg(short, long)]
    pub force: bool,

    /// Don't pull the image before installing.
    #[arg(long)]
    pub no_pull: bool,

    /// Create a fresh sandbox on every invocation (no persistent state).
    #[arg(long)]
    pub tmp: bool,

    /// List all installed sandbox commands.
    #[arg(short, long)]
    pub list: bool,

    /// Default command to run in the sandbox (after --).
    #[arg(last = true)]
    pub command: Vec<String>,
}

//--------------------------------------------------------------------------------------------------
// Functions
//--------------------------------------------------------------------------------------------------

/// Execute the `msb install` command.
pub async fn run(args: InstallArgs) -> anyhow::Result<()> {
    let bin_dir = resolve_bin_dir();

    if args.list {
        return list_aliases(&bin_dir);
    }

    let image = args.image.as_deref().unwrap();
    let no_pull = args.no_pull;
    let alias_name = args.name.as_deref().unwrap_or_else(|| derive_name(image));

    validate_alias_name(alias_name)?;
    for volume in &args.volume {
        validate_volume_spec(volume)?;
    }
    for mount in &args.mount_dir {
        validate_mount_dir_spec(mount)?;
    }
    for mount in &args.mount_file {
        validate_mount_file_spec(mount)?;
    }
    for mount in &args.mount_disk {
        validate_mount_disk_spec(mount)?;
    }
    for mount in &args.mount_named {
        validate_mount_named_spec(mount)?;
    }

    let alias_path = alias_path(&bin_dir, alias_name);
    prepare_alias_path(&bin_dir, alias_name, args.force)?;

    // Pre-pull the image so the alias is ready to use immediately.
    if !no_pull {
        super::image::pull_if_missing(image, false).await?;
    }

    // Ensure bin dir exists.
    fs::create_dir_all(&bin_dir)?;

    // Build the script.
    let script = build_script(image, alias_name, &args);

    // Write and make executable.
    fs::write(&alias_path, &script)?;
    #[cfg(unix)]
    fs::set_permissions(&alias_path, fs::Permissions::from_mode(0o755))?;

    ui::success("Installed", alias_name);

    // PATH hint.
    if !is_in_path(&bin_dir) {
        #[cfg(windows)]
        eprintln!("  Add to your user PATH:\n    {}", bin_dir.display());
        #[cfg(not(windows))]
        eprintln!(
            "  Add to your shell profile:\n    export PATH=\"{}:$PATH\"",
            bin_dir.display()
        );
    }

    Ok(())
}

/// Resolve the bin directory for installed aliases.
fn resolve_bin_dir() -> PathBuf {
    let backend = microsandbox::backend::default_backend();
    let home = match backend.as_local() {
        Some(local) => local.config().home(),
        None => microsandbox_utils::resolve_home(),
    };
    home.join("bin")
}

/// Validate that an alias name is safe to use as a filename in the bin directory.
fn validate_alias_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() {
        anyhow::bail!("alias name cannot be empty");
    }
    if name.contains('/') || name.contains('\\') || name.contains(':') || name.contains("..") {
        anyhow::bail!("alias name must not contain '/', '\\', ':', or '..'");
    }

    const RESERVED: &[&str] = &["msb", "agentd"];
    if RESERVED.contains(&name) {
        anyhow::bail!("alias name '{name}' would shadow a microsandbox binary");
    }

    Ok(())
}

/// Derive an alias name from an image reference.
///
/// Strips the digest, tag, and registry/namespace prefix:
/// - `ubuntu` → `ubuntu`
/// - `ghcr.io/foo/bar:latest` → `bar`
/// - `library/alpine` → `alpine`
/// - `ubuntu@sha256:abc` → `ubuntu`
fn derive_name(image: &str) -> &str {
    let without_digest = image.split('@').next().unwrap_or(image);
    let without_tag = without_digest.split(':').next().unwrap_or(without_digest);
    without_tag.rsplit('/').next().unwrap_or(without_tag)
}

/// Shell-quote a string, using single quotes only when necessary.
#[cfg(not(windows))]
fn shell_quote(s: &str) -> String {
    if s.is_empty() {
        return "''".to_string();
    }
    if s.chars()
        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | '+'))
    {
        s.to_string()
    } else {
        format!("'{}'", s.replace('\'', "'\\''"))
    }
}

/// Quote an argument for the generated Windows command shim.
#[cfg(windows)]
fn cmd_quote(s: &str) -> String {
    if s.is_empty() {
        return "\"\"".to_string();
    }

    // Batch files expand `%...%` before invoking the command. Doubling
    // percent signs preserves literal env values and command arguments.
    let escaped = s.replace('%', "%%").replace('"', "\"\"");
    format!("\"{escaped}\"")
}

/// Strip control characters for safe embedding in shell comments.
fn sanitize_comment(s: &str) -> String {
    s.chars().filter(|c| !c.is_control()).collect()
}

/// Build the shell script content for an alias.
fn build_script(image: &str, alias_name: &str, args: &InstallArgs) -> String {
    #[cfg(windows)]
    {
        build_cmd_script(image, alias_name, args)
    }
    #[cfg(not(windows))]
    {
        if args.tmp {
            build_tmp_script(image, args)
        } else {
            build_persisted_script(image, alias_name, args)
        }
    }
}

/// Build an ephemeral (--tmp) alias script.
///
/// Each invocation creates a fresh sandbox that is removed on exit.
#[cfg(not(windows))]
fn build_tmp_script(image: &str, args: &InstallArgs) -> String {
    let mut parts = vec!["exec".to_string(), "msb".to_string(), "run".to_string()];
    parts.push(shell_quote(image));
    append_resource_options(&mut parts, args, shell_quote);

    if !args.command.is_empty() {
        parts.push("--".into());
        for c in &args.command {
            parts.push(shell_quote(c));
        }
    }

    let mut script = format!("#!/bin/sh\n{MARKER}\n");
    script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
    script.push_str("# mode: tmp\n");
    if !args.command.is_empty() {
        script.push_str(&format!(
            "# command: {}\n",
            sanitize_comment(&args.command.join(" "))
        ));
    }
    script.push_str(&parts.join(" "));
    script.push('\n');
    script
}

/// Build a persisted alias script.
///
/// Uses `msb run -n` which creates on first use and reuses on subsequent
/// invocations. State (installed packages, files, etc.) persists across calls.
#[cfg(not(windows))]
fn build_persisted_script(image: &str, sandbox_name: &str, args: &InstallArgs) -> String {
    let mut parts = vec![
        "exec".to_string(),
        "msb".to_string(),
        "run".to_string(),
        "-n".to_string(),
        shell_quote(sandbox_name),
        shell_quote(image),
    ];
    append_resource_options(&mut parts, args, shell_quote);

    if !args.command.is_empty() {
        parts.push("--".into());
        for c in &args.command {
            parts.push(shell_quote(c));
        }
    }

    let mut script = format!("#!/bin/sh\n{MARKER}\n");
    script.push_str(&format!("# image: {}\n", sanitize_comment(image)));
    script.push_str("# mode: persisted\n");
    if !args.command.is_empty() {
        script.push_str(&format!(
            "# command: {}\n",
            sanitize_comment(&args.command.join(" "))
        ));
    }
    script.push_str(&parts.join(" "));
    script.push('\n');
    script
}

/// Build a Windows `.cmd` alias shim.
#[cfg(windows)]
fn build_cmd_script(image: &str, alias_name: &str, args: &InstallArgs) -> String {
    let mut parts = vec!["\"%~dp0msb.exe\"".to_string(), "run".to_string()];
    if !args.tmp {
        parts.push("-n".to_string());
        parts.push(cmd_quote(alias_name));
    }
    parts.push(cmd_quote(image));
    append_resource_options(&mut parts, args, cmd_quote);

    if !args.command.is_empty() {
        parts.push("--".into());
        for c in &args.command {
            parts.push(cmd_quote(c));
        }
    }

    let mut script = "@echo off\r\n".to_string();
    script.push_str(&format!("rem {MARKER}\r\n"));
    script.push_str(&format!("rem # image: {}\r\n", sanitize_comment(image)));
    script.push_str(if args.tmp {
        "rem # mode: tmp\r\n"
    } else {
        "rem # mode: persisted\r\n"
    });
    if !args.command.is_empty() {
        script.push_str(&format!(
            "rem # command: {}\r\n",
            sanitize_comment(&args.command.join(" "))
        ));
    }
    script.push_str(&parts.join(" "));
    script.push_str("\r\nexit /b %ERRORLEVEL%\r\n");
    script
}

/// Append resource/environment options to a command parts list.
fn append_resource_options(parts: &mut Vec<String>, args: &InstallArgs, quote: fn(&str) -> String) {
    if let Some(cpus) = args.cpus {
        parts.push("-c".into());
        parts.push(cpus.to_string());
    }
    if let Some(ref mem) = args.memory {
        parts.push("-m".into());
        parts.push(quote(mem));
    }
    for vol in &args.volume {
        parts.push("-v".into());
        parts.push(quote(vol));
    }
    for mount in &args.mount_dir {
        parts.push("--mount-dir".into());
        parts.push(quote(mount));
    }
    for mount in &args.mount_file {
        parts.push("--mount-file".into());
        parts.push(quote(mount));
    }
    for mount in &args.mount_disk {
        parts.push("--mount-disk".into());
        parts.push(quote(mount));
    }
    for mount in &args.mount_named {
        parts.push("--mount-named".into());
        parts.push(quote(mount));
    }
    if let Some(ref workdir) = args.workdir {
        parts.push("-w".into());
        parts.push(quote(workdir));
    }
    if let Some(ref shell) = args.shell {
        parts.push("--shell".into());
        parts.push(quote(shell));
    }
    for env_str in &args.env {
        parts.push("-e".into());
        parts.push(quote(env_str));
    }
}

/// List all msb-installed aliases in the bin directory.
fn list_aliases(bin_dir: &Path) -> anyhow::Result<()> {
    let mut table = ui::Table::new(&["NAME", "IMAGE", "MODE", "COMMAND"]);

    if bin_dir.is_dir() {
        let mut entries: Vec<_> = fs::read_dir(bin_dir)?.filter_map(|e| e.ok()).collect();
        entries.sort_by_key(|e| e.file_name());

        for entry in entries {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }

            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            if !is_generated_alias(&content) {
                continue;
            }

            let name = display_alias_name(&entry.file_name().to_string_lossy());
            let image = alias_comment_value(&content, "image: ")
                .unwrap_or("-")
                .to_string();
            let mode = alias_comment_value(&content, "mode: ")
                .unwrap_or("-")
                .to_string();
            let command = alias_comment_value(&content, "command: ")
                .unwrap_or("")
                .to_string();

            table.add_row(vec![name, image, mode, command]);
        }
    }

    table.print();

    Ok(())
}

/// Check whether a directory is already in the PATH.
fn is_in_path(dir: &Path) -> bool {
    std::env::var_os("PATH")
        .map(|path| std::env::split_paths(&path).any(|p| p == dir))
        .unwrap_or(false)
}

/// Return the primary alias path for the current platform.
pub(super) fn alias_path(bin_dir: &Path, alias_name: &str) -> PathBuf {
    #[cfg(windows)]
    {
        bin_dir.join(format!("{alias_name}.cmd"))
    }
    #[cfg(not(windows))]
    {
        bin_dir.join(alias_name)
    }
}

/// Return every filename that may represent a single alias.
pub(super) fn alias_candidates(bin_dir: &Path, alias_name: &str) -> Vec<PathBuf> {
    #[cfg(windows)]
    {
        vec![
            bin_dir.join(format!("{alias_name}.cmd")),
            bin_dir.join(format!("{alias_name}.ps1")),
            bin_dir.join(alias_name),
        ]
    }
    #[cfg(not(windows))]
    {
        vec![bin_dir.join(alias_name)]
    }
}

/// Remove or reject existing files for an alias before writing the new shim.
fn prepare_alias_path(bin_dir: &Path, alias_name: &str, force: bool) -> anyhow::Result<()> {
    for path in alias_candidates(bin_dir, alias_name) {
        if !path.exists() {
            continue;
        }
        if !force {
            anyhow::bail!("alias '{alias_name}' already exists (use --force to overwrite)");
        }

        let content = fs::read_to_string(&path)?;
        if !is_generated_alias(&content) {
            anyhow::bail!("refusing to overwrite non-msb alias {}", path.display());
        }
        fs::remove_file(path)?;
    }
    Ok(())
}

/// Return true if a script/shim was generated by `msb install`.
pub(super) fn is_generated_alias(content: &str) -> bool {
    content.lines().nth(1).is_some_and(is_marker_line)
}

/// Extract a generated alias metadata value.
fn alias_comment_value<'a>(content: &'a str, key: &str) -> Option<&'a str> {
    content.lines().find_map(|line| {
        let comment = line
            .strip_prefix("# ")
            .or_else(|| line.strip_prefix("rem # "))
            .or_else(|| line.strip_prefix("REM # "))?;
        comment.strip_prefix(key)
    })
}

/// Return true if a line is the platform marker line.
fn is_marker_line(line: &str) -> bool {
    line == MARKER
        || line
            .strip_prefix("rem ")
            .or_else(|| line.strip_prefix("REM "))
            .is_some_and(|line| line == MARKER)
}

/// Convert the alias filename shown by the filesystem into the user-facing name.
fn display_alias_name(file_name: &str) -> String {
    #[cfg(windows)]
    {
        file_name
            .strip_suffix(".cmd")
            .or_else(|| file_name.strip_suffix(".ps1"))
            .unwrap_or(file_name)
            .to_string()
    }
    #[cfg(not(windows))]
    {
        file_name.to_string()
    }
}

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

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

    #[cfg(windows)]
    fn args(tmp: bool) -> InstallArgs {
        InstallArgs {
            image: Some("alpine".to_string()),
            name: Some("hello".to_string()),
            cpus: None,
            memory: None,
            volume: Vec::new(),
            mount_dir: Vec::new(),
            mount_file: Vec::new(),
            mount_disk: Vec::new(),
            mount_named: Vec::new(),
            workdir: None,
            shell: None,
            env: Vec::new(),
            force: false,
            no_pull: true,
            tmp,
            list: false,
            command: vec!["echo".to_string(), "alias-ok".to_string()],
        }
    }

    #[test]
    fn detects_posix_generated_alias_marker() {
        let script = "#!/bin/sh\n# generated by msb install\n# image: alpine\n";
        assert!(is_generated_alias(script));
    }

    #[test]
    fn detects_windows_generated_alias_marker() {
        let script = "@echo off\r\nrem # generated by msb install\r\nrem # image: alpine\r\n";
        assert!(is_generated_alias(script));
    }

    #[test]
    #[cfg(windows)]
    fn windows_alias_path_uses_cmd_extension() {
        let path = alias_path(Path::new(r"C:\Users\Stephen\.microsandbox\bin"), "hello");
        assert_eq!(
            path,
            PathBuf::from(r"C:\Users\Stephen\.microsandbox\bin\hello.cmd")
        );
    }

    #[test]
    #[cfg(windows)]
    fn windows_build_script_invokes_adjacent_msb_exe() {
        let args = args(true);
        let script = build_script("alpine", "hello", &args);
        assert!(is_generated_alias(&script));
        assert!(script.contains(r#""%~dp0msb.exe" run "alpine" -- "echo" "alias-ok""#));
        assert!(script.contains("rem # mode: tmp"));
    }
}
