use clap::parser::ValueSource;
use clap::ArgMatches;
use std::path::{Path, PathBuf};

const DEFAULT_CEDAR_POLICY: &str = r#"// Default Symbiont policy — generated by `symbi init`
//
// Principle: deny by default, allow explicitly.
// Edit this file or add new .cedar files in policies/ to customize.

// Allow all read operations
permit(
    principal,
    action == Action::"read",
    resource
);

// Allow tool invocations that have been verified by SchemaPin
permit(
    principal,
    action == Action::"invoke_tool",
    resource
) when {
    resource.schema_verified == true
};

// Gate write operations on approval
forbid(
    principal,
    action == Action::"write",
    resource
) unless {
    principal.approved == true
};

// Require audit on all state-changing operations
permit(
    principal,
    action == Action::"audit",
    resource
);
"#;

const SYMBIONT_GITIGNORE_ENTRIES: &[&str] = &[
    "# Symbiont",
    ".symbiont/audit/*.jsonl",
    "symbi.db",
    "symbi.db-wal",
    "symbi.db-shm",
    "symbi.quick.toml",
    ".env",
];

const ASSISTANT_DSL: &str = r#"metadata {
    version = "1.0.0"
    description = "Default governed assistant"
    author = ""
}

agent assistant(input: Query) -> Response {
    capabilities = ["read", "analyze", "summarize"]

    policy default_access {
        allow: read(input) if true
        deny: write(any) if not approved
        deny: invoke_tool(any) if not schema_verified
        audit: all_operations
    }

    with memory = "session", sandbox = "tier1" {
        result = process(input);
        return result;
    }
}
"#;

const DEV_AGENT_DSL: &str = r#"metadata {
    version = "1.0.0"
    description = "Governed development agent"
    author = ""
    executor = "claude_code"
    model = "claude-sonnet-4-5"
    allowed_tools = "Bash,Read,Write,Edit"
}

agent dev(task: String) -> Result {
    capabilities = ["read", "write", "execute", "test"]

    policy dev_safety {
        allow: read(any) if true
        allow: write(file) if file.in_project == true
        deny: write(file) if file.path.starts_with("/etc")
        deny: write(file) if file.path.starts_with("/usr")
        deny: execute(cmd) if cmd.contains("--force") && cmd.contains("push")
        deny: execute(cmd) if cmd.contains("rm -rf /")
        audit: all_operations
    }

    with memory = "persistent", sandbox = "tier1", timeout = "30m" {
        result = execute(task);
        return result;
    }
}
"#;

const DEV_AGENT_CEDAR: &str = r#"// Development agent policies — require PR for production branches

// Allow writes to feature branches
permit(
    principal == Agent::"dev",
    action == Action::"write",
    resource
) when {
    resource.branch != "main" && resource.branch != "production"
};

// Require approval for main branch
forbid(
    principal == Agent::"dev",
    action == Action::"write",
    resource
) when {
    resource.branch == "main"
} unless {
    context.has_approved_pr == true
};
"#;

const COORDINATOR_DSL: &str = r#"metadata {
    version = "1.0.0"
    description = "Multi-agent coordinator"
    author = ""
}

agent coordinator(task: String) -> Result {
    capabilities = ["read", "delegate", "aggregate"]

    policy coordination {
        allow: delegate(agent) if agent.registered == true
        deny: delegate(agent) if agent.suspended == true
        audit: all_operations
    }

    with memory = "persistent", sandbox = "tier1" {
        subtasks = decompose(task);
        results = parallel_map(subtasks, delegate_to_worker);
        return aggregate(results);
    }
}
"#;

const WORKER_DSL: &str = r#"metadata {
    version = "1.0.0"
    description = "Multi-agent worker"
    author = ""
}

agent worker(subtask: String) -> Result {
    capabilities = ["read", "write", "analyze"]

    policy worker_limits {
        allow: read(any) if true
        deny: write(any) if not approved_by_coordinator
        audit: all_operations
    }

    with memory = "session", sandbox = "tier1", timeout = "10m" {
        result = process(subtask);
        return result;
    }
}
"#;

const INTER_AGENT_CEDAR: &str = r#"// Inter-agent communication policies

// Coordinator can delegate to any registered worker
permit(
    principal == Agent::"coordinator",
    action == Action::"delegate",
    resource
) when {
    resource.type == "agent" && resource.registered == true
};

// Workers can only respond to their coordinator
permit(
    principal == Agent::"worker",
    action == Action::"respond",
    resource
) when {
    resource.requester == "coordinator"
};

// Workers cannot invoke other workers directly
forbid(
    principal == Agent::"worker",
    action == Action::"delegate",
    resource
);
"#;

struct CatalogEntry {
    name: &'static str,
    description: &'static str,
    dsl: &'static str,
    cedar: Option<&'static str>,
}

const CATALOG: &[CatalogEntry] = &[
    CatalogEntry {
        name: "assistant",
        description: "Default governed assistant",
        dsl: ASSISTANT_DSL,
        cedar: None,
    },
    CatalogEntry {
        name: "dev",
        description: "CliExecutor agent for Claude Code / Gemini CLI",
        dsl: DEV_AGENT_DSL,
        cedar: Some(DEV_AGENT_CEDAR),
    },
    CatalogEntry {
        name: "coordinator",
        description: "Multi-agent coordinator with message passing",
        dsl: COORDINATOR_DSL,
        cedar: Some(INTER_AGENT_CEDAR),
    },
    CatalogEntry {
        name: "worker",
        description: "Multi-agent worker",
        dsl: WORKER_DSL,
        cedar: None,
    },
];

fn list_catalog() {
    println!("\nAvailable agents:");
    for entry in CATALOG {
        println!("  {:<20} {}", entry.name, entry.description);
    }
    println!();
}

