//! Project configuration — parses `project.toml` for agents, components, and server settings.

/// Typed agent name (mirrors the agents.toml roster).
pub mod agent_name;
pub use agent_name::AgentName;

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

/// Top-level project configuration loaded from `.huskies/project.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig {
    #[serde(default)]
    pub component: Vec<ComponentConfig>,
    #[serde(default)]
    pub agent: Vec<AgentConfig>,
    #[serde(default)]
    pub watcher: WatcherConfig,
    /// Configuration for the low-disk-space watchdog (story 1200).
    #[serde(default)]
    pub disk_watch: DiskWatchConfig,
    /// 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<AgentModel>,
    /// 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>,
    /// Whether to send `RateLimitWarning` chat notifications.
    /// Set to `false` to suppress noisy soft rate-limit warnings while still
    /// receiving `RateLimitHardBlock` and `StoryBlocked` notifications.
    /// Default: `true`.
    #[serde(default = "default_rate_limit_notifications")]
    pub rate_limit_notifications: bool,
    /// Whether the web UI WebSocket consumer subscribes to the status broadcaster.
    /// Set to `false` to disable status event forwarding to the web UI without
    /// affecting other consumers (chat transports, agent context).
    /// Default: `true`.
    #[serde(default = "default_web_ui_status_consumer")]
    pub web_ui_status_consumer: bool,
    /// Whether the Matrix bot subscribes to the status broadcaster and forwards
    /// pipeline events to its configured rooms.
    /// Set to `false` to silence Matrix status notifications without affecting
    /// other consumers (web UI, Slack, Discord, WhatsApp, agent context).
    /// Default: `true`.
    #[serde(default = "default_matrix_status_consumer")]
    pub matrix_status_consumer: bool,
    /// Whether the Slack bot subscribes to the status broadcaster and forwards
    /// pipeline events to its configured channels.
    /// Set to `false` to silence Slack status notifications without affecting
    /// other consumers (web UI, Matrix, Discord, WhatsApp, agent context).
    /// Default: `true`.
    #[serde(default = "default_slack_status_consumer")]
    pub slack_status_consumer: bool,
    /// Whether the Discord bot subscribes to the status broadcaster and forwards
    /// pipeline events to its configured channels.
    /// Set to `false` to silence Discord status notifications without affecting
    /// other consumers (web UI, Matrix, Slack, WhatsApp, agent context).
    /// Default: `true`.
    #[serde(default = "default_discord_status_consumer")]
    pub discord_status_consumer: bool,
    /// Whether the WhatsApp bot subscribes to the status broadcaster and forwards
    /// pipeline events to all active senders.
    /// Set to `false` to silence WhatsApp status notifications without affecting
    /// other consumers (web UI, Matrix, Slack, Discord, agent context).
    /// Default: `true`.
    #[serde(default = "default_whatsapp_status_consumer")]
    pub whatsapp_status_consumer: bool,
    /// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
    /// When set, timer HH:MM inputs are interpreted in this timezone instead
    /// of the container/host local time.  Falls back to `chrono::Local` when absent.
    #[serde(default)]
    pub timezone: Option<String>,
    /// WebSocket URL of a remote huskies node to sync CRDT state with.
    /// Example: `rendezvous = "ws://server:3001/crdt-sync"`
    /// When set, this node connects to the remote and exchanges CRDT ops
    /// so both machines see the same pipeline state in real-time.
    #[serde(default)]
    pub rendezvous: Option<String>,
    /// Hex-encoded Ed25519 public keys of trusted peers for WebSocket mutual auth.
    /// When present, only peers whose pubkey is in this list are allowed to connect.
    /// When empty or missing, all peers are rejected (closed-by-default).
    #[serde(default)]
    pub trusted_keys: Vec<String>,
    /// When `true`, `/crdt-sync` WebSocket connections must supply a valid
    /// `?token=<bearer-token>` query parameter or receive HTTP 401.
    /// Defaults to `false` so trusted-network deployments keep the current
    /// open behaviour.
    #[serde(default)]
    pub crdt_require_token: bool,
    /// Static bearer tokens accepted for `/crdt-sync` connections.
    /// Each entry is a raw token string; tokens expire 30 days after the
    /// server starts.  Only meaningful when `crdt_require_token` is `true`.
    #[serde(default)]
    pub crdt_tokens: Vec<String>,
    /// Maximum number of supplementary mesh peer connections an agent opens.
    /// The mesh discovery loop reads the CRDT `nodes` list and connects to up
    /// to this many alive peers in addition to the primary rendezvous connection.
    /// Default: 3. Set to 0 to disable mesh discovery entirely.
    #[serde(default = "default_max_mesh_peers")]
    pub max_mesh_peers: usize,
    /// Base URL of the gateway this project should push status events to.
    ///
    /// When set, a relay task is started that connects to the gateway's
    /// `/gateway/events/push` WebSocket and forwards every [`StatusEvent`] from
    /// the local broadcaster.  Example: `gateway_url = "http://gateway:3000"`.
    /// Disabled when absent.  Also readable from the `HUSKIES_GATEWAY_URL`
    /// environment variable.
    #[serde(default)]
    pub gateway_url: Option<String>,
    /// Project name this instance identifies as when pushing events to the
    /// gateway.  Defaults to the project root directory name when not set.
    /// Example: `gateway_project = "huskies"`.
    #[serde(default)]
    pub gateway_project: Option<String>,
    /// Whether the notification listener pushes status messages to chat rooms.
    ///
    /// Set to `false` for quiet mode: all WatcherEvent-driven chat pushes
    /// (stage transitions, agent started/completed, blocked, merge failures)
    /// are suppressed.  Other bot functionality (commands, responses) is
    /// unaffected.  Default: `true`.
    #[serde(default = "default_status_push_enabled")]
    pub status_push_enabled: bool,
    /// Number of consecutive `MergeFailure` transitions before a story is
    /// automatically blocked.  The auto-block subscriber resets the counter
    /// whenever the story leaves `MergeFailure` (e.g. after a successful
    /// merge or `FixupRequested`).  Default: 3.  Set to 0 to disable.
    #[serde(default = "default_merge_failure_block_threshold")]
    pub merge_failure_block_threshold: u32,
    /// Free space (in GB) below which an automatic build-directory GC pass
    /// (story 1199) is triggered to reclaim orphaned worktree `target/` dirs
    /// and a stale `.huskies/merge_workspace`. Checked on the same tick as the
    /// low-disk watchdog (story 1200), not a separate timer. Default: 0
    /// (disabled) — the on-demand `gc` MCP tool remains available regardless.
    #[serde(default)]
    pub gc_min_free_gb: u64,
}

