//! `new project <name>` chat command — Phase 6: `--adopt` flow.
//!
//! Provisions a project container and registers it with the gateway.
//! The command is gateway-only:
//!   `new project <name> [--stack <stack>] [--git <url>] [--git-token <token>] [--path <dir>] [--adopt <dir>]`
//!
//! Without `--stack`, the orchestrator inspects the (just-cloned or
//! just-init'd) source tree for stack markers found in
//! `docker/stacks/<name>/markers` files and auto-selects one, warning in
//! chat if multiple stacks matched.  With `--stack`, the named stack is used
//! unconditionally.
//!
//! Stack images follow the naming convention `huskies-project-<stack>`.
//! The base image (no language tooling) is `huskies-project-base`.
//!
//! Phase 3 (story 1108): an ed25519 SSH keypair is generated per project.
//! The private key is stored at `~/.huskies/<name>/id_ed25519` on the host.
//! The public key is passed to the container as `HUSKIES_SSH_PUBKEY` and
//! installed in `~/.ssh/authorized_keys` by the entrypoint.  The SSH server
//! is bound to a host-local port in the 2200–2300 range and recorded in
//! `projects.toml` as `ssh_port`.
//!
//! Phase 4 (story 1109):
//! - `--git <url>` clones the URL into the host project directory instead of
//!   running `git init`.  Clone failure aborts the bootstrap with no partial
//!   state left on disk.
//! - The container receives `GIT_USER_NAME` and `GIT_USER_EMAIL` env vars drawn
//!   from `git_user_name`/`git_user_email` in the gateway's `bot.toml`, with
//!   fallback to the host's `git config user.name`/`user.email`.
//! - The host user's SSH keys (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`) are
//!   bind-mounted read-only into the container so `git push` over SSH works.
//! - `--git-token <token>` stores the HTTPS push token in the container's
//!   git credential store via the entrypoint; the token is never echoed in
//!   chat or logs.
//! - After the container starts, `git ls-remote` verifies push credentials and
//!   surfaces success or an actionable failure message in the chat reply.
//!
//! Adding a new stack requires only:
//! 1. `docker/stacks/<name>/Dockerfile.fragment` — overlay instructions
//! 2. `docker/stacks/<name>/markers` — detection marker filenames
//! No changes to this orchestration module are needed.

use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;

use crate::service::gateway::config::ProjectEntry;

/// Parsed result of a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>] [--skip-config]` command.
pub struct NewProjectCommand {
    /// Project name (alphanumeric, hyphens, underscores).
    pub name: String,
    /// Explicitly requested stack, or `None` for auto-detection.
    pub stack: Option<String>,
    /// Git repository URL to clone into the project directory instead of running `git init`.
    ///
    /// When `Some`, the bootstrap runs `git clone <url>` instead of `git init`.
    /// Failure aborts the whole bootstrap with no partial state left on disk.
    pub git_url: Option<String>,
    /// HTTPS push token for the repository.
    ///
    /// Stored in the container's git credential helper by the entrypoint.
    /// **Never echoed in any chat reply or log line.**
    pub git_token: Option<String>,
    /// Override the default host directory (`~/huskies/<name>/`).
    ///
    /// When `Some`, the project is created at this path instead of the default.
    /// The same existence check applies: the path must not already exist.
    pub host_path: Option<String>,
    /// Wrap a container around an existing checkout at this path.
    ///
    /// When `Some`, the directory must already exist.  No `git clone` or
    /// `git init` is performed — the container is simply launched with the
    /// existing directory bind-mounted at `/workspace`.
    /// Mutually exclusive with `--path` and `--git`.
    pub adopt_path: Option<String>,
    /// Suppress the first-run configuration summary in the bootstrap reply.
    ///
    /// When `true`, the success reply omits the "Default configuration" block
    /// that lists agents, models, and override commands.
    pub skip_config: bool,
}

/// Parse a `new project <name> [--stack <s>] [--git <url>] [--git-token <tok>] [--path <dir>] [--adopt <dir>]` command.
///
/// Returns `Some(NewProjectCommand)` when the stripped message starts with
/// "new project" (case-insensitive). An empty name (bare "new project" with
/// no arg) is returned as `Some(name="")` so the handler can emit a usage
/// error. Returns `None` for any other message.
pub fn extract_new_project_command(
    message: &str,
    bot_name: &str,
    bot_user_id: &str,
) -> Option<NewProjectCommand> {
    let mention_stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
    // Strip leading punctuation (e.g. colon after "@timmy: new project …")
    let trimmed = mention_stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());

    let mut words = trimmed.split_whitespace();
    let first = words.next()?;
    if !first.eq_ignore_ascii_case("new") {
        return None;
    }
    let second = words.next()?;
    if !second.eq_ignore_ascii_case("project") {
        return None;
    }
    let name = words.next().unwrap_or("").to_string();

    let remaining: Vec<&str> = words.collect();
    let stack = parse_flag(&remaining, "--stack");
    let git_url = parse_flag(&remaining, "--git");
    let git_token = parse_flag(&remaining, "--git-token");
    let host_path = parse_flag(&remaining, "--path");
    let adopt_path = parse_flag(&remaining, "--adopt");
    let skip_config = remaining.contains(&"--skip-config");

    Some(NewProjectCommand {
        name,
        stack,
        git_url,
        git_token,
        host_path,
        adopt_path,
        skip_config,
    })
}

/// Extract the value of `--<flag> <value>` from a token slice.
fn parse_flag(tokens: &[&str], flag: &str) -> Option<String> {
    let mut iter = tokens.iter().peekable();
    while let Some(tok) = iter.next() {
        if *tok == flag
            && let Some(val) = iter.next()
        {
            return Some(val.to_string());
        }
    }
    None
}

/// Build the first-run configuration summary appended to the bootstrap success reply.
///
/// Reads `.huskies/agents.toml` from the project root and formats a Markdown
/// block listing each agent's name, stage, model, budget, and turn limit.
/// Returns an empty string when the file is missing or unreadable so callers
/// can always concatenate it unconditionally.
fn format_config_summary(host_path: &Path, name: &str) -> String {
    let agents_path = host_path.join(".huskies").join("agents.toml");
    let Ok(content) = std::fs::read_to_string(&agents_path) else {
        return String::new();
    };
    let Ok(val) = toml::from_str::<toml::Value>(&content) else {
        return String::new();
    };
    let Some(agents) = val.get("agent").and_then(|v| v.as_array()) else {
        return String::new();
    };

    let mut lines = vec![format!(
        "\n**Default configuration** ({} agent{}):",
        agents.len(),
        if agents.len() == 1 { "" } else { "s" }
    )];
    for agent in agents {
        let agent_name = agent.get("name").and_then(|v| v.as_str()).unwrap_or("?");
        let stage = agent.get("stage").and_then(|v| v.as_str()).unwrap_or("?");
        let model = agent
            .get("model")
            .and_then(|v| v.as_str())
            .unwrap_or("sonnet");
        let budget = agent
            .get("max_budget_usd")
            .and_then(|v| v.as_float())
            .unwrap_or(5.0);
        let turns = agent
            .get("max_turns")
            .and_then(|v| v.as_integer())
            .unwrap_or(50);
        lines.push(format!(
            "- **{agent_name}** ({stage}): model=`{model}`, budget=${budget:.2}, max_turns={turns}"
        ));
    }
    lines.push(String::new());
    lines.push(format!(
        "Override via chat: `huskies config {name} coder.model=opus`"
    ));
    lines.push(format!(
        "Project settings:  `huskies config {name} default_qa=human`"
    ));
    lines.push(
        "Accept all defaults silently: add `--skip-config` to the bootstrap command.".to_string(),
    );
    lines.join("\n")
}

/// Apply a single configuration override to a project's `.huskies/` files.
///
/// `key` is either `<agent_specifier>.<field>` (writes to `agents.toml`) or a
/// bare project-level key (writes to `project.toml`).
///
/// Supported agent fields: `model`, `max_turns`, `max_budget` / `max_budget_usd`.
/// Supported project keys: `default_qa`, `max_retries`, `max_coders`,
/// `base_branch`, `timezone`, `default_coder_model`.
pub fn apply_project_config(host_path: &Path, key: &str, value: &str) -> Result<String, String> {
    if let Some((specifier, field)) = key.split_once('.') {
        // Agent-scoped key: write to agents.toml
        let path = host_path.join(".huskies").join("agents.toml");
        apply_agent_config_file(&path, specifier, field, value)
    } else {
        // Project-level key: write to project.toml
        const SUPPORTED: &[&str] = &[
            "default_qa",
            "max_retries",
            "max_coders",
            "base_branch",
            "timezone",
            "default_coder_model",
        ];
        if !SUPPORTED.contains(&key) {
            return Err(format!(
                "Unknown project key `{key}`. Supported: {}",
                SUPPORTED.join(", ")
            ));
        }
        let path = host_path.join(".huskies").join("project.toml");
        apply_project_toml_key(&path, key, value)
    }
}

/// Modify a single `[[agent]]` entry in an `agents.toml` file.
fn apply_agent_config_file(
    path: &std::path::Path,
    specifier: &str,
    field: &str,
    value: &str,
) -> Result<String, String> {
    let content =
        std::fs::read_to_string(path).map_err(|e| format!("Cannot read agents.toml: {e}"))?;

    let new_content = apply_agent_field_in_text(&content, specifier, field, value)?;

    std::fs::write(path, &new_content).map_err(|e| format!("Cannot write agents.toml: {e}"))?;

    let canonical_field = if field == "max_budget" {
        "max_budget_usd"
    } else {
        field
    };
    Ok(format!(
        "Set `{specifier}.{canonical_field} = {value}` in `agents.toml`."
    ))
}