fn import_catalog_agents(dir: &Path, names: &[&str]) {
    std::fs::create_dir_all(dir.join("agents")).expect("Failed to create agents/");

    for name in names {
        match CATALOG.iter().find(|e| e.name == *name) {
            Some(entry) => {
                // Canonical extension is .symbi; .dsl is still parsed for legacy projects
                // but new scaffolding emits .symbi only.
                let dsl_path = dir.join(format!("agents/{}.symbi", entry.name));
                let legacy_path = dir.join(format!("agents/{}.dsl", entry.name));
                if dsl_path.exists() || legacy_path.exists() {
                    println!(
                        "  \u{26a0} Skipping agents/{}.symbi (already exists)",
                        entry.name
                    );
                } else {
                    std::fs::write(&dsl_path, entry.dsl)
                        .unwrap_or_else(|_| panic!("Failed to write {}.symbi", entry.name));
                    println!("\u{2713} Created agents/{}.symbi", entry.name);
                    validate_dsl(&format!("agents/{}.symbi", entry.name), entry.dsl);
                }

                if let Some(cedar) = entry.cedar {
                    let cedar_path = dir.join(format!("policies/{}.cedar", entry.name));
                    if cedar_path.exists() {
                        println!(
                            "  \u{26a0} Skipping policies/{}.cedar (already exists)",
                            entry.name
                        );
                    } else {
                        std::fs::create_dir_all(dir.join("policies")).ok();
                        std::fs::write(&cedar_path, cedar)
                            .unwrap_or_else(|_| panic!("Failed to write {}.cedar", entry.name));
                        println!("\u{2713} Created policies/{}.cedar", entry.name);
                    }
                }
            }
            None => {
                eprintln!("\u{2717} Unknown catalog agent: {}", name);
                eprintln!("  Run `symbi init --catalog list` to see available agents");
            }
        }
    }
}

/// Operator-supplied Firecracker paths captured from `symbi init` flags.
#[derive(Debug, Clone)]
pub struct FirecrackerInit {
    pub kernel_image_path: PathBuf,
    pub rootfs_path: PathBuf,
}

/// Read `--firecracker-kernel` / `--firecracker-rootfs` and validate them
/// against the chosen sandbox tier.
///
/// - When `sandbox != "tier3"`, both flags are ignored if absent and
///   silently dropped if present (no surprise — the project's tier
///   doesn't use Firecracker).
/// - When `sandbox == "tier3"`, both flags are required and both paths
///   must exist on disk; otherwise we fail-fast at scaffold time.
fn resolve_firecracker_paths(
    matches: &ArgMatches,
    sandbox: &str,
) -> Result<Option<FirecrackerInit>, String> {
    let kernel = matches.get_one::<String>("firecracker-kernel");
    let rootfs = matches.get_one::<String>("firecracker-rootfs");

    if sandbox != "tier3" {
        return Ok(None);
    }

    let (kernel, rootfs) = match (kernel, rootfs) {
        (Some(k), Some(r)) => (PathBuf::from(k), PathBuf::from(r)),
        _ => {
            return Err(
                "tier3 (Firecracker) requires --firecracker-kernel <path> and \
                 --firecracker-rootfs <path>"
                    .to_string(),
            );
        }
    };

    if !kernel.exists() {
        return Err(format!(
            "--firecracker-kernel path does not exist: {}",
            kernel.display()
        ));
    }
    if !rootfs.exists() {
        return Err(format!(
            "--firecracker-rootfs path does not exist: {}",
            rootfs.display()
        ));
    }

    Ok(Some(FirecrackerInit {
        kernel_image_path: kernel,
        rootfs_path: rootfs,
    }))
}

fn generate_toml(
    profile: &str,
    schemapin: &str,
    sandbox: &str,
    firecracker: Option<&FirecrackerInit>,
) -> String {
    let firecracker_block = match firecracker {
        Some(fc) => format!(
            "\n[sandbox.firecracker]\n\
             kernel_image_path = \"{kernel}\"\n\
             rootfs_path       = \"{rootfs}\"\n\
             vcpus             = 1\n\
             mem_mib           = 512\n\
             rootfs_read_only  = true\n",
            kernel = fc.kernel_image_path.display(),
            rootfs = fc.rootfs_path.display(),
        ),
        None => String::from(
            "\n# Uncomment + populate to use Firecracker (tier3). Both files must exist on the host.\n\
             # See docs/firecracker-setup.md for kernel + rootfs prep.\n\
             # [sandbox.firecracker]\n\
             # kernel_image_path = \"/var/lib/firecracker/vmlinux\"\n\
             # rootfs_path = \"/var/lib/firecracker/rootfs.ext4\"\n\
             # vcpus = 1\n\
             # mem_mib = 512\n",
        ),
    };

    format!(
        r#"# Generated by `symbi init` — profile: {profile}
# Docs: https://docs.symbiont.dev/configuration

[runtime]
mode = "dev"
hot_reload = true
log_level = "info"

[policy]
engine = "cedar"
enforcement = "strict"
policy_dir = "policies"

[schemapin]
mode = "{schemapin}"

[sandbox]
# Default tier: tier1 (Docker), tier2 (gVisor), tier3 (Firecracker), or tier0 (none, dev only).
# Per-agent overrides come from the DSL `with {{ sandbox = "..." }}` block.
tier = "{sandbox}"
{firecracker_block}
# Uncomment to override the default gVisor binary or runtime name.
# [sandbox.gvisor]
# runtime_name = "runsc"
# runsc_binary = "/usr/local/bin/runsc"

[storage]
type = "sqlite"
path = "./symbi.db"

[http]
enabled = true
port = 8081
bind = "127.0.0.1"
"#
    )
}

fn update_gitignore(dir: &Path) -> std::io::Result<()> {
    let gitignore_path = dir.join(".gitignore");
    let existing = if gitignore_path.exists() {
        std::fs::read_to_string(&gitignore_path)?
    } else {
        String::new()
    };
    let mut lines_to_add = Vec::new();
    for entry in SYMBIONT_GITIGNORE_ENTRIES {
        if !existing.contains(entry) {
            lines_to_add.push(*entry);
        }
    }
    if lines_to_add.is_empty() {
        return Ok(());
    }
    let mut content = existing;
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    if !content.is_empty() {
        content.push('\n');
    }
    for line in &lines_to_add {
        content.push_str(line);
        content.push('\n');
    }
    std::fs::write(&gitignore_path, content)
}

fn should_proceed(dir: &Path, force: bool) -> Result<(), String> {
    if dir.join("symbiont.toml").exists() && !force {
        return Err("symbiont.toml already exists".into());
    }
    Ok(())
}

