Skip to content
use std::fs;
use std::path::Path;

pub fn generate_dockerfile(
    project_name: &str,
    includes: &[String],
    additional_tools: &[String],
    runtime_deps: &[String],
) -> String {
    let mut dockerfile = String::new();

    dockerfile.push_str("# Base builder stage with cargo-chef for dependency caching\n");
    dockerfile.push_str("FROM rust:slim-bookworm AS base-builder\n\n");
    dockerfile.push_str("WORKDIR /app\n\n");
    dockerfile.push_str("# Install cargo-chef for dependency caching\n");
    dockerfile.push_str("RUN cargo install cargo-chef\n\n");
    dockerfile.push_str("# Install system dependencies\n");
    dockerfile.push_str("RUN apt-get update && apt-get install -y \\\n");
    dockerfile.push_str("    pkg-config \\\n");
    dockerfile.push_str("    libssl-dev \\\n");

    for tool in additional_tools {
        dockerfile.push_str(&format!("    {} \\\n", tool));
    }

    dockerfile.push_str("    && rm -rf /var/lib/apt/lists/*\n\n");

    dockerfile.push_str("# Planner stage - analyzes dependencies\n");
    dockerfile.push_str("FROM base-builder AS planner\n\n");
    dockerfile.push_str("COPY . .\n");
    dockerfile.push_str("RUN cargo chef prepare --recipe-path recipe.json\n\n");

    dockerfile.push_str("# Builder stage - builds with cached dependencies\n");
    dockerfile.push_str("FROM base-builder AS builder\n\n");
    dockerfile
        .push_str("# Copy dependency recipe (cached layer if dependencies haven't changed)\n");
    dockerfile.push_str("COPY --from=planner /app/recipe.json recipe.json\n\n");
    dockerfile.push_str("# Build dependencies (cached if recipe.json is unchanged)\n");
    dockerfile.push_str("RUN cargo chef cook --release --recipe-path recipe.json\n\n");

    if !includes.is_empty() {
        dockerfile.push_str("# Copy source files (invalidates cache only when source changes)\n");
        for item in includes {
            dockerfile.push_str(&format!("COPY {} ./{}\n", item, item));
        }
        dockerfile.push('\n');
    }

    dockerfile.push_str("# Copy dependency files\n");
    dockerfile.push_str("COPY Cargo.toml Cargo.lock ./\n\n");

    dockerfile.push_str("# Build the project (only compiles project code, not dependencies)\n");
    dockerfile.push_str("RUN cargo build --release\n\n");

    dockerfile.push_str("# Runtime stage\n");
    dockerfile.push_str("FROM debian:bookworm-slim\n\n");
    dockerfile.push_str("WORKDIR /app\n\n");
    dockerfile.push_str("# Install runtime dependencies\n");
    dockerfile.push_str("RUN apt-get update && apt-get install -y \\\n");
    dockerfile.push_str("    ca-certificates \\\n");
    dockerfile.push_str("    libssl3 \\\n");

    for dep in runtime_deps {
        dockerfile.push_str(&format!("    {} \\\n", dep));
    }

    dockerfile.push_str("    && rm -rf /var/lib/apt/lists/*\n\n");

    dockerfile.push_str("# Copy the binary from builder\n");
    dockerfile.push_str(&format!(
        "COPY --from=builder /app/target/release/{} /app/\n\n",
        project_name
    ));

    dockerfile.push_str("# Expose port 80\n");
    dockerfile.push_str("EXPOSE 80\n\n");

    dockerfile.push_str("# Run the binary\n");
    dockerfile.push_str(&format!("ENTRYPOINT [\"./{}\"]\n", project_name));

    dockerfile
}