/// Rewrite the agent field in raw TOML text without re-serializing the whole file.
///
/// Splits the file into `[[agent]]` blocks, finds the one matching `specifier`
/// by name or stage, replaces the target field line, and rejoins.
fn apply_agent_field_in_text(
    content: &str,
    specifier: &str,
    field: &str,
    value: &str,
) -> Result<String, String> {
    const SUPPORTED_FIELDS: &[&str] = &["model", "max_turns", "max_budget", "max_budget_usd"];
    if !SUPPORTED_FIELDS.contains(&field) {
        return Err(format!(
            "Unknown agent field `{field}`. Supported: model, max_turns, max_budget"
        ));
    }
    let canonical_field = if field == "max_budget" {
        "max_budget_usd"
    } else {
        field
    };

    // Format the replacement TOML value.
    let toml_value = match canonical_field {
        "max_turns" => {
            let _n: i64 = value
                .parse()
                .map_err(|_| format!("`max_turns` must be an integer, got `{value}`"))?;
            value.to_string()
        }
        "max_budget_usd" => {
            let _f: f64 = value
                .parse()
                .map_err(|_| format!("`max_budget` must be a number, got `{value}`"))?;
            value.to_string()
        }
        _ => format!("\"{value}\""),
    };

    // Split on [[agent]] boundaries (keeping the marker with each block).
    let raw_blocks: Vec<&str> = content.split("\n[[agent]]").collect();
    let mut out_blocks: Vec<String> = Vec::with_capacity(raw_blocks.len());
    let mut matched = false;

    for (i, block) in raw_blocks.iter().enumerate() {
        // Re-attach the [[agent]] prefix that split() removed.
        let prefixed: std::borrow::Cow<str> = if i == 0 {
            std::borrow::Cow::Borrowed(block)
        } else {
            std::borrow::Cow::Owned(format!("\n[[agent]]{block}"))
        };

        // Check if this block contains a matching name or stage line.
        let matches_name = prefixed
            .lines()
            .any(|l| l.trim() == format!("name = \"{specifier}\""));
        let matches_stage = prefixed
            .lines()
            .any(|l| l.trim() == format!("stage = \"{specifier}\""));

        if (matches_name || matches_stage) && !matched {
            matched = true;
            // Replace the field line in this block.
            let mut new_block = String::new();
            let mut field_written = false;
            for line in prefixed.lines() {
                let trimmed = line.trim();
                if trimmed.starts_with(&format!("{canonical_field} =")) {
                    new_block.push_str(&format!("{canonical_field} = {toml_value}"));
                    new_block.push('\n');
                    field_written = true;
                } else {
                    new_block.push_str(line);
                    new_block.push('\n');
                }
            }
            // Remove the trailing newline we added (the original may not have one at end).
            if new_block.ends_with('\n') && !prefixed.ends_with('\n') {
                new_block.pop();
            }
            if !field_written {
                return Err(format!(
                    "Field `{canonical_field}` not found in the `{specifier}` agent block"
                ));
            }
            out_blocks.push(new_block);
        } else {
            out_blocks.push(prefixed.into_owned());
        }
    }

    if !matched {
        return Err(format!(
            "No agent with name or stage `{specifier}` found in agents.toml"
        ));
    }

    Ok(out_blocks.join(""))
}

/// Set or uncomment a key in a `project.toml` file.
///
/// Tries the following in order:
/// 1. Replace an existing uncommented `key = ...` line.
/// 2. Uncomment and replace a `# key = ...` line.
/// 3. Append the key at the end of the file.
fn apply_project_toml_key(
    path: &std::path::Path,
    key: &str,
    value: &str,
) -> Result<String, String> {
    let content =
        std::fs::read_to_string(path).map_err(|e| format!("Cannot read project.toml: {e}"))?;

    // Format as TOML: integer if parseable, else quoted string.
    let toml_value = if value.parse::<i64>().is_ok() {
        value.to_string()
    } else {
        format!("\"{value}\"")
    };
    let replacement_line = format!("{key} = {toml_value}");

    let mut found = false;
    let new_content: String = content
        .lines()
        .map(|line| {
            if found {
                return format!("{line}\n");
            }
            let trimmed = line.trim();
            // Match uncommented `key = ...`
            if trimmed.starts_with(&format!("{key} =")) {
                found = true;
                return format!("{replacement_line}\n");
            }
            // Match commented `# key = ...` variants
            let without_hash = trimmed.strip_prefix('#').map(|s| s.trim()).unwrap_or("");
            if without_hash.starts_with(&format!("{key} =")) {
                found = true;
                return format!("{replacement_line}\n");
            }
            format!("{line}\n")
        })
        .collect();

    let new_content = if found {
        // Trim the extra newline added by our map if the original didn't end with one.
        if content.ends_with('\n') {
            new_content
        } else {
            new_content.trim_end_matches('\n').to_string()
        }
    } else {
        // Append.
        format!("{new_content}{replacement_line}\n")
    };

    std::fs::write(path, &new_content).map_err(|e| format!("Cannot write project.toml: {e}"))?;

    Ok(format!("Set `{key} = {toml_value}` in `project.toml`."))
}

/// Return the path to `docker/stacks/` in the huskies source tree.
///
/// Uses `CARGO_MANIFEST_DIR` (baked in at compile time) to find the workspace
/// root, which is correct regardless of which project directory the gateway is
/// running from.
pub fn stacks_dir() -> std::path::PathBuf {
    let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    manifest_dir
        .parent()
        .expect("CARGO_MANIFEST_DIR has no parent")
        .join("docker")
        .join("stacks")
}

/// Scan `stacks_dir` for per-stack `markers` files and detect which stacks
/// match the given project directory.
///
/// Returns `(selected_stack, warnings)` where `selected_stack` is the
/// auto-detected stack name (or `None` if no markers matched) and `warnings`
/// carries a human-readable message when multiple stacks matched.
///
/// Each `stacks_dir/<name>/markers` file lists one filename per line
/// (relative to the project root). Lines starting with `#` and blank lines
/// are ignored. If any listed file exists in `project_path`, that stack is
/// considered a match.
pub fn detect_stack(project_path: &Path, stacks_dir: &Path) -> (Option<String>, Vec<String>) {
    let entries = match std::fs::read_dir(stacks_dir) {
        Ok(e) => e,
        Err(_) => return (None, vec![]),
    };

    // (stack_name, number_of_matched_marker_files)
    let mut matched: Vec<(String, usize)> = Vec::new();

    let mut stack_dirs: Vec<_> = entries
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .collect();
    // Deterministic order so multi-match selection is stable.
    stack_dirs.sort_by_key(|e| e.file_name());

    for entry in stack_dirs {
        let stack_name = entry.file_name().to_string_lossy().into_owned();
        let markers_path = entry.path().join("markers");
        let Ok(content) = std::fs::read_to_string(&markers_path) else {
            continue;
        };
        let count = content
            .lines()
            .filter(|line| {
                let trimmed = line.trim();
                !trimmed.is_empty()
                    && !trimmed.starts_with('#')
                    && project_path.join(trimmed).exists()
            })
            .count();
        if count > 0 {
            matched.push((stack_name, count));
        }
    }

    match matched.len() {
        0 => (None, vec![]),
        1 => (Some(matched.remove(0).0), vec![]),
        _ => {
            // Dominant stack: most marker files matched; alphabetical tiebreak for stability.
            matched.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
            let names: Vec<String> = matched.iter().map(|(n, _)| n.clone()).collect();
            let names_str = names.join(", ");
            let chosen = matched.swap_remove(0).0;
            let warning = format!(
                "Multiple stacks detected ({names_str}); using **{chosen}** \
                 (most marker files matched). \
                 Pass `--stack <name>` to override."
            );
            (Some(chosen), vec![warning])
        }
    }
}

/// Return the Docker image name for the given stack.
///
/// Stack images follow the convention `huskies-project-<stack>`.
/// When no stack is specified, the base image `huskies-project-base` is used.
pub fn image_for_stack(stack: Option<&str>) -> String {
    match stack {
        Some(s) => format!("huskies-project-{s}"),
        None => "huskies-project-base".to_string(),
    }
}

/// Read `git_user_name` and `git_user_email` from the gateway's `bot.toml`.
///
/// Deserialises directly into `BotConfig` without the enabled/transport
/// validation that `BotConfig::load` enforces, so the identity fields are
/// always available even when the bot transport is not yet configured.
async fn read_git_identity_from_bot_toml(
    config_dir: &std::path::Path,
) -> (Option<String>, Option<String>) {
    use crate::chat::transport::matrix::BotConfig;
    let path = config_dir.join(".huskies").join("bot.toml");
    let Ok(content) = tokio::fs::read_to_string(&path).await else {
        return (None, None);
    };
    let Ok(cfg) = toml::from_str::<BotConfig>(&content) else {
        return (None, None);
    };
    let name = cfg.git_user_name.filter(|s: &String| !s.is_empty());
    let email = cfg.git_user_email.filter(|s: &String| !s.is_empty());
    (name, email)
}

/// Read a single key from the host's global git config.
async fn read_host_git_config(key: &str) -> Option<String> {
    let out = tokio::process::Command::new("git")
        .args(["config", "--global", key])
        .output()
        .await
        .ok()?;
    if out.status.success() {
        let val = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if val.is_empty() { None } else { Some(val) }
    } else {
        None
    }
}

/// Resolve the git identity to use for new project containers.
///
/// Priority: `bot.toml` fields → host `git config` → hardcoded fallback.
pub(crate) async fn resolve_git_identity(config_dir: &std::path::Path) -> (String, String) {
    let (bot_name, bot_email) = read_git_identity_from_bot_toml(config_dir).await;

    let name = if let Some(n) = bot_name {
        n
    } else if let Some(n) = read_host_git_config("user.name").await {
        n
    } else {
        "Huskies Agent".to_string()
    };

    let email = if let Some(e) = bot_email {
        e
    } else if let Some(e) = read_host_git_config("user.email").await {
        e
    } else {
        "agent@huskies.local".to_string()
    };

    (name, email)
}

