use crate::slog;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;

#[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig {
    #[serde(default)]
    pub component: Vec<ComponentConfig>,
    #[serde(default)]
    pub agent: Vec<AgentConfig>,
    #[serde(default)]
    pub watcher: WatcherConfig,
    /// Project-wide default QA mode: "server", "agent", or "human".
    /// Per-story `qa` front matter overrides this. Default: "server".
    #[serde(default = "default_qa")]
    pub default_qa: String,
    /// Default model for coder-stage agents (e.g. "sonnet").
    /// When set, `find_free_agent_for_stage` only considers coder agents whose
    /// model matches this value, so opus agents are only used when explicitly
    /// requested via story front matter `agent:` field.
    #[serde(default)]
    pub default_coder_model: Option<String>,
    /// Maximum number of concurrent coder-stage agents.
    /// When set, `auto_assign_available_work` will not start more than this many
    /// coder agents at once. Stories wait in `2_current/` until a slot frees up.
    #[serde(default)]
    pub max_coders: Option<usize>,
    /// Maximum number of retries per story per pipeline stage before marking as blocked.
    /// Default: 2. Set to 0 to disable retry limits.
    #[serde(default = "default_max_retries")]
    pub max_retries: u32,
    /// Optional base branch name (e.g. "main", "master", "develop").
    /// When set, overrides the auto-detection logic (`detect_base_branch`) for all
    /// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution.
    /// When not set, the system falls back to `detect_base_branch` (reads current HEAD).
    #[serde(default)]
    pub base_branch: Option<String>,
}

/// Configuration for the filesystem watcher's sweep behaviour.
///
/// Controls how often the watcher checks `5_done/` for items to promote to
/// `6_archived/`, and how long items must remain in `5_done/` before promotion.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct WatcherConfig {
    /// How often (in seconds) to check `5_done/` for items to archive.
    /// Default: 60 seconds.
    #[serde(default = "default_sweep_interval_secs")]
    pub sweep_interval_secs: u64,
    /// How long (in seconds) an item must remain in `5_done/` before being
    /// moved to `6_archived/`. Default: 14400 (4 hours).
    #[serde(default = "default_done_retention_secs")]
    pub done_retention_secs: u64,
}

impl Default for WatcherConfig {
    fn default() -> Self {
        Self {
            sweep_interval_secs: default_sweep_interval_secs(),
            done_retention_secs: default_done_retention_secs(),
        }
    }
}

fn default_sweep_interval_secs() -> u64 {
    60
}

fn default_done_retention_secs() -> u64 {
    4 * 60 * 60 // 4 hours
}

fn default_qa() -> String {
    "server".to_string()
}

fn default_max_retries() -> u32 {
    2
}

#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct ComponentConfig {
    pub name: String,
    #[serde(default = "default_path")]
    pub path: String,
    #[serde(default)]
    pub setup: Vec<String>,
    #[serde(default)]
    pub teardown: Vec<String>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AgentConfig {
    #[serde(default = "default_agent_name")]
    pub name: String,
    #[serde(default)]
    pub role: String,
    #[serde(default = "default_agent_command")]
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default = "default_agent_prompt")]
    pub prompt: String,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub allowed_tools: Option<Vec<String>>,
    #[serde(default)]
    pub max_turns: Option<u32>,
    #[serde(default)]
    pub max_budget_usd: Option<f64>,
    #[serde(default)]
    pub system_prompt: Option<String>,
    /// Pipeline stage this agent belongs to. Supported values: "coder", "qa",
    /// "mergemaster", "other". When set, overrides the legacy name-based
    /// detection used by `pipeline_stage()`.
    #[serde(default)]
    pub stage: Option<String>,
    /// Inactivity timeout in seconds for the PTY read loop.
    /// If no output is received within this duration, the agent process is killed
    /// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable.
    #[serde(default = "default_inactivity_timeout_secs")]
    pub inactivity_timeout_secs: u64,
    /// Agent runtime backend. Controls how the agent process is spawned and
    /// how events are streamed. Default: `"claude-code"` (spawns the `claude`
    /// CLI in a PTY). Future values: `"openai"`, `"gemini"`.
    #[serde(default)]
    pub runtime: Option<String>,
}

fn default_path() -> String {
    ".".to_string()
}

fn default_agent_name() -> String {
    "default".to_string()
}

fn default_inactivity_timeout_secs() -> u64 {
    300
}

fn default_agent_command() -> String {
    "claude".to_string()
}