pub fn generate_dockerignore(excludes: &[String]) -> String {
    let mut content = String::new();

    content.push_str("# Auto-generated by spruce\n");
    content.push_str("# Files and directories to exclude from Docker build context\n\n");

    content.push_str("# System files\n");
    content.push_str(".git\n");
    content.push_str(".gitignore\n");
    content.push_str(".github\n");
    content.push_str(".DS_Store\n");
    content.push_str("*.swp\n");
    content.push_str("*.swo\n");
    content.push_str("*~\n\n");

    content.push_str("# Rust build artifacts\n");
    content.push_str("target/\n");
    content.push_str("Cargo.lock\n");
    content.push_str("**/*.rs.bk\n\n");

    content.push_str("# Docker files\n");
    content.push_str("Dockerfile\n");
    content.push_str("Dockerfile.*\n");
    content.push_str("docker-compose*.yml\n");
    content.push_str("docker-compose*.yaml\n");
    content.push_str("compose*.yml\n");
    content.push_str("compose*.yaml\n");
    content.push_str(".dockerignore\n\n");

    if !excludes.is_empty() {
        content.push_str("# Project-specific excludes\n");
        for exclude in excludes {
            content.push_str(exclude);
            content.push('\n');
        }
        content.push('\n');
    }

    content
}

pub fn handle_dockerignore(excludes: &[String]) -> Result<(), Box<dyn std::error::Error>> {
    let dockerignore_exists = Path::new(".dockerignore").exists();
    let dockerignore_content = generate_dockerignore(excludes);

    if dockerignore_exists {
        let overwrite = inquire::Confirm::new(
            ".dockerignore already exists. Overwrite it with spruce defaults?",
        )
        .with_default(false)
        .with_help_message("This will replace your existing .dockerignore")
        .prompt()?;

        if overwrite {
            fs::write(".dockerignore", dockerignore_content)?;
            println!("[OK] Overwrote .dockerignore");
        } else {
            println!("  Skipped .dockerignore (kept existing)");
        }
    } else {
        fs::write(".dockerignore", dockerignore_content)?;
        println!("[OK] Created .dockerignore");
    }

    Ok(())
}

const ALWAYS_IGNORED: &[&str] = &[
    ".git",
    ".gitignore",
    ".github",
    ".gitlab-ci.yml",
    ".travis.yml",
    ".circleci",
    "target",
    "Dockerfile",
    "dockerfile",
    "Dockerfile.",
    "docker-compose.yml",
    "docker-compose.yaml",
    "compose.yml",
    "compose.yaml",
    ".dockerignore",
];

fn read_dockerignore_patterns() -> Vec<String> {
    let mut patterns = Vec::new();

    if let Ok(content) = fs::read_to_string(".dockerignore") {
        for line in content.lines() {
            let line = line.trim();
            if !line.is_empty() && !line.starts_with('#') {
                patterns.push(line.to_string());
            }
        }
    }

    patterns
}

fn matches_dockerignore_pattern(name: &str, pattern: &str) -> bool {
    let pattern = pattern.trim();

    if let Some(dir_pattern) = pattern.strip_suffix('/') {
        return name == dir_pattern;
    }

    if pattern.starts_with("*.") {
        let ext = &pattern[1..];
        return name.ends_with(ext);
    }

    if pattern.contains('*') {
        if let Some(suffix) = pattern.strip_prefix("*") {
            return name.ends_with(suffix);
        }
        if let Some(prefix) = pattern.strip_suffix("*") {
            return name.starts_with(prefix);
        }
        if let Some(pos) = pattern.find("*") {
            let prefix = &pattern[..pos];
            let suffix = &pattern[pos + 1..];
            return name.starts_with(prefix) && name.ends_with(suffix);
        }
    }

    name == pattern
}

fn should_exclude_by_dockerignore(name: &str) -> bool {
    let patterns = read_dockerignore_patterns();

    for pattern in patterns {
        if matches_dockerignore_pattern(name, &pattern) {
            return true;
        }
    }

    false
}