/// Inject a token into an HTTPS git URL for credential-passing.
///
/// `https://github.com/user/repo` → `https://x-access-token:<token>@github.com/user/repo`
/// The returned URL must NEVER be included in user-visible replies or logs.
fn inject_token_into_url(url: &str, token: &str) -> String {
    if let Some(rest) = url.strip_prefix("https://") {
        format!("https://x-access-token:{token}@{rest}")
    } else if let Some(rest) = url.strip_prefix("http://") {
        format!("http://x-access-token:{token}@{rest}")
    } else {
        url.to_string()
    }
}

/// Verify push credentials by running `git ls-remote` against the repository.
///
/// Returns `Ok(message)` on success or `Err(actionable_message)` on failure.
/// The token (if any) is never included in the returned strings.
async fn verify_push_credentials(git_url: &str, git_token: Option<&str>) -> Result<String, String> {
    let url_for_cmd = match git_token {
        Some(token) => inject_token_into_url(git_url, token),
        None => git_url.to_string(),
    };

    let mut cmd = tokio::process::Command::new("git");
    cmd.arg("ls-remote").arg(&url_for_cmd);
    cmd.env("GIT_TERMINAL_PROMPT", "0");
    if git_token.is_none() {
        // SSH: accept new host keys non-interactively; fail fast if no agent.
        cmd.env(
            "GIT_SSH_COMMAND",
            "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
        );
    }

    let output = cmd
        .output()
        .await
        .map_err(|e| format!("git ls-remote unavailable: {e}"))?;

    if output.status.success() {
        return Ok("Push credentials verified.".to_string());
    }

    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    // Map common failure patterns to actionable messages (no token in output).
    if stderr.contains("Permission denied") || stderr.contains("publickey") {
        Err(
            "SSH key not authorised by remote — check that your public key is \
             added to the repository's deploy keys or your account."
                .to_string(),
        )
    } else if stderr.contains("401")
        || stderr.contains("403")
        || stderr.contains("Authentication failed")
        || stderr.contains("could not read Username")
    {
        Err(
            "Token rejected — verify that the token has read/write access \
             to the repository."
                .to_string(),
        )
    } else {
        Err(format!(
            "Push verification failed for `{git_url}`: {stderr}"
        ))
    }
}