fn default_agent_prompt() -> String {
    "You are working in a git worktree on story {{story_id}}. \
     Read .storkit/README.md to understand the dev process, then pick up the story. \
     Commit all your work when done — the server will automatically run acceptance \
     gates (cargo clippy + tests) when your process exits."
        .to_string()
}

/// Legacy config format with `agent` as an optional single table (`[agent]`).
#[derive(Debug, Deserialize)]
struct LegacyProjectConfig {
    #[serde(default)]
    component: Vec<ComponentConfig>,
    agent: Option<AgentConfig>,
    #[serde(default)]
    watcher: WatcherConfig,
    #[serde(default = "default_qa")]
    default_qa: String,
    #[serde(default)]
    default_coder_model: Option<String>,
    #[serde(default)]
    max_coders: Option<usize>,
    #[serde(default = "default_max_retries")]
    max_retries: u32,
    #[serde(default)]
    base_branch: Option<String>,
}

impl Default for ProjectConfig {
    fn default() -> Self {
        Self {
            component: Vec::new(),
            agent: vec![AgentConfig {
                name: default_agent_name(),
                role: String::new(),
                command: default_agent_command(),
                args: vec![],
                prompt: default_agent_prompt(),
                model: None,
                allowed_tools: None,
                max_turns: None,
                max_budget_usd: None,
                system_prompt: None,
                stage: None,
                inactivity_timeout_secs: default_inactivity_timeout_secs(),
                runtime: None,
            }],
            watcher: WatcherConfig::default(),
            default_qa: default_qa(),
            default_coder_model: None,
            max_coders: None,
            max_retries: default_max_retries(),
            base_branch: None,
        }
    }
}

impl ProjectConfig {
    /// Load from `.storkit/project.toml` relative to the given root.
    /// Falls back to sensible defaults if the file doesn't exist.
    ///
    /// Supports both the new `[[agent]]` array format and the legacy
    /// `[agent]` single-table format (with a deprecation warning).
    pub fn load(project_root: &Path) -> Result<Self, String> {
        let config_path = project_root.join(".storkit/project.toml");
        if !config_path.exists() {
            return Ok(Self::default());
        }
        let content =
            std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
        Self::parse(&content)
    }

    /// Parse config from a TOML string, supporting both new and legacy formats.
    pub fn parse(content: &str) -> Result<Self, String> {
        // Try new format first (agent as array of tables)
        match toml::from_str::<ProjectConfig>(content) {
            Ok(config) if !config.agent.is_empty() => {
                validate_agents(&config.agent)?;
                Ok(config)
            }
            Ok(config) => {
                // Parsed successfully but no agents — could be legacy or no agent section.
                // Try legacy format.
                if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
                    && let Some(agent) = legacy.agent {
                        slog!(
                            "[config] Warning: [agent] table is deprecated. \
                             Use [[agent]] array format instead."
                        );
                        let config = ProjectConfig {
                            component: legacy.component,
                            agent: vec![agent],
                            watcher: legacy.watcher,
                            default_qa: legacy.default_qa,
                            default_coder_model: legacy.default_coder_model,
                            max_coders: legacy.max_coders,
                            max_retries: legacy.max_retries,
                            base_branch: legacy.base_branch,
                        };
                        validate_agents(&config.agent)?;
                        return Ok(config);
                    }
                // No agent section at all
                Ok(config)
            }
            Err(_) => {
                // New format failed — try legacy
                let legacy: LegacyProjectConfig =
                    toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?;
                if let Some(agent) = legacy.agent {
                    slog!(
                        "[config] Warning: [agent] table is deprecated. \
                         Use [[agent]] array format instead."
                    );
                    let config = ProjectConfig {
                        component: legacy.component,
                        agent: vec![agent],
                        watcher: legacy.watcher,
                        default_qa: legacy.default_qa,
                        default_coder_model: legacy.default_coder_model,
                        max_coders: legacy.max_coders,
                        max_retries: legacy.max_retries,
                        base_branch: legacy.base_branch,
                    };
                    validate_agents(&config.agent)?;
                    Ok(config)
                } else {
                    Ok(ProjectConfig {
                        component: legacy.component,
                        agent: Vec::new(),
                        watcher: legacy.watcher,
                        default_qa: legacy.default_qa,
                        default_coder_model: legacy.default_coder_model,
                        max_coders: legacy.max_coders,
                        max_retries: legacy.max_retries,
                        base_branch: legacy.base_branch,
                    })
                }
            }
        }
    }