/// 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,
    /// How often (in seconds) the periodic reconciler runs to converge
    /// subscriber side effects.  The reconciler calls each subscriber's
    /// `reconcile()` entry point so that Lagged events never leave persistent
    /// state diverged. Default: 30 seconds.
    #[serde(default = "default_reconcile_interval_secs")]
    pub reconcile_interval_secs: u64,
    /// How often (in seconds) the liveness tick task emits a heartbeat log line.
    /// Reduce if 5-second granularity causes noise; increase to quiet the logs.
    /// Default: 5 seconds.
    #[serde(default = "default_liveness_tick_secs")]
    pub liveness_tick_secs: u64,
}

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

fn default_sweep_interval_secs() -> u64 {
    60
}

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

fn default_reconcile_interval_secs() -> u64 {
    30
}

fn default_liveness_tick_secs() -> u64 {
    5
}

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

fn default_max_retries() -> u32 {
    2
}

fn default_rate_limit_notifications() -> bool {
    true
}

fn default_web_ui_status_consumer() -> bool {
    true
}

fn default_matrix_status_consumer() -> bool {
    true
}

fn default_slack_status_consumer() -> bool {
    true
}

fn default_discord_status_consumer() -> bool {
    true
}

fn default_whatsapp_status_consumer() -> bool {
    true
}

