//! Configuration file discovery and loading.
//!
//! This module handles finding and loading configuration files from
//! various locations in the correct priority order.

use crate::config::discovery::Discovery;
use crate::config::extends::ExtendsResolver;
use crate::config::merger::merge_configs;
use crate::config::schema::{BivvyConfig, StepConfig, WorkflowConfig, WorkflowFile};
use crate::config::trust::{TrustPolicy, TrustStore};
use crate::error::{BivvyError, Result};
use crate::ui::{Prompt, PromptResult, PromptType, Prompter};
use std::fs;
use std::path::{Path, PathBuf};

/// Selects which workflow files participate in a merge.
///
/// `bivvy run` only merges the named workflow file, while `bivvy config
/// --merged` (and the legacy `load_merged_config` entry point) merge all
/// of them.
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum WorkflowSelection<'a> {
    /// Include every workflow file under `.bivvy/workflows/`.
    All,
    /// Include only the workflow file matching the given filename stem.
    One(&'a str),
    /// Include no workflow files (used by per-target lint paths).
    None,
}

/// Paths to configuration files in priority order (later overrides earlier).
///
/// Merge order:
/// 1. Remote base configs (from `extends:`)
/// 2. User global config (`~/.bivvy/config.yml`)
/// 3. Project config (`.bivvy/config.yml`)
/// 4. Split-file steps (`.bivvy/steps/*.yml`)
/// 5. Split-file workflows (`.bivvy/workflows/*.yml`)
/// 6. Local overrides (`.bivvy/config.local.yml`)
#[derive(Debug, Clone)]
pub struct ConfigPaths {
    /// Remote base config (if extends: is used)
    pub extends: Vec<PathBuf>,

    /// User's global config: ~/.bivvy/config.yml
    pub user_global: Option<PathBuf>,

    /// Project config: .bivvy/config.yml
    pub project: Option<PathBuf>,

    /// Split-file step definitions: .bivvy/steps/*.yml
    pub split_steps: Vec<PathBuf>,

    /// Split-file workflow definitions: .bivvy/workflows/*.yml
    pub split_workflows: Vec<PathBuf>,

    /// Local overrides: .bivvy/config.local.yml
    pub project_local: Option<PathBuf>,
}

impl ConfigPaths {
    /// Discover config files for the given project root.
    pub fn discover(project_root: &Path) -> Self {
        Self {
            extends: Vec::new(), // Populated later after parsing project config
            user_global: Self::find_user_global(),
            project: Self::find_project_config(project_root),
            split_steps: Self::find_split_files(project_root, "steps"),
            split_workflows: Self::find_split_files(project_root, "workflows"),
            project_local: Self::find_project_local(project_root),
        }
    }

    /// Find user's global config at ~/.bivvy/config.yml
    fn find_user_global() -> Option<PathBuf> {
        let path = crate::sys::home_dir()?.join(".bivvy").join("config.yml");
        if path.exists() {
            Some(path)
        } else {
            None
        }
    }

    /// Find project config at .bivvy/config.yml
    fn find_project_config(project_root: &Path) -> Option<PathBuf> {
        let path = project_root.join(".bivvy").join("config.yml");
        if path.exists() {
            Some(path)
        } else {
            None
        }
    }

    /// Find local overrides at .bivvy/config.local.yml
    fn find_project_local(project_root: &Path) -> Option<PathBuf> {
        let path = project_root.join(".bivvy").join("config.local.yml");
        if path.exists() {
            Some(path)
        } else {
            None
        }
    }

    /// Find YAML files in `.bivvy/<subdir>/`.
    ///
    /// Returns sorted paths for deterministic merge order.
    fn find_split_files(project_root: &Path, subdir: &str) -> Vec<PathBuf> {
        let dir = project_root.join(".bivvy").join(subdir);
        if !dir.is_dir() {
            return Vec::new();
        }

        let mut files: Vec<PathBuf> = fs::read_dir(&dir)
            .into_iter()
            .flatten()
            .filter_map(|entry| entry.ok())
            .map(|entry| entry.path())
            .filter(|path| {
                path.extension()
                    .is_some_and(|ext| ext == "yml" || ext == "yaml")
            })
            .collect();

        files.sort();
        files
    }

    /// Returns all existing config paths in merge order (excludes split files).
    pub fn all_existing(&self) -> Vec<&PathBuf> {
        let mut paths = Vec::new();

        for p in &self.extends {
            paths.push(p);
        }

        if let Some(p) = &self.user_global {
            paths.push(p);
        }

        if let Some(p) = &self.project {
            paths.push(p);
        }

        if let Some(p) = &self.project_local {
            paths.push(p);
        }

        paths
    }

    /// Check if any project config exists.
    ///
    /// Returns true if `.bivvy/config.yml` exists OR if any split-file
    /// steps/workflows are present.
    pub fn has_project_config(&self) -> bool {
        self.project.is_some() || !self.split_steps.is_empty() || !self.split_workflows.is_empty()
    }

    /// Whether any split files were discovered.
    pub fn has_split_files(&self) -> bool {
        !self.split_steps.is_empty() || !self.split_workflows.is_empty()
    }
}

/// Ensure the global config directory, config file, and schema exist.
///
/// Creates `~/.bivvy/config.yml` (with commented-out examples) and
/// `~/.bivvy/schema.json` if they don't already exist. Called on every
/// invocation so every install path (Homebrew, `cargo install`,
/// `install.sh`) gets the global config without relying on the installer.
///
/// The schema file is always rewritten so it stays current after upgrades.
///
/// Failures are silently ignored — a missing global config is not fatal.
pub fn ensure_global_config() {
    let Some(home) = crate::sys::home_dir() else {
        return;
    };
    ensure_global_config_in(home);
}

/// Inner implementation that accepts an explicit home directory.
///
/// Separated from [`ensure_global_config`] so tests can point at a temp dir.
fn ensure_global_config_in(home: PathBuf) {
    let bivvy_dir = home.join(".bivvy");

    if fs::create_dir_all(&bivvy_dir).is_err() {
        return;
    }

    // Write schema.json on every run so it stays current after upgrades.
    let schema_path = bivvy_dir.join("schema.json");
    let _ = fs::write(&schema_path, crate::lint::schema_json());

    // Only create config.yml if it doesn't exist yet.
    let config_path = bivvy_dir.join("config.yml");
    if !config_path.exists() {
        let schema_display = schema_path.display();
        let content = format!(
            "\
# yaml-language-server: $schema={schema_display}
#
# Bivvy global configuration
# Docs: https://bivvy.dev/configuration
#
# Settings here apply to ALL projects on this machine.
# Project-level .bivvy/config.yml overrides these defaults.

# settings:
#   defaults:
#     output: verbose        # verbose | quiet | silent
#     auto_run: true         # auto-run steps or prompt first
#     prompt_on_rerun: false # prompt before re-running completed steps
#   parallel: false          # enable parallel step execution
#   max_parallel: 4          # max concurrent steps (when parallel: true)
#   auto_update: true        # check for bivvy updates in the background
#   logging: false           # enable JSONL event logging
"
        );

        let _ = fs::write(&config_path, content);
    }
}

/// Find the project root by walking up from current directory.
///
/// Looks for:
/// 1. `.bivvy` directory (primary indicator)
/// 2. `.git` directory (fallback)
///
/// # Returns
///
/// The path to the project root, or None if not found.
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
    let mut current = start.to_path_buf();

    loop {
        // Check for .bivvy directory
        if current.join(".bivvy").is_dir() {
            return Some(current);
        }

        // Check for .git directory (fallback)
        if current.join(".git").exists() {
            return Some(current);
        }

        // Move up one directory
        if !current.pop() {
            return None;
        }
    }
}