    /// Return the project-wide default QA mode parsed from `default_qa`.
    /// Falls back to `Server` if the value is unrecognised.
    pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode {
        crate::io::story_metadata::QaMode::from_str(&self.default_qa)
            .unwrap_or(crate::io::story_metadata::QaMode::Server)
    }

    /// Look up an agent config by name.
    pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
        self.agent.iter().find(|a| a.name == name)
    }

    /// Get the default (first) agent config.
    pub fn default_agent(&self) -> Option<&AgentConfig> {
        self.agent.first()
    }

    /// Render template variables in agent args and prompt for the given agent.
    /// If `agent_name` is None, uses the first (default) agent.
    pub fn render_agent_args(
        &self,
        worktree_path: &str,
        story_id: &str,
        agent_name: Option<&str>,
        base_branch: Option<&str>,
    ) -> Result<(String, Vec<String>, String), String> {
        let agent = match agent_name {
            Some(name) => self
                .find_agent(name)
                .ok_or_else(|| format!("No agent named '{name}' in config"))?,
            None => self
                .default_agent()
                .ok_or_else(|| "No agents configured".to_string())?,
        };

        let bb = base_branch
            .or(self.base_branch.as_deref())
            .unwrap_or("master");
        let aname = agent.name.as_str();
        let render = |s: &str| {
            s.replace("{{worktree_path}}", worktree_path)
                .replace("{{story_id}}", story_id)
                .replace("{{base_branch}}", bb)
                .replace("{{agent_name}}", aname)
        };

        let command = render(&agent.command);
        let mut args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
        let prompt = render(&agent.prompt);

        // Append structured CLI flags
        if let Some(ref model) = agent.model {
            args.push("--model".to_string());
            args.push(model.clone());
        }
        if let Some(ref tools) = agent.allowed_tools
            && !tools.is_empty() {
                args.push("--allowedTools".to_string());
                args.push(tools.join(","));
            }
        if let Some(turns) = agent.max_turns {
            args.push("--max-turns".to_string());
            args.push(turns.to_string());
        }
        if let Some(budget) = agent.max_budget_usd {
            args.push("--max-budget-usd".to_string());
            args.push(budget.to_string());
        }
        if let Some(ref sp) = agent.system_prompt {
            args.push("--append-system-prompt".to_string());
            args.push(render(sp));
        }

        Ok((command, args, prompt))
    }
}

