//! Tests for project configuration parsing and validation.
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(crate::agents::AgentModel::Opus));
    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(crate::agents::AgentModel::Sonnet)
    );
    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(
            std::path::Path::new("/tmp/wt"),
            "/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(
            std::path::Path::new("/tmp/wt"),
            "/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(
            std::path::Path::new("/tmp/wt"),
            "/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(
        std::path::Path::new("/tmp/wt"),
        "/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(".huskies");
    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(crate::agents::AgentModel::Sonnet)
    );
}

#[test]
fn agents_toml_overrides_project_toml_agents() {
    let tmp = tempfile::tempdir().unwrap();
    let sk = tmp.path().join(".huskies");
    fs::create_dir_all(&sk).unwrap();
    // project.toml has inline agents
    fs::write(
        sk.join("project.toml"),
        r#"
[[agent]]
name = "from-project-toml"
model = "sonnet"
"#,
    )
    .unwrap();
    // agents.toml overrides with different agents
    fs::write(
        sk.join("agents.toml"),
        r#"
[[agent]]
name = "from-agents-toml"
model = "opus"
"#,
    )
    .unwrap();

    let config = ProjectConfig::load(tmp.path()).unwrap();
    assert_eq!(config.agent.len(), 1);
    assert_eq!(config.agent[0].name, "from-agents-toml");
    assert_eq!(config.agent[0].model, Some(crate::agents::AgentModel::Opus));
}

#[test]
fn agents_toml_absent_falls_back_to_project_toml() {
    let tmp = tempfile::tempdir().unwrap();
    let sk = tmp.path().join(".huskies");
    fs::create_dir_all(&sk).unwrap();
    fs::write(
        sk.join("project.toml"),
        r#"
[[agent]]
name = "inline-agent"
model = "sonnet"
"#,
    )
    .unwrap();
    // No agents.toml — should use inline agents from project.toml

    let config = ProjectConfig::load(tmp.path()).unwrap();
    assert_eq!(config.agent.len(), 1);
    assert_eq!(config.agent[0].name, "inline-agent");
}

// ── 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(".huskies");
    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("Bug Workflow") || combined.contains("trust the story"),
            "Coder agent '{}' must include bug workflow guidance in prompt or system_prompt",
            agent.name
        );
        assert!(
            combined.contains("surgical") || combined.to_lowercase().contains("minimal"),
            "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(crate::agents::AgentModel::Sonnet)
    );
    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(crate::agents::AgentModel::Sonnet)
    );
    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'"));
}

#[test]
fn unrecognized_key_in_component_table_errors_instead_of_silently_dropping() {
    // Regression for story 1177: a top-level setting that ends up attached to
    // a `[[component]]` table (e.g. because it was placed below the last
    // `[[component]]` header) must be a hard error, not a silently swallowed
    // key.
    let toml_str = r#"
[[component]]
name = "server"
path = "."
base_branch = "main"
"#;

    let err = ProjectConfig::parse(toml_str).unwrap_err();
    assert!(
        err.contains("base_branch"),
        "expected error to mention the unrecognized key, got: {err}"
    );
    assert!(
        err.contains("before the first `[[component]]`"),
        "expected error to include the misplaced-top-level-key hint, got: {err}"
    );
}

#[test]
fn unrecognized_key_error_includes_toml_line_and_column() {
    // Story 1183: the hinted error must still carry the original toml
    // parse error, including its line/column location.
    let toml_str = r#"
[[component]]
name = "server"
path = "."
base_branch = "main"
"#;

    let err = ProjectConfig::parse(toml_str).unwrap_err();
    assert!(
        err.contains("line") && err.contains("column"),
        "expected error to retain toml's line/column info, got: {err}"
    );
}

#[test]
fn typo_field_in_component_table_gets_same_hint() {
    // Story 1183 AC3: a genuine typo'd field (not a misplaced top-level
    // setting) inside [[component]] still errors, and reusing the same
    // hint text is acceptable since the error can't tell the two apart.
    let toml_str = r#"
[[component]]
nam = "server"
path = "."
"#;

    let err = ProjectConfig::parse(toml_str).unwrap_err();
    assert!(
        err.contains("before the first `[[component]]`"),
        "expected typo'd field error to include the same hint, got: {err}"
    );
}

#[test]
fn valid_config_with_component_still_parses_unchanged() {
    // Story 1183 AC3: valid configs must keep parsing unchanged.
    let toml_str = r#"
base_branch = "main"

[[component]]
name = "server"
path = "."
setup = ["cargo check"]

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

    let config = ProjectConfig::parse(toml_str).unwrap();
    assert_eq!(config.base_branch, Some("main".to_string()));
    assert_eq!(config.component.len(), 1);
    assert_eq!(config.component[0].name, "server");
}

#[test]
fn agent_config_does_not_use_strict_parsing_yet() {
    // Story 1183 AC4: AgentConfig has no `deny_unknown_fields`, so a
    // top-level setting placed after the last [[agent]] section is
    // silently absorbed rather than erroring. Per AC4's own conditional
    // ("if agents use strict parsing"), no hint is required for this case
    // today — this test documents the current (non-strict) behavior.
    let toml_str = r#"
[[agent]]
name = "coder"

max_retries = 5
"#;

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

// ── 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(
            std::path::Path::new("/tmp/wt"),
            "/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(
            std::path::Path::new("/tmp/wt"),
            "/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 rate_limit_notifications_defaults_to_true() {
    let toml_str = r#"
[[agent]]
name = "coder"
"#;
    let config = ProjectConfig::parse(toml_str).unwrap();
    assert!(
        config.rate_limit_notifications,
        "rate_limit_notifications should default to true"
    );
}

#[test]
fn rate_limit_notifications_can_be_disabled() {
    let toml_str = r#"
rate_limit_notifications = false

[[agent]]
name = "coder"
"#;
    let config = ProjectConfig::parse(toml_str).unwrap();
    assert!(!config.rate_limit_notifications);
}

#[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_ref() == Some(&crate::agents::AgentModel::Sonnet)
        })
        .collect();

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

#[test]
fn render_agent_args_disallowed_tools() {
    let toml_str = r#"
[[agent]]
name = "coder"
model = "sonnet"
disallowed_tools = ["ScheduleWakeup", "SomeTool"]
"#;

    let config = ProjectConfig::parse(toml_str).unwrap();
    let (_, args, _) = config
        .render_agent_args(
            std::path::Path::new("/tmp/wt"),
            "/tmp/wt",
            "42_foo",
            None,
            None,
        )
        .unwrap();
    assert!(
        args.contains(&"--disallowedTools".to_string()),
        "Expected --disallowedTools flag in args"
    );
    assert!(
        args.contains(&"ScheduleWakeup,SomeTool".to_string()),
        "Expected disallowed tools joined as comma-separated string"
    );
}

#[test]
fn coder_agents_disallow_schedule_wakeup() {
    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"
    );

    for agent in coder_agents {
        let disallowed = agent.disallowed_tools.as_deref().unwrap_or(&[]);
        assert!(
            disallowed.iter().any(|t| t == "ScheduleWakeup"),
            "Coder agent '{}' must have ScheduleWakeup in disallowed_tools",
            agent.name
        );
    }
}