#[cfg(feature = "interactive")]
fn select_profile_interactive() -> String {
    use dialoguer::{theme::ColorfulTheme, Select};

    let profiles = &[
        "minimal      \u{2014} symbiont.toml + default Cedar policy (add agents later)",
        "assistant     \u{2014} single governed assistant agent",
        "dev-agent     \u{2014} CliExecutor agent for coding tasks (Claude Code / Gemini CLI)",
        "multi-agent   \u{2014} coordinator + worker pattern with inter-agent policies",
    ];
    let profile_values = &["minimal", "assistant", "dev-agent", "multi-agent"];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Select a project profile")
        .items(profiles)
        .default(1)
        .interact()
        .expect("Failed to select profile");

    profile_values[selection].to_string()
}

#[cfg(feature = "interactive")]
fn select_schemapin_interactive() -> String {
    use dialoguer::{theme::ColorfulTheme, Select};

    let modes = &[
        "tofu          \u{2014} Trust-On-First-Use (pin on first verify)",
        "strict        \u{2014} require pre-configured trust bundles",
        "disabled      \u{2014} no MCP tool verification",
    ];
    let mode_values = &["tofu", "strict", "disabled"];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("SchemaPin mode")
        .items(modes)
        .default(0)
        .interact()
        .expect("Failed to select SchemaPin mode");

    mode_values[selection].to_string()
}

#[cfg(feature = "interactive")]
fn select_sandbox_interactive() -> String {
    use dialoguer::{theme::ColorfulTheme, Select};

    let tiers = &[
        "tier1         \u{2014} Docker isolation (recommended)",
        "tier2         \u{2014} gVisor hardened sandbox (requires runsc)",
        "tier3         \u{2014} Firecracker microVM (requires kernel + rootfs)",
        "tier0         \u{2014} no sandbox (development only)",
    ];
    let tier_values = &["tier1", "tier2", "tier3", "tier0"];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Sandbox tier")
        .items(tiers)
        .default(0)
        .interact()
        .expect("Failed to select sandbox tier");

    tier_values[selection].to_string()
}

fn validate_dsl(filename: &str, source: &str) {
    match dsl::parse_dsl(source) {
        Ok(tree) if !tree.root_node().has_error() => {}
        Ok(_) => println!("  \u{26a0} Warning: {} has parse warnings", filename),
        Err(e) => println!("  \u{26a0} Warning: {} parse error: {}", filename, e),
    }
}

fn generate_agents_md(dir: &Path) {
    let agents_dir = dir.join("agents");
    if !agents_dir.exists() {
        return;
    }
    let mut content = String::from("# AGENTS.md\n\n");
    content.push_str("<!-- agents-md:auto-start -->\n");
    content.push_str("<!-- This section is auto-generated. Do not edit manually. -->\n\n");
    content.push_str("| Agent | Description |\n");
    content.push_str("|-------|-------------|\n");
    if let Ok(entries) = std::fs::read_dir(&agents_dir) {
        let mut entries: Vec<_> = entries.flatten().collect();
        entries.sort_by_key(|e| e.file_name());
        for entry in entries {
            let path = entry.path();
            if dsl::is_symbi_file(&path) {
                if let Ok(source) = std::fs::read_to_string(&path) {
                    if let Ok(tree) = dsl::parse_dsl(&source) {
                        let meta = dsl::extract_metadata(&tree, &source);
                        let name = path.file_stem().unwrap_or_default().to_string_lossy();
                        let desc = meta.get("description").cloned().unwrap_or_default();
                        content.push_str(&format!("| {} | {} |\n", name, desc));
                    }
                }
            }
        }
    }
    content.push_str("\n<!-- agents-md:auto-end -->\n");
    std::fs::write(dir.join("AGENTS.md"), content).ok();
    println!("\u{2713} Created AGENTS.md");
}

fn write_agent_if_not_exists(dir: &Path, filename: &str, content: &str) -> bool {
    let path = dir.join("agents").join(filename);
    // Don't overwrite a legacy `.dsl` version of the same agent either —
    // users mid-migration shouldn't end up with both files.
    let legacy_twin = dsl::strip_symbi_extension(filename)
        .map(|stem| dir.join("agents").join(format!("{}.dsl", stem)));
    if path.exists() {
        println!("  \u{26a0} Skipping agents/{} (already exists)", filename);
        return false;
    }
    if let Some(twin) = legacy_twin.as_ref() {
        if twin != &path && twin.exists() {
            println!(
                "  \u{26a0} Skipping agents/{} (legacy {} already exists)",
                filename,
                twin.file_name().unwrap_or_default().to_string_lossy()
            );
            return false;
        }
    }
    std::fs::write(&path, content)
        .unwrap_or_else(|_| panic!("Failed to write agents/{}", filename));
    println!("\u{2713} Created agents/{}", filename);
    validate_dsl(&format!("agents/{}", filename), content);
    true
}

fn write_policy_if_not_exists(dir: &Path, filename: &str, content: &str) -> bool {
    let path = dir.join("policies").join(filename);
    if path.exists() {
        println!("  \u{26a0} Skipping policies/{} (already exists)", filename);
        return false;
    }
    std::fs::write(&path, content)
        .unwrap_or_else(|_| panic!("Failed to write policies/{}", filename));
    println!("\u{2713} Created policies/{}", filename);
    true
}

fn detect_project_hints(dir: &Path) -> Vec<&'static str> {
    let mut hints = Vec::new();
    if dir.join(".claude").exists() || dir.join("CLAUDE.md").exists() {
        hints.push("Detected Claude Code project \u{2014} consider `--profile dev-agent`");
    }
    if dir.join("GEMINI.md").exists() || dir.join(".gemini").exists() {
        hints.push("Detected Gemini CLI project \u{2014} consider `--profile dev-agent`");
    }
    if dir.join("Cargo.toml").exists() {
        hints.push("Detected Rust project");
    }
    if dir.join("package.json").exists() {
        hints.push("Detected Node.js project");
    }
    if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
        hints.push("Detected Python project");
    }
    hints
}

