use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::config::{
    AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
    resolve_token_limit,
};
use crate::skills::SkillsLoader;
use crate::tools::ToolRegistry;

use super::definition::{AgentDefinition, AgentDefinitionError, parse_definition};

/// Fallback delegation target for Agents that do not declare a `delegates`
/// field.  The built-in `general-purpose` definition is released on first
/// run, so the default works out of the box; if it is deleted, an Agent with
/// no explicit `delegates` simply cannot delegate further.
pub const DEFAULT_DELEGATE: &str = "general-purpose";

#[derive(Debug, thiserror::Error)]
pub enum AgentCatalogError {
    #[error("invalid Agent orchestration config: {0}")]
    Config(String),
    #[error(transparent)]
    Definition(#[from] AgentDefinitionError),
    #[error("Agent definition directory error: {0}")]
    Directory(String),
    #[error("Agent '{agent}' references unknown Provider profile '{profile}'")]
    UnknownProfile { agent: String, profile: String },
    #[error("Agent '{agent}' references unknown provider '{provider}'")]
    UnknownProvider { agent: String, provider: String },
    #[error("Agent '{agent}' references unknown model '{model}'")]
    UnknownModel { agent: String, model: String },
    #[error("Agent '{agent}' references invalid tool '{tool}': {reason}")]
    InvalidTool {
        agent: String,
        tool: String,
        reason: String,
    },
    #[error("Agent '{agent}' references unknown delegate '{target}'")]
    UnknownDelegate { agent: String, target: String },
    #[error("Agent '{agent}' references unknown skill '{skill}'")]
    UnknownSkill { agent: String, skill: String },
}

/// A single Agent definition that failed to load.  The definition stays on
/// disk and is reported to the management UI, but it does not enter the active
/// catalog (it is effectively disabled), so a broken file never blocks gateway
/// startup or reload.
#[derive(Debug, Clone)]
pub struct CatalogEntryError {
    pub id: String,
    pub reason: String,
    pub path: PathBuf,
}

#[derive(Debug)]
pub struct AgentCatalog {
    definitions: BTreeMap<String, Arc<AgentDefinition>>,
    /// Definitions that failed validation during this generation's load.
    /// They are excluded from `definitions` but remain listed for the
    /// management UI so users can fix and re-enable them.
    load_errors: Vec<CatalogEntryError>,
    runtime_generation: u64,
    max_tree_depth: u16,
    max_runs_per_tree: usize,
}

impl AgentCatalog {
    pub fn legacy() -> Self {
        Self {
            definitions: BTreeMap::new(),
            load_errors: Vec::new(),
            runtime_generation: 0,
            max_tree_depth: 4,
            max_runs_per_tree: 16,
        }
    }