/// Validate agent configs: no duplicate names, no empty names, positive budgets/turns.
fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
    let mut names = HashSet::new();
    for agent in agents {
        if agent.name.trim().is_empty() {
            return Err("Agent name must not be empty".to_string());
        }
        if !names.insert(&agent.name) {
            return Err(format!("Duplicate agent name: '{}'", agent.name));
        }
        if let Some(budget) = agent.max_budget_usd
            && budget <= 0.0 {
                return Err(format!(
                    "Agent '{}': max_budget_usd must be positive, got {budget}",
                    agent.name
                ));
            }
        if let Some(turns) = agent.max_turns
            && turns == 0 {
                return Err(format!(
                    "Agent '{}': max_turns must be positive, got 0",
                    agent.name
                ));
            }
        if let Some(ref runtime) = agent.runtime {
            match runtime.as_str() {
                "claude-code" | "gemini" => {}
                other => {
                    return Err(format!(
                        "Agent '{}': unknown runtime '{other}'. Supported: 'claude-code', 'gemini'",
                        agent.name
                    ));
                }
            }
        }
    }
    Ok(())
}

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

    #[test]
    fn default_config_when_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let config = ProjectConfig::load(tmp.path()).unwrap();
        assert_eq!(config.agent.len(), 1);
        assert_eq!(config.agent[0].name, "default");
        assert!(config.component.is_empty());
    }

    #[test]
    fn parse_multi_agent_toml() {
        let toml_str = r#"
[[component]]
name = "server"
path = "."
setup = ["cargo check"]

[[agent]]
name = "supervisor"
role = "Coordinates work"
model = "opus"
max_turns = 50
max_budget_usd = 10.00
system_prompt = "You are a senior engineer"

[[agent]]
name = "coder-1"
role = "Full-stack engineer"
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
"#;

        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.agent.len(), 2);
        assert_eq!(config.agent[0].name, "supervisor");
        assert_eq!(config.agent[0].role, "Coordinates work");
        assert_eq!(config.agent[0].model, Some("opus".to_string()));
        assert_eq!(config.agent[0].max_turns, Some(50));
        assert_eq!(config.agent[0].max_budget_usd, Some(10.0));
        assert_eq!(
            config.agent[0].system_prompt,
            Some("You are a senior engineer".to_string())
        );
        assert_eq!(config.agent[1].name, "coder-1");
        assert_eq!(config.agent[1].model, Some("sonnet".to_string()));
        assert_eq!(config.component.len(), 1);
    }

    #[test]
    fn parse_legacy_single_agent() {
        let toml_str = r#"
[[component]]
name = "server"
path = "."

[agent]
command = "claude"
args = ["--print", "--directory", "{{worktree_path}}"]
prompt = "Pick up story {{story_id}}"
"#;

        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.agent.len(), 1);
        assert_eq!(config.agent[0].name, "default");
        assert_eq!(config.agent[0].command, "claude");
    }

    #[test]
    fn validate_duplicate_names() {
        let toml_str = r#"
[[agent]]
name = "coder"
role = "Engineer"

[[agent]]
name = "coder"
role = "Another engineer"
"#;

        let err = ProjectConfig::parse(toml_str).unwrap_err();
        assert!(err.contains("Duplicate agent name: 'coder'"));
    }

    #[test]
    fn validate_empty_name() {
        let toml_str = r#"
[[agent]]
name = ""
role = "Engineer"
"#;

        let err = ProjectConfig::parse(toml_str).unwrap_err();
        assert!(err.contains("Agent name must not be empty"));
    }

    #[test]
    fn validate_non_positive_budget() {
        let toml_str = r#"
[[agent]]
name = "coder"
max_budget_usd = -1.0
"#;

        let err = ProjectConfig::parse(toml_str).unwrap_err();
        assert!(err.contains("must be positive"));
    }

    #[test]
    fn validate_zero_max_turns() {
        let toml_str = r#"
[[agent]]
name = "coder"
max_turns = 0
"#;

        let err = ProjectConfig::parse(toml_str).unwrap_err();
        assert!(err.contains("max_turns must be positive"));
    }

    #[test]
    fn render_agent_args_default() {
        let config = ProjectConfig::default();
        let (cmd, args, prompt) = config
            .render_agent_args("/tmp/wt", "42_foo", None, None)
            .unwrap();
        assert_eq!(cmd, "claude");
        assert!(args.is_empty());
        assert!(prompt.contains("42_foo"));
    }

    #[test]
    fn render_agent_args_by_name() {
        let toml_str = r#"
[[agent]]
name = "supervisor"
model = "opus"
max_turns = 50
max_budget_usd = 10.00
system_prompt = "You lead story {{story_id}}"
allowed_tools = ["Read", "Write", "Bash"]

[[agent]]
name = "coder"
model = "sonnet"
max_turns = 30
"#;

        let config = ProjectConfig::parse(toml_str).unwrap();
        let (cmd, args, prompt) = config
            .render_agent_args("/tmp/wt", "42_foo", Some("supervisor"), Some("master"))
            .unwrap();
        assert_eq!(cmd, "claude");
        assert!(args.contains(&"--model".to_string()));
        assert!(args.contains(&"opus".to_string()));
        assert!(args.contains(&"--max-turns".to_string()));
        assert!(args.contains(&"50".to_string()));
        assert!(args.contains(&"--max-budget-usd".to_string()));
        assert!(args.contains(&"10".to_string()));
        assert!(args.contains(&"--allowedTools".to_string()));
        assert!(args.contains(&"Read,Write,Bash".to_string()));
        assert!(args.contains(&"--append-system-prompt".to_string()));
        // System prompt should have template rendered
        assert!(args.contains(&"You lead story 42_foo".to_string()));
        assert!(prompt.contains("42_foo"));

        // Render for coder
        let (_, coder_args, _) = config
            .render_agent_args("/tmp/wt", "42_foo", Some("coder"), Some("master"))
            .unwrap();
        assert!(coder_args.contains(&"sonnet".to_string()));
        assert!(coder_args.contains(&"30".to_string()));
        assert!(!coder_args.contains(&"--max-budget-usd".to_string()));
        assert!(!coder_args.contains(&"--append-system-prompt".to_string()));
    }

    #[test]
    fn render_agent_args_not_found() {
        let config = ProjectConfig::default();
        let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"), None);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
    }

    #[test]
    fn find_agent_and_default() {
        let toml_str = r#"
[[agent]]
name = "first"

[[agent]]
name = "second"
"#;

        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.default_agent().unwrap().name, "first");
        assert_eq!(config.find_agent("second").unwrap().name, "second");
        assert!(config.find_agent("missing").is_none());
    }

    #[test]
    fn parse_project_toml_from_file() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".storkit");
        fs::create_dir_all(&sk).unwrap();
        fs::write(
            sk.join("project.toml"),
            r#"
[[component]]
name = "server"
path = "."
setup = ["cargo check"]
teardown = []

[[component]]
name = "frontend"
path = "frontend"
setup = ["pnpm install"]

[[agent]]
name = "main"
command = "claude"
args = ["--print", "--directory", "{{worktree_path}}"]
prompt = "Pick up story {{story_id}}"
model = "sonnet"
"#,
        )
        .unwrap();

        let config = ProjectConfig::load(tmp.path()).unwrap();
        assert_eq!(config.component.len(), 2);
        assert_eq!(config.component[0].name, "server");
        assert_eq!(config.component[1].setup, vec!["pnpm install"]);
        assert_eq!(config.agent.len(), 1);
        assert_eq!(config.agent[0].name, "main");
        assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
    }

    // ── WatcherConfig ──────────────────────────────────────────────────────

    #[test]
    fn watcher_config_defaults_when_omitted() {
        let toml_str = r#"
[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.watcher.sweep_interval_secs, 60);
        assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
    }

    #[test]
    fn watcher_config_custom_values() {
        let toml_str = r#"
[watcher]
sweep_interval_secs = 30
done_retention_secs = 7200

[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.watcher.sweep_interval_secs, 30);
        assert_eq!(config.watcher.done_retention_secs, 7200);
    }

    #[test]
    fn watcher_config_partial_override() {
        let toml_str = r#"
[watcher]
sweep_interval_secs = 10

[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.watcher.sweep_interval_secs, 10);
        // done_retention_secs should fall back to the default (4 hours).
        assert_eq!(config.watcher.done_retention_secs, 4 * 60 * 60);
    }

    #[test]
    fn watcher_config_from_file() {
        let tmp = tempfile::tempdir().unwrap();
        let sk = tmp.path().join(".storkit");
        fs::create_dir_all(&sk).unwrap();
        fs::write(
            sk.join("project.toml"),
            r#"
[watcher]
sweep_interval_secs = 120
done_retention_secs = 3600

[[agent]]
name = "coder"
"#,
        )
        .unwrap();

        let config = ProjectConfig::load(tmp.path()).unwrap();
        assert_eq!(config.watcher.sweep_interval_secs, 120);
        assert_eq!(config.watcher.done_retention_secs, 3600);
    }

    #[test]
    fn watcher_config_default_when_no_file() {
        let tmp = tempfile::tempdir().unwrap();
        let config = ProjectConfig::load(tmp.path()).unwrap();
        assert_eq!(config.watcher, WatcherConfig::default());
    }

    #[test]
    fn coder_agents_have_root_cause_guidance() {
        // Load the actual project.toml and verify all coder-stage agents
        // include root cause investigation guidance for bugs.
        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let project_root = manifest_dir.parent().unwrap();
        let config = ProjectConfig::load(project_root).unwrap();

        let coder_agents: Vec<_> = config
            .agent
            .iter()
            .filter(|a| a.stage.as_deref() == Some("coder"))
            .collect();

        assert!(
            !coder_agents.is_empty(),
            "Expected at least one coder-stage agent in project.toml"
        );

        for agent in coder_agents {
            let prompt = &agent.prompt;
            let system_prompt = agent.system_prompt.as_deref().unwrap_or("");
            let combined = format!("{prompt} {system_prompt}");

            assert!(
                combined.contains("root cause"),
                "Coder agent '{}' must mention 'root cause' in prompt or system_prompt",
                agent.name
            );
            assert!(
                combined.contains("git bisect") || combined.contains("git log"),
                "Coder agent '{}' must mention 'git bisect' or 'git log' for bug investigation",
                agent.name
            );
            assert!(
                combined.to_lowercase().contains("do not") || combined.contains("surgical"),
                "Coder agent '{}' must discourage adding abstractions/workarounds",
                agent.name
            );
        }
    }

    #[test]
    fn watcher_config_preserved_in_legacy_format() {
        let toml_str = r#"
[watcher]
sweep_interval_secs = 15
done_retention_secs = 900

[agent]
command = "claude"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.watcher.sweep_interval_secs, 15);
        assert_eq!(config.watcher.done_retention_secs, 900);
        assert_eq!(config.agent.len(), 1);
    }

    // ── default_coder_model & max_coders ─────────────────────────────────

    #[test]
    fn parse_default_coder_model_and_max_coders() {
        let toml_str = r#"
default_coder_model = "sonnet"
max_coders = 3

[[agent]]
name = "coder-1"
stage = "coder"
model = "sonnet"

[[agent]]
name = "coder-opus"
stage = "coder"
model = "opus"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
        assert_eq!(config.max_coders, Some(3));
    }

    #[test]
    fn default_coder_model_and_max_coders_default_to_none() {
        let toml_str = r#"
[[agent]]
name = "coder-1"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.default_coder_model, None);
        assert_eq!(config.max_coders, None);
    }

    #[test]
    fn project_toml_has_default_coder_model_and_max_coders() {
        // Verify the actual project.toml has the new settings.
        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let project_root = manifest_dir.parent().unwrap();
        let config = ProjectConfig::load(project_root).unwrap();
        assert_eq!(config.default_coder_model, Some("sonnet".to_string()));
        assert_eq!(config.max_coders, Some(3));
    }

    // ── runtime config ────────────────────────────────────────────────

    #[test]
    fn runtime_defaults_to_none() {
        let toml_str = r#"
[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.agent[0].runtime, None);
    }

    #[test]
    fn runtime_claude_code_accepted() {
        let toml_str = r#"
[[agent]]
name = "coder"
runtime = "claude-code"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(
            config.agent[0].runtime,
            Some("claude-code".to_string())
        );
    }

    #[test]
    fn runtime_gemini_accepted() {
        let toml_str = r#"
[[agent]]
name = "coder"
runtime = "gemini"
model = "gemini-2.5-pro"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.agent[0].runtime, Some("gemini".to_string()));
    }

    #[test]
    fn runtime_unknown_rejected() {
        let toml_str = r#"
[[agent]]
name = "coder"
runtime = "openai"
"#;
        let err = ProjectConfig::parse(toml_str).unwrap_err();
        assert!(err.contains("unknown runtime 'openai'"));
    }

    // ── base_branch config ──────────────────────────────────────────────────

    #[test]
    fn base_branch_defaults_to_none() {
        let toml_str = r#"
[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.base_branch, None);
    }

    #[test]
    fn base_branch_parsed_when_set() {
        let toml_str = r#"
base_branch = "main"

[[agent]]
name = "coder"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        assert_eq!(config.base_branch, Some("main".to_string()));
    }

    #[test]
    fn render_agent_args_uses_config_base_branch_when_caller_passes_none() {
        let toml_str = r#"
base_branch = "develop"

[[agent]]
name = "coder"
prompt = "git difftool {{base_branch}}...HEAD"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        let (_, _, prompt) = config
            .render_agent_args("/tmp/wt", "42_foo", None, None)
            .unwrap();
        assert!(
            prompt.contains("develop"),
            "Expected 'develop' in prompt, got: {prompt}"
        );
    }

    #[test]
    fn render_agent_args_caller_base_branch_takes_precedence_over_config() {
        let toml_str = r#"
base_branch = "develop"

[[agent]]
name = "coder"
prompt = "git difftool {{base_branch}}...HEAD"
"#;
        let config = ProjectConfig::parse(toml_str).unwrap();
        let (_, _, prompt) = config
            .render_agent_args("/tmp/wt", "42_foo", None, Some("feature-x"))
            .unwrap();
        assert!(
            prompt.contains("feature-x"),
            "Caller-supplied base_branch should win, got: {prompt}"
        );
    }

    #[test]
    fn project_toml_has_base_branch_master() {
        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let project_root = manifest_dir.parent().unwrap();
        let config = ProjectConfig::load(project_root).unwrap();
        assert_eq!(
            config.base_branch,
            Some("master".to_string()),
            "project.toml must have base_branch = \"master\""
        );
    }

    #[test]
    fn project_toml_has_three_sonnet_coders() {
        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let project_root = manifest_dir.parent().unwrap();
        let config = ProjectConfig::load(project_root).unwrap();

        let sonnet_coders: Vec<_> = config
            .agent
            .iter()
            .filter(|a| a.stage.as_deref() == Some("coder") && a.model.as_deref() == Some("sonnet"))
            .collect();

        assert_eq!(
            sonnet_coders.len(),
            3,
            "Expected 3 sonnet coders (coder-1, coder-2, coder-3), found {}",
            sonnet_coders.len()
        );
    }
}