/// Generate an ed25519 SSH keypair at `key_path` (private) and `key_path.pub` (public).
///
/// Calls `ssh-keygen -t ed25519 -N "" -f <key_path>` with no passphrase.
/// Returns the public key string (trimmed) on success.
async fn generate_ssh_keypair(key_path: &std::path::Path) -> Result<String, String> {
    if let Some(parent) = key_path.parent() {
        tokio::fs::create_dir_all(parent)
            .await
            .map_err(|e| format!("Cannot create key directory {}: {e}", parent.display()))?;
    }
    let out = tokio::process::Command::new("ssh-keygen")
        .args(["-t", "ed25519", "-N", ""])
        .arg("-f")
        .arg(key_path)
        .output()
        .await
        .map_err(|e| format!("ssh-keygen not available: {e}"))?;

    if !out.status.success() {
        return Err(format!(
            "ssh-keygen failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        ));
    }

    let pub_path = key_path.with_extension("pub");
    tokio::fs::read_to_string(&pub_path)
        .await
        .map(|s| s.trim().to_string())
        .map_err(|e| format!("Cannot read public key {}: {e}", pub_path.display()))
}

/// Bootstrap a project container around an existing host checkout (`--adopt`).
///
/// The directory at `host_path` is bind-mounted at `/workspace`.  No `git
/// clone` or `git init` is performed.  `.huskies/` is scaffolded (write-if-
/// missing) so the pipeline files are present.  Stack auto-detection runs
/// against the existing directory contents.
async fn handle_adopt_project(
    name: &str,
    stack: Option<&str>,
    host_path: &std::path::Path,
    home: &str,
    projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
    config_dir: &Path,
    skip_config: bool,
) -> String {
    // ── Credentials pre-flight ───────────────────────────────────────────────
    // Agents inside the container need Claude credentials to spawn.  Fail fast
    // with an actionable message rather than launching a sled that immediately
    // errors with "Not logged in" when `start_agent` is called.
    let credentials_file = std::path::PathBuf::from(home)
        .join(".claude")
        .join(".credentials.json");
    if !credentials_file.exists() {
        return format!(
            "No Claude credentials found at `{}/.claude/.credentials.json`. \
             Run `claude login` on the host first, then retry.",
            home
        );
    }

    // Scaffold .huskies/ into the existing repo (write-if-missing — safe).
    if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) {
        return format!("Scaffold failed: {e}");
    }
    crate::service::gateway::io::init_wizard_state(host_path);

    // ── Detect or validate stack ─────────────────────────────────────────────
    let (resolved_stack, detect_warnings) = match stack {
        Some(s) => (Some(s.to_string()), vec![]),
        None => detect_stack(host_path, &stacks_dir()),
    };
    let stack_image = image_for_stack(resolved_stack.as_deref());
    let image = match build_project_image(host_path, &stack_image, name).await {
        Ok(img) => img,
        Err(e) => return format!("Failed to build project-specific image: {e}"),
    };

    // ── Generate SSH keypair ─────────────────────────────────────────────────
    let ssh_key_dir = std::path::PathBuf::from(home).join(".huskies").join(name);
    if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await {
        return format!(
            "Failed to create SSH key directory `{}`: {e}",
            ssh_key_dir.display()
        );
    }
    let private_key_path = ssh_key_dir.join("id_ed25519");
    let pubkey = match generate_ssh_keypair(&private_key_path).await {
        Ok(k) => k,
        Err(e) => {
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            return format!("SSH keypair generation failed: {e}");
        }
    };

    // ── Resolve git identity ─────────────────────────────────────────────────
    let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await;

    // ── Discover host SSH keys for bind-mounting ─────────────────────────────
    let host_ssh_dir = std::path::PathBuf::from(home).join(".ssh");
    let mut ssh_key_mounts: Vec<String> = Vec::new();
    for key_name in &["id_ed25519", "id_rsa"] {
        let key_path = host_ssh_dir.join(key_name);
        if key_path.exists() {
            ssh_key_mounts.push(format!(
                "{}:/home/huskies/.ssh/{key_name}:ro",
                key_path.display()
            ));
        }
    }

    // ── Allocate ports and launch container ──────────────────────────────────
    let Some(port) = find_free_port(3100) else {
        return "No free port in range 3100–3200. Stop unused containers and retry.".to_string();
    };
    let Some(ssh_port) = find_free_port(2200) else {
        return "No free SSH port in range 2200–2300. Stop unused containers and retry."
            .to_string();
    };
    let container_url = format!("http://127.0.0.1:{port}");
    let container_name = format!("huskies-{name}");

    let mut docker_args = project_docker_run_args(
        &container_name,
        name,
        port,
        ssh_port,
        &pubkey,
        &git_user_name,
        &git_user_email,
        Some(&credentials_file),
        &resolve_gateway_url(),
    );

    docker_args.push("-v".into());
    docker_args.push(format!("{}:/workspace", host_path.display()));

    for mount in &ssh_key_mounts {
        docker_args.push("-v".into());
        docker_args.push(mount.clone());
    }

    docker_args.push("--restart".into());
    docker_args.push("unless-stopped".into());
    docker_args.push(image.clone());
    docker_args.push("huskies".into());
    docker_args.push("/workspace".into());

    let docker_result = tokio::process::Command::new("docker")
        .args(&docker_args)
        .output()
        .await;

    match docker_result {
        Ok(out) if out.status.success() => {
            crate::crdt_state::write_gateway_project(name, &container_url);
            {
                let mut projects = projects_store.write().await;
                projects.insert(
                    name.to_string(),
                    ProjectEntry {
                        url: Some(container_url.clone()),
                        auth_token: None,
                        ssh_port: Some(ssh_port),
                        host_path: Some(host_path.to_string_lossy().into_owned()),
                        expected_node_id: None,
                    },
                );
                crate::service::gateway::io::save_config(&projects, config_dir).await;
            }

            crate::slog!(
                "[new-project] Adopted project '{name}' at {container_url} \
                 ssh=127.0.0.1:{ssh_port} (image={image})"
            );

            let stack_note = match resolved_stack.as_deref() {
                Some(s) => format!("- Stack detected: **{s}** (`{image}`)\n"),
                None => "- Stack: not detected (pass `--stack <name>` to set one)\n".to_string(),
            };
            let warning_block = if detect_warnings.is_empty() {
                String::new()
            } else {
                format!("\n> {}\n", detect_warnings.join("\n> "))
            };

            let config_block = if skip_config {
                String::new()
            } else {
                format_config_summary(host_path, name)
            };
            format!(
                "{warning_block}Project **{name}** adopted.\n\
                 - Host path: `{host}` (existing checkout, bind-mounted)\n\
                 - Container: `{container_name}` → `{container_url}`\n\
                 {stack_note}\
                 - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
                 -i ~/.huskies/{name}/id_ed25519`\n\
                 \n\
                 Use `switch {name}` then `status` to view the pipeline.\
                 {config_block}",
                host = host_path.display()
            )
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            interpret_docker_run_error(&stderr, &image)
        }
        Err(e) => {
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            format!("Docker container launch failed: {e}")
        }
    }
}

/// Bootstrap a new project from the `new project` chat command.
///
/// Creates the project directory (default `~/huskies/<name>/`, or `host_path`
/// when `--path` is supplied), scaffolds `.huskies/`, runs `git clone` (when
/// `git_url` is provided) or `git init`, auto-detects or honours the requested
/// stack, generates an SSH keypair, launches the appropriate Docker container,
/// and registers the project in the gateway's in-memory store and the CRDT.
///
/// When `adopt_path` is provided the directory must already exist; no clone or
/// init is performed and the container is launched with the existing checkout
/// bind-mounted at `/workspace`.  `adopt_path` is mutually exclusive with
/// `host_path_override` and `git_url`.
///
/// On any failure after a directory is created, the directory is removed and
/// the error message includes "Partial state removed at `<path>`".
///
/// `git_token` is never echoed in any returned string or log line.
#[allow(clippy::too_many_arguments)]
pub async fn handle_new_project(
    name: &str,
    stack: Option<&str>,
    git_url: Option<&str>,
    git_token: Option<&str>,
    host_path_override: Option<&str>,
    adopt_path_override: Option<&str>,
    skip_config: bool,
    projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
    config_dir: &Path,
) -> String {
    let name = name.trim();

    if name.is_empty() {
        return "Usage: `new project <name>` — e.g. `new project myapp`".to_string();
    }
    if !name
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return format!(
            "Invalid project name `{name}`. \
             Use letters, digits, hyphens, or underscores only."
        );
    }

    // --adopt is mutually exclusive with --path and --git.
    if adopt_path_override.is_some() && (host_path_override.is_some() || git_url.is_some()) {
        return "`--adopt` is mutually exclusive with `--path` and `--git`. \
                Use `--adopt <existing-path>` alone to wrap an existing checkout, \
                or use `--path`/`--git` to create a new project."
            .to_string();
    }

    // Name conflict — check both the in-memory store and the CRDT.
    {
        let projects = projects_store.read().await;
        if projects.contains_key(name) {
            return format!(
                "Project `{name}` is already registered. \
                 Use `switch {name}` to activate it."
            );
        }
    }

    let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string());

    // ── Adopt path: wrap container around an existing checkout ───────────────
    if let Some(adopt) = adopt_path_override {
        let host_path = std::path::PathBuf::from(adopt);
        if !host_path.exists() {
            return format!(
                "Adopt path `{}` does not exist — specify the path to an existing checkout.",
                host_path.display()
            );
        }
        if !host_path.is_dir() {
            return format!("Adopt path `{}` is not a directory.", host_path.display());
        }
        return handle_adopt_project(
            name,
            stack,
            &host_path,
            &home,
            projects_store,
            config_dir,
            skip_config,
        )
        .await;
    }

    // `--path` overrides the default `~/huskies/<name>/`.
    let host_path = match host_path_override {
        Some(p) => std::path::PathBuf::from(p),
        None => std::path::PathBuf::from(&home).join("huskies").join(name),
    };

    if host_path.exists() {
        return format!(
            "Path `{}` already exists. \
             Choose a different project name or remove the directory first.",
            host_path.display()
        );
    }

    // ── git clone or init + scaffold ─────────────────────────────────────────
    //
    // For `--git <url>`: git clone creates host_path itself, so we must NOT call
    // ensure_directory on it beforehand.  Scaffold runs after a successful clone.
    //
    // For no `--git`: create host_path first, scaffold, then git init (unchanged
    // from Phase 3 behaviour).

    if let Some(url) = git_url {
        // Ensure the parent directory (~/.huskies/) exists but not host_path itself.
        if let Some(parent) = host_path.parent()
            && let Err(e) = crate::service::gateway::io::ensure_directory(parent)
        {
            return format!(
                "Failed to create parent directory `{}`: {e}",
                parent.display()
            );
        }

        let clone_out = tokio::process::Command::new("git")
            .arg("clone")
            .arg(url)
            .arg(&host_path)
            .env("GIT_TERMINAL_PROMPT", "0")
            .output()
            .await;

        match clone_out {
            Err(e) => {
                return format!("git clone failed: {e}");
            }
            Ok(out) if !out.status.success() => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                let _ = tokio::fs::remove_dir_all(&host_path).await;
                return format!(
                    "git clone failed: {}\n\nPartial state removed at `{}`.",
                    stderr.trim(),
                    host_path.display()
                );
            }
            Ok(_) => {}
        }

        // Scaffold .huskies/ into the cloned repo (write_file_if_missing — safe on existing repos).
        if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) {
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            return format!(
                "Scaffold failed: {e}\n\nPartial state removed at `{}`.",
                host_path.display()
            );
        }
        crate::service::gateway::io::init_wizard_state(&host_path);
    } else {
        // No --git: create directory, scaffold, then git init.
        if let Err(e) = crate::service::gateway::io::ensure_directory(&host_path) {
            return format!("Failed to create `{}`: {e}", host_path.display());
        }

        if let Err(e) = crate::service::gateway::io::scaffold_project(&host_path) {
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            return format!(
                "Scaffold failed: {e}\n\nPartial state removed at `{}`.",
                host_path.display()
            );
        }
        crate::service::gateway::io::init_wizard_state(&host_path);

        match tokio::process::Command::new("git")
            .arg("init")
            .arg(&host_path)
            .output()
            .await
        {
            Err(e) => {
                let _ = tokio::fs::remove_dir_all(&host_path).await;
                return format!(
                    "git init failed: {e}\n\nPartial state removed at `{}`.",
                    host_path.display()
                );
            }
            Ok(out) if !out.status.success() => {
                let stderr = String::from_utf8_lossy(&out.stderr);
                let _ = tokio::fs::remove_dir_all(&host_path).await;
                return format!(
                    "git init failed: {}\n\nPartial state removed at `{}`.",
                    stderr.trim(),
                    host_path.display()
                );
            }
            Ok(_) => {}
        }
    }

    // ── Detect or validate stack ─────────────────────────────────────────────

    let (resolved_stack, detect_warnings) = match stack {
        Some(s) => (Some(s.to_string()), vec![]),
        None => detect_stack(&host_path, &stacks_dir()),
    };
    let stack_image = image_for_stack(resolved_stack.as_deref());
    let image = match build_project_image(&host_path, &stack_image, name).await {
        Ok(img) => img,
        Err(e) => {
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            return format!(
                "Failed to build project-specific image: {e}\n\nPartial state removed at `{}`.",
                host_path.display()
            );
        }
    };

    // ── Generate SSH keypair ─────────────────────────────────────────────────
    // Private key: ~/.huskies/<name>/id_ed25519 (host-side, mode 600 by ssh-keygen)
    // Public key:  installed in the container via HUSKIES_SSH_PUBKEY env var

    let ssh_key_dir = std::path::PathBuf::from(&home).join(".huskies").join(name);
    if let Err(e) = tokio::fs::create_dir_all(&ssh_key_dir).await {
        let _ = tokio::fs::remove_dir_all(&host_path).await;
        return format!(
            "Failed to create SSH key directory `{}`: {e}\n\nPartial state removed at `{}`.",
            ssh_key_dir.display(),
            host_path.display()
        );
    }
    let private_key_path = ssh_key_dir.join("id_ed25519");
    let pubkey = match generate_ssh_keypair(&private_key_path).await {
        Ok(k) => k,
        Err(e) => {
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            return format!(
                "SSH keypair generation failed: {e}\n\nPartial state removed at `{}`.",
                host_path.display()
            );
        }
    };

    // ── Credentials pre-flight ───────────────────────────────────────────────
    let credentials_file = std::path::PathBuf::from(&home)
        .join(".claude")
        .join(".credentials.json");
    if !credentials_file.exists() {
        let _ = tokio::fs::remove_dir_all(&host_path).await;
        let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
        return format!(
            "No Claude credentials found at `{home}/.claude/.credentials.json`. \
             Run `claude login` on the host first, then retry.\n\n\
             Partial state removed at `{}`.",
            host_path.display()
        );
    }

    // ── Resolve git identity ─────────────────────────────────────────────────
    // Read from bot.toml → fallback to host git config → hardcoded default.

    let (git_user_name, git_user_email) = resolve_git_identity(config_dir).await;

    // ── Discover host SSH keys for bind-mounting ─────────────────────────────
    // The user's personal SSH keys are mounted read-only so `git push` over SSH
    // works inside the container without copying secrets into the image.

    let host_ssh_dir = std::path::PathBuf::from(&home).join(".ssh");
    let mut ssh_key_mounts: Vec<String> = Vec::new();
    for key_name in &["id_ed25519", "id_rsa"] {
        let key_path = host_ssh_dir.join(key_name);
        if key_path.exists() {
            ssh_key_mounts.push(format!(
                "{}:/home/huskies/.ssh/{key_name}:ro",
                key_path.display()
            ));
        }
    }

    // ── Allocate ports and launch container ──────────────────────────────────

    let Some(port) = find_free_port(3100) else {
        let _ = tokio::fs::remove_dir_all(&host_path).await;
        let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
        return "No free port in range 3100–3200. Stop unused containers and retry.".to_string();
    };
    let Some(ssh_port) = find_free_port(2200) else {
        let _ = tokio::fs::remove_dir_all(&host_path).await;
        let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
        return "No free SSH port in range 2200–2300. Stop unused containers and retry."
            .to_string();
    };
    let container_url = format!("http://127.0.0.1:{port}");
    let container_name = format!("huskies-{name}");

    // Build the `docker run` argument list.  The token must never appear in any
    // string that is returned to the caller or written to a log.
    let mut docker_args = project_docker_run_args(
        &container_name,
        name,
        port,
        ssh_port,
        &pubkey,
        &git_user_name,
        &git_user_email,
        Some(&credentials_file),
        &resolve_gateway_url(),
    );

    // HTTPS push token: passed as env vars consumed by the entrypoint credential helper.
    if let Some(token) = git_token {
        docker_args.push("-e".into());
        docker_args.push(format!("GIT_PUSH_TOKEN={token}"));
        if let Some(url) = git_url {
            docker_args.push("-e".into());
            docker_args.push(format!("GIT_CLONE_URL={url}"));
        }
    }

    // Workspace mount.
    docker_args.push("-v".into());
    docker_args.push(format!("{}:/workspace", host_path.display()));

    // SSH key bind-mounts (read-only).
    for mount in &ssh_key_mounts {
        docker_args.push("-v".into());
        docker_args.push(mount.clone());
    }

    docker_args.push("--restart".into());
    docker_args.push("unless-stopped".into());
    docker_args.push(image.clone());
    docker_args.push("huskies".into());
    docker_args.push("/workspace".into());

    let docker_result = tokio::process::Command::new("docker")
        .args(&docker_args)
        .output()
        .await;

    match docker_result {
        Ok(out) if out.status.success() => {
            // Register in the CRDT (survives restarts).
            crate::crdt_state::write_gateway_project(name, &container_url);

            // Update the in-memory projects store and persist to projects.toml.
            {
                let mut projects = projects_store.write().await;
                projects.insert(
                    name.to_string(),
                    ProjectEntry {
                        url: Some(container_url.clone()),
                        auth_token: None,
                        ssh_port: Some(ssh_port),
                        host_path: Some(host_path.to_string_lossy().into_owned()),
                        expected_node_id: None,
                    },
                );
                crate::service::gateway::io::save_config(&projects, config_dir).await;
            }

            crate::slog!(
                "[new-project] Created project '{name}' at {container_url} \
                 ssh=127.0.0.1:{ssh_port} (image={image})"
            );

            // ── Push credential verification ─────────────────────────────────
            // Only run when a git URL was provided (clone path); skip for plain
            // git init projects where there is no remote to verify.
            let push_note = if let Some(url) = git_url {
                match verify_push_credentials(url, git_token).await {
                    Ok(msg) => format!("- Push credentials: {msg}\n"),
                    Err(err) => format!("> ⚠ Push verification: {err}\n"),
                }
            } else {
                String::new()
            };

            let stack_note = match resolved_stack.as_deref() {
                Some(s) => format!("- Stack: **{s}** (`{image}`)\n"),
                None => String::new(),
            };
            let warning_block = if detect_warnings.is_empty() {
                String::new()
            } else {
                format!("\n> {}\n", detect_warnings.join("\n> "))
            };

            let config_block = if skip_config {
                String::new()
            } else {
                format_config_summary(&host_path, name)
            };
            format!(
                "{warning_block}Project **{name}** is ready.\n\
                 - Host path: `{host}`\n\
                 - Container: `{container_name}` → `{container_url}`\n\
                 {stack_note}\
                 {push_note}\
                 - SSH: `ssh huskies@127.0.0.1 -p {ssh_port} \
                 -i ~/.huskies/{name}/id_ed25519`\n\
                 \n\
                 Use `switch {name}` then `status` to view the pipeline.\
                 {config_block}",
                host = host_path.display()
            )
        }
        Ok(out) => {
            let stderr = String::from_utf8_lossy(&out.stderr);
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            let base_msg = interpret_docker_run_error(&stderr, &image);
            format!(
                "{base_msg}\n\nPartial state removed at `{}`.",
                host_path.display()
            )
        }
        Err(e) => {
            let _ = tokio::fs::remove_dir_all(&host_path).await;
            let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
            format!(
                "Docker container launch failed: {e}\n\nPartial state removed at `{}`.",
                host_path.display()
            )
        }
    }
}

/// Compose the Dockerfile content used to build a project-specific image.
///
/// Concatenates `FROM {base_image}` with the project's own fragment so the
/// result can be piped directly to `docker build -`.
pub fn dockerfile_for_project(base_image: &str, fragment: &str) -> String {
    format!("FROM {base_image}\n{fragment}")
}

/// Extend `base_image` with the project's own `Dockerfile.fragment` and build
/// a project-specific Docker image.
///
/// Looks for `{project_path}/.huskies/Dockerfile.fragment`.  When the file is
/// absent the `base_image` is returned unchanged so the caller can skip the
/// build step entirely.  When the file exists its content is appended to
/// `FROM {base_image}` and piped to `docker build -t huskies-project-local-{project_name} -`.
///
/// Returns `Ok(image_name)` on success or `Err(message)` if the build fails.
pub async fn build_project_image(
    project_path: &Path,
    base_image: &str,
    project_name: &str,
) -> Result<String, String> {
    let fragment_path = project_path.join(".huskies").join("Dockerfile.fragment");
    let Ok(fragment) = tokio::fs::read_to_string(&fragment_path).await else {
        return Ok(base_image.to_string());
    };

    let project_image = format!("huskies-project-local-{project_name}");
    let dockerfile_content = dockerfile_for_project(base_image, &fragment);

    let mut child = tokio::process::Command::new("docker")
        .args(["build", "-t", &project_image, "-"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .map_err(|e| format!("docker build failed to start: {e}"))?;

    if let Some(mut stdin) = child.stdin.take() {
        use tokio::io::AsyncWriteExt;
        stdin
            .write_all(dockerfile_content.as_bytes())
            .await
            .map_err(|e| format!("Failed to write Dockerfile to docker build stdin: {e}"))?;
    }

    let output = child
        .wait_with_output()
        .await
        .map_err(|e| format!("docker build failed: {e}"))?;

    if output.status.success() {
        Ok(project_image)
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(format!(
            "docker build for project image `{project_image}` failed:\n{stderr}"
        ))
    }
}

/// Build the base `docker run` argument list for a project container.
///
/// Includes `-e HUSKIES_HOST=0.0.0.0` so the server inside the container binds
/// to all interfaces, making Docker port forwarding reachable from the host.
/// Without this the server defaults to `127.0.0.1` inside the container —
/// reachable only from within the container itself, not via `docker -p`.
///
/// When `gateway_url` is non-empty, `-e HUSKIES_GATEWAY_URL=<url>` is added so
/// the sled's relay task connects back to the gateway and forwards CRDT events.
///
/// When `credentials_path` is `Some`, the file is bind-mounted read-only at
/// `/run/claude-credentials-src` so the container entrypoint can copy it into
/// `/home/huskies/.claude/.credentials.json` with mode 0600.  Mounting to an
/// intermediate path (rather than directly to the destination) ensures the
/// huskies user owns the copy regardless of the host user's UID.
///
/// Log rotation flags (`--log-driver json-file`, `max-size=50m`, `max-file=3`)
/// match `docker/docker-compose.yml` so containers launched directly via
/// `docker run` don't grow logs unbounded the way compose-launched ones can't.
#[allow(clippy::too_many_arguments)]
pub(crate) fn project_docker_run_args(
    container_name: &str,
    project_name: &str,
    port: u16,
    ssh_port: u16,
    pubkey: &str,
    git_user_name: &str,
    git_user_email: &str,
    credentials_path: Option<&std::path::Path>,
    gateway_url: &str,
) -> Vec<String> {
    let mut args = vec![
        "run".into(),
        "-d".into(),
        "--log-driver".into(),
        "json-file".into(),
        "--log-opt".into(),
        "max-size=50m".into(),
        "--log-opt".into(),
        "max-file=3".into(),
        "--name".into(),
        container_name.to_string(),
        "-p".into(),
        format!("127.0.0.1:{port}:3001"),
        "-p".into(),
        format!("127.0.0.1:{ssh_port}:22"),
        "-e".into(),
        "HUSKIES_HOST=0.0.0.0".into(),
        "-e".into(),
        "HUSKIES_PORT=3001".into(),
        "-e".into(),
        format!("HUSKIES_PROJECT_NAME={project_name}"),
        "-e".into(),
        format!("HUSKIES_SSH_PUBKEY={pubkey}"),
        "-e".into(),
        format!("GIT_USER_NAME={git_user_name}"),
        "-e".into(),
        format!("GIT_USER_EMAIL={git_user_email}"),
    ];
    if !gateway_url.is_empty() {
        args.push("-e".into());
        args.push(format!("HUSKIES_GATEWAY_URL={gateway_url}"));
    }
    if let Some(creds) = credentials_path {
        args.push("-v".into());
        args.push(format!(
            "{}:/run/claude-credentials-src:ro",
            creds.display()
        ));
    }
    args
}

/// Resolve the gateway URL to inject into project sled containers.
///
/// Reads `HUSKIES_GATEWAY_URL` from the environment first; falls back to
/// `http://host.docker.internal:3000` so containers launched by the gateway
/// can always relay events back without explicit configuration.
pub(crate) fn resolve_gateway_url() -> String {
    std::env::var("HUSKIES_GATEWAY_URL")
        .unwrap_or_else(|_| "http://host.docker.internal:3000".to_string())
}

/// Convert a failed `docker run` stderr into an actionable chat message.
///
/// When Docker cannot find the image locally it prints `Unable to find image`.
/// That bare error is unhelpful — this function maps it to a message that tells
/// the user which script to run.  All other failures are passed through as-is.
fn interpret_docker_run_error(stderr: &str, image: &str) -> String {
    if stderr.contains("Unable to find image") {
        format!(
            "Image `{image}` not found locally. \
             Build project images first by running `script/build-project-images`."
        )
    } else {
        format!("Docker container launch failed: {}", stderr.trim())
    }
}

/// Scan `start..start+range` for a bindable TCP port on 127.0.0.1.
///
/// Returns `Some(port)` for the first port that can be bound, or `None` if all
/// ports in the range are occupied.
fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> {
    (start..start.saturating_add(range))
        .find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok())
}

/// Find a free TCP port by attempting to bind starting from `start`.
///
/// Scans up to 100 ports above `start` and returns `Some(port)` for the first
/// bindable one, or `None` when the entire range `start..start+100` is exhausted.
fn find_free_port(start: u16) -> Option<u16> {
    find_free_port_in_range(start, 100)
}

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

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

    #[test]
    fn extract_parses_name() {
        let cmd =
            extract_new_project_command("@timmy new project myapp", "Timmy", "@timmy:srv.local")
                .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(cmd.stack, None);
    }

    #[test]
    fn extract_case_insensitive() {
        let cmd =
            extract_new_project_command("@timmy NEW PROJECT myapp", "Timmy", "@timmy:srv.local")
                .unwrap();
        assert_eq!(cmd.name, "myapp");
    }

    #[test]
    fn extract_bare_project_keyword_returns_empty_name() {
        let cmd =
            extract_new_project_command("@timmy new project", "Timmy", "@timmy:srv.local").unwrap();
        assert_eq!(cmd.name, "");
    }

    #[test]
    fn extract_parses_stack_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack rust",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(cmd.stack, Some("rust".to_string()));
    }

    #[test]
    fn extract_stack_node() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack node",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.stack, Some("node".to_string()));
    }

    #[test]
    fn extract_no_match_for_other_commands() {
        assert!(
            extract_new_project_command("@timmy status", "Timmy", "@timmy:srv.local").is_none()
        );
    }

    #[test]
    fn extract_no_match_when_second_word_is_not_project() {
        assert!(
            extract_new_project_command("@timmy new myapp", "Timmy", "@timmy:srv.local").is_none()
        );
    }

    #[test]
    fn extract_handles_extra_whitespace() {
        let cmd = extract_new_project_command(
            "@timmy   new   project   myapp",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
    }

    #[test]
    fn image_for_stack_rust() {
        assert_eq!(image_for_stack(Some("rust")), "huskies-project-rust");
    }

    #[test]
    fn image_for_stack_node() {
        assert_eq!(image_for_stack(Some("node")), "huskies-project-node");
    }

    #[test]
    fn image_for_stack_base() {
        assert_eq!(image_for_stack(None), "huskies-project-base");
    }

    #[test]
    fn detect_stack_rust_marker() {
        let dir = tempfile::tempdir().unwrap();
        // Create a fake project with Cargo.toml
        std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();

        // Create a fake stacks dir
        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
        std::fs::write(
            stacks.path().join("rust/markers"),
            "# comment\nCargo.toml\n",
        )
        .unwrap();
        std::fs::create_dir_all(stacks.path().join("node")).unwrap();
        std::fs::write(
            stacks.path().join("node/markers"),
            "package.json\ntsconfig.json\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("rust".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_node_tsconfig() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("node")).unwrap();
        std::fs::write(
            stacks.path().join("node/markers"),
            "package.json\ntsconfig.json\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("node".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_no_markers_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
        std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, None);
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_multiple_warn() {
        let dir = tempfile::tempdir().unwrap();
        // Both Cargo.toml and package.json exist.
        std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
        std::fs::write(dir.path().join("package.json"), "{}").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("node")).unwrap();
        std::fs::write(stacks.path().join("node/markers"), "package.json\n").unwrap();
        std::fs::create_dir_all(stacks.path().join("rust")).unwrap();
        std::fs::write(stacks.path().join("rust/markers"), "Cargo.toml\n").unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        // Alphabetically first: "node" < "rust"
        assert_eq!(stack, Some("node".to_string()));
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("Multiple stacks"));
    }

    #[test]
    fn detect_stack_missing_stacks_dir_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        let (stack, warnings) = detect_stack(dir.path(), Path::new("/nonexistent/stacks"));
        assert_eq!(stack, None);
        assert!(warnings.is_empty());
    }

    #[tokio::test]
    async fn generate_ssh_keypair_creates_key_files() {
        // Skip if ssh-keygen is not available in this environment.
        if tokio::process::Command::new("ssh-keygen")
            .arg("-V")
            .output()
            .await
            .is_err()
        {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        let key_path = dir.path().join("id_ed25519");
        let pubkey = generate_ssh_keypair(&key_path).await.unwrap();

        // Private key file must exist and be non-empty.
        assert!(key_path.exists(), "private key file not created");
        // Public key is returned as a trimmed string starting with the key type.
        assert!(
            pubkey.starts_with("ssh-ed25519"),
            "public key should start with ssh-ed25519, got: {pubkey}"
        );
        // Public key file must also exist.
        let pub_path = dir.path().join("id_ed25519.pub");
        assert!(pub_path.exists(), "public key file not created");
        let pub_contents = std::fs::read_to_string(&pub_path).unwrap();
        assert_eq!(pub_contents.trim(), pubkey);
    }

    #[tokio::test]
    async fn generate_ssh_keypair_creates_missing_parent_dirs() {
        // Skip if ssh-keygen is not available in this environment.
        if tokio::process::Command::new("ssh-keygen")
            .arg("-V")
            .output()
            .await
            .is_err()
        {
            return;
        }

        let base = tempfile::tempdir().unwrap();
        // Use a nested path whose intermediate directories do not yet exist.
        let key_path = base.path().join("a").join("b").join("c").join("id_ed25519");
        assert!(
            !key_path.parent().unwrap().exists(),
            "precondition: parent should not exist"
        );

        let pubkey = generate_ssh_keypair(&key_path).await.unwrap();

        assert!(key_path.exists(), "private key file not created");
        assert!(
            pubkey.starts_with("ssh-ed25519"),
            "public key should start with ssh-ed25519, got: {pubkey}"
        );
    }

    #[test]
    fn find_free_port_returns_bindable_port() {
        // Scan from an OS-assigned ephemeral port rather than a hardcoded low
        // port. Fixed ports like 2200 are also used as scan starts elsewhere
        // in production code and tests, so a second, independent bind on a
        // fixed port can race against those under parallel test execution.
        let reservation = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
        let start = reservation.local_addr().unwrap().port();
        drop(reservation);

        let port = find_free_port(start).expect("expected Some(port) in scan range");
        assert!((start..start.saturating_add(100)).contains(&port));
        let listener = std::net::TcpListener::bind(("127.0.0.1", port));
        assert!(listener.is_ok(), "returned port {port} is not bindable");
    }

    #[test]
    fn find_free_port_exhausted_range_returns_none() {
        // Bind the single port in a 1-wide scan window, then verify None is returned.
        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();
        // The range [port, port+1) contains only `port`, which is already bound.
        assert_eq!(find_free_port_in_range(port, 1), None);
    }

    #[test]
    fn detect_stack_go_mod() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("go.mod"), "module example").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("go")).unwrap();
        std::fs::write(stacks.path().join("go/markers"), "go.mod\n").unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("go".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_python_pyproject() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("python")).unwrap();
        std::fs::write(
            stacks.path().join("python/markers"),
            "pyproject.toml\nrequirements.txt\nsetup.py\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("python".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_python_requirements_txt() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("python")).unwrap();
        std::fs::write(
            stacks.path().join("python/markers"),
            "pyproject.toml\nrequirements.txt\nsetup.py\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("python".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_python_setup_py() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("setup.py"), "from setuptools import setup").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("python")).unwrap();
        std::fs::write(
            stacks.path().join("python/markers"),
            "pyproject.toml\nrequirements.txt\nsetup.py\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("python".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_ruby_gemfile() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("Gemfile"), "source 'https://rubygems.org'").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("ruby")).unwrap();
        std::fs::write(stacks.path().join("ruby/markers"), "Gemfile\n").unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("ruby".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_jvm_pom_xml() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("pom.xml"), "<project/>").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
        std::fs::write(
            stacks.path().join("jvm/markers"),
            "pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("jvm".to_string()));
        assert!(warnings.is_empty());
    }

    #[test]
    fn detect_stack_jvm_build_gradle() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("build.gradle"), "plugins { }").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
        std::fs::write(
            stacks.path().join("jvm/markers"),
            "pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("jvm".to_string()));
        assert!(warnings.is_empty());
    }

    // ── Missing-image error path ─────────────────────────────────────────────

    #[test]
    fn project_docker_args_include_huskies_host_env() {
        let args = project_docker_run_args(
            "huskies-myapp",
            "myapp",
            3100,
            2200,
            "ssh-ed25519 AAAA...",
            "Test User",
            "test@example.com",
            None,
            "http://host.docker.internal:3000",
        );
        let pairs: Vec<_> = args.windows(2).collect();
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "-e" && w[1] == "HUSKIES_HOST=0.0.0.0"),
            "expected -e HUSKIES_HOST=0.0.0.0 in docker args, got: {args:?}"
        );
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "-e" && w[1] == "HUSKIES_PORT=3001"),
            "expected -e HUSKIES_PORT=3001 in docker args, got: {args:?}"
        );
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "-e"
                    && w[1] == "HUSKIES_GATEWAY_URL=http://host.docker.internal:3000"),
            "expected -e HUSKIES_GATEWAY_URL=http://host.docker.internal:3000 in docker args, got: {args:?}"
        );
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "--log-driver" && w[1] == "json-file"),
            "expected --log-driver json-file in docker args, got: {args:?}"
        );
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "--log-opt" && w[1] == "max-size=50m"),
            "expected --log-opt max-size=50m in docker args, got: {args:?}"
        );
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "--log-opt" && w[1] == "max-file=3"),
            "expected --log-opt max-file=3 in docker args, got: {args:?}"
        );
    }

    #[test]
    fn project_docker_args_include_project_name_env() {
        let args = project_docker_run_args(
            "huskies-myapp",
            "myapp",
            3100,
            2200,
            "ssh-ed25519 AAAA...",
            "Test User",
            "test@example.com",
            None,
            "",
        );
        let pairs: Vec<_> = args.windows(2).collect();
        assert!(
            pairs
                .iter()
                .any(|w| w[0] == "-e" && w[1] == "HUSKIES_PROJECT_NAME=myapp"),
            "expected -e HUSKIES_PROJECT_NAME=myapp in docker args, got: {args:?}"
        );
    }

    #[test]
    fn project_docker_args_no_gateway_url_when_empty() {
        let args = project_docker_run_args(
            "huskies-myapp",
            "myapp",
            3100,
            2200,
            "ssh-ed25519 AAAA...",
            "Test User",
            "test@example.com",
            None,
            "",
        );
        assert!(
            !args.iter().any(|a| a.contains("HUSKIES_GATEWAY_URL")),
            "expected no HUSKIES_GATEWAY_URL when gateway_url is empty, got: {args:?}"
        );
    }

    #[test]
    fn project_docker_args_include_credentials_mount() {
        let creds = std::path::Path::new("/home/user/.claude/.credentials.json");
        let args = project_docker_run_args(
            "huskies-myapp",
            "myapp",
            3100,
            2200,
            "ssh-ed25519 AAAA...",
            "Test User",
            "test@example.com",
            Some(creds),
            "",
        );
        let pairs: Vec<_> = args.windows(2).collect();
        assert!(
            pairs.iter().any(|w| w[0] == "-v"
                && w[1] == "/home/user/.claude/.credentials.json:/run/claude-credentials-src:ro"),
            "expected credentials bind-mount in docker args, got: {args:?}"
        );
    }

    #[test]
    fn project_docker_args_no_credentials_mount_when_none() {
        let args = project_docker_run_args(
            "huskies-myapp",
            "myapp",
            3100,
            2200,
            "ssh-ed25519 AAAA...",
            "Test User",
            "test@example.com",
            None,
            "",
        );
        assert!(
            !args.iter().any(|a| a.contains("claude-credentials-src")),
            "expected no credentials mount when credentials_path is None, got: {args:?}"
        );
    }

    #[tokio::test]
    async fn handle_adopt_project_missing_credentials_returns_error() {
        let adopt_dir = tempfile::tempdir().unwrap();
        let home_dir = tempfile::tempdir().unwrap();
        // home_dir has no .claude/.credentials.json

        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();

        let result = handle_adopt_project(
            "myapp",
            None,
            adopt_dir.path(),
            home_dir.path().to_str().unwrap(),
            &store,
            config_dir.path(),
            false,
        )
        .await;

        assert!(
            result.contains("claude login"),
            "expected claude login suggestion in error, got: {result}"
        );
        assert!(
            result.contains(".credentials.json"),
            "expected credentials path in error, got: {result}"
        );
    }

    #[test]
    fn interpret_docker_run_error_missing_image_points_at_script() {
        let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\
                      docker: Error response from daemon: pull access denied";
        let msg = interpret_docker_run_error(stderr, "huskies-project-rust");
        assert!(
            msg.contains("script/build-project-images"),
            "expected script name in error message, got: {msg}"
        );
        assert!(
            msg.contains("huskies-project-rust"),
            "expected image name in error message, got: {msg}"
        );
    }

    #[test]
    fn interpret_docker_run_error_other_failure_passes_through() {
        let stderr = "Error response from daemon: container name already in use";
        let msg = interpret_docker_run_error(stderr, "huskies-project-rust");
        assert!(
            msg.contains("Docker container launch failed"),
            "expected generic error message, got: {msg}"
        );
        assert!(
            msg.contains("container name already in use"),
            "expected original stderr in message, got: {msg}"
        );
    }

    // ── Dockerfile fragment ──────────────────────────────────────────────────

    #[test]
    fn dockerfile_for_project_prepends_from() {
        let content = dockerfile_for_project("huskies-project-rust", "RUN echo hello\n");
        assert_eq!(
            content, "FROM huskies-project-rust\nRUN echo hello\n",
            "Dockerfile should start with FROM line followed by fragment"
        );
    }

    #[test]
    fn dockerfile_for_project_base_image() {
        let content = dockerfile_for_project("huskies-project-base", "");
        assert_eq!(content, "FROM huskies-project-base\n");
    }

    #[tokio::test]
    async fn build_project_image_no_fragment_returns_base_image() {
        let dir = tempfile::tempdir().unwrap();
        // .huskies/ exists but contains no Dockerfile.fragment
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();

        let result = build_project_image(dir.path(), "huskies-project-rust", "myapp").await;
        assert_eq!(
            result.unwrap(),
            "huskies-project-rust",
            "should return base image unchanged when no fragment is present"
        );
    }

    #[tokio::test]
    async fn build_project_image_missing_huskies_dir_returns_base_image() {
        // No .huskies/ directory at all.
        let dir = tempfile::tempdir().unwrap();

        let result = build_project_image(dir.path(), "huskies-project-base", "myapp").await;
        assert_eq!(
            result.unwrap(),
            "huskies-project-base",
            "should return base image unchanged when .huskies/ dir is absent"
        );
    }

    /// End-to-end test: a project fragment that installs `jq` produces an
    /// image where `docker run ... which jq` exits successfully.
    ///
    /// Skipped when Docker is not available in the test environment.
    #[tokio::test]
    async fn build_project_image_fragment_installs_jq() {
        // Skip if docker is not available.
        let docker_check = tokio::process::Command::new("docker")
            .args(["info"])
            .output()
            .await;
        if docker_check.map(|o| !o.status.success()).unwrap_or(true) {
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
        std::fs::write(
            dir.path().join(".huskies").join("Dockerfile.fragment"),
            "RUN apt-get update && apt-get install -y jq\n",
        )
        .unwrap();

        let result =
            build_project_image(dir.path(), "debian:bookworm-slim", "test-jq-fragment").await;
        let image = match result {
            Ok(img) => img,
            Err(e) => {
                // Docker is available but the build failed — likely the base image
                // is not cached and pull failed in an offline environment.  Skip.
                eprintln!("build_project_image_fragment_installs_jq: skipped ({e})");
                return;
            }
        };

        assert_eq!(image, "huskies-project-local-test-jq-fragment");

        let which = tokio::process::Command::new("docker")
            .args(["run", "--rm", &image, "which", "jq"])
            .output()
            .await
            .expect("docker run should not fail to start");
        assert!(
            which.status.success(),
            "`which jq` should succeed inside the built image"
        );

        // Clean up the test image.
        let _ = tokio::process::Command::new("docker")
            .args(["rmi", &image])
            .output()
            .await;
    }

    #[test]
    fn detect_stack_jvm_build_gradle_kts() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("build.gradle.kts"), "plugins { }").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("jvm")).unwrap();
        std::fs::write(
            stacks.path().join("jvm/markers"),
            "pom.xml\nbuild.gradle\nbuild.gradle.kts\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        assert_eq!(stack, Some("jvm".to_string()));
        assert!(warnings.is_empty());
    }

    // ── Phase 4: --git and --git-token flag parsing ──────────────────────────

    #[test]
    fn extract_parses_git_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --git https://github.com/user/repo",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(
            cmd.git_url,
            Some("https://github.com/user/repo".to_string())
        );
        assert_eq!(cmd.git_token, None);
    }

    #[test]
    fn extract_parses_git_token_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --git https://github.com/user/repo --git-token ghp_secret",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(
            cmd.git_url,
            Some("https://github.com/user/repo".to_string())
        );
        assert_eq!(cmd.git_token, Some("ghp_secret".to_string()));
    }

    #[test]
    fn extract_git_token_without_git_url() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --git-token ghp_secret",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.git_url, None);
        assert_eq!(cmd.git_token, Some("ghp_secret".to_string()));
    }

    #[test]
    fn extract_git_flag_with_stack() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack rust --git git@github.com:user/repo.git",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.stack, Some("rust".to_string()));
        assert_eq!(
            cmd.git_url,
            Some("git@github.com:user/repo.git".to_string())
        );
    }

    #[test]
    fn extract_no_git_flag_returns_none_fields() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack node",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.git_url, None);
        assert_eq!(cmd.git_token, None);
    }

    // ── Phase 5: --path flag parsing ─────────────────────────────────────────

    #[test]
    fn extract_parses_path_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --path /projects/myapp",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(cmd.host_path, Some("/projects/myapp".to_string()));
    }

    #[test]
    fn extract_path_flag_with_stack_and_git() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack rust --git https://github.com/u/r --path /srv/myapp",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(cmd.stack, Some("rust".to_string()));
        assert_eq!(cmd.git_url, Some("https://github.com/u/r".to_string()));
        assert_eq!(cmd.host_path, Some("/srv/myapp".to_string()));
    }

    #[test]
    fn extract_no_path_flag_returns_none() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack node",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.host_path, None);
    }

    #[test]
    fn inject_token_into_https_url() {
        let result = inject_token_into_url("https://github.com/user/repo", "mytoken");
        assert_eq!(
            result,
            "https://x-access-token:mytoken@github.com/user/repo"
        );
    }

    #[test]
    fn inject_token_into_http_url() {
        let result = inject_token_into_url("http://gitea.local/user/repo", "tok123");
        assert_eq!(result, "http://x-access-token:tok123@gitea.local/user/repo");
    }

    #[test]
    fn inject_token_into_ssh_url_passthrough() {
        // SSH URLs are returned unchanged — token injection only applies to HTTPS.
        let url = "git@github.com:user/repo.git";
        let result = inject_token_into_url(url, "token");
        assert_eq!(result, url);
    }

    #[tokio::test]
    async fn read_git_identity_from_bot_toml_reads_fields() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(
            huskies_dir.join("bot.toml"),
            "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Test User\"\ngit_user_email = \"test@example.com\"\n",
        )
        .unwrap();

        let (name, email) = read_git_identity_from_bot_toml(dir.path()).await;
        assert_eq!(name, Some("Test User".to_string()));
        assert_eq!(email, Some("test@example.com".to_string()));
    }

    #[tokio::test]
    async fn read_git_identity_from_bot_toml_missing_file_returns_nones() {
        let dir = tempfile::tempdir().unwrap();
        let (name, email) = read_git_identity_from_bot_toml(dir.path()).await;
        assert_eq!(name, None);
        assert_eq!(email, None);
    }

    #[tokio::test]
    async fn resolve_git_identity_uses_bot_toml_values() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(
            huskies_dir.join("bot.toml"),
            "enabled = true\ntransport = \"matrix\"\ngit_user_name = \"Bot Name\"\ngit_user_email = \"bot@example.com\"\n",
        )
        .unwrap();

        let (name, email) = resolve_git_identity(dir.path()).await;
        assert_eq!(name, "Bot Name");
        assert_eq!(email, "bot@example.com");
    }

    #[tokio::test]
    async fn resolve_git_identity_falls_back_to_defaults_when_no_config() {
        let dir = tempfile::tempdir().unwrap();
        // No bot.toml and git config may or may not have values on CI — we only
        // check that the result is non-empty strings (not panics or errors).
        let (name, email) = resolve_git_identity(dir.path()).await;
        assert!(!name.is_empty(), "name must be non-empty");
        assert!(!email.is_empty(), "email must be non-empty");
    }

    /// A polyglot repo with more Python markers than Node markers should prefer python.
    #[test]
    fn detect_stack_multiple_dominant_wins() {
        let dir = tempfile::tempdir().unwrap();
        // Two Python markers: pyproject.toml + requirements.txt
        std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
        std::fs::write(dir.path().join("requirements.txt"), "").unwrap();
        // One Node marker: package.json (e.g. for a build tool)
        std::fs::write(dir.path().join("package.json"), "{}").unwrap();

        let stacks = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(stacks.path().join("node")).unwrap();
        std::fs::write(
            stacks.path().join("node/markers"),
            "package.json\ntsconfig.json\n",
        )
        .unwrap();
        std::fs::create_dir_all(stacks.path().join("python")).unwrap();
        std::fs::write(
            stacks.path().join("python/markers"),
            "pyproject.toml\nrequirements.txt\nsetup.py\n",
        )
        .unwrap();

        let (stack, warnings) = detect_stack(dir.path(), stacks.path());
        // python matches 2 markers, node matches 1 — python should win.
        assert_eq!(stack, Some("python".to_string()));
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("Multiple stacks"));
    }

    // ── Phase 6: --adopt flag parsing and validation ─────────────────────────

    #[test]
    fn extract_parses_adopt_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --adopt /projects/myapp",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.name, "myapp");
        assert_eq!(cmd.adopt_path, Some("/projects/myapp".to_string()));
        assert_eq!(cmd.host_path, None);
        assert_eq!(cmd.git_url, None);
    }

    #[test]
    fn extract_adopt_with_stack() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --adopt /srv/myapp --stack rust",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string()));
        assert_eq!(cmd.stack, Some("rust".to_string()));
    }

    #[test]
    fn extract_no_adopt_flag_returns_none_field() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --stack node",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.adopt_path, None);
    }

    #[tokio::test]
    async fn handle_new_project_adopt_and_path_are_mutually_exclusive() {
        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();
        let result = handle_new_project(
            "myapp",
            None,
            None,
            None,
            Some("/tmp/something"),
            Some("/existing/checkout"),
            false,
            &store,
            config_dir.path(),
        )
        .await;
        assert!(
            result.contains("mutually exclusive"),
            "expected mutual-exclusion error, got: {result}"
        );
    }

    #[tokio::test]
    async fn handle_new_project_adopt_and_git_are_mutually_exclusive() {
        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();
        let result = handle_new_project(
            "myapp",
            None,
            Some("https://github.com/user/repo"),
            None,
            None,
            Some("/existing/checkout"),
            false,
            &store,
            config_dir.path(),
        )
        .await;
        assert!(
            result.contains("mutually exclusive"),
            "expected mutual-exclusion error, got: {result}"
        );
    }

    #[tokio::test]
    async fn handle_new_project_adopt_missing_path_returns_error() {
        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();
        let result = handle_new_project(
            "myapp",
            None,
            None,
            None,
            None,
            Some("/nonexistent/path/that/does/not/exist"),
            false,
            &store,
            config_dir.path(),
        )
        .await;
        assert!(
            result.contains("does not exist"),
            "expected missing-path error, got: {result}"
        );
    }

    #[tokio::test]
    async fn handle_new_project_adopt_file_not_dir_returns_error() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("a_file.txt");
        std::fs::write(&file_path, "not a directory").unwrap();

        let store = Arc::new(RwLock::new(BTreeMap::new()));
        let config_dir = tempfile::tempdir().unwrap();
        let result = handle_new_project(
            "myapp",
            None,
            None,
            None,
            None,
            Some(file_path.to_str().unwrap()),
            false,
            &store,
            config_dir.path(),
        )
        .await;
        assert!(
            result.contains("not a directory"),
            "expected not-a-directory error, got: {result}"
        );
    }

    // ── Story 1137: first-run config summary and config override ─────────────

    // Minimal agents.toml matching the scaffold default (no comments to preserve).
    const TEST_AGENTS_TOML: &str = r#"[[agent]]