    #[allow(clippy::too_many_arguments)]
    pub fn load(
        config: &AgentOrchestrationConfig,
        config_dir: &Path,
        provider_profiles: &HashMap<String, LLMProviderConfig>,
        providers: &HashMap<String, ProviderConfig>,
        models: &HashMap<String, ModelConfig>,
        workspace_dir: &Path,
        tools: &ToolRegistry,
        skills_loader: &SkillsLoader,
        runtime_generation: u64,
    ) -> Result<Self, AgentCatalogError> {
        config.validate().map_err(AgentCatalogError::Config)?;

        let trusted_root = config_dir.canonicalize().map_err(|error| {
            AgentCatalogError::Directory(format!("{}: {error}", config_dir.display()))
        })?;
        let configured = expand_path(&config.definitions_dir);
        let definitions_dir = if configured.is_absolute() {
            configured
        } else {
            trusted_root.join(configured)
        };
        let definitions_dir = definitions_dir.canonicalize().map_err(|error| {
            AgentCatalogError::Directory(format!("{}: {error}", definitions_dir.display()))
        })?;
        if !definitions_dir.starts_with(&trusted_root) {
            return Err(AgentCatalogError::Directory(format!(
                "{} escapes trusted config directory {}",
                definitions_dir.display(),
                trusted_root.display()
            )));
        }

        let mut paths = definition_paths(&definitions_dir)?;
        paths.sort();
        let loaded_skills: HashSet<String> = skills_loader
            .list_skills()
            .into_iter()
            .map(|(name, _)| name)
            .collect();
        let mut definitions = BTreeMap::new();
        let mut load_errors: Vec<CatalogEntryError> = Vec::new();

        for path in paths {
            let spec = match read_provider_spec(&path) {
                Ok(spec) => spec,
                Err(error) => {
                    record_error(&mut load_errors, &file_stem_id(&path), &path, error);
                    continue;
                }
            };
            // Disabled definitions stay on disk for the management UI but
            // never enter the active catalog.
            if !spec.enabled {
                continue;
            }
            let provider = match resolve_provider(
                &spec,
                provider_profiles,
                providers,
                models,
                workspace_dir,
            ) {
                Ok(provider) => provider,
                Err(error) => {
                    record_error(&mut load_errors, &spec.id, &path, error);
                    continue;
                }
            };
            let definition = match parse_definition(&path, Arc::new(provider)) {
                Ok(definition) => Arc::new(definition),
                Err(error) => {
                    record_error(&mut load_errors, &spec.id, &path, error);
                    continue;
                }
            };
            if definitions.contains_key(&definition.id) {
                record_error(
                    &mut load_errors,
                    &definition.id,
                    &path,
                    format!("duplicate Agent id '{}'", definition.id),
                );
                continue;
            }
            if let Err(error) = validate_definition_tools(&definition, tools) {
                record_error(&mut load_errors, &definition.id, &path, error);
                continue;
            }
            if let Err(error) = validate_definition_skills(&definition, &loaded_skills) {
                record_error(&mut load_errors, &definition.id, &path, error);
                continue;
            }
            definitions.insert(definition.id.clone(), definition);
        }

        // Delegation edges are validated only after every candidate has been
        // loaded.  An explicit `delegates` entry that points at an Agent absent
        // from the active catalog (missing file, frontmatter-disabled, or
        // failed validation) disables the delegating Agent as well, so no
        // invalid edge is ever activated.  A `*` entry and the implicit
        // default (`general-purpose`) are not cascaded.
        let mut cascaded: Vec<String> = Vec::new();
        for definition in definitions.values() {
            let Some(delegates) = definition.delegates.as_deref() else {
                continue;
            };
            if delegates.iter().any(|target| target == "*") {
                continue;
            }
            if let Some(target) = delegates
                .iter()
                .find(|target| !definitions.contains_key(*target))
            {
                record_error(
                    &mut load_errors,
                    &definition.id,
                    &definition.source_path,
                    format!(
                        "delegates to '{target}' which is not in the active catalog (missing, \
                         disabled, or failed validation)"
                    ),
                );
                cascaded.push(definition.id.clone());
            }
        }
        for id in cascaded {
            definitions.remove(&id);
        }

        Ok(Self {
            definitions,
            load_errors,
            runtime_generation,
            max_tree_depth: config.max_tree_depth,
            max_runs_per_tree: config.max_runs_per_tree,
        })
    }

    pub fn load_errors(&self) -> &[CatalogEntryError] {
        &self.load_errors
    }

    pub fn runtime_generation(&self) -> u64 {
        self.runtime_generation
    }

    pub fn max_tree_depth(&self) -> u16 {
        self.max_tree_depth
    }

    pub fn max_runs_per_tree(&self) -> usize {
        self.max_runs_per_tree
    }

    pub fn get(&self, id: &str) -> Option<Arc<AgentDefinition>> {
        self.definitions.get(id).cloned()
    }

    /// ROOT may delegate to any named Agent.  ROOT has no self to exclude.
    pub fn root_can_delegate(&self, target: &str) -> bool {
        self.definitions.contains_key(target)
    }

    /// Whether `caller` may delegate to `target`, honouring the
    /// default/`*`/empty/list semantics.  Self-delegation is never allowed.
    pub fn can_delegate(&self, caller: &str, target: &str) -> bool {
        if caller == target {
            return false;
        }
        let Some(definition) = self.definitions.get(caller) else {
            return false;
        };
        match definition.delegates.as_deref() {
            None => target == DEFAULT_DELEGATE && self.definitions.contains_key(target),
            Some(list) if list.iter().any(|entry| entry == "*") => {
                self.definitions.contains_key(target)
            }
            Some(list) => list.iter().any(|entry| entry == target),
        }
    }