/// Load a single config file and parse it into BivvyConfig.
///
/// # Errors
///
/// Returns `ConfigNotFound` if the file doesn't exist.
/// Returns `ConfigParseError` if the YAML is invalid.
pub fn load_config_file(path: &Path) -> Result<BivvyConfig> {
    let content = fs::read_to_string(path).map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            BivvyError::ConfigNotFound {
                path: path.to_path_buf(),
            }
        } else {
            BivvyError::Io(e)
        }
    })?;

    parse_config(&content, path)
}

/// Parse YAML content into BivvyConfig.
///
/// # Arguments
///
/// * `content` - The YAML content to parse
/// * `source_path` - Path for error reporting
pub fn parse_config(content: &str, source_path: &Path) -> Result<BivvyConfig> {
    serde_yaml::from_str(content).map_err(|e| BivvyError::ConfigParseError {
        path: source_path.to_path_buf(),
        message: e.to_string(),
    })
}

/// Load a config file as raw YAML Value (for merging).
///
/// Returns the raw serde_yaml::Value without deserializing into
/// BivvyConfig, which allows for deep merging before final parsing.
pub fn load_config_value(path: &Path) -> Result<serde_yaml::Value> {
    let content = fs::read_to_string(path).map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            BivvyError::ConfigNotFound {
                path: path.to_path_buf(),
            }
        } else {
            BivvyError::Io(e)
        }
    })?;

    serde_yaml::from_str(&content).map_err(|e| BivvyError::ConfigParseError {
        path: path.to_path_buf(),
        message: e.to_string(),
    })
}

/// Load split-file definitions and return a synthetic YAML value.
///
/// Reads each file in `paths`, wraps its content under `top_key.<stem>`,
/// and merges them all into one value. For example, `.bivvy/steps/database.yml`
/// becomes `{ steps: { database: <file content> } }`.
///
/// # Errors
///
/// Returns `ConfigParseError` if any file cannot be read or parsed.
fn load_split_files(paths: &[PathBuf], top_key: &str) -> Result<Option<serde_yaml::Value>> {
    if paths.is_empty() {
        return Ok(None);
    }

    let mut entries = serde_yaml::Mapping::new();

    for path in paths {
        let stem = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
            BivvyError::ConfigParseError {
                path: path.clone(),
                message: "Could not determine name from filename".to_string(),
            }
        })?;

        let value = load_config_value(path)?;

        entries.insert(serde_yaml::Value::String(stem.to_string()), value);
    }

    let mut wrapper = serde_yaml::Mapping::new();
    wrapper.insert(
        serde_yaml::Value::String(top_key.to_string()),
        serde_yaml::Value::Mapping(entries),
    );

    Ok(Some(serde_yaml::Value::Mapping(wrapper)))
}

/// Parse a single workflow file into a [`WorkflowFile`], auto-detecting
/// new vs. legacy format.
///
/// New format: file has a top-level `workflow:` mapping. The whole file
/// is parsed as `WorkflowFile`.
///
/// Legacy format: no top-level `workflow:` key. The file is parsed as
/// `WorkflowConfig` and wrapped in a `WorkflowFile` with empty
/// `steps`/`vars`, preserving the workflow declaration as-is.
pub fn parse_workflow_file_value(
    value: serde_yaml::Value,
    source_path: &Path,
) -> Result<WorkflowFile> {
    let is_new_format = value
        .as_mapping()
        .map(|m| m.contains_key(serde_yaml::Value::String("workflow".to_string())))
        .unwrap_or(false);

    if is_new_format {
        serde_yaml::from_value(value).map_err(|e| BivvyError::ConfigParseError {
            path: source_path.to_path_buf(),
            message: e.to_string(),
        })
    } else {
        let workflow: WorkflowConfig =
            serde_yaml::from_value(value).map_err(|e| BivvyError::ConfigParseError {
                path: source_path.to_path_buf(),
                message: e.to_string(),
            })?;
        Ok(WorkflowFile {
            description: workflow.description.clone(),
            steps: Default::default(),
            vars: Default::default(),
            workflow,
        })
    }
}

/// Read a workflow file from disk and parse it into a [`WorkflowFile`].
///
/// This is the public entry point used by `bivvy lint --workflow <name>`
/// and discovery-based commands that want a single workflow file in
/// isolation, without merging anything else.
pub fn load_single_workflow_file(path: &Path) -> Result<WorkflowFile> {
    let value = load_config_value(path)?;
    parse_workflow_file_value(value, path)
}

/// Read a step file from disk and parse it into a [`StepConfig`].
///
/// Step files are simpler than workflow files — they have only one shape.
pub fn load_single_step_file(path: &Path) -> Result<StepConfig> {
    let content = fs::read_to_string(path).map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            BivvyError::ConfigNotFound {
                path: path.to_path_buf(),
            }
        } else {
            BivvyError::Io(e)
        }
    })?;
    serde_yaml::from_str(&content).map_err(|e| BivvyError::ConfigParseError {
        path: path.to_path_buf(),
        message: e.to_string(),
    })
}

/// Convert a [`WorkflowFile`] into the partial `BivvyConfig` YAML value it
/// contributes to the merge chain.
///
/// Produces a mapping with `vars`, `steps`, and `workflows.<stem>` keys.
/// The workflow's description falls back to the file-level description
/// when not set inside the `workflow:` block.
fn workflow_file_to_partial(file: &WorkflowFile, stem: &str) -> Result<serde_yaml::Value> {
    let mut wrapper = serde_yaml::Mapping::new();

    if !file.vars.is_empty() {
        let vars_value =
            serde_yaml::to_value(&file.vars).map_err(|e| BivvyError::ConfigParseError {
                path: PathBuf::from(format!(".bivvy/workflows/{stem}.yml")),
                message: format!("Failed to serialize workflow vars: {e}"),
            })?;
        wrapper.insert(serde_yaml::Value::String("vars".to_string()), vars_value);
    }

    if !file.steps.is_empty() {
        let steps_value =
            serde_yaml::to_value(&file.steps).map_err(|e| BivvyError::ConfigParseError {
                path: PathBuf::from(format!(".bivvy/workflows/{stem}.yml")),
                message: format!("Failed to serialize workflow steps: {e}"),
            })?;
        wrapper.insert(serde_yaml::Value::String("steps".to_string()), steps_value);
    }

    let mut workflow = file.workflow.clone();
    if workflow.description.is_none() {
        if let Some(ref desc) = file.description {
            workflow.description = Some(desc.clone());
        }
    }

    let workflow_value =
        serde_yaml::to_value(&workflow).map_err(|e| BivvyError::ConfigParseError {
            path: PathBuf::from(format!(".bivvy/workflows/{stem}.yml")),
            message: format!("Failed to serialize workflow: {e}"),
        })?;

    let mut workflows_map = serde_yaml::Mapping::new();
    workflows_map.insert(serde_yaml::Value::String(stem.to_string()), workflow_value);
    wrapper.insert(
        serde_yaml::Value::String("workflows".to_string()),
        serde_yaml::Value::Mapping(workflows_map),
    );

    Ok(serde_yaml::Value::Mapping(wrapper))
}