name = "coder-1"
stage = "coder"
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00

[[agent]]
name = "qa"
stage = "qa"
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00

[[agent]]
name = "mergemaster"
stage = "mergemaster"
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
"#;

    // Minimal project.toml with one active key and one commented key.
    const TEST_PROJECT_TOML: &str = r#"default_qa = "server"
max_retries = 2
# max_coders = 3
# default_coder_model = "sonnet"
"#;

    #[test]
    fn apply_agent_field_in_text_changes_coder_model() {
        let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "model", "opus")
            .expect("should succeed");
        // The coder agent block should now contain `model = "opus"`.
        assert!(
            result.contains("model = \"opus\""),
            "expected model = \"opus\" in result, got:\n{result}"
        );
        // Verify the qa agent block still contains the original model.
        // Split on [[agent]] boundaries and check the qa block independently.
        let qa_block = result
            .split("\n[[agent]]")
            .find(|block| block.contains("stage = \"qa\""));
        assert!(
            qa_block.is_some_and(|b| b.contains("model = \"sonnet\"")),
            "qa agent model should remain sonnet in result:\n{result}"
        );
    }

    #[test]
    fn apply_agent_field_in_text_changes_qa_max_turns() {
        let result = apply_agent_field_in_text(TEST_AGENTS_TOML, "qa", "max_turns", "25")
            .expect("should succeed");
        assert!(
            result.contains("max_turns = 25"),
            "expected max_turns = 25 in result, got:\n{result}"
        );
    }

    #[test]
    fn apply_agent_field_in_text_unknown_agent_returns_error() {
        let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "bogus", "model", "opus")
            .expect_err("should fail for unknown agent");
        assert!(
            err.contains("bogus"),
            "error should mention the unknown specifier, got: {err}"
        );
    }

    #[test]
    fn apply_agent_field_in_text_unknown_field_returns_error() {
        let err = apply_agent_field_in_text(TEST_AGENTS_TOML, "coder", "unknown_field", "value")
            .expect_err("should fail for unknown field");
        assert!(
            err.contains("unknown_field"),
            "error should mention the unknown field, got: {err}"
        );
    }

    #[test]
    fn apply_project_config_coder_model_writes_to_agents_toml() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap();

        let msg = apply_project_config(dir.path(), "coder.model", "opus").expect("should succeed");
        assert!(
            msg.contains("agents.toml"),
            "success message should mention agents.toml, got: {msg}"
        );

        // Verify the file was mutated correctly.
        let content = std::fs::read_to_string(huskies_dir.join("agents.toml")).unwrap();
        assert!(
            content.contains("model = \"opus\""),
            "agents.toml should contain model = \"opus\" after override, got:\n{content}"
        );
    }

    #[test]
    fn apply_project_config_default_qa_writes_to_project_toml() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap();

        let msg = apply_project_config(dir.path(), "default_qa", "human").expect("should succeed");
        assert!(
            msg.contains("project.toml"),
            "success message should mention project.toml, got: {msg}"
        );

        let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap();
        assert!(
            content.contains("default_qa = \"human\""),
            "project.toml should contain default_qa = \"human\" after override, got:\n{content}"
        );
    }

    #[test]
    fn apply_project_config_uncomments_commented_key() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(huskies_dir.join("project.toml"), TEST_PROJECT_TOML).unwrap();

        // max_coders is commented out in the template; setting it should uncomment it.
        apply_project_config(dir.path(), "max_coders", "4").expect("should succeed");

        let content = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap();
        assert!(
            content.contains("max_coders = 4"),
            "project.toml should contain uncommented max_coders = 4, got:\n{content}"
        );
        // Should not still appear as a comment.
        assert!(
            !content.contains("# max_coders = 4"),
            "commented form should have been replaced, got:\n{content}"
        );
    }

    #[test]
    fn format_config_summary_lists_default_agents() {
        let dir = tempfile::tempdir().unwrap();
        let huskies_dir = dir.path().join(".huskies");
        std::fs::create_dir_all(&huskies_dir).unwrap();
        std::fs::write(huskies_dir.join("agents.toml"), TEST_AGENTS_TOML).unwrap();

        let summary = format_config_summary(dir.path(), "myapp");
        assert!(
            summary.contains("3 agents"),
            "summary should show agent count, got: {summary}"
        );
        assert!(
            summary.contains("sonnet"),
            "summary should list default model, got: {summary}"
        );
        assert!(
            summary.contains("huskies config myapp"),
            "summary should include override command, got: {summary}"
        );
        assert!(
            summary.contains("--skip-config"),
            "summary should mention --skip-config, got: {summary}"
        );
    }

    #[test]
    fn extract_parses_skip_config_flag() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --adopt /srv/myapp --skip-config",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert_eq!(cmd.adopt_path, Some("/srv/myapp".to_string()));
        assert!(cmd.skip_config, "skip_config should be true");
    }

    #[test]
    fn extract_skip_config_false_by_default() {
        let cmd = extract_new_project_command(
            "@timmy new project myapp --adopt /srv/myapp",
            "Timmy",
            "@timmy:srv.local",
        )
        .unwrap();
        assert!(!cmd.skip_config, "skip_config should default to false");
    }
}