    /// Concrete delegation targets for `agent_id` after applying the
    /// default/`*`/empty/list semantics.  Used to scope the model-visible
    /// `delegate` tool schema.  Self is never a valid target.
    pub fn delegate_targets(&self, agent_id: &str) -> Vec<String> {
        let Some(definition) = self.definitions.get(agent_id) else {
            return Vec::new();
        };
        match definition.delegates.as_deref() {
            None => {
                if agent_id != DEFAULT_DELEGATE && self.definitions.contains_key(DEFAULT_DELEGATE) {
                    vec![DEFAULT_DELEGATE.to_string()]
                } else {
                    Vec::new()
                }
            }
            Some(list) if list.iter().any(|entry| entry == "*") => self
                .definitions
                .keys()
                .filter(|id| id.as_str() != agent_id)
                .cloned()
                .collect(),
            Some(list) => list
                .iter()
                .filter(|entry| entry.as_str() != agent_id)
                .cloned()
                .collect(),
        }
    }

    /// Every named Agent, exposed to the root `delegate` tool schema.
    pub fn root_targets(&self) -> Vec<Arc<AgentDefinition>> {
        self.definitions.values().cloned().collect()
    }
}

fn definition_paths(directory: &Path) -> Result<Vec<PathBuf>, AgentCatalogError> {
    let mut paths = Vec::new();
    let entries = std::fs::read_dir(directory).map_err(|error| {
        AgentCatalogError::Directory(format!("{}: {error}", directory.display()))
    })?;
    for entry in entries {
        let entry = entry.map_err(|error| AgentCatalogError::Directory(error.to_string()))?;
        let path = entry.path();
        if path.extension().and_then(|value| value.to_str()) != Some("md") {
            continue;
        }
        let canonical = path.canonicalize().map_err(|error| {
            AgentCatalogError::Directory(format!("{}: {error}", path.display()))
        })?;
        if !canonical.starts_with(directory) {
            return Err(AgentCatalogError::Directory(format!(
                "{} escapes definitions directory",
                path.display()
            )));
        }
        paths.push(path);
    }
    Ok(paths)
}

/// Provider/model fields read from a definition's frontmatter before the
/// full definition is parsed, so the catalog can resolve the provider config
/// and skip disabled definitions in one pass.
struct ProviderSpec {
    id: String,
    llm_profile: Option<String>,
    provider: Option<String>,
    model: Option<String>,
    token_limit: Option<usize>,
    max_tool_iterations: Option<usize>,
    enabled: bool,
}

fn read_provider_spec(path: &Path) -> Result<ProviderSpec, AgentCatalogError> {
    let metadata = std::fs::symlink_metadata(path).map_err(|source| AgentDefinitionError::Io {
        path: path.display().to_string(),
        source,
    })?;
    if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
        return Err(AgentDefinitionError::Invalid(format!(
            "{} must be a regular non-symlink file",
            path.display()
        ))
        .into());
    }
    if metadata.len() > super::definition::MAX_DEFINITION_FILE_BYTES {
        return Err(AgentDefinitionError::Invalid(format!(
            "{} exceeds the {} byte limit",
            path.display(),
            super::definition::MAX_DEFINITION_FILE_BYTES
        ))
        .into());
    }
    let content = std::fs::read_to_string(path).map_err(|source| AgentDefinitionError::Io {
        path: path.display().to_string(),
        source,
    })?;
    let normalized = content.replace("\r\n", "\n");
    let rest = normalized.strip_prefix("---\n").ok_or_else(|| {
        AgentDefinitionError::Invalid(format!(
            "{} must start with a standalone --- line",
            path.display()
        ))
    })?;
    let (yaml, _) = rest.split_once("\n---\n").ok_or_else(|| {
        AgentDefinitionError::Invalid(format!(
            "{} has no closing frontmatter delimiter",
            path.display()
        ))
    })?;
    #[derive(serde::Deserialize)]
    struct Spec {
        id: String,
        llm_profile: Option<String>,
        provider: Option<String>,
        model: Option<String>,
        token_limit: Option<usize>,
        max_tool_iterations: Option<usize>,
        #[serde(default = "crate::agent::definition::default_true")]
        enabled: bool,
    }
    let parsed: Spec = serde_yaml::from_str(yaml).map_err(AgentDefinitionError::Yaml)?;
    Ok(ProviderSpec {
        id: parsed.id,
        llm_profile: parsed.llm_profile,
        provider: parsed.provider,
        model: parsed.model,
        token_limit: parsed.token_limit,
        max_tool_iterations: parsed.max_tool_iterations,
        enabled: parsed.enabled,
    })
}