fn init_project(
    dir: &Path,
    profile: &str,
    schemapin: &str,
    sandbox: &str,
    firecracker: Option<&FirecrackerInit>,
) {
    let toml = generate_toml(profile, schemapin, sandbox, firecracker);
    std::fs::write(dir.join("symbiont.toml"), toml).expect("Failed to write symbiont.toml");
    println!("\u{2713} Created symbiont.toml");

    std::fs::create_dir_all(dir.join("policies")).expect("Failed to create policies/");
    std::fs::write(dir.join("policies/default.cedar"), DEFAULT_CEDAR_POLICY)
        .expect("Failed to write default.cedar");
    println!("\u{2713} Created policies/default.cedar");

    std::fs::create_dir_all(dir.join(".symbiont/audit"))
        .expect("Failed to create .symbiont/audit/");
    println!("\u{2713} Created .symbiont/audit/");

    update_gitignore(dir).expect("Failed to update .gitignore");
    println!("\u{2713} Updated .gitignore");

    match profile {
        "minimal" => {}
        "assistant" => {
            std::fs::create_dir_all(dir.join("agents")).expect("Failed to create agents/");
            write_agent_if_not_exists(dir, "assistant.symbi", ASSISTANT_DSL);
            generate_agents_md(dir);
        }
        "dev-agent" => {
            std::fs::create_dir_all(dir.join("agents")).expect("Failed to create agents/");
            write_agent_if_not_exists(dir, "dev.symbi", DEV_AGENT_DSL);
            write_policy_if_not_exists(dir, "dev-agent.cedar", DEV_AGENT_CEDAR);
            generate_agents_md(dir);
        }
        "multi-agent" => {
            std::fs::create_dir_all(dir.join("agents")).expect("Failed to create agents/");
            write_agent_if_not_exists(dir, "coordinator.symbi", COORDINATOR_DSL);
            write_agent_if_not_exists(dir, "worker.symbi", WORKER_DSL);
            write_policy_if_not_exists(dir, "inter-agent.cedar", INTER_AGENT_CEDAR);
            generate_agents_md(dir);
        }
        "import" => {}
        _ => unreachable!("profile validated before reaching init_project"),
    }
}

/// Whether a canonical Docker Compose file exists in `dir`.
fn compose_file_present(dir: &Path) -> bool {
    [
        "docker-compose.yml",
        "docker-compose.yaml",
        "compose.yml",
        "compose.yaml",
    ]
    .iter()
    .any(|f| dir.join(f).exists())
}

/// Print the closing "Next steps" guidance. Called once, last, after every
/// file (including `.env` and the compose file) has been written — so it is
/// the final thing the user sees and reflects what actually got generated.
fn print_next_steps(dir: &Path, profile: &str, docker_compose_enabled: bool) {
    let compose = docker_compose_enabled && compose_file_present(dir);
    let validate_target = match profile {
        "minimal" | "import" => None,
        "dev-agent" => Some("agents/dev.symbi"),
        "multi-agent" => Some("agents/coordinator.symbi"),
        _ => Some("agents/assistant.symbi"),
    };

    println!();
    println!("Next steps:");
    if let Some(target) = validate_target {
        println!("  Validate   symbi dsl -f {target}");
    }
    if compose {
        println!(
            "  Run        docker compose up   # Docker \u{2014} reads .env automatically (recommended)"
        );
        println!("             symbi up            # or run the local binary (also loads .env)");
    } else {
        println!("  Run        symbi up            # loads .env automatically");
    }
    if validate_target.is_some() {
        println!(
            "  Update     symbi agents-md generate   # regenerate AGENTS.md after editing agents"
        );
    }
}

/// Generate 32 random bytes as a lowercase-hex string for use as SYMBIONT_MASTER_KEY.
fn generate_master_key() -> String {
    use std::io::Read;
    let mut bytes = [0u8; 32];
    // /dev/urandom is guaranteed non-blocking and entropy-sufficient on Linux.
    // On other Unix platforms it's the standard source. If it's unavailable,
    // fall back to a time-seeded PRNG — good enough to avoid hard-failing init,
    // the user can regenerate with `openssl rand -hex 32` if they care.
    if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
        if f.read_exact(&mut bytes).is_ok() {
            return bytes.iter().map(|b| format!("{:02x}", b)).collect();
        }
    }
    let seed = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    for (i, b) in bytes.iter_mut().enumerate() {
        *b = ((seed >> (i * 3)) ^ (seed.wrapping_mul((i as u128) + 1))) as u8;
    }
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

/// Write `.env` (with a generated SYMBIONT_MASTER_KEY) and `.env.example`
/// unless they already exist. Prints guidance on keeping the key secret.
fn write_env_files(dir: &Path) {
    let env_path = dir.join(".env");
    let example_path = dir.join(".env.example");

    // Example file is always useful to commit, regenerate if missing.
    if !example_path.exists() {
        let example = r#"# Copy this file to .env and fill in secrets.
# Never commit .env — it's ignored by .gitignore.

# Required: 32-byte hex key used to encrypt persistent state.
# Generate with: openssl rand -hex 32
SYMBIONT_MASTER_KEY=

# Optional: LLM provider credentials (any one enables the coordinator)
# OPENROUTER_API_KEY=
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=

# Optional: structured logging level
RUST_LOG=info
"#;
        if std::fs::write(&example_path, example).is_ok() {
            println!("\u{2713} Created .env.example");
        }
    }

    if env_path.exists() {
        // Respect whatever the user already has. If the key is missing we still
        // tell them, rather than mutate the file and surprise them.
        if let Ok(existing) = std::fs::read_to_string(&env_path) {
            let has_key = existing.lines().any(|line| {
                let trimmed = line.trim_start();
                trimmed.starts_with("SYMBIONT_MASTER_KEY=")
                    && !matches!(
                        trimmed.trim_start_matches("SYMBIONT_MASTER_KEY=").trim(),
                        "" | "\"\"" | "''"
                    )
            });
            if !has_key {
                println!(
                    "\u{26a0} .env exists but has no SYMBIONT_MASTER_KEY — add one with: \
                     openssl rand -hex 32"
                );
            }
        }
        return;
    }

    let key = generate_master_key();
    let content = format!(
        "# Generated by `symbi init` — KEEP THIS FILE OUT OF VERSION CONTROL.\n\
         # Regenerate the master key with: openssl rand -hex 32\n\
         SYMBIONT_MASTER_KEY={}\n\
         RUST_LOG=info\n",
        key
    );

    if let Err(e) = std::fs::write(&env_path, content) {
        eprintln!("\u{26a0} Failed to write .env: {}", e);
        return;
    }
    set_env_permissions(&env_path);
    println!(
        "\u{2713} Generated SYMBIONT_MASTER_KEY in .env (keep this file out of version control)"
    );
}