/// Load every workflow file in `paths`, parse each as `WorkflowFile`
/// (with legacy fallback), and produce one merged partial config value.
fn load_split_workflow_files_all(paths: &[PathBuf]) -> Result<Option<serde_yaml::Value>> {
    if paths.is_empty() {
        return Ok(None);
    }

    let partials: Vec<serde_yaml::Value> = paths
        .iter()
        .map(|path| {
            let stem = path.file_stem().and_then(|s| s.to_str()).ok_or_else(|| {
                BivvyError::ConfigParseError {
                    path: path.clone(),
                    message: "Could not determine name from filename".to_string(),
                }
            })?;
            let file = load_single_workflow_file(path)?;
            workflow_file_to_partial(&file, stem)
        })
        .collect::<Result<Vec<_>>>()?;

    Ok(Some(merge_configs(&partials)))
}

/// Collect config values in merge order, including split files.
///
/// Merge order:
/// 1. Remote base configs (from `extends:`)
/// 2. User global config (`~/.bivvy/config.yml`)
/// 3. Project config (`.bivvy/config.yml`)
/// 4. Split-file steps (`.bivvy/steps/*.yml`)
/// 5. Split-file workflows (`.bivvy/workflows/*.yml`) — selection
///    determines which files participate.
/// 6. Local overrides (`.bivvy/config.local.yml`)
fn collect_config_values(
    paths: &ConfigPaths,
    workflows: WorkflowSelection<'_>,
) -> Result<Vec<serde_yaml::Value>> {
    let mut configs = Vec::new();

    // 1. Remote extends (populated later by resolver)
    for p in &paths.extends {
        configs.push(load_config_value(p)?);
    }

    // 2. User global
    if let Some(p) = &paths.user_global {
        configs.push(load_config_value(p)?);
    }

    // 3. Project config
    if let Some(p) = &paths.project {
        configs.push(load_config_value(p)?);
    }

    // 4. Split-file steps
    if let Some(v) = load_split_files(&paths.split_steps, "steps")? {
        configs.push(v);
    }

    // 5. Split-file workflows (selected per request)
    match workflows {
        WorkflowSelection::All => {
            if let Some(v) = load_split_workflow_files_all(&paths.split_workflows)? {
                configs.push(v);
            }
        }
        WorkflowSelection::One(name) => {
            if let Some(path) = paths.split_workflows.iter().find(|p| {
                p.file_stem()
                    .and_then(|s| s.to_str())
                    .is_some_and(|stem| stem == name)
            }) {
                let file = load_single_workflow_file(path)?;
                configs.push(workflow_file_to_partial(&file, name)?);
            }
        }
        WorkflowSelection::None => {}
    }

    // 6. Local overrides
    if let Some(p) = &paths.project_local {
        configs.push(load_config_value(p)?);
    }

    Ok(configs)
}

/// Load and merge all config files for a project.
///
/// Discovers and merges configs in this order:
/// 1. Remote base configs (from `extends:`)
/// 2. User global config (`~/.bivvy/config.yml`)
/// 3. Project config (`.bivvy/config.yml`)
/// 4. Split-file steps (`.bivvy/steps/*.yml`)
/// 5. Split-file workflows (`.bivvy/workflows/*.yml`)
/// 6. Local overrides (`.bivvy/config.local.yml`)
///
/// # Errors
///
/// Returns `ConfigNotFound` if no project config exists.
/// Returns `ConfigParseError` if any config file is invalid.
pub fn load_merged_config(project_root: &Path) -> Result<BivvyConfig> {
    load_merged_config_with_resolver(project_root, &ExtendsResolver::default())
}

/// Load and merge configs with a specific extends resolver.
///
/// This is the implementation behind `load_merged_config`, exposed
/// separately to allow injecting a custom resolver for testing.
pub fn load_merged_config_with_resolver(
    project_root: &Path,
    resolver: &ExtendsResolver,
) -> Result<BivvyConfig> {
    let paths = ConfigPaths::discover(project_root);

    if !paths.has_project_config() {
        return Err(BivvyError::ConfigNotFound {
            path: project_root.join(".bivvy").join("config.yml"),
        });
    }

    let configs = collect_config_values(&paths, WorkflowSelection::All)?;

    // Merge all configs
    let merged = merge_configs(&configs);

    // Parse merged value into typed config
    let config: BivvyConfig =
        serde_yaml::from_value(merged).map_err(|e| BivvyError::ConfigParseError {
            path: project_root.join(".bivvy").join("config.yml"),
            message: format!("Failed to parse merged config: {}", e),
        })?;

    // Migrate deprecated fields (watches → change checks, etc.)
    let mut config = config;
    config.migrate_deprecated_fields();

    // Resolve extends if present
    if config.extends.is_some() {
        let resolved = resolver.resolve(&config)?;
        Ok(resolved)
    } else {
        Ok(config)
    }
}

/// Load and merge configs with trust verification for remote extends URLs.
///
/// This is the full-featured config loader that verifies trust for remote
/// extends URLs. The `trust_policy` determines behavior for untrusted URLs:
/// - `TrustAll`: auto-trusts (--trust flag)
/// - `Prompt`: asks the user via UI (interactive mode)
/// - `Reject`: errors on untrusted URLs (non-interactive mode)
///
/// Trusted URLs are persisted so they don't need to be re-approved.
pub fn load_merged_config_with_trust(
    project_root: &Path,
    resolver: &ExtendsResolver,
    trust_policy: TrustPolicy,
    trust_store_path: &Path,
    ui: &mut dyn Prompter,
) -> Result<BivvyConfig> {
    let paths = ConfigPaths::discover(project_root);

    if !paths.has_project_config() {
        return Err(BivvyError::ConfigNotFound {
            path: project_root.join(".bivvy").join("config.yml"),
        });
    }

    let configs = collect_config_values(&paths, WorkflowSelection::All)?;

    let merged = merge_configs(&configs);

    let mut config: BivvyConfig =
        serde_yaml::from_value(merged).map_err(|e| BivvyError::ConfigParseError {
            path: project_root.join(".bivvy").join("config.yml"),
            message: format!("Failed to parse merged config: {}", e),
        })?;

    // Migrate deprecated fields (watches → change checks, etc.)
    config.migrate_deprecated_fields();

    if config.extends.is_some() {
        let mut trust_store = TrustStore::load(trust_store_path).map_err(BivvyError::Other)?;
        let mut store_modified = false;

        let resolved = resolver
            .resolve_with_trust(&config, &mut |url: &str| {
                if trust_store.is_trusted(url) {
                    return Ok(());
                }

                match trust_policy {
                    TrustPolicy::TrustAll => {
                        trust_store.trust(url);
                        store_modified = true;
                        Ok(())
                    }
                    TrustPolicy::Reject => Err(anyhow::anyhow!(
                        "Untrusted remote config URL: {}. Use --trust to auto-approve or run interactively to confirm.",
                        url
                    )),
                    TrustPolicy::Prompt => {
                        let prompt = Prompt {
                            key: format!("trust_url_{}", url),
                            question: format!(
                                "Trust remote config from {}? (y/N)",
                                url
                            ),
                            prompt_type: PromptType::Confirm,
                            default: Some("false".to_string()),
                        };

                        match ui.prompt(&prompt) {
                            Ok(PromptResult::Bool(true)) => {
                                trust_store.trust(url);
                                store_modified = true;
                                Ok(())
                            }
                            _ => Err(anyhow::anyhow!(
                                "Untrusted remote config URL rejected by user: {}",
                                url
                            )),
                        }
                    }
                }
            })
            .map_err(BivvyError::Other)?;

        if store_modified {
            trust_store
                .save(trust_store_path)
                .map_err(BivvyError::Other)?;
        }

        Ok(resolved)
    } else {
        Ok(config)
    }
}