pub fn discover_files() -> (Vec<String>, Vec<String>) {
    let mut includes = Vec::new();
    let mut excludes = Vec::new();

    if let Ok(entries) = fs::read_dir(".") {
        for entry in entries.flatten() {
            let path = entry.path();
            let name = path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("")
                .to_string();

            if name.starts_with('.') {
                excludes.push(name);
                continue;
            }

            if ALWAYS_IGNORED.iter().any(|&ignored| {
                name == ignored
                    || name.starts_with("Dockerfile.")
                    || name.starts_with("dockerfile.")
            }) {
                excludes.push(name);
                continue;
            }

            if should_exclude_by_dockerignore(&name) {
                excludes.push(name);
                continue;
            }

            includes.push(name);
        }
    }

    includes.sort();
    excludes.sort();
    (includes, excludes)
}

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

    #[test]
    fn test_generate_dockerfile_without_additional_tools() {
        let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &[], &[]);

        assert!(dockerfile.contains("pkg-config"));
        assert!(dockerfile.contains("libssl-dev"));
        assert!(!dockerfile.contains("curl"));
        assert!(!dockerfile.contains("jq"));

        assert!(dockerfile.contains("cargo-chef"));
        assert!(dockerfile.contains("cargo chef prepare"));
        assert!(dockerfile.contains("cargo chef cook"));
        assert!(dockerfile.contains("recipe.json"));

        assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
        assert!(dockerfile.contains("FROM base-builder AS builder"));
        assert!(dockerfile.contains("FROM debian:bookworm-slim"));
        assert!(dockerfile.contains("COPY --from=builder /app/target/release/myapp"));
    }

    #[test]
    fn test_generate_dockerfile_with_additional_tools() {
        let tools = vec!["curl".to_string(), "jq".to_string(), "git".to_string()];
        let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &tools, &[]);

        assert!(dockerfile.contains("pkg-config"));
        assert!(dockerfile.contains("libssl-dev"));

        assert!(dockerfile.contains("curl"));
        assert!(dockerfile.contains("jq"));
        assert!(dockerfile.contains("git"));

        assert!(dockerfile.contains("cargo-chef"));
        assert!(dockerfile.contains("cargo chef prepare"));
        assert!(dockerfile.contains("cargo chef cook"));

        assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
        assert!(dockerfile.contains("FROM base-builder AS builder"));
        assert!(dockerfile.contains("FROM debian:bookworm-slim"));
    }

    #[test]
    fn test_generate_dockerfile_with_runtime_deps() {
        let runtime_deps = vec!["postgresql-client".to_string(), "redis-tools".to_string()];
        let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &[], &runtime_deps);

        assert!(dockerfile.contains("ca-certificates"));
        assert!(dockerfile.contains("libssl3"));

        assert!(dockerfile.contains("postgresql-client"));
        assert!(dockerfile.contains("redis-tools"));

        assert!(dockerfile.contains("cargo-chef"));
        assert!(dockerfile.contains("cargo chef prepare"));
        assert!(dockerfile.contains("cargo chef cook"));

        assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
        assert!(dockerfile.contains("FROM base-builder AS builder"));
        assert!(dockerfile.contains("FROM debian:bookworm-slim"));
    }

    #[test]
    fn test_generate_dockerignore_default() {
        let dockerignore = generate_dockerignore(&[]);

        assert!(dockerignore.contains(".git"));
        assert!(dockerignore.contains(".gitignore"));
        assert!(dockerignore.contains(".github"));
        assert!(dockerignore.contains(".DS_Store"));

        assert!(dockerignore.contains("target/"));
        assert!(dockerignore.contains("Cargo.lock"));
        assert!(dockerignore.contains("**/*.rs.bk"));

        assert!(dockerignore.contains("Dockerfile"));
        assert!(dockerignore.contains("Dockerfile.*"));
        assert!(dockerignore.contains("docker-compose*.yml"));
        assert!(dockerignore.contains(".dockerignore"));

        assert!(dockerignore.contains("# Auto-generated by spruce"));
    }

    #[test]
    fn test_generate_dockerignore_with_excludes() {
        let excludes = vec!["tests".to_string(), "*.log".to_string(), "docs".to_string()];
        let dockerignore = generate_dockerignore(&excludes);

        assert!(dockerignore.contains(".git"));
        assert!(dockerignore.contains("target/"));

        assert!(dockerignore.contains("tests"));
        assert!(dockerignore.contains("*.log"));
        assert!(dockerignore.contains("docs"));

        assert!(dockerignore.contains("# Project-specific excludes"));
    }

    #[test]
    fn test_generate_dockerignore_format() {
        let excludes = vec!["node_modules".to_string()];
        let dockerignore = generate_dockerignore(&excludes);

        let lines: Vec<&str> = dockerignore.lines().collect();
        assert!(lines.iter().any(|&line| line == "node_modules"));
        assert!(lines.iter().any(|&line| line == ".git"));
        assert!(lines.iter().any(|&line| line == "target/"));
    }
}