fn default_status_push_enabled() -> bool {
    true
}

fn default_merge_failure_block_threshold() -> u32 {
    3
}

fn default_max_mesh_peers() -> usize {
    3
}

/// Configuration for the low-disk-space watchdog's free-space thresholds.
///
/// Sleds check free space on the `/workspace` filesystem each tick and
/// compare it against `warn_gb` / `critical_gb`, rate-limiting repeat
/// notifications per level to `rate_limit_secs` and sending a single
/// recovery notice once free space climbs back above
/// `warn_gb * (1 + recovery_margin_pct / 100)`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct DiskWatchConfig {
    /// Free space (in GB) below which a "warn" notification fires. Default: 50.
    #[serde(default = "default_disk_warn_gb")]
    pub warn_gb: u64,
    /// Free space (in GB) below which a "critical" notification fires. Default: 20.
    #[serde(default = "default_disk_critical_gb")]
    pub critical_gb: u64,
    /// Minimum time (in seconds) between repeat notifications at the same
    /// level while the condition persists. Default: 21600 (6 hours).
    #[serde(default = "default_disk_rate_limit_secs")]
    pub rate_limit_secs: u64,
    /// Percentage above `warn_gb` free space must climb before a single
    /// recovery notice is sent. Default: 10.
    #[serde(default = "default_disk_recovery_margin_pct")]
    pub recovery_margin_pct: u64,
}

impl Default for DiskWatchConfig {
    fn default() -> Self {
        Self {
            warn_gb: default_disk_warn_gb(),
            critical_gb: default_disk_critical_gb(),
            rate_limit_secs: default_disk_rate_limit_secs(),
            recovery_margin_pct: default_disk_recovery_margin_pct(),
        }
    }
}

fn default_disk_warn_gb() -> u64 {
    50
}

fn default_disk_critical_gb() -> u64 {
    20
}

fn default_disk_rate_limit_secs() -> u64 {
    6 * 60 * 60
}

fn default_disk_recovery_margin_pct() -> u64 {
    10
}

/// Configuration for a project component (name, path, setup/teardown commands).
///
/// `deny_unknown_fields` turns a top-level setting that lands inside a
/// `[[component]]` table (e.g. because it was placed below the last
/// `[[component]]` header) into a hard parse error instead of a silently
/// dropped key.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
#[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>,
}

/// Configuration for a single agent definition from `[[agent]]` in `project.toml`.
#[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<AgentModel>,
    #[serde(default)]
    pub allowed_tools: Option<Vec<String>>,
    #[serde(default)]
    pub disallowed_tools: Option<Vec<String>>,
    #[serde(default)]
    pub max_turns: Option<u32>,
    /// Watchdog cap on **tool-using** assistant turns (story 923).
    ///
    /// Narration-only turns (assistant messages with no `tool_use` block)
    /// don't count against this. When unset, the watchdog falls back to
    /// `max_turns`. Set this lower than `max_turns` so claude-code's own
    /// `--max-turns` cap (which counts narration) doesn't fire before the
    /// watchdog.
    #[serde(default)]
    pub max_tool_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 .huskies/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)]
    disk_watch: DiskWatchConfig,
    #[serde(default = "default_qa")]
    default_qa: String,
    #[serde(default)]
    default_coder_model: Option<AgentModel>,
    #[serde(default)]
    max_coders: Option<usize>,
    #[serde(default = "default_max_retries")]
    max_retries: u32,
    #[serde(default)]
    base_branch: Option<String>,
    #[serde(default = "default_rate_limit_notifications")]
    rate_limit_notifications: bool,
    #[serde(default)]
    timezone: 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,
                disallowed_tools: None,
                max_turns: None,
                max_tool_turns: None,
                max_budget_usd: None,
                system_prompt: None,
                stage: None,
                inactivity_timeout_secs: default_inactivity_timeout_secs(),
                runtime: None,
            }],
            watcher: WatcherConfig::default(),
            disk_watch: DiskWatchConfig::default(),
            default_qa: default_qa(),
            default_coder_model: None,
            max_coders: None,
            max_retries: default_max_retries(),
            base_branch: None,
            rate_limit_notifications: default_rate_limit_notifications(),
            web_ui_status_consumer: default_web_ui_status_consumer(),
            matrix_status_consumer: default_matrix_status_consumer(),
            slack_status_consumer: default_slack_status_consumer(),
            discord_status_consumer: default_discord_status_consumer(),
            whatsapp_status_consumer: default_whatsapp_status_consumer(),
            timezone: None,
            rendezvous: None,
            trusted_keys: Vec::new(),
            crdt_require_token: false,
            crdt_tokens: Vec::new(),
            max_mesh_peers: default_max_mesh_peers(),
            gateway_url: None,
            gateway_project: None,
            status_push_enabled: default_status_push_enabled(),
            merge_failure_block_threshold: default_merge_failure_block_threshold(),
            gc_min_free_gb: 0,
        }
    }
}