/// Load config with optional path override.
///
/// If `config_override` is provided, loads only that file without merging.
/// Otherwise, discovers and merges all config files.
pub fn load_config(project_root: &Path, config_override: Option<&Path>) -> Result<BivvyConfig> {
    if let Some(override_path) = config_override {
        // Direct load of specified file, no merging
        load_config_file(override_path)
    } else {
        load_merged_config(project_root)
    }
}

/// Load only `.bivvy/config.yml`, with no merging of other files.
///
/// This is the cheap path used by commands that only operate on the
/// project file (like `bivvy add`). It does not consult `~/.bivvy/`,
/// split-file directories, or `config.local.yml`. Returns an empty
/// `BivvyConfig` when the file does not exist (callers can detect this
/// via [`Discovery::project_config_path`] if needed).
pub fn load_project_config(project_root: &Path) -> Result<BivvyConfig> {
    let discovery = Discovery::new(project_root);
    match discovery.project_config_path() {
        Some(path) => load_config_file(&path),
        None => Err(BivvyError::ConfigNotFound {
            path: project_root.join(".bivvy").join("config.yml"),
        }),
    }
}

/// Full deep-merge load path, but only the named workflow file
/// participates in the chain.
///
/// This is the loader used by `bivvy run <workflow>`. It walks the
/// resolution chain (extends → user-global → project → split steps →
/// the named workflow file → local) and produces a final
/// [`BivvyConfig`]. Other workflow files are not parsed, so a malformed
/// sibling cannot break a run of an unrelated workflow.
pub fn load_for_run(project_root: &Path, workflow_name: &str) -> Result<BivvyConfig> {
    load_for_run_with_resolver(project_root, workflow_name, &ExtendsResolver::default())
}

/// Variant of [`load_for_run`] that accepts a custom extends resolver.
///
/// Exposed for tests that inject a mocked HTTP server.
pub fn load_for_run_with_resolver(
    project_root: &Path,
    workflow_name: &str,
    resolver: &ExtendsResolver,
) -> Result<BivvyConfig> {
    let paths = ConfigPaths::discover(project_root);

    if !paths.has_project_config() {
        return Err(BivvyError::ConfigNotFound {
            path: project_root.join(".bivvy").join("config.yml"),
        });
    }

    let configs = collect_config_values(&paths, WorkflowSelection::One(workflow_name))?;
    let merged = merge_configs(&configs);

    let mut config: BivvyConfig =
        serde_yaml::from_value(merged).map_err(|e| BivvyError::ConfigParseError {
            path: project_root.join(".bivvy").join("config.yml"),
            message: format!("Failed to parse merged config: {}", e),
        })?;

    config.migrate_deprecated_fields();

    if config.extends.is_some() {
        let resolved = resolver.resolve(&config)?;
        Ok(resolved)
    } else {
        Ok(config)
    }
}