#[cfg(unix)]
fn set_env_permissions(path: &Path) {
    use std::os::unix::fs::PermissionsExt;
    let perms = std::fs::Permissions::from_mode(0o600);
    let _ = std::fs::set_permissions(path, perms);
}

#[cfg(not(unix))]
fn set_env_permissions(_path: &Path) {}

/// Write a ready-to-run `docker-compose.yml` matching the profile. Skips if a
/// compose file already exists (any of the canonical names).
fn write_docker_compose(dir: &Path, profile: &str) {
    for candidate in &[
        "docker-compose.yml",
        "docker-compose.yaml",
        "compose.yml",
        "compose.yaml",
    ] {
        if dir.join(candidate).exists() {
            println!(
                "  \u{26a0} Skipping {} (compose file already exists)",
                candidate
            );
            return;
        }
    }

    let compose = generate_docker_compose(profile);
    let compose_path = dir.join("docker-compose.yml");
    match std::fs::write(&compose_path, compose) {
        Ok(()) => {
            println!("\u{2713} Created docker-compose.yml");
        }
        Err(e) => eprintln!("\u{26a0} Failed to write docker-compose.yml: {}", e),
    }
}

fn generate_docker_compose(profile: &str) -> String {
    // HTTP input binds to 0.0.0.0 inside the container so the host port
    // mapping actually works (the default is 127.0.0.1 which is unreachable
    // from outside the container). The API port is bound the same way.
    format!(
        r#"# Generated by `symbi init` — profile: {profile}
# Docs: https://docs.symbiont.dev/docker
#
# Quick start:
#   1. Review .env (SYMBIONT_MASTER_KEY was generated for you).
#   2. `docker compose up` — Runtime API on :8080, HTTP Input on :8081.
#
# Volumes mount the host project into the container so edits to agents/,
# policies/, symbiont.toml, and tools/ are picked up on restart.

services:
  symbi:
    image: ghcr.io/thirdkeyai/symbi:latest
    command: ["up", "--http-bind", "0.0.0.0"]
    ports:
      - "8080:8080"
      - "8081:8081"
    volumes:
      - ./symbiont.toml:/var/lib/symbi/symbiont.toml:ro
      - ./agents:/var/lib/symbi/agents:ro
      - ./policies:/var/lib/symbi/policies:ro
      - ./tools:/var/lib/symbi/tools:ro
      - symbi-data:/var/lib/symbi/.symbi
      - symbi-audit:/var/lib/symbi/.symbiont
    environment:
      SYMBIONT_MASTER_KEY: ${{SYMBIONT_MASTER_KEY:?set SYMBIONT_MASTER_KEY in .env}}
      RUST_LOG: ${{RUST_LOG:-info}}
      # Uncomment any one of these to enable the Coordinator / LLM features:
      # OPENROUTER_API_KEY: ${{OPENROUTER_API_KEY}}
      # OPENAI_API_KEY:     ${{OPENAI_API_KEY}}
      # ANTHROPIC_API_KEY:  ${{ANTHROPIC_API_KEY}}
    restart: unless-stopped

volumes:
  symbi-data:
  symbi-audit:
"#
    )
}

pub async fn run(matches: &ArgMatches) {
    let force = matches.get_flag("force");
    let no_interact = matches.get_flag("no-interact");
    let no_docker_compose = matches.get_flag("no-docker-compose");

    // --dir PATH overrides CWD — essential for `docker run -v $(pwd):/workspace ... init --dir /workspace`.
    let target_dir = resolve_target_dir(matches);

    if !no_interact {
        let hints = detect_project_hints(&target_dir);
        if !hints.is_empty() {
            for hint in &hints {
                println!("\u{2139} {}", hint);
            }
            println!();
        }
    }

    let profile = match matches.get_one::<String>("profile") {
        Some(p) => p.clone(),
        None if no_interact => "assistant".to_string(),
        #[cfg(feature = "interactive")]
        None => select_profile_interactive(),
        #[cfg(not(feature = "interactive"))]
        None => {
            println!("No --profile specified, using default: assistant");
            "assistant".to_string()
        }
    };

    let schemapin = match matches.value_source("schemapin") {
        Some(ValueSource::CommandLine) => matches.get_one::<String>("schemapin").unwrap().clone(),
        _ if no_interact => "tofu".to_string(),
        #[cfg(feature = "interactive")]
        _ => select_schemapin_interactive(),
        #[cfg(not(feature = "interactive"))]
        _ => "tofu".to_string(),
    };

    let sandbox = match matches.value_source("sandbox") {
        Some(ValueSource::CommandLine) => matches.get_one::<String>("sandbox").unwrap().clone(),
        _ if no_interact => "tier1".to_string(),
        #[cfg(feature = "interactive")]
        _ => select_sandbox_interactive(),
        #[cfg(not(feature = "interactive"))]
        _ => "tier1".to_string(),
    };

    if !["minimal", "assistant", "dev-agent", "multi-agent", "import"].contains(&profile.as_str()) {
        eprintln!("\u{2717} Unknown profile: {}", profile);
        eprintln!("  Available: minimal, assistant, dev-agent, multi-agent, import");
        std::process::exit(1);
    }
    if profile == "import" {
        let catalog_arg = matches.get_one::<String>("catalog");
        if catalog_arg.is_none() || catalog_arg.map(|s| s.as_str()) == Some("list") {
            eprintln!("\u{2717} --profile import requires --catalog <agent1,agent2,...>");
            eprintln!("  Run `symbi init --catalog list` to see available agents");
            std::process::exit(1);
        }
    }

    if !["tofu", "strict", "disabled"].contains(&schemapin.as_str()) {
        eprintln!("\u{2717} Unknown schemapin mode: {}", schemapin);
        std::process::exit(1);
    }
    if !["tier0", "tier1", "tier2", "tier3"].contains(&sandbox.as_str()) {
        eprintln!("\u{2717} Unknown sandbox tier: {}", sandbox);
        std::process::exit(1);
    }

    // Firecracker (tier3) requires operator-supplied kernel + rootfs paths.
    // We validate at scaffold time so misconfigurations don't surface only
    // when the first agent runs.
    let firecracker_paths = match resolve_firecracker_paths(matches, &sandbox) {
        Ok(paths) => paths,
        Err(e) => {
            eprintln!("\u{2717} {}", e);
            eprintln!("  See docs/firecracker-setup.md for kernel + rootfs prep.");
            std::process::exit(1);
        }
    };

    // Handle --catalog list
    if let Some(catalog_arg) = matches.get_one::<String>("catalog") {
        if catalog_arg == "list" {
            list_catalog();
            return;
        }
    }

    if let Err(e) = std::fs::create_dir_all(&target_dir) {
        eprintln!(
            "\u{2717} Failed to create target directory {}: {}",
            target_dir.display(),
            e
        );
        std::process::exit(1);
    }

    if should_proceed(&target_dir, force).is_err() {
        eprintln!("\u{2717} symbiont.toml already exists. Use --force to overwrite.");
        std::process::exit(1);
    }

    init_project(
        &target_dir,
        &profile,
        &schemapin,
        &sandbox,
        firecracker_paths.as_ref(),
    );

    // Import catalog agents if --catalog was provided alongside a profile
    if let Some(catalog_arg) = matches.get_one::<String>("catalog") {
        let names: Vec<&str> = catalog_arg.split(',').map(|s| s.trim()).collect();
        import_catalog_agents(&target_dir, &names);
        generate_agents_md(&target_dir);
    }

    // Master key: generate a .env with SYMBIONT_MASTER_KEY unless one is already present.
    write_env_files(&target_dir);

    // Docker compose: emit a ready-to-run compose file unless opted out.
    if !no_docker_compose {
        write_docker_compose(&target_dir, &profile);
    }

    // Closing guidance — printed last so it's the final thing on screen and
    // reflects everything that was actually generated above.
    print_next_steps(&target_dir, &profile, !no_docker_compose);
}