/// Config parsed from `.huskies/agents.toml` — agent definitions only.
#[derive(Debug, Deserialize)]
struct AgentsConfig {
    #[serde(default)]
    agent: Vec<AgentConfig>,
}

impl ProjectConfig {
    /// Load from `.huskies/project.toml` relative to the given root,
    /// then overlay agents from `.huskies/agents.toml` if present.
    ///
    /// Loading order:
    /// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`.
    /// 2. Agent definitions come from `agents.toml` when that file exists.
    /// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards
    ///    compatibility with projects that haven't migrated yet.
    /// 4. Falls back to a single default agent when neither file defines agents.
    pub fn load(project_root: &Path) -> Result<Self, String> {
        let config_path = project_root.join(".huskies/project.toml");
        let mut config = if config_path.exists() {
            let content =
                std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
            Self::parse(&content)?
        } else {
            Self::default()
        };

        // agents.toml takes priority over inline [[agent]] in project.toml.
        let agents_path = project_root.join(".huskies/agents.toml");
        if agents_path.exists() {
            let content = std::fs::read_to_string(&agents_path)
                .map_err(|e| format!("Read agents.toml: {e}"))?;
            let agents_cfg: AgentsConfig =
                toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?;
            if !agents_cfg.agent.is_empty() {
                validate_agents(&agents_cfg.agent)?;
                config.agent = agents_cfg.agent;
            }
        }

        Ok(config)
    }