/// Variant of [`load_for_run`] with trust verification for remote `extends:` URLs.
///
/// This is the production loader for `bivvy run`. Behavior is identical
/// to [`load_merged_config_with_trust`] except that only the named
/// workflow file participates in the merge chain.
pub fn load_for_run_with_trust(
    project_root: &Path,
    workflow_name: &str,
    resolver: &ExtendsResolver,
    trust_policy: TrustPolicy,
    trust_store_path: &Path,
    ui: &mut dyn Prompter,
) -> Result<BivvyConfig> {
    let paths = ConfigPaths::discover(project_root);

    if !paths.has_project_config() {
        return Err(BivvyError::ConfigNotFound {
            path: project_root.join(".bivvy").join("config.yml"),
        });
    }

    let configs = collect_config_values(&paths, WorkflowSelection::One(workflow_name))?;
    let merged = merge_configs(&configs);

    let mut config: BivvyConfig =
        serde_yaml::from_value(merged).map_err(|e| BivvyError::ConfigParseError {
            path: project_root.join(".bivvy").join("config.yml"),
            message: format!("Failed to parse merged config: {}", e),
        })?;

    config.migrate_deprecated_fields();

    if config.extends.is_some() {
        let mut trust_store = TrustStore::load(trust_store_path).map_err(BivvyError::Other)?;
        let mut store_modified = false;

        let resolved = resolver
            .resolve_with_trust(&config, &mut |url: &str| {
                if trust_store.is_trusted(url) {
                    return Ok(());
                }

                match trust_policy {
                    TrustPolicy::TrustAll => {
                        trust_store.trust(url);
                        store_modified = true;
                        Ok(())
                    }
                    TrustPolicy::Reject => Err(anyhow::anyhow!(
                        "Untrusted remote config URL: {}. Use --trust to auto-approve or run interactively to confirm.",
                        url
                    )),
                    TrustPolicy::Prompt => {
                        let prompt = Prompt {
                            key: format!("trust_url_{}", url),
                            question: format!(
                                "Trust remote config from {}? (y/N)",
                                url
                            ),
                            prompt_type: PromptType::Confirm,
                            default: Some("false".to_string()),
                        };

                        match ui.prompt(&prompt) {
                            Ok(PromptResult::Bool(true)) => {
                                trust_store.trust(url);
                                store_modified = true;
                                Ok(())
                            }
                            _ => Err(anyhow::anyhow!(
                                "Untrusted remote config URL rejected by user: {}",
                                url
                            )),
                        }
                    }
                }
            })
            .map_err(BivvyError::Other)?;

        if store_modified {
            trust_store
                .save(trust_store_path)
                .map_err(BivvyError::Other)?;
        }

        Ok(resolved)
    } else {
        Ok(config)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::remote::RemoteFetcher;
    use httpmock::prelude::*;
    use std::fs;
    use std::time::Duration;
    use tempfile::TempDir;

    #[test]
    fn ensure_global_config_creates_config_and_schema() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let config_path = bivvy_dir.join("config.yml");
        let schema_path = bivvy_dir.join("schema.json");

        assert!(!config_path.exists());
        assert!(!schema_path.exists());

        ensure_global_config_in(temp.path().to_path_buf());

        // config.yml created with expected content
        let written = fs::read_to_string(&config_path).unwrap();
        assert!(written.contains("# Bivvy global configuration"));
        assert!(written.contains("# settings:"));
        assert!(written.contains("#     output: verbose"));
        assert!(written.contains("#   parallel: false"));
        assert!(written.contains("#   auto_update: true"));
        assert!(written.contains("#   logging: false"));

        // schema line points to the local schema.json
        assert!(written.contains(&format!(
            "# yaml-language-server: $schema={}",
            schema_path.display()
        )));

        // File should be valid YAML (all comments, so it parses as empty/null)
        let parsed: serde_yaml::Value = serde_yaml::from_str(&written).unwrap();
        assert!(parsed.is_null());

        // schema.json created and is valid JSON
        let schema_content = fs::read_to_string(&schema_path).unwrap();
        let schema: serde_json::Value = serde_json::from_str(&schema_content).unwrap();
        assert_eq!(schema["type"], "object");
    }

    #[test]
    fn ensure_global_config_does_not_overwrite_existing() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let config_path = bivvy_dir.join("config.yml");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(&config_path, "app_name: custom\n").unwrap();

        ensure_global_config_in(temp.path().to_path_buf());

        // config.yml should be unchanged
        let content = fs::read_to_string(&config_path).unwrap();
        assert_eq!(content, "app_name: custom\n");

        // schema.json should still be written
        let schema_path = bivvy_dir.join("schema.json");
        assert!(schema_path.exists());
    }

    #[test]
    fn ensure_global_config_updates_schema_on_rerun() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let schema_path = bivvy_dir.join("schema.json");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(&schema_path, "old content").unwrap();

        ensure_global_config_in(temp.path().to_path_buf());

        // schema.json should be overwritten with the real schema
        let content = fs::read_to_string(&schema_path).unwrap();
        assert_ne!(content, "old content");
        let schema: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(schema["type"], "object");
    }

    #[test]
    fn discover_finds_project_config() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: test").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert!(paths.project.is_some());
        assert!(paths.has_project_config());
    }

    #[test]
    fn discover_finds_local_overrides() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "").unwrap();
        fs::write(bivvy_dir.join("config.local.yml"), "").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert!(paths.project_local.is_some());
    }

    #[test]
    fn discover_returns_none_for_missing_configs() {
        let temp = TempDir::new().unwrap();
        let paths = ConfigPaths::discover(temp.path());
        assert!(paths.project.is_none());
        assert!(paths.project_local.is_none());
        assert!(!paths.has_project_config());
    }

    #[test]
    fn find_project_root_finds_bivvy_dir() {
        let temp = TempDir::new().unwrap();
        let subdir = temp.path().join("foo").join("bar");
        fs::create_dir_all(&subdir).unwrap();
        fs::create_dir_all(temp.path().join(".bivvy")).unwrap();

        let root = find_project_root(&subdir);
        assert_eq!(root, Some(temp.path().to_path_buf()));
    }

    #[test]
    fn find_project_root_finds_git_dir() {
        let temp = TempDir::new().unwrap();
        let subdir = temp.path().join("src");
        fs::create_dir_all(&subdir).unwrap();
        fs::create_dir_all(temp.path().join(".git")).unwrap();

        let root = find_project_root(&subdir);
        assert_eq!(root, Some(temp.path().to_path_buf()));
    }

    #[test]
    fn find_project_root_prefers_bivvy_over_git() {
        let temp = TempDir::new().unwrap();
        let subdir = temp.path().join("nested").join("project");
        fs::create_dir_all(&subdir).unwrap();
        fs::create_dir_all(temp.path().join(".git")).unwrap();
        fs::create_dir_all(subdir.join(".bivvy")).unwrap();

        let root = find_project_root(&subdir);
        assert_eq!(root, Some(subdir));
    }

    #[test]
    fn all_existing_returns_in_merge_order() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "").unwrap();
        fs::write(bivvy_dir.join("config.local.yml"), "").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        let all = paths.all_existing();

        // Project should come before local
        assert!(all.len() >= 2);
    }

    #[test]
    fn load_config_file_parses_valid_yaml() {
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.yml");
        fs::write(&config_path, "app_name: TestApp").unwrap();

        let config = load_config_file(&config_path).unwrap();
        assert_eq!(config.app_name, Some("TestApp".to_string()));
    }

    #[test]
    fn load_config_file_returns_not_found_error() {
        let result = load_config_file(Path::new("/nonexistent/config.yml"));
        assert!(matches!(result, Err(BivvyError::ConfigNotFound { .. })));
    }

    #[test]
    fn parse_config_returns_parse_error_for_invalid_yaml() {
        let content = "invalid: yaml: content: [";
        let result = parse_config(content, Path::new("test.yml"));
        assert!(matches!(result, Err(BivvyError::ConfigParseError { .. })));
    }

    #[test]
    fn load_config_value_returns_raw_value() {
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.yml");
        fs::write(&config_path, "key: value\nnested:\n  inner: 42").unwrap();

        let value = load_config_value(&config_path).unwrap();
        assert_eq!(value["key"], "value");
        assert_eq!(value["nested"]["inner"], 42);
    }

    #[test]
    fn load_config_file_handles_empty_file() {
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.yml");
        fs::write(&config_path, "").unwrap();

        let config = load_config_file(&config_path).unwrap();
        assert!(config.app_name.is_none());
        assert!(config.steps.is_empty());
    }

    #[test]
    fn load_config_file_parses_full_config() {
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.yml");
        fs::write(
            &config_path,
            r#"
app_name: "FullApp"
settings:
  defaults:
    output: quiet
steps:
  test:
    command: "echo test"
workflows:
  default:
    steps: [test]
"#,
        )
        .unwrap();

        let config = load_config_file(&config_path).unwrap();
        assert_eq!(config.app_name, Some("FullApp".to_string()));
        assert!(config.steps.contains_key("test"));
        assert!(config.workflows.contains_key("default"));
    }

    #[test]
    fn load_merged_config_merges_project_and_local() {
        use crate::config::schema::OutputMode;

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();

        // Project config
        fs::write(
            bivvy_dir.join("config.yml"),
            r#"
app_name: TestApp
settings:
  defaults:
    output: verbose
steps:
  test:
    command: "echo test"
"#,
        )
        .unwrap();

        // Local overrides
        fs::write(
            bivvy_dir.join("config.local.yml"),
            r#"
settings:
  defaults:
    output: quiet
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();

        assert_eq!(config.app_name, Some("TestApp".to_string()));
        assert_eq!(config.settings.defaults.output, OutputMode::Quiet);
        assert!(config.steps.contains_key("test"));
    }

    #[test]
    fn load_merged_config_fails_without_project_config() {
        let temp = TempDir::new().unwrap();
        let result = load_merged_config(temp.path());
        assert!(matches!(result, Err(BivvyError::ConfigNotFound { .. })));
    }

    #[test]
    fn load_config_with_override_skips_merge() {
        let temp = TempDir::new().unwrap();
        let override_path = temp.path().join("custom.yml");
        fs::write(&override_path, "app_name: CustomApp").unwrap();

        let config = load_config(temp.path(), Some(&override_path)).unwrap();
        assert_eq!(config.app_name, Some("CustomApp".to_string()));
    }

    #[test]
    fn load_merged_config_preserves_step_definitions() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();

        fs::write(
            bivvy_dir.join("config.yml"),
            r#"
steps:
  deps:
    command: "yarn install"
    watches:
      - package.json
  database:
    command: "rails db:setup"
"#,
        )
        .unwrap();

        fs::write(
            bivvy_dir.join("config.local.yml"),
            r#"
steps:
  deps:
    command: "yarn install --frozen-lockfile"
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();

        // deps command should be overridden
        assert_eq!(
            config.steps["deps"].execution.command,
            Some("yarn install --frozen-lockfile".to_string())
        );
        // database should still exist
        assert!(config.steps.contains_key("database"));
    }

    #[test]
    fn load_config_without_override_uses_merge() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: Merged").unwrap();

        let config = load_config(temp.path(), None).unwrap();
        assert_eq!(config.app_name, Some("Merged".to_string()));
    }

    // --- Extends pipeline integration tests ---

    fn resolver_for_mock(_server: &MockServer) -> ExtendsResolver {
        let temp = TempDir::new().unwrap();
        let cache_dir = temp.keep().join("cache");
        let fetcher = RemoteFetcher::with_cache_dir(Duration::from_secs(10), cache_dir);
        ExtendsResolver::with_fetcher(fetcher)
    }

    #[test]
    fn load_merged_config_resolves_extends() {
        let server = MockServer::start();

        let base_yaml = r#"
steps:
  lint:
    command: eslint .
    title: Lint code
"#;

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            format!(
                r#"
extends:
  - url: {}
app_name: MyApp
steps:
  test:
    command: jest
"#,
                server.url("/base.yml")
            ),
        )
        .unwrap();

        let resolver = resolver_for_mock(&server);
        let config = load_merged_config_with_resolver(temp.path(), &resolver).unwrap();

        // Local app_name is preserved
        assert_eq!(config.app_name, Some("MyApp".to_string()));
        // Base step is merged in
        assert!(config.steps.contains_key("lint"));
        assert_eq!(
            config.steps["lint"].execution.command,
            Some("eslint .".to_string())
        );
        // Local step is preserved
        assert!(config.steps.contains_key("test"));
        // Extends is cleared
        assert!(config.extends.is_none());
    }

    #[test]
    fn extends_merges_before_local_override() {
        let server = MockServer::start();

        let base_yaml = r#"
app_name: BaseApp
settings:
  defaults:
    output: quiet
steps:
  install:
    command: npm install
    title: Base install
"#;

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();

        // Project config extends base and overrides install command
        fs::write(
            bivvy_dir.join("config.yml"),
            format!(
                r#"
extends:
  - url: {}
app_name: MyApp
steps:
  install:
    command: yarn install
"#,
                server.url("/base.yml")
            ),
        )
        .unwrap();

        let resolver = resolver_for_mock(&server);
        let config = load_merged_config_with_resolver(temp.path(), &resolver).unwrap();

        // Local overrides base
        assert_eq!(config.app_name, Some("MyApp".to_string()));
        assert_eq!(
            config.steps["install"].execution.command,
            Some("yarn install".to_string())
        );
    }

    #[test]
    fn extends_with_local_override_file() {
        let server = MockServer::start();

        let base_yaml = r#"
app_name: BaseApp
steps:
  install:
    command: npm install
"#;

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();

        // Project config extends base
        fs::write(
            bivvy_dir.join("config.yml"),
            format!(
                r#"
extends:
  - url: {}
app_name: ProjectApp
"#,
                server.url("/base.yml")
            ),
        )
        .unwrap();

        // Local override file overrides app_name
        fs::write(
            bivvy_dir.join("config.local.yml"),
            "app_name: LocalOverride\n",
        )
        .unwrap();

        let resolver = resolver_for_mock(&server);
        let config = load_merged_config_with_resolver(temp.path(), &resolver).unwrap();

        // The extends resolution happens on the merged config,
        // so local override takes effect before extends
        // But since extends is in config.yml and local overrides app_name,
        // the merged config will have app_name=LocalOverride + extends from config.yml
        assert_eq!(config.app_name, Some("LocalOverride".to_string()));
        // Base step should be present (extends still works)
        assert!(config.steps.contains_key("install"));
    }

    #[test]
    fn config_without_extends_still_works() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: NoExtends").unwrap();

        // Using default resolver — won't make any HTTP calls
        let config = load_merged_config(temp.path()).unwrap();
        assert_eq!(config.app_name, Some("NoExtends".to_string()));
    }

    // --- Trust verification tests ---

    #[test]
    fn trust_all_policy_auto_approves_urls() {
        let server = MockServer::start();
        let base_yaml = "app_name: BaseApp\n";

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            format!(
                "extends:\n  - url: {}\napp_name: MyApp\n",
                server.url("/base.yml")
            ),
        )
        .unwrap();

        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");

        let resolver = resolver_for_mock(&server);
        let mut ui = crate::ui::MockUI::new();

        let config = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::TrustAll,
            &trust_path,
            &mut ui,
        )
        .unwrap();

        assert_eq!(config.app_name, Some("MyApp".to_string()));

        // Trust should have been persisted
        let store = TrustStore::load(&trust_path).unwrap();
        assert!(store.is_trusted(&server.url("/base.yml")));
    }

    #[test]
    fn reject_policy_errors_on_untrusted_url() {
        let server = MockServer::start();
        let base_yaml = "app_name: BaseApp\n";

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            format!(
                "extends:\n  - url: {}\napp_name: MyApp\n",
                server.url("/base.yml")
            ),
        )
        .unwrap();

        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");

        let resolver = resolver_for_mock(&server);
        let mut ui = crate::ui::MockUI::new();

        let result = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::Reject,
            &trust_path,
            &mut ui,
        );

        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("Untrusted") || err.contains("--trust"),
            "Error should mention untrusted or --trust: {}",
            err
        );
    }

    #[test]
    fn already_trusted_url_is_fetched_without_prompt() {
        let server = MockServer::start();
        let base_yaml = "app_name: BaseApp\n";

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        let url = server.url("/base.yml");
        fs::write(
            bivvy_dir.join("config.yml"),
            format!("extends:\n  - url: {}\napp_name: MyApp\n", url),
        )
        .unwrap();

        // Pre-trust the URL
        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");
        let mut store = TrustStore::default();
        store.trust(&url);
        store.save(&trust_path).unwrap();

        let resolver = resolver_for_mock(&server);
        let mut ui = crate::ui::MockUI::new();

        // Even with Reject policy, it should succeed because URL is already trusted
        let config = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::Reject,
            &trust_path,
            &mut ui,
        )
        .unwrap();

        assert_eq!(config.app_name, Some("MyApp".to_string()));
        // No prompts should have been shown
        assert!(ui.prompts_shown().is_empty());
    }

    #[test]
    fn prompt_policy_trusts_on_user_confirmation() {
        let server = MockServer::start();
        let base_yaml = "app_name: BaseApp\n";

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        let url = server.url("/base.yml");
        fs::write(
            bivvy_dir.join("config.yml"),
            format!("extends:\n  - url: {}\napp_name: MyApp\n", url),
        )
        .unwrap();

        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");

        let resolver = resolver_for_mock(&server);
        let mut ui = crate::ui::MockUI::new();
        // Pre-configure the mock to answer "yes" to the trust prompt
        ui.set_prompt_response(&format!("trust_url_{}", url), "yes");

        let config = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::Prompt,
            &trust_path,
            &mut ui,
        )
        .unwrap();

        assert_eq!(config.app_name, Some("MyApp".to_string()));

        // URL should have been persisted
        let store = TrustStore::load(&trust_path).unwrap();
        assert!(store.is_trusted(&url));
    }

    #[test]
    fn prompt_policy_rejects_on_user_denial() {
        let server = MockServer::start();
        let base_yaml = "app_name: BaseApp\n";

        server.mock(|when, then| {
            when.method(GET).path("/base.yml");
            then.status(200).body(base_yaml);
        });

        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        let url = server.url("/base.yml");
        fs::write(
            bivvy_dir.join("config.yml"),
            format!("extends:\n  - url: {}\napp_name: MyApp\n", url),
        )
        .unwrap();

        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");

        let resolver = resolver_for_mock(&server);
        let mut ui = crate::ui::MockUI::new();
        // Pre-configure the mock to answer "no" to the trust prompt
        ui.set_prompt_response(&format!("trust_url_{}", url), "no");

        let result = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::Prompt,
            &trust_path,
            &mut ui,
        );

        assert!(result.is_err());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("rejected"),
            "Error should mention rejection: {}",
            err
        );

        // URL should NOT have been persisted
        let store = TrustStore::load(&trust_path).unwrap();
        assert!(!store.is_trusted(&url));
    }

    #[test]
    fn config_without_extends_skips_trust_check() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: NoExtends").unwrap();

        let trust_dir = TempDir::new().unwrap();
        let trust_path = trust_dir.path().join("trusted_urls.yml");

        let resolver = ExtendsResolver::default();
        let mut ui = crate::ui::MockUI::new();

        // Even with Reject policy, no-extends configs should work fine
        let config = load_merged_config_with_trust(
            temp.path(),
            &resolver,
            TrustPolicy::Reject,
            &trust_path,
            &mut ui,
        )
        .unwrap();

        assert_eq!(config.app_name, Some("NoExtends".to_string()));
    }

    // --- Split-file config tests ---

    #[test]
    fn discover_finds_split_steps() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: test").unwrap();
        fs::write(steps_dir.join("database.yml"), "command: rails db:setup").unwrap();
        fs::write(steps_dir.join("deps.yml"), "command: yarn install").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert_eq!(paths.split_steps.len(), 2);
        assert!(paths.has_split_files());
    }

    #[test]
    fn discover_finds_split_workflows() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let workflows_dir = bivvy_dir.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: test").unwrap();
        fs::write(workflows_dir.join("ci.yml"), "steps: [deps, test]").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert_eq!(paths.split_workflows.len(), 1);
        assert!(paths.has_split_files());
    }

    #[test]
    fn discover_ignores_non_yaml_in_split_dirs() {
        let temp = TempDir::new().unwrap();
        let steps_dir = temp.path().join(".bivvy").join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(
            temp.path().join(".bivvy").join("config.yml"),
            "app_name: test",
        )
        .unwrap();
        fs::write(steps_dir.join("database.yml"), "command: rails db:setup").unwrap();
        fs::write(steps_dir.join("README.md"), "# Steps").unwrap();
        fs::write(steps_dir.join(".gitkeep"), "").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert_eq!(paths.split_steps.len(), 1);
    }

    #[test]
    fn discover_accepts_yaml_extension() {
        let temp = TempDir::new().unwrap();
        let steps_dir = temp.path().join(".bivvy").join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(
            temp.path().join(".bivvy").join("config.yml"),
            "app_name: test",
        )
        .unwrap();
        fs::write(steps_dir.join("database.yaml"), "command: rails db:setup").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert_eq!(paths.split_steps.len(), 1);
    }

    #[test]
    fn split_steps_only_counts_as_project_config() {
        let temp = TempDir::new().unwrap();
        let steps_dir = temp.path().join(".bivvy").join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(steps_dir.join("database.yml"), "command: rails db:setup").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        // No config.yml, but split files exist
        assert!(paths.project.is_none());
        assert!(paths.has_project_config());
    }

    #[test]
    fn load_merged_config_loads_split_steps() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: SplitApp").unwrap();
        fs::write(
            steps_dir.join("database.yml"),
            r#"
command: "rails db:setup"
title: "Setup database"
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert_eq!(config.app_name, Some("SplitApp".to_string()));
        assert!(config.steps.contains_key("database"));
        assert_eq!(
            config.steps["database"].execution.command,
            Some("rails db:setup".to_string())
        );
        assert_eq!(
            config.steps["database"].title,
            Some("Setup database".to_string())
        );
    }

    #[test]
    fn load_merged_config_loads_split_workflows() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let workflows_dir = bivvy_dir.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            r#"