fn resolve_provider(
    spec: &ProviderSpec,
    provider_profiles: &HashMap<String, LLMProviderConfig>,
    providers: &HashMap<String, ProviderConfig>,
    models: &HashMap<String, ModelConfig>,
    workspace_dir: &Path,
) -> Result<LLMProviderConfig, AgentCatalogError> {
    let inline = spec.provider.is_some() || spec.model.is_some();
    if inline {
        let provider_name = spec.provider.as_deref().unwrap_or_default();
        let model_name = spec.model.as_deref().unwrap_or_default();
        let provider =
            providers
                .get(provider_name)
                .ok_or_else(|| AgentCatalogError::UnknownProvider {
                    agent: spec.id.clone(),
                    provider: provider_name.to_string(),
                })?;
        let model = models
            .get(model_name)
            .ok_or_else(|| AgentCatalogError::UnknownModel {
                agent: spec.id.clone(),
                model: model_name.to_string(),
            })?;
        return Ok(LLMProviderConfig {
            provider_type: provider.provider_type.clone(),
            name: provider_name.to_string(),
            base_url: provider.base_url.clone(),
            api_key: provider.api_key.clone(),
            extra_headers: provider.extra_headers.clone(),
            model_id: model.model_id.clone(),
            temperature: model.temperature,
            max_tokens: model.max_tokens,
            model_extra: model.extra.clone(),
            max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
            token_limit: resolve_token_limit(spec.token_limit, model.token_limit),
            workspace_dir: workspace_dir.to_path_buf(),
            input_types: model.input_type.clone(),
            price_input_per_million: None,
            price_output_per_million: None,
        });
    }
    let profile = spec.llm_profile.as_deref().unwrap_or_default();
    let mut resolved = provider_profiles.get(profile).cloned().ok_or_else(|| {
        AgentCatalogError::UnknownProfile {
            agent: spec.id.clone(),
            profile: profile.to_string(),
        }
    })?;
    resolved.token_limit = resolve_token_limit(spec.token_limit, Some(resolved.token_limit));
    Ok(resolved)
}

fn validate_definition_tools(
    definition: &AgentDefinition,
    tools: &ToolRegistry,
) -> Result<(), AgentCatalogError> {
    for name in &definition.tools {
        let tool = tools
            .get(name)
            .ok_or_else(|| AgentCatalogError::InvalidTool {
                agent: definition.id.clone(),
                tool: name.clone(),
                reason: "tool is not registered in the prepared runtime".to_string(),
            })?;
        // Which tools a named Agent receives is decided by its definition
        // file alone.  Runtime-injected tools (delegate/emit_signal/
        // get_skill/agent_task) are assembled from dedicated fields
        // (delegates/signal/skills) and must never appear in `tools`;
        // `get_skill` is the one exception: listing it turns on the scoped
        // skill wrapper, which is injected at resolve time.
        if tool.runtime_injected() && name != "get_skill" {
            return Err(AgentCatalogError::InvalidTool {
                agent: definition.id.clone(),
                tool: name.clone(),
                reason: "runtime-injected tool cannot be declared in a definition".to_string(),
            });
        }
    }
    Ok(())
}

fn validate_definition_skills(
    definition: &AgentDefinition,
    loaded_skills: &HashSet<String>,
) -> Result<(), AgentCatalogError> {
    for skill in &definition.skills {
        if !loaded_skills.contains(skill) {
            return Err(AgentCatalogError::UnknownSkill {
                agent: definition.id.clone(),
                skill: skill.clone(),
            });
        }
    }
    if !definition.skills.is_empty() && !definition.tools.iter().any(|tool| tool == "get_skill") {
        return Err(AgentCatalogError::InvalidTool {
            agent: definition.id.clone(),
            tool: "get_skill".to_string(),
            reason: "skills require get_skill in the definition tool list".to_string(),
        });
    }
    Ok(())
}

fn file_stem_id(path: &Path) -> String {
    path.file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or("(unknown)")
        .to_string()
}