    /// 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,
                        disk_watch: legacy.disk_watch,
                        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,
                        rate_limit_notifications: legacy.rate_limit_notifications,
                        web_ui_status_consumer: default_web_ui_status_consumer(),
                        matrix_status_consumer: default_matrix_status_consumer(),
                        slack_status_consumer: default_slack_status_consumer(),
                        discord_status_consumer: default_discord_status_consumer(),
                        whatsapp_status_consumer: default_whatsapp_status_consumer(),
                        timezone: legacy.timezone,
                        rendezvous: None,
                        trusted_keys: Vec::new(),
                        crdt_require_token: false,
                        crdt_tokens: Vec::new(),
                        max_mesh_peers: default_max_mesh_peers(),
                        gateway_url: None,
                        gateway_project: None,
                        status_push_enabled: default_status_push_enabled(),
                        merge_failure_block_threshold: default_merge_failure_block_threshold(),
                        gc_min_free_gb: 0,
                    };
                    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_error(&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,
                        disk_watch: legacy.disk_watch,
                        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,
                        rate_limit_notifications: legacy.rate_limit_notifications,
                        web_ui_status_consumer: default_web_ui_status_consumer(),
                        matrix_status_consumer: default_matrix_status_consumer(),
                        slack_status_consumer: default_slack_status_consumer(),
                        discord_status_consumer: default_discord_status_consumer(),
                        whatsapp_status_consumer: default_whatsapp_status_consumer(),
                        timezone: legacy.timezone,
                        rendezvous: None,
                        trusted_keys: Vec::new(),
                        crdt_require_token: false,
                        crdt_tokens: Vec::new(),
                        max_mesh_peers: default_max_mesh_peers(),
                        gateway_url: None,
                        gateway_project: None,
                        status_push_enabled: default_status_push_enabled(),
                        merge_failure_block_threshold: default_merge_failure_block_threshold(),
                        gc_min_free_gb: 0,
                    };
                    validate_agents(&config.agent)?;
                    Ok(config)
                } else {
                    Ok(ProjectConfig {
                        component: legacy.component,
                        agent: Vec::new(),
                        watcher: legacy.watcher,
                        disk_watch: legacy.disk_watch,
                        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,
                        rate_limit_notifications: legacy.rate_limit_notifications,
                        web_ui_status_consumer: default_web_ui_status_consumer(),
                        matrix_status_consumer: default_matrix_status_consumer(),
                        slack_status_consumer: default_slack_status_consumer(),
                        discord_status_consumer: default_discord_status_consumer(),
                        whatsapp_status_consumer: default_whatsapp_status_consumer(),
                        timezone: legacy.timezone,
                        rendezvous: None,
                        trusted_keys: Vec::new(),
                        crdt_require_token: false,
                        crdt_tokens: Vec::new(),
                        max_mesh_peers: default_max_mesh_peers(),
                        gateway_url: None,
                        gateway_project: None,
                        status_push_enabled: default_status_push_enabled(),
                        merge_failure_block_threshold: default_merge_failure_block_threshold(),
                        gc_min_free_gb: 0,
                    })
                }
            }
        }
    }

    /// 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,
        project_root: &Path,
        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_owned = crate::worktree::resolve_base_branch(
            project_root,
            base_branch.or(self.base_branch.as_deref()),
        );
        let bb = bb_owned.as_str();
        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.as_str().to_string());
        }
        if let Some(ref tools) = agent.allowed_tools
            && !tools.is_empty()
        {
            args.push("--allowedTools".to_string());
            args.push(tools.join(","));
        }
        if let Some(ref tools) = agent.disallowed_tools
            && !tools.is_empty()
        {
            args.push("--disallowedTools".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))
    }
}

/// Format a `toml::de::Error` for display, appending a hint when the error is
/// an "unknown field" rejection (raised by `ComponentConfig`'s
/// `deny_unknown_fields`). Such errors most commonly happen when a top-level
/// setting is placed after the last `[[component]]` header — TOML attaches
/// trailing bare keys to the table above them, so the setting silently lands
/// inside `ComponentConfig` instead of `ProjectConfig`. The same hint is also
/// shown for a genuine typo'd field inside `[[component]]`, since the error
/// text can't distinguish the two cases.
fn format_parse_error(e: &toml::de::Error) -> String {
    let mut msg = format!("Parse config: {e}");
    if e.to_string().contains("unknown field") {
        msg.push_str(
            "\n\nhint: top-level settings must appear before the first `[[component]]` \
             section in project.toml — keys placed after a `[[component]]` table are \
             attached to that component instead of the project.",
        );
    }
    msg
}

/// 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
                    ));
                }
            }
        }
        // Skip the AgentName roster check in test mode — unit tests use
        // synthetic agent names (e.g. "first", "second") that are not in the
        // production enum.  The check is meaningful only at server startup.
        #[cfg(not(test))]
        agent.name.parse::<AgentName>().map_err(|_| {
            format!(
                "Agent '{}': name is not in the AgentName roster; \
                 add a variant to `config::AgentName` or remove this agent from agents.toml",
                agent.name
            )
        })?;
    }
    Ok(())
}

#[cfg(test)]
mod tests;