app_name: SplitApp
steps:
  deps:
    command: "yarn install"
  test:
    command: "yarn test"
"#,
        )
        .unwrap();
        fs::write(
            workflows_dir.join("ci.yml"),
            r#"
description: "CI pipeline"
steps: [deps, test]
settings:
  non_interactive: true
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert!(config.workflows.contains_key("ci"));
        assert_eq!(
            config.workflows["ci"].description,
            Some("CI pipeline".to_string())
        );
        assert_eq!(config.workflows["ci"].steps, vec!["deps", "test"]);
    }

    #[test]
    fn split_steps_override_inline_steps() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            r#"
steps:
  database:
    command: "rails db:setup"
    title: "Original title"
"#,
        )
        .unwrap();
        // Split file overrides the command
        fs::write(
            steps_dir.join("database.yml"),
            r#"
command: "rails db:prepare"
title: "Overridden title"
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert_eq!(
            config.steps["database"].execution.command,
            Some("rails db:prepare".to_string())
        );
        assert_eq!(
            config.steps["database"].title,
            Some("Overridden title".to_string())
        );
    }

    #[test]
    fn local_overrides_override_split_files() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: Test").unwrap();
        fs::write(
            steps_dir.join("database.yml"),
            r#"
command: "rails db:setup"
title: "From split file"
"#,
        )
        .unwrap();
        // config.local.yml overrides the split file
        fs::write(
            bivvy_dir.join("config.local.yml"),
            r#"
steps:
  database:
    command: "rails db:prepare"
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert_eq!(
            config.steps["database"].execution.command,
            Some("rails db:prepare".to_string())
        );
        // title from split file should be preserved (deep merge)
        assert_eq!(
            config.steps["database"].title,
            Some("From split file".to_string())
        );
    }

    #[test]
    fn split_steps_and_inline_steps_coexist() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(
            bivvy_dir.join("config.yml"),
            r#"
steps:
  deps:
    command: "yarn install"
"#,
        )
        .unwrap();
        fs::write(
            steps_dir.join("database.yml"),
            "command: \"rails db:setup\"",
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert!(config.steps.contains_key("deps"));
        assert!(config.steps.contains_key("database"));
    }

    #[test]
    fn split_files_work_without_config_yml() {
        let temp = TempDir::new().unwrap();
        let steps_dir = temp.path().join(".bivvy").join("steps");
        let workflows_dir = temp.path().join(".bivvy").join("workflows");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::create_dir_all(&workflows_dir).unwrap();
        fs::write(steps_dir.join("deps.yml"), "command: \"yarn install\"").unwrap();
        fs::write(workflows_dir.join("default.yml"), "steps: [deps]").unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        assert!(config.steps.contains_key("deps"));
        assert!(config.workflows.contains_key("default"));
    }

    #[test]
    fn empty_split_dirs_are_ignored() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        fs::create_dir_all(bivvy_dir.join("steps")).unwrap();
        fs::create_dir_all(bivvy_dir.join("workflows")).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: Test").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        assert!(paths.split_steps.is_empty());
        assert!(paths.split_workflows.is_empty());
        assert!(!paths.has_split_files());
    }

    #[test]
    fn split_files_sorted_for_deterministic_order() {
        let temp = TempDir::new().unwrap();
        let steps_dir = temp.path().join(".bivvy").join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(
            temp.path().join(".bivvy").join("config.yml"),
            "app_name: test",
        )
        .unwrap();
        fs::write(steps_dir.join("zebra.yml"), "command: echo z").unwrap();
        fs::write(steps_dir.join("alpha.yml"), "command: echo a").unwrap();
        fs::write(steps_dir.join("middle.yml"), "command: echo m").unwrap();

        let paths = ConfigPaths::discover(temp.path());
        let names: Vec<&str> = paths
            .split_steps
            .iter()
            .filter_map(|p| p.file_stem().and_then(|s| s.to_str()))
            .collect();
        assert_eq!(names, vec!["alpha", "middle", "zebra"]);
    }

    #[test]
    fn split_step_with_full_config() {
        let temp = TempDir::new().unwrap();
        let bivvy_dir = temp.path().join(".bivvy");
        let steps_dir = bivvy_dir.join("steps");
        fs::create_dir_all(&steps_dir).unwrap();
        fs::write(bivvy_dir.join("config.yml"), "app_name: Test").unwrap();
        fs::write(
            steps_dir.join("database.yml"),
            r#"
title: "Setup database"
command: "rails db:setup"
depends_on: [deps]
check:
  type: execution
  command: "rails db:version"
retry: 2
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        let step = &config.steps["database"];
        assert_eq!(step.title, Some("Setup database".to_string()));
        assert_eq!(step.execution.command, Some("rails db:setup".to_string()));
        assert_eq!(step.depends_on, vec!["deps"]);
        assert!(step.execution.check.is_some());
        assert_eq!(step.execution.retry, 2);
    }

    // --- Targeted loader tests (Phase 1) ---

    #[test]
    fn load_project_config_reads_only_project_file() {
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        fs::create_dir_all(&bivvy).unwrap();
        fs::write(bivvy.join("config.yml"), "app_name: ProjectOnly").unwrap();

        // Local file that load_project_config should ignore
        fs::write(bivvy.join("config.local.yml"), "app_name: LocalOverride\n").unwrap();

        let config = load_project_config(temp.path()).unwrap();
        assert_eq!(config.app_name, Some("ProjectOnly".to_string()));
    }

    #[test]
    fn load_project_config_errors_when_missing() {
        let temp = TempDir::new().unwrap();
        let result = load_project_config(temp.path());
        assert!(matches!(result, Err(BivvyError::ConfigNotFound { .. })));
    }

    #[test]
    fn load_single_workflow_file_parses_legacy_format() {
        let temp = TempDir::new().unwrap();
        let workflows_dir = temp.path().join(".bivvy").join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();
        let path = workflows_dir.join("ci.yml");
        fs::write(
            &path,
            r#"
description: "CI pipeline"
steps: [deps, test]
"#,
        )
        .unwrap();

        let file = load_single_workflow_file(&path).unwrap();
        assert_eq!(file.description, Some("CI pipeline".to_string()));
        assert!(file.steps.is_empty());
        assert!(file.vars.is_empty());
        assert_eq!(file.workflow.steps, vec!["deps", "test"]);
    }

    #[test]
    fn load_single_workflow_file_parses_new_format() {
        let temp = TempDir::new().unwrap();
        let workflows_dir = temp.path().join(".bivvy").join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();
        let path = workflows_dir.join("release.yml");
        fs::write(
            &path,
            r#"
description: "Release prep"
steps:
  fetch:
    command: "git fetch"
vars:
  ver: "1.0"
workflow:
  steps:
    - fetch
"#,
        )
        .unwrap();

        let file = load_single_workflow_file(&path).unwrap();
        assert_eq!(file.description, Some("Release prep".to_string()));
        assert!(file.steps.contains_key("fetch"));
        assert!(file.vars.contains_key("ver"));
        assert_eq!(file.workflow.steps, vec!["fetch"]);
    }

    #[test]
    fn load_single_step_file_parses_step_yaml() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("step.yml");
        fs::write(
            &path,
            r#"
title: "Setup database"
command: "rails db:setup"
"#,
        )
        .unwrap();

        let step = load_single_step_file(&path).unwrap();
        assert_eq!(step.title, Some("Setup database".to_string()));
        assert_eq!(step.execution.command, Some("rails db:setup".to_string()));
    }

    #[test]
    fn load_for_run_only_loads_named_workflow_file() {
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        let workflows_dir = bivvy.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();

        fs::write(bivvy.join("config.yml"), "app_name: Test").unwrap();

        // The workflow we want to run.
        fs::write(
            workflows_dir.join("good.yml"),
            r#"
description: "Good workflow"
steps:
  hello:
    command: "echo hello"
workflow:
  steps:
    - hello
"#,
        )
        .unwrap();

        // A malformed sibling — must not be parsed.
        fs::write(
            workflows_dir.join("broken.yml"),
            "this: is: not: valid: yaml: at all",
        )
        .unwrap();

        let config = load_for_run(temp.path(), "good").unwrap();
        assert!(config.workflows.contains_key("good"));
        assert!(!config.workflows.contains_key("broken"));
        assert!(config.steps.contains_key("hello"));
    }

    #[test]
    fn load_for_run_workflow_step_overrides_project_step() {
        // Resolution chain: project (3) → workflow file (5) → local (6).
        // Workflow files override project config; local overrides everything.
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        let workflows_dir = bivvy.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();

        fs::write(
            bivvy.join("config.yml"),
            r#"
app_name: Test
steps:
  hello:
    command: "from project"
"#,
        )
        .unwrap();

        fs::write(
            workflows_dir.join("greet.yml"),
            r#"
steps:
  hello:
    command: "from workflow file"
workflow:
  steps:
    - hello
"#,
        )
        .unwrap();

        let config = load_for_run(temp.path(), "greet").unwrap();
        assert_eq!(
            config.steps["hello"].execution.command,
            Some("from workflow file".to_string())
        );
    }

    #[test]
    fn load_for_run_local_overrides_workflow_step() {
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        let workflows_dir = bivvy.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();

        fs::write(bivvy.join("config.yml"), "app_name: Test").unwrap();

        fs::write(
            workflows_dir.join("greet.yml"),
            r#"
steps:
  hello:
    command: "workflow"
workflow:
  steps:
    - hello
"#,
        )
        .unwrap();

        fs::write(
            bivvy.join("config.local.yml"),
            r#"
steps:
  hello:
    command: "local override"
"#,
        )
        .unwrap();

        let config = load_for_run(temp.path(), "greet").unwrap();
        assert_eq!(
            config.steps["hello"].execution.command,
            Some("local override".to_string())
        );
    }

    #[test]
    fn load_for_run_handles_legacy_workflow_layout() {
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        let workflows_dir = bivvy.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();

        fs::write(
            bivvy.join("config.yml"),
            r#"
app_name: Legacy
steps:
  deps:
    command: "yarn install"
"#,
        )
        .unwrap();

        fs::write(
            workflows_dir.join("ci.yml"),
            r#"
description: "Legacy CI"
steps: [deps]
"#,
        )
        .unwrap();

        let config = load_for_run(temp.path(), "ci").unwrap();
        let workflow = config.workflows.get("ci").unwrap();
        assert_eq!(workflow.description, Some("Legacy CI".to_string()));
        assert_eq!(workflow.steps, vec!["deps"]);
        assert!(config.steps.contains_key("deps"));
    }

    #[test]
    fn load_merged_config_handles_new_format_workflow_files() {
        let temp = TempDir::new().unwrap();
        let bivvy = temp.path().join(".bivvy");
        let workflows_dir = bivvy.join("workflows");
        fs::create_dir_all(&workflows_dir).unwrap();

        fs::write(bivvy.join("config.yml"), "app_name: Mixed").unwrap();

        fs::write(
            workflows_dir.join("release.yml"),
            r#"
description: "Release"
steps:
  fetch:
    command: "git fetch"
workflow:
  steps:
    - fetch
"#,
        )
        .unwrap();

        let config = load_merged_config(temp.path()).unwrap();
        let workflow = config.workflows.get("release").unwrap();
        assert_eq!(workflow.description, Some("Release".to_string()));
        assert_eq!(workflow.steps, vec!["fetch"]);
        assert!(config.steps.contains_key("fetch"));
    }
}