fn resolve_target_dir(matches: &ArgMatches) -> PathBuf {
    if let Some(dir) = matches.get_one::<String>("dir") {
        PathBuf::from(dir)
    } else {
        std::env::current_dir().expect("Failed to get current directory")
    }
}

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

    #[test]
    fn test_generate_toml_minimal() {
        let toml = generate_toml("minimal", "tofu", "tier1", None);
        assert!(toml.contains("profile: minimal"));
        assert!(toml.contains(r#"mode = "tofu""#));
        assert!(toml.contains(r#"tier = "tier1""#));
        assert!(toml.contains(r#"mode = "dev""#));
        assert!(toml.contains("policy_dir = \"policies\""));
    }

    #[test]
    fn test_generate_toml_custom_options() {
        let toml = generate_toml("assistant", "strict", "tier2", None);
        assert!(toml.contains("profile: assistant"));
        assert!(toml.contains(r#"mode = "strict""#));
        assert!(toml.contains(r#"tier = "tier2""#));
    }

    #[test]
    fn test_generate_toml_tier3_with_firecracker_paths() {
        let fc = FirecrackerInit {
            kernel_image_path: PathBuf::from("/k/vmlinux"),
            rootfs_path: PathBuf::from("/k/rootfs.ext4"),
        };
        let toml = generate_toml("assistant", "tofu", "tier3", Some(&fc));
        assert!(toml.contains(r#"tier = "tier3""#));
        assert!(toml.contains("[sandbox.firecracker]"));
        assert!(
            toml.contains(r#"kernel_image_path = "/k/vmlinux""#),
            "missing kernel path: {}",
            toml
        );
        assert!(
            toml.contains(r#"rootfs_path       = "/k/rootfs.ext4""#),
            "missing rootfs path: {}",
            toml
        );
        // The commented placeholder block should NOT also be emitted —
        // operators see the live config, not example values.
        assert!(
            !toml.contains("# kernel_image_path = "),
            "live config should suppress the commented placeholder: {}",
            toml
        );
    }

    #[test]
    fn test_generate_toml_tier1_keeps_commented_firecracker_block() {
        let toml = generate_toml("assistant", "tofu", "tier1", None);
        assert!(toml.contains(r#"tier = "tier1""#));
        // Tier1 projects get the commented skeleton so users can
        // discover Firecracker exists without scaffolding a broken setup.
        assert!(toml.contains("# [sandbox.firecracker]"));
        assert!(toml.contains("# kernel_image_path"));
        assert!(toml.contains("docs/firecracker-setup.md"));
    }
}

#[cfg(test)]
mod firecracker_init_tests {
    use super::*;
    use clap::{Arg, ArgMatches, Command};

    fn parse_init_args(args: &[&str]) -> ArgMatches {
        // Minimal clap surface mirroring the real `init` subcommand for the
        // bits this test cares about. We only validate `resolve_firecracker_paths`,
        // which only reads three flags.
        Command::new("init")
            .arg(Arg::new("sandbox").long("sandbox"))
            .arg(Arg::new("firecracker-kernel").long("firecracker-kernel"))
            .arg(Arg::new("firecracker-rootfs").long("firecracker-rootfs"))
            .try_get_matches_from(
                std::iter::once("init")
                    .chain(args.iter().copied())
                    .collect::<Vec<_>>(),
            )
            .expect("test args should parse")
    }

    #[test]
    fn tier1_ignores_firecracker_flags() {
        let m = parse_init_args(&["--sandbox", "tier1"]);
        let res = resolve_firecracker_paths(&m, "tier1").unwrap();
        assert!(res.is_none());
    }

    #[test]
    fn tier3_without_paths_errors() {
        let m = parse_init_args(&["--sandbox", "tier3"]);
        let err = resolve_firecracker_paths(&m, "tier3").unwrap_err();
        assert!(err.contains("--firecracker-kernel"), "got: {}", err);
        assert!(err.contains("--firecracker-rootfs"), "got: {}", err);
    }

    #[test]
    fn tier3_with_missing_kernel_errors() {
        let rootfs = tempfile::NamedTempFile::new().unwrap();
        let m = parse_init_args(&[
            "--sandbox",
            "tier3",
            "--firecracker-kernel",
            "/definitely/not/here/vmlinux",
            "--firecracker-rootfs",
            rootfs.path().to_str().unwrap(),
        ]);
        let err = resolve_firecracker_paths(&m, "tier3").unwrap_err();
        assert!(err.contains("--firecracker-kernel"), "got: {}", err);
        assert!(err.contains("does not exist"), "got: {}", err);
    }

    #[test]
    fn tier3_with_both_paths_present_succeeds() {
        let kernel = tempfile::NamedTempFile::new().unwrap();
        let rootfs = tempfile::NamedTempFile::new().unwrap();
        let m = parse_init_args(&[
            "--sandbox",
            "tier3",
            "--firecracker-kernel",
            kernel.path().to_str().unwrap(),
            "--firecracker-rootfs",
            rootfs.path().to_str().unwrap(),
        ]);
        let fc = resolve_firecracker_paths(&m, "tier3")
            .expect("paths should resolve")
            .expect("tier3 should yield Some");
        assert_eq!(fc.kernel_image_path, kernel.path());
        assert_eq!(fc.rootfs_path, rootfs.path());
    }

    #[test]
    fn test_default_cedar_policy_content() {
        assert!(DEFAULT_CEDAR_POLICY.contains("deny by default"));
        assert!(DEFAULT_CEDAR_POLICY.contains("schema_verified"));
        assert!(DEFAULT_CEDAR_POLICY.contains("Action::\"read\""));
        assert!(DEFAULT_CEDAR_POLICY.contains("Action::\"audit\""));
    }

    #[test]
    fn test_update_gitignore_creates_new() {
        let dir = tempfile::tempdir().unwrap();
        update_gitignore(dir.path()).unwrap();
        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.contains("# Symbiont"));
        assert!(content.contains("symbi.db"));
        assert!(content.contains(".symbiont/audit/*.jsonl"));
        assert!(content.contains("symbi.quick.toml"));
    }

    #[test]
    fn test_update_gitignore_appends_to_existing() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "node_modules/\n").unwrap();
        update_gitignore(dir.path()).unwrap();
        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.starts_with("node_modules/"));
        assert!(content.contains("symbi.db"));
    }

    #[test]
    fn test_update_gitignore_no_duplicates() {
        let dir = tempfile::tempdir().unwrap();
        update_gitignore(dir.path()).unwrap();
        update_gitignore(dir.path()).unwrap();
        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert_eq!(content.matches("symbi.db\n").count(), 1);
    }

    #[test]
    fn test_assistant_dsl_has_metadata() {
        match dsl::parse_dsl(ASSISTANT_DSL) {
            Ok(tree) => {
                let meta = dsl::extract_metadata(&tree, ASSISTANT_DSL);
                match meta.get("description").map(|s| s.as_str()) {
                    Some("Default governed assistant") => {} // grammar supports full extraction
                    _ => {
                        // Grammar parsed but didn't extract metadata; check content instead
                        assert!(ASSISTANT_DSL.contains("Default governed assistant"));
                        assert!(ASSISTANT_DSL.contains("agent assistant"));
                    }
                }
            }
            Err(_) => {
                assert!(ASSISTANT_DSL.contains("Default governed assistant"));
                assert!(ASSISTANT_DSL.contains("agent assistant"));
            }
        }
    }

    #[test]
    fn test_init_project_minimal() {
        let dir = tempfile::tempdir().unwrap();
        init_project(dir.path(), "minimal", "tofu", "tier1", None);
        assert!(dir.path().join("symbiont.toml").exists());
        assert!(dir.path().join("policies/default.cedar").exists());
        assert!(dir.path().join(".symbiont/audit").exists());
        assert!(dir.path().join(".gitignore").exists());
        assert!(!dir.path().join("agents").exists());
        assert!(!dir.path().join("AGENTS.md").exists());
    }

    #[test]
    fn test_init_project_assistant() {
        let dir = tempfile::tempdir().unwrap();
        init_project(dir.path(), "assistant", "strict", "tier2", None);
        assert!(dir.path().join("symbiont.toml").exists());
        assert!(dir.path().join("policies/default.cedar").exists());
        assert!(dir.path().join("agents/assistant.symbi").exists());
        assert!(dir.path().join("AGENTS.md").exists());
        assert!(dir.path().join(".symbiont/audit").exists());
        let toml = std::fs::read_to_string(dir.path().join("symbiont.toml")).unwrap();
        assert!(toml.contains(r#"mode = "strict""#));
        assert!(toml.contains(r#"tier = "tier2""#));
    }

    #[test]
    fn test_should_proceed_blocks_without_force() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("symbiont.toml"), "existing").unwrap();
        assert!(should_proceed(dir.path(), false).is_err());
        assert!(should_proceed(dir.path(), true).is_ok());
    }

    #[test]
    fn test_should_proceed_allows_fresh_dir() {
        let dir = tempfile::tempdir().unwrap();
        assert!(should_proceed(dir.path(), false).is_ok());
    }

    #[test]
    fn test_dev_agent_dsl_has_metadata() {
        assert!(DEV_AGENT_DSL.contains("Governed development agent"));
        assert!(DEV_AGENT_DSL.contains("agent dev"));
        assert!(DEV_AGENT_DSL.contains(r#"executor = "claude_code""#));
    }

    #[test]
    fn test_coordinator_dsl_has_metadata() {
        assert!(COORDINATOR_DSL.contains("Multi-agent coordinator"));
        assert!(COORDINATOR_DSL.contains("agent coordinator"));
    }

    #[test]
    fn test_init_templates_parse_without_errors() {
        // Every generated agent template must parse cleanly (no MISSING/ERROR
        // nodes — e.g. statements need terminating `;`) and expose an
        // extractable sandbox tier. Regression guard: the v1.15.0 templates
        // shipped without semicolons and with a tier value the extractor rejected.
        for (name, src) in [
            ("assistant", ASSISTANT_DSL),
            ("dev", DEV_AGENT_DSL),
            ("coordinator", COORDINATOR_DSL),
            ("worker", WORKER_DSL),
        ] {
            let tree = dsl::parse_dsl(src)
                .unwrap_or_else(|e| panic!("{name} template failed to parse: {e}"));
            assert!(
                !tree.root_node().has_error(),
                "{name} template has parse errors (missing statement semicolons?)"
            );
            let blocks = dsl::extract_with_blocks(&tree, src)
                .unwrap_or_else(|e| panic!("{name} with-block extraction failed: {e}"));
            assert!(
                blocks.iter().any(|b| b.sandbox_tier.is_some()),
                "{name} template should declare an extractable sandbox tier"
            );
        }
    }

    #[test]
    fn test_worker_dsl_has_metadata() {
        assert!(WORKER_DSL.contains("Multi-agent worker"));
        assert!(WORKER_DSL.contains("agent worker"));
    }

    #[test]
    fn test_init_project_dev_agent() {
        let dir = tempfile::tempdir().unwrap();
        init_project(dir.path(), "dev-agent", "tofu", "tier1", None);
        assert!(dir.path().join("agents/dev.symbi").exists());
        assert!(dir.path().join("policies/default.cedar").exists());
        assert!(dir.path().join("policies/dev-agent.cedar").exists());
        assert!(dir.path().join("AGENTS.md").exists());
    }

    #[test]
    fn test_init_project_multi_agent() {
        let dir = tempfile::tempdir().unwrap();
        init_project(dir.path(), "multi-agent", "tofu", "tier1", None);
        assert!(dir.path().join("agents/coordinator.symbi").exists());
        assert!(dir.path().join("agents/worker.symbi").exists());
        assert!(dir.path().join("policies/default.cedar").exists());
        assert!(dir.path().join("policies/inter-agent.cedar").exists());
    }

    #[test]
    fn test_catalog_has_entries() {
        assert!(!CATALOG.is_empty());
        assert!(CATALOG.iter().any(|e| e.name == "assistant"));
        assert!(CATALOG.iter().any(|e| e.name == "dev"));
        assert!(CATALOG.iter().any(|e| e.name == "coordinator"));
        assert!(CATALOG.iter().any(|e| e.name == "worker"));
    }

    #[test]
    fn test_import_catalog_agents() {
        let dir = tempfile::tempdir().unwrap();
        import_catalog_agents(dir.path(), &["assistant", "dev"]);
        assert!(dir.path().join("agents/assistant.symbi").exists());
        assert!(dir.path().join("agents/dev.symbi").exists());
        assert!(dir.path().join("policies/dev.cedar").exists());
    }

    #[test]
    fn test_import_catalog_skips_existing() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
        // Pre-existing legacy `.dsl` should still block re-creation.
        std::fs::write(dir.path().join("agents/assistant.dsl"), "custom content").unwrap();
        import_catalog_agents(dir.path(), &["assistant"]);
        let content = std::fs::read_to_string(dir.path().join("agents/assistant.dsl")).unwrap();
        assert_eq!(content, "custom content");
        // No `.symbi` file should have been written either.
        assert!(!dir.path().join("agents/assistant.symbi").exists());
    }

    #[test]
    fn test_init_preserves_existing_agents() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("agents")).unwrap();
        // Existing `.symbi` agents are preserved untouched.
        std::fs::write(dir.path().join("agents/assistant.symbi"), "my custom agent").unwrap();
        init_project(dir.path(), "assistant", "tofu", "tier1", None);
        let content = std::fs::read_to_string(dir.path().join("agents/assistant.symbi")).unwrap();
        assert_eq!(content, "my custom agent");
    }

    #[test]
    fn test_detect_project_hints() {
        let dir = tempfile::tempdir().unwrap();
        assert!(detect_project_hints(dir.path()).is_empty());

        std::fs::create_dir(dir.path().join(".claude")).unwrap();
        let hints = detect_project_hints(dir.path());
        assert!(hints.iter().any(|h| h.contains("dev-agent")));
    }

    #[test]
    fn test_generate_master_key_is_hex_and_64_chars() {
        let key = generate_master_key();
        assert_eq!(key.len(), 64);
        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
        // Two successive calls should not collide.
        assert_ne!(generate_master_key(), key);
    }

    #[test]
    fn test_write_env_files_creates_env_and_example() {
        let dir = tempfile::tempdir().unwrap();
        write_env_files(dir.path());
        let env = std::fs::read_to_string(dir.path().join(".env")).unwrap();
        assert!(env.contains("SYMBIONT_MASTER_KEY="));
        let key_line = env
            .lines()
            .find(|l| l.starts_with("SYMBIONT_MASTER_KEY="))
            .unwrap();
        let value = key_line.trim_start_matches("SYMBIONT_MASTER_KEY=");
        assert_eq!(value.len(), 64);
        assert!(value.chars().all(|c| c.is_ascii_hexdigit()));
        let example = std::fs::read_to_string(dir.path().join(".env.example")).unwrap();
        assert!(example.contains("SYMBIONT_MASTER_KEY="));
    }

    #[test]
    fn test_write_env_files_respects_existing_env() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join(".env"), "SYMBIONT_MASTER_KEY=preserved\n").unwrap();
        write_env_files(dir.path());
        let env = std::fs::read_to_string(dir.path().join(".env")).unwrap();
        assert_eq!(env, "SYMBIONT_MASTER_KEY=preserved\n");
    }

    #[test]
    fn test_generate_docker_compose_has_expected_sections() {
        let compose = generate_docker_compose("assistant");
        assert!(compose.contains("profile: assistant"));
        assert!(compose.contains("image: ghcr.io/thirdkeyai/symbi:latest"));
        assert!(compose.contains("8080:8080"));
        assert!(compose.contains("8081:8081"));
        assert!(compose.contains("./agents:/var/lib/symbi/agents:ro"));
        assert!(compose.contains("./policies:/var/lib/symbi/policies:ro"));
        assert!(compose.contains("./symbiont.toml:/var/lib/symbi/symbiont.toml:ro"));
        assert!(compose.contains("SYMBIONT_MASTER_KEY"));
        // No unescaped Rust format braces leaked into the output.
        assert!(!compose.contains("{{"));
        assert!(!compose.contains("}}"));
    }

    #[test]
    fn test_write_docker_compose_skips_when_present() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("compose.yaml"), "pre-existing").unwrap();
        write_docker_compose(dir.path(), "assistant");
        // compose.yaml was the canonical file; we must not clobber it by
        // writing docker-compose.yml alongside it.
        assert!(!dir.path().join("docker-compose.yml").exists());
        assert_eq!(
            std::fs::read_to_string(dir.path().join("compose.yaml")).unwrap(),
            "pre-existing"
        );
    }

    #[test]
    fn test_write_docker_compose_writes_when_absent() {
        let dir = tempfile::tempdir().unwrap();
        write_docker_compose(dir.path(), "assistant");
        let path = dir.path().join("docker-compose.yml");
        assert!(path.exists());
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("symbi"));
    }

    #[test]
    fn test_gitignore_includes_env() {
        let dir = tempfile::tempdir().unwrap();
        update_gitignore(dir.path()).unwrap();
        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.contains(".env"));
    }
}