fn record_error(
    load_errors: &mut Vec<CatalogEntryError>,
    id: &str,
    path: &Path,
    reason: impl ToString,
) {
    let reason = reason.to_string();
    tracing::warn!(
        agent = id,
        path = %path.display(),
        error = %reason,
        "Agent definition disabled due to load error"
    );
    load_errors.push(CatalogEntryError {
        id: id.to_string(),
        reason,
        path: path.to_path_buf(),
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::{CalculatorTool, GetSkillTool};

    fn provider() -> LLMProviderConfig {
        LLMProviderConfig {
            provider_type: "openai".to_string(),
            name: "test".to_string(),
            base_url: "https://example.invalid/v1".to_string(),
            api_key: "test".to_string(),
            extra_headers: HashMap::new(),
            model_id: "test-model".to_string(),
            temperature: None,
            max_tokens: None,
            model_extra: HashMap::new(),
            max_tool_iterations: 99,
            token_limit: 4096,
            workspace_dir: std::env::temp_dir(),
            input_types: vec!["text".to_string()],
            price_input_per_million: None,
            price_output_per_million: None,
        }
    }

    fn config() -> AgentOrchestrationConfig {
        AgentOrchestrationConfig {
            definitions_dir: "agents".to_string(),
            ..Default::default()
        }
    }

    /// `delegates`: `None` omits the field (default semantics), `Some([])`
    /// writes an explicit empty list, `Some(list)` writes the entries.
    fn write_agent(root: &Path, id: &str, tools: &[&str], delegates: Option<&[&str]>) {
        let tools = (!tools.is_empty()).then(|| {
            format!(
                "tools:\n{}\n",
                tools
                    .iter()
                    .map(|name| format!("  - {name}"))
                    .collect::<Vec<_>>()
                    .join("\n")
            )
        });
        let delegates = delegates.map(|delegates| {
            if delegates.is_empty() {
                "delegates: []\n".to_string()
            } else {
                format!(
                    "delegates:\n{}\n",
                    delegates
                        .iter()
                        .map(|name| {
                            if *name == "*" {
                                "  - \"*\"".to_string()
                            } else {
                                format!("  - {name}")
                            }
                        })
                        .collect::<Vec<_>>()
                        .join("\n")
                )
            }
        });
        std::fs::write(
            root.join("agents").join(format!("{id}.md")),
            format!(
                "---\nid: {id}\ndescription: {id} role\nllm_profile: research\n{}{}---\n# Role\n\nDo the assigned work.\n",
                tools.unwrap_or_default(),
                delegates.unwrap_or_default()
            ),
        )
        .unwrap();
    }

    #[test]
    fn catalog_loads_provider_tools_and_delegation_graph() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        write_agent(
            root.path(),
            "researcher",
            &["calculator"],
            Some(&["reviewer"]),
        );
        write_agent(root.path(), "reviewer", &["calculator"], None);
        let tools = ToolRegistry::new();
        tools.register(CalculatorTool::new());
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            7,
        )
        .unwrap();

        assert!(catalog.root_can_delegate("researcher"));
        assert!(catalog.can_delegate("researcher", "reviewer"));
        assert_eq!(
            catalog.get("researcher").unwrap().provider_config.model_id,
            "test-model"
        );
        assert_eq!(catalog.runtime_generation(), 7);
    }

    #[test]
    fn inline_agent_context_window_uses_agent_model_minimum_then_default() {
        let providers = HashMap::from([(
            "provider".to_string(),
            ProviderConfig {
                provider_type: "openai".to_string(),
                base_url: "https://example.invalid/v1".to_string(),
                api_key: "test".to_string(),
                extra_headers: HashMap::new(),
            },
        )]);
        let mut models = HashMap::from([(
            "model".to_string(),
            ModelConfig {
                model_id: "model-id".to_string(),
                temperature: None,
                max_tokens: None,
                token_limit: Some(64_000),
                input_type: vec!["text".to_string()],
                extra: HashMap::new(),
            },
        )]);
        let mut spec = ProviderSpec {
            id: "inline".to_string(),
            llm_profile: None,
            provider: Some("provider".to_string()),
            model: Some("model".to_string()),
            token_limit: None,
            max_tool_iterations: None,
            enabled: true,
        };

        assert_eq!(
            resolve_provider(
                &spec,
                &HashMap::new(),
                &providers,
                &models,
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            64_000
        );

        spec.token_limit = Some(32_000);
        assert_eq!(
            resolve_provider(
                &spec,
                &HashMap::new(),
                &providers,
                &models,
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            32_000
        );

        spec.token_limit = Some(128_000);
        assert_eq!(
            resolve_provider(
                &spec,
                &HashMap::new(),
                &providers,
                &models,
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            64_000
        );

        spec.token_limit = None;
        models.get_mut("model").unwrap().token_limit = None;
        assert_eq!(
            resolve_provider(
                &spec,
                &HashMap::new(),
                &providers,
                &models,
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            128_000
        );
    }

    #[test]
    fn profile_agent_context_window_can_only_narrow_resolved_profile() {
        let profiles = HashMap::from([("profile".to_string(), provider())]);
        let mut spec = ProviderSpec {
            id: "profile-agent".to_string(),
            llm_profile: Some("profile".to_string()),
            provider: None,
            model: None,
            token_limit: None,
            max_tool_iterations: None,
            enabled: true,
        };

        assert_eq!(
            resolve_provider(
                &spec,
                &profiles,
                &HashMap::new(),
                &HashMap::new(),
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            4_096
        );

        spec.token_limit = Some(2_048);
        assert_eq!(
            resolve_provider(
                &spec,
                &profiles,
                &HashMap::new(),
                &HashMap::new(),
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            2_048
        );

        spec.token_limit = Some(8_192);
        assert_eq!(
            resolve_provider(
                &spec,
                &profiles,
                &HashMap::new(),
                &HashMap::new(),
                Path::new("/tmp")
            )
            .unwrap()
            .token_limit,
            4_096
        );
    }

    #[test]
    fn delegation_semantics_default_empty_any_and_list() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        write_agent(root.path(), "general-purpose", &[], None);
        write_agent(root.path(), "researcher", &[], None);
        write_agent(root.path(), "reviewer", &[], Some(&[]));
        write_agent(root.path(), "coder", &[], Some(&["*"]));
        write_agent(root.path(), "writer", &[], Some(&["reviewer"]));
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);
        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        // ROOT may delegate to every named Agent.
        for id in [
            "general-purpose",
            "researcher",
            "reviewer",
            "coder",
            "writer",
        ] {
            assert!(catalog.root_can_delegate(id), "ROOT -> {id}");
        }

        // Unset `delegates` defaults to general-purpose only.
        assert!(catalog.can_delegate("researcher", "general-purpose"));
        assert!(!catalog.can_delegate("researcher", "reviewer"));
        assert_eq!(catalog.delegate_targets("researcher"), ["general-purpose"]);

        // general-purpose itself has no further delegate (self excluded).
        assert!(!catalog.can_delegate("general-purpose", "researcher"));
        assert!(catalog.delegate_targets("general-purpose").is_empty());

        // Explicit empty list forbids delegation.
        assert!(!catalog.can_delegate("reviewer", "researcher"));
        assert!(!catalog.can_delegate("reviewer", "general-purpose"));
        assert!(catalog.delegate_targets("reviewer").is_empty());

        // `*` allows any other Agent, never self.
        assert!(catalog.can_delegate("coder", "researcher"));
        assert!(catalog.can_delegate("coder", "writer"));
        assert!(!catalog.can_delegate("coder", "coder"));
        assert_eq!(catalog.delegate_targets("coder").len(), 4);

        // Explicit list allows exactly the listed targets.
        assert!(catalog.can_delegate("writer", "reviewer"));
        assert!(!catalog.can_delegate("writer", "researcher"));
        assert_eq!(catalog.delegate_targets("writer"), ["reviewer"]);
    }

    #[test]
    fn catalog_accepts_any_ordinary_tool_but_rejects_runtime_injected() {
        // Ordinary tools (including side-effecting ones like file_write) are
        // now accepted purely by the definition file.
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        write_agent(root.path(), "researcher", &["file_write"], None);
        let tools = ToolRegistry::new();
        tools.register(crate::tools::FileWriteTool::new());
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);
        AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        // Runtime-injected tools (e.g. delegate) must not be declared in a
        // definition's `tools` list; get_skill remains the one exception.
        let root2 = tempfile::tempdir().unwrap();
        std::fs::create_dir(root2.path().join("agents")).unwrap();
        write_agent(root2.path(), "researcher", &["get_skill"], None);
        let tools2 = ToolRegistry::new();
        tools2.register(GetSkillTool::new(Arc::new(
            crate::skills::SkillsLoader::new_for_testing(
                root2.path().join("skills"),
                root2.path().join("external-skills"),
            ),
        )));
        let loader2 = SkillsLoader::new_for_testing(
            root2.path().join("skills"),
            root2.path().join("external-skills"),
        );
        let profiles2 = HashMap::from([("research".to_string(), provider())]);
        AgentCatalog::load(
            &config(),
            root2.path(),
            &profiles2,
            &HashMap::new(),
            &HashMap::new(),
            root2.path(),
            &tools2,
            &loader2,
            1,
        )
        .unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn catalog_rejects_definition_symlink_escape() {
        use std::os::unix::fs::symlink;

        let root = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        let outside_file = outside.path().join("researcher.md");
        std::fs::write(
            &outside_file,
            "---\nid: researcher\ndescription: test\nllm_profile: research\n---\n# Role\n",
        )
        .unwrap();
        symlink(&outside_file, root.path().join("agents/researcher.md")).unwrap();
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        assert!(
            AgentCatalog::load(
                &config(),
                root.path(),
                &profiles,
                &HashMap::new(),
                &HashMap::new(),
                root.path(),
                &tools,
                &loader,
                1
            )
            .is_err()
        );
    }

    #[test]
    fn broken_definition_is_skipped_and_recorded() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        write_agent(root.path(), "researcher", &[], None);
        std::fs::write(
            root.path().join("agents/broken.md"),
            "this is not a valid definition file\n",
        )
        .unwrap();
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        assert!(catalog.get("researcher").is_some());
        assert!(catalog.get("broken").is_none());
        let errors = catalog.load_errors();
        assert_eq!(errors.len(), 1);
        assert_eq!(errors[0].id, "broken");
    }

    #[test]
    fn unknown_profile_disables_agent_but_loads() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        std::fs::write(
            root.path().join("agents/researcher.md"),
            "---\nid: researcher\ndescription: research\nllm_profile: missing\n---\n# Role\n\nDo work.\n",
        )
        .unwrap();
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        assert!(catalog.get("researcher").is_none());
        let errors = catalog.load_errors();
        assert_eq!(errors.len(), 1);
        assert_eq!(errors[0].id, "researcher");
    }

    #[test]
    fn delegate_to_disabled_target_cascades() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        // reviewer is explicitly disabled; researcher delegates to it, so it
        // must be cascaded out too. coder uses `*` and stays loaded.
        std::fs::write(
            root.path().join("agents/reviewer.md"),
            "---\nid: reviewer\ndescription: review\nllm_profile: research\nenabled: false\n---\n# Role\n\nReview.\n",
        )
        .unwrap();
        write_agent(root.path(), "researcher", &[], Some(&["reviewer"]));
        write_agent(root.path(), "coder", &[], Some(&["*"]));
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        assert!(catalog.get("reviewer").is_none());
        assert!(catalog.get("researcher").is_none(), "must cascade");
        assert!(catalog.get("coder").is_some(), "`*` must not cascade");
        assert!(
            catalog
                .load_errors()
                .iter()
                .any(|error| error.id == "researcher" && error.reason.contains("reviewer"))
        );
    }

    #[test]
    fn implicit_default_delegation_tolerates_missing_general_purpose() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("agents")).unwrap();
        // No `delegates` field means "default to general-purpose"; when
        // general-purpose is absent, the Agent still loads but cannot
        // delegate further.
        write_agent(root.path(), "writer", &[], None);
        let tools = ToolRegistry::new();
        let loader = SkillsLoader::new_for_testing(
            root.path().join("skills"),
            root.path().join("external-skills"),
        );
        let profiles = HashMap::from([("research".to_string(), provider())]);

        let catalog = AgentCatalog::load(
            &config(),
            root.path(),
            &profiles,
            &HashMap::new(),
            &HashMap::new(),
            root.path(),
            &tools,
            &loader,
            1,
        )
        .unwrap();

        assert!(catalog.get("writer").is_some());
        assert!(catalog.load_errors().is_empty());
        assert!(catalog.delegate_targets("writer").is_empty());
    }
}
