use crate::core::component::{self, Component};
use crate::core::engine::command::CapturedOutput;
use crate::core::engine::shell;
use crate::core::engine::{template, validation};
use crate::core::error::{Error, Result};
use crate::core::project::{self, Project};
use crate::core::rig::toolchain;
use crate::core::server::{
    execute_local_command_in_dir, execute_local_command_in_dir_with_timeout,
    execute_local_command_interactive, execute_local_command_passthrough,
    execute_local_command_passthrough_with_timeout, execute_local_command_stderr_passthrough,
    execute_local_command_stderr_passthrough_with_timeout, CommandOutput,
};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;

mod action;
mod readiness;
mod settings;

use super::env_provider;
use super::exec_context;
use super::load_extension;
use super::manifest::{ExtensionManifest, RuntimeConfig};
use super::read_source_revision;
use super::runner_contract::RunnerStepFilter;
use super::runtime_helper;
use super::scope::ExtensionScope;

pub(crate) use action::execute_action;
pub use readiness::{extension_ready_status, is_extension_compatible, ExtensionReadyStatus};
use settings::serialize_settings;
pub(crate) use settings::{build_settings_json_from_manifest, load_extension_manifest_from_dir};

/// Result of executing a extension.
pub struct ExtensionRunResult {
    pub exit_code: i32,
    pub project_id: Option<String>,
    pub output: Option<CapturedOutput>,
}

pub(crate) struct ExtensionExecutionResult {
    pub output: CapturedOutput,
    pub exit_code: i32,
    pub success: bool,
}

pub(crate) struct ExtensionExecutionOutcome {
    pub project_id: Option<String>,
    pub result: ExtensionExecutionResult,
}

pub enum ExtensionExecutionMode {
    Interactive,
    Captured,
}

/// Result of running extension setup.
pub struct ExtensionSetupResult {
    pub exit_code: i32,
}

struct ExtensionExecutionContext {
    extension_id: String,
    project_id: Option<String>,
    component_id: Option<String>,
    project: Option<Project>,
    settings: HashMap<String, serde_json::Value>,
}

/// Run a extension's setup command (if defined).
pub fn run_setup(extension_id: &str) -> Result<ExtensionSetupResult> {
    let extension = load_extension(extension_id)?;

    let runtime = match extension.runtime() {
        Some(r) => r,
        None => {
            return Ok(ExtensionSetupResult { exit_code: 0 });
        }
    };

    let setup_command = match &runtime.setup_command {
        Some(cmd) => cmd,
        None => {
            return Ok(ExtensionSetupResult { exit_code: 0 });
        }
    };

    let extension_path = validation::require(
        extension.extension_path.as_ref(),
        "extension",
        "extension_path not set",
    )?;

    let entrypoint = runtime.entrypoint.clone().unwrap_or_default();
    let vars: Vec<(&str, &str)> = vec![
        ("extension_path", extension_path.as_str()),
        ("entrypoint", entrypoint.as_str()),
    ];

    let command = template::render(setup_command, &vars);
    let exit_code = execute_local_command_interactive(&command, Some(extension_path), None);

    if exit_code != 0 {
        return Err(Error::internal_io(
            format!("Setup command failed with exit code {}", exit_code),
            Some("extension setup".to_string()),
        ));
    }

    // Persist the runtime env the extension's provider discovers now that setup
    // has run. On a Lab runner, setup configures runner-local state (e.g. a
    // freshly built Managed Sandbox core module path); persisting the resolved env
    // lets a later capability execution (`homeboy fuzz run`) replay the same
    // effective runtime env without manual operator injection. Persistence is
    // best-effort: a discovery failure must not fail an otherwise-successful
    // setup.
    persist_setup_runtime_env(&extension, extension_id);

    Ok(ExtensionSetupResult { exit_code })
}

/// Capture and persist the runtime env an extension's env provider discovers
/// after a successful setup, so later capability executions on the same runner
/// replay the same effective env. Best-effort: failures are swallowed.
fn persist_setup_runtime_env(extension: &ExtensionManifest, extension_id: &str) {
    let Some(extension_path) = extension.extension_path.as_deref() else {
        return;
    };
    if extension.env_provider_script().is_none() {
        return;
    }

    // Seed the provider with the canonical exec-context vars so the discovery
    // script sees the same baseline a capability execution would. We resolve the
    // env against the extension path itself because setup has no component
    // context; env providers that depend on runner-local setup state (rather
    // than a specific component checkout) resolve correctly here.
    let extension_dir = Path::new(extension_path);
    let base_env = build_exec_env(
        extension_id,
        None,
        None,
        "{}",
        Some(extension_path),
        None,
        None,
        None,
    );

    match super::env_provider::env_vars(extension, extension_dir, &base_env) {
        Ok(discovered) if !discovered.is_empty() => {
            let _ = super::setup_env::persist(extension_id, &discovered);
        }
        // No discovered env (or a discovery error): clear any stale persisted
        // document so a previous run's values never leak into fuzz execution.
        _ => {
            let _ = super::setup_env::persist(extension_id, &[]);
        }
    }
}

/// Backward-compatible alias for existing command API usage.
pub type ExtensionStepFilter = RunnerStepFilter;

/// Execute a extension with optional project context.
pub fn run_extension(
    extension_id: &str,
    project_id: Option<&str>,
    component_id: Option<&str>,
    inputs: Vec<(String, String)>,
    args: Vec<String>,
    mode: ExtensionExecutionMode,
    filter: ExtensionStepFilter,
) -> Result<ExtensionRunResult> {
    let is_captured = matches!(mode, ExtensionExecutionMode::Captured);
    let execution = execute_extension_runtime(
        extension_id,
        project_id,
        component_id,
        inputs,
        args,
        None,
        None,
        mode,
        &filter,
    )?;

    let output = if is_captured && !execution.result.output.is_empty() {
        Some(execution.result.output)
    } else {
        None
    };

    Ok(ExtensionRunResult {
        exit_code: execution.result.exit_code,
        project_id: execution.project_id,
        output,
    })
}

/// Execute a extension action (API call).
pub fn run_action(
    extension_id: &str,
    action_id: &str,
    project_id: Option<&str>,
    data: Option<&str>,
) -> Result<serde_json::Value> {
    execute_action(extension_id, action_id, project_id, data, None)
}

fn extension_runtime(extension: &ExtensionManifest) -> Result<&RuntimeConfig> {
    extension.runtime().ok_or_else(|| {
        Error::config(format!(
            "Extension '{}' does not have a runtime configuration and cannot be executed",
            extension.id
        ))
    })
}

fn build_args_string(
    extension: &ExtensionManifest,
    inputs: Vec<(String, String)>,
    args: Vec<String>,
) -> String {
    let input_values: HashMap<String, String> = inputs.into_iter().collect();
    let mut argv = Vec::new();
    for input in extension.inputs() {
        if let Some(value) = input_values.get(&input.id) {
            if !value.is_empty() {
                argv.push(input.arg.clone());
                argv.push(value.clone());
            }
        }
    }
    argv.extend(args);
    argv.join(" ")
}

fn resolve_extension_context(
    extension: &ExtensionManifest,
    extension_id: &str,
    project_id: Option<&str>,
    component_id: Option<&str>,
    run_command: &str,
) -> Result<ExtensionExecutionContext> {
    let requires_project = extension.requires.is_some()
        || template::is_present(run_command, "projectId")
        || template::is_present(run_command, "sitePath")
        || template::is_present(run_command, "cliPath")
        || template::is_present(run_command, "domain");

    let mut project = None;
    let mut component = None;
    let mut resolved_project_id = None;
    let mut resolved_component_id = None;

    // Handle component-only execution (no project required)
    if let Some(cid) = component_id {
        if let Ok(loaded_component) = component::resolve_effective(Some(cid), None, None) {
            component = Some(loaded_component);
            resolved_component_id = Some(cid.to_string());
        }
    }

    if requires_project {
        let pid = project_id.ok_or_else(|| {
            Error::config(format!(
                "Extension {} requires a project context, but no project ID was provided",
                extension.id
            ))
        })?;

        let loaded_project = project::load(pid)?;
        ExtensionScope::validate_project_compatibility(extension, &loaded_project)?;

        resolved_component_id =
            ExtensionScope::resolve_component_scope(extension, &loaded_project, component_id)?;

        if let Some(ref comp_id) = resolved_component_id {
            component = Some(
                component::resolve_effective(Some(comp_id), None, Some(&loaded_project)).map_err(
                    |_| {
                        Error::config(format!(
                            "Component {} required by extension {} is not configured",
                            comp_id, &extension.id
                        ))
                    },
                )?,
            );
        }

        resolved_project_id = Some(pid.to_string());
        project = Some(loaded_project);
    }

    let settings =
        ExtensionScope::effective_settings(extension_id, project.as_ref(), component.as_ref())?;

    Ok(ExtensionExecutionContext {
        extension_id: extension_id.to_string(),
        project_id: resolved_project_id,
        component_id: resolved_component_id,
        project,
        settings,
    })
}

pub(crate) fn validate_capability_script_exists(
    extension_path: &Path,
    script_path: &str,
    capability: super::ExtensionCapability,
) -> Result<()> {
    let script_path = extension_path.join(script_path);
    if !script_path.exists() {
        return Err(Error::validation_invalid_argument(
            "extension",
            format!(
                "Extension at {} does not have {} infrastructure (missing {})",
                extension_path.display(),
                capability.label(),
                script_path.display()
            ),
            None,
            None,
        ));
    }
    Ok(())
}

pub(crate) fn build_capability_env(
    extension_name: &str,
    component_id: &str,
    extension_path: &Path,
    component_path: &Path,
    settings_json: &str,
    extra_env: &[(String, String)],
) -> Result<Vec<(String, String)>> {
    build_capability_env_with_additional_providers(
        extension_name,
        component_id,
        extension_path,
        component_path,
        settings_json,
        &[],
        extra_env,
    )
}

pub(crate) fn build_capability_env_with_additional_providers(
    extension_name: &str,
    component_id: &str,
    extension_path: &Path,
    component_path: &Path,
    settings_json: &str,
    additional_env_provider_paths: &[(String, std::path::PathBuf)],
    extra_env: &[(String, String)],
) -> Result<Vec<(String, String)>> {
    let component_path_value = component_path.to_string_lossy();
    let mut env = build_exec_env(
        extension_name,
        None,
        Some(component_id),
        settings_json,
        Some(&extension_path.to_string_lossy()),
        None,
        None,
        Some(&component_path_value),
    );
    // Replay the runtime env discovered during extension setup before invoking
    // the live env provider. On a Lab runner, fuzz execution runs in a fresh
    // process from the one that ran setup; the persisted setup env guarantees
    // capability execution receives the same effective runtime env (e.g. the
    // configured Managed Sandbox core module path) without manual injection. Live
    // provider discovery below still overrides any value it can re-resolve.
    let persisted_setup_env = super::setup_env::load(extension_name);
    env.extend(persisted_setup_env.iter().cloned());

    let mut provider_env = env.clone();
    provider_env.extend(extra_env.iter().cloned());
    if let Ok(extension) = env_provider::load_manifest_from_dir(extension_path) {
        env.extend(env_provider::env_vars(
            &extension,
            component_path,
            &provider_env,
        )?);
    }
    for (_extension_id, provider_path) in additional_env_provider_paths {
        if let Ok(extension) = env_provider::load_manifest_from_dir(provider_path) {
            env.extend(env_provider::env_vars(
                &extension,
                component_path,
                &provider_env,
            )?);
        }
    }
    env.extend(extra_env.iter().cloned());
    Ok(env)
}

pub(crate) fn execute_capability_script(
    extension_path: &Path,
    script_path: &str,
    script_args: &[String],
    env_vars: &[(String, String)],
    working_dir: Option<&str>,
    command_override: Option<&str>,
    options: CapabilityScriptOptions,
) -> Result<CommandOutput> {
    let command = if let Some(cmd) = command_override {
        cmd.to_string()
    } else {
        let resolved = extension_path.join(script_path);
        let mut cmd = shell::quote_path(&resolved.to_string_lossy());
        if !script_args.is_empty() {
            cmd.push(' ');
            cmd.push_str(&shell::quote_args(script_args));
        }
        cmd
    };

    let env_refs: Vec<(&str, &str)> = env_vars
        .iter()
        .map(|(k, v)| (k.as_str(), v.as_str()))
        .collect();

    let env_opt = if env_refs.is_empty() {
        None
    } else {
        Some(env_refs.as_slice())
    };

    let current_dir = working_dir;

    // Select the timeout-aware vs plain variant of the chosen execution mode,
    // keeping the `options.timeout` dispatch in one place rather than repeating
    // it per passthrough mode.
    let dispatch = |with_timeout: &dyn Fn(Duration) -> CommandOutput,
                    without_timeout: &dyn Fn() -> CommandOutput| {
        match options.timeout {
            Some(timeout) => with_timeout(timeout),
            None => without_timeout(),
        }
    };

    if options.passthrough {
        Ok(dispatch(
            &|timeout| {
                execute_local_command_passthrough_with_timeout(
                    &command,
                    current_dir,
                    env_opt,
                    timeout,
                )
            },
            &|| execute_local_command_passthrough(&command, current_dir, env_opt),
        ))
    } else if options.stderr_passthrough {
        Ok(dispatch(
            &|timeout| {
                execute_local_command_stderr_passthrough_with_timeout(
                    &command,
                    current_dir,
                    env_opt,
                    timeout,
                )
            },
            &|| execute_local_command_stderr_passthrough(&command, current_dir, env_opt),
        ))
    } else {
        Ok(dispatch(
            &|timeout| {
                execute_local_command_in_dir_with_timeout(&command, current_dir, env_opt, timeout)
            },
            &|| execute_local_command_in_dir(&command, current_dir, env_opt),
        ))
    }
}

pub(crate) struct CapabilityScriptOptions {
    pub passthrough: bool,
    pub stderr_passthrough: bool,
    pub timeout: Option<Duration>,
}

pub(crate) struct PreparedCapabilityRun {
    pub execution: super::ExtensionExecutionContext,
    pub settings_json: String,
}

pub(crate) fn resolve_capability_component(
    execution_context: &super::ExtensionExecutionContext,
    pre_loaded_component: Option<&Component>,
    path_override: Option<&str>,
) -> Result<Component> {
    let mut comp = if let Some(pre_loaded) = pre_loaded_component {
        pre_loaded.clone()
    } else {
        component::resolve_effective(Some(&execution_context.component.id), path_override, None)?
    };

    if let Some(path) = path_override {
        comp.local_path = absolutize_path_override(path);
    }

    Ok(comp)
}

pub(crate) fn build_capability_execution_context(
    execution_context: &super::ExtensionExecutionContext,
    component: Component,
    path_override: Option<&str>,
) -> super::ExtensionExecutionContext {
    let mut execution = execution_context.clone();
    execution.component = component;

    if let Some(path) = path_override {
        execution.component.local_path = absolutize_path_override(path);
    }

    execution
}

/// Resolve a `--path` override to an absolute, canonical path so downstream
/// consumers receive a stable component root regardless of the caller's working
/// directory.
///
/// The override is published verbatim to extension runners via
/// `HOMEBOY_COMPONENT_PATH`. A relative override such as `--path .` would
/// otherwise reach a runner as-is; any tool that composes a config file from the
/// component root and then executes from a different working directory (for
/// example a generated config in a temp dir that references files relative to
/// the component) cannot resolve those references, so an otherwise-clean run
/// fails (Extra-Chill/homeboy#6818). Absolutizing here makes a relative `--path`
/// behave identically to an absolute one. This is framework-agnostic: core
/// guarantees an absolute component root and leaves all language/tool specifics
/// to the component's own extension runner.
fn absolutize_path_override(path: &str) -> String {
    let candidate = Path::new(path);
    if let Ok(canonical) = candidate.canonicalize() {
        return canonical.to_string_lossy().into_owned();
    }
    if candidate.is_absolute() {
        return path.to_string();
    }
    match std::env::current_dir() {
        Ok(cwd) => cwd.join(candidate).to_string_lossy().into_owned(),
        Err(_) => path.to_string(),
    }
}

pub(crate) fn prepare_capability_run(
    execution_context: &super::ExtensionExecutionContext,
    pre_loaded_component: Option<&Component>,
    path_override: Option<&str>,
    settings_overrides: &[(String, String)],
    settings_json_overrides: &[(String, serde_json::Value)],
    skip_script_validation: bool,
) -> Result<PreparedCapabilityRun> {
    let component =
        resolve_capability_component(execution_context, pre_loaded_component, path_override)?;
    let execution = build_capability_execution_context(execution_context, component, path_override);

    // Skip validation when a command_override is provided (e.g., Build with command_template)
    // since the script_path may be empty or not point to an actual file.
    if !skip_script_validation && !execution.script_path.is_empty() {
        validate_capability_script_exists(
            &execution.extension_path,
            &execution.script_path,
            execution.capability,
        )?;
    }

    let manifest = load_extension_manifest_from_dir(&execution.extension_path)?;
    super::validate_core_compatibility(
        "extension",
        &execution.extension_id,
        manifest
            .get("requires")
            .and_then(|requires| requires.get("homeboy"))
            .and_then(serde_json::Value::as_str),
        read_source_revision(&execution.extension_id),
    )?;
    let settings_json = build_settings_json_from_manifest(
        &manifest,
        &execution.settings,
        settings_overrides,
        settings_json_overrides,
    )?;

    Ok(PreparedCapabilityRun {
        execution,
        settings_json,
    })
}

fn build_template_vars<'a>(
    extension_path: &'a str,
    args_str: &'a str,
    runtime: &'a RuntimeConfig,
    project: Option<&'a Project>,
    project_id: &'a Option<String>,
) -> Vec<(&'a str, &'a str)> {
    let entrypoint = runtime.entrypoint.as_deref().unwrap_or("");

    if let Some(proj) = project {
        let domain = proj.domain.as_deref().unwrap_or("");
        let site_path = proj.base_path.as_deref().unwrap_or("");
        vec![
            ("extension_path", extension_path),
            ("entrypoint", entrypoint),
            ("args", args_str),
            ("projectId", project_id.as_deref().unwrap_or("")),
            ("domain", domain),
            ("sitePath", site_path),
        ]
    } else {
        vec![
            ("extension_path", extension_path),
            ("entrypoint", entrypoint),
            ("args", args_str),
        ]
    }
}

fn build_runtime_env(
    runtime: &RuntimeConfig,
    context: &ExtensionExecutionContext,
    vars: &[(&str, &str)],
    settings_json: &str,
    extension_path: &str,
) -> Vec<(String, String)> {
    let project_base_path = context
        .project
        .as_ref()
        .and_then(|p| p.base_path.as_deref());

    let mut env = build_exec_env(
        &context.extension_id,
        context.project_id.as_deref(),
        context.component_id.as_deref(),
        settings_json,
        Some(extension_path),
        project_base_path,
        Some(&context.settings),
        None, // no path override in runtime context
    );

    if let Some(ref extension_env) = runtime.env {
        for (key, value) in extension_env {
            let rendered_value = template::render(value, vars);
            env.push((key.clone(), rendered_value));
        }
    }

    env
}

pub(super) fn build_action_env(
    extension_id: &str,
    project_id: Option<&str>,
    payload: &serde_json::Value,
    extension_path: Option<&str>,
    project_base_path: Option<&str>,
) -> Vec<(String, String)> {
    let settings_json = payload.to_string();
    let component_id =
        crate::core::engine::text::json_path_str(payload, &["release", "component_id"]);
    let component_path =
        crate::core::engine::text::json_path_str(payload, &["release", "local_path"]);

    build_exec_env(
        extension_id,
        project_id,
        component_id,
        &settings_json,
        extension_path,
        project_base_path,
        None,
        component_path,
    )
}

pub(super) fn execute_extension_command(
    command_template: &str,
    vars: &[(&str, &str)],
    working_dir: Option<&str>,
    env_pairs: &[(String, String)],
    mode: ExtensionExecutionMode,
) -> Result<ExtensionExecutionResult> {
    let command = template::render(command_template, vars);
    let env_refs: Vec<(&str, &str)> = env_pairs
        .iter()
        .map(|(key, value)| (key.as_str(), value.as_str()))
        .collect();
    match mode {
        ExtensionExecutionMode::Interactive => {
            let exit_code =
                execute_local_command_interactive(&command, working_dir, Some(&env_refs));
            Ok(ExtensionExecutionResult {
                output: CapturedOutput::default(),
                exit_code,
                success: exit_code == 0,
            })
        }
        ExtensionExecutionMode::Captured => {
            let cmd_output = execute_local_command_in_dir(&command, working_dir, Some(&env_refs));
            Ok(ExtensionExecutionResult {
                output: CapturedOutput::new(cmd_output.stdout, cmd_output.stderr),
                exit_code: cmd_output.exit_code,
                success: cmd_output.success,
            })
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn execute_extension_runtime(
    extension_id: &str,
    project_id: Option<&str>,
    component_id: Option<&str>,
    inputs: Vec<(String, String)>,
    args: Vec<String>,
    payload: Option<&serde_json::Value>,
    working_dir: Option<&str>,
    mode: ExtensionExecutionMode,
    filter: &ExtensionStepFilter,
) -> Result<ExtensionExecutionOutcome> {
    // Shell execution is required for extension runtime commands by design:
    // - Runtime commands execute bash scripts (set -euo pipefail, arrays, jq)
    // - Scripts use bash features (arrays, variable expansion, subshells)
    // - Commands like "{{extensionPath}}/scripts/publish-github.sh" need shell
    // - Environment variable passing requires shell environment
    // - Direct execution cannot handle bash scripts or shell features
    // See executor.rs for detailed execution strategy decision tree
    let extension = load_extension(extension_id)?;
    super::validate_core_compatibility(
        "extension",
        extension_id,
        extension
            .requires
            .as_ref()
            .and_then(|requires| requires.homeboy.as_deref()),
        read_source_revision(extension_id),
    )?;
    let runtime = extension_runtime(&extension)?;
    let run_command = runtime.run_command.as_ref().ok_or_else(|| {
        Error::config(format!(
            "Extension '{}' does not have a runCommand defined",
            extension_id
        ))
    })?;

    let extension_path = validation::require(
        extension.extension_path.as_ref(),
        "extension",
        "extension_path not set",
    )?;

    let args_str = build_args_string(&extension, inputs, args);
    let context = resolve_extension_context(
        &extension,
        extension_id,
        project_id,
        component_id,
        run_command,
    )?;

    let settings_json = if let Some(payload) = payload {
        payload.to_string()
    } else {
        serialize_settings(&context.settings)?
    };

    let vars = build_template_vars(
        extension_path,
        &args_str,
        runtime,
        context.project.as_ref(),
        &context.project_id,
    );
    let mut env_pairs = build_runtime_env(runtime, &context, &vars, &settings_json, extension_path);

    env_pairs.extend(filter.to_env_pairs());

    let execution = execute_extension_command(
        run_command,
        &vars,
        working_dir.or(Some(extension_path.as_str())),
        &env_pairs,
        mode,
    )?;

    Ok(ExtensionExecutionOutcome {
        project_id: context.project_id,
        result: execution,
    })
}

/// Build execution environment variables for a extension.
///
/// This is the single canonical env builder for all extension execution contexts
/// (test, lint, build, extension run, deploy hooks, action handlers).
///
/// When `component_path_override` is provided, it is used as the component path
/// instead of loading the component from storage. This supports `--path` overrides
/// in commands like `homeboy test --path /alt/path`.
#[allow(clippy::too_many_arguments)]
fn build_exec_env(
    extension_id: &str,
    project_id: Option<&str>,
    component_id: Option<&str>,
    settings_json: &str,
    extension_path: Option<&str>,
    project_base_path: Option<&str>,
    settings: Option<&HashMap<String, serde_json::Value>>,
    component_path_override: Option<&str>,
) -> Vec<(String, String)> {
    let mut env = vec![
        (
            exec_context::VERSION.to_string(),
            exec_context::CURRENT_VERSION.to_string(),
        ),
        (
            exec_context::EXTENSION_ID.to_string(),
            extension_id.to_string(),
        ),
        (
            exec_context::SETTINGS_JSON.to_string(),
            settings_json.to_string(),
        ),
    ];

    if let Some(pid) = project_id {
        env.push((exec_context::PROJECT_ID.to_string(), pid.to_string()));
    }

    if let Some(cid) = component_id {
        env.push((exec_context::COMPONENT_ID.to_string(), cid.to_string()));

        // Use override path if provided, otherwise load from storage
        let component_path = if let Some(override_path) = component_path_override {
            override_path.to_string()
        } else {
            match component::resolve_effective(Some(cid), None, None) {
                Ok(component) => component.local_path,
                Err(e) => {
                    env.push(("HOMEBOY_COMPONENT_LOAD_ERROR".to_string(), e.to_string()));
                    format!("/debug/component-not-found/{}", cid)
                }
            }
        };
        env.push((exec_context::COMPONENT_PATH.to_string(), component_path));
    }

    if let Some(mp) = extension_path {
        env.push((exec_context::EXTENSION_PATH.to_string(), mp.to_string()));
    }

    if let Ok(helper_pairs) = runtime_helper::ensure_all_helpers() {
        env.extend(helper_pairs);
    }

    if let Some(path) = toolchain::command_step_path() {
        env.push(("PATH".to_string(), path.to_string_lossy().to_string()));
    }

    if let Some(pbp) = project_base_path {
        env.push((exec_context::PROJECT_PATH.to_string(), pbp.to_string()));
    }

    if let Some(settings_map) = settings {
        for (key, value) in settings_map {
            let env_key = format!("HOMEBOY_SETTINGS_{}", key.to_uppercase());
            let env_value = match value {
                serde_json::Value::String(s) => s.clone(),
                _ => value.to_string(),
            };
            env.push((env_key, env_value));
        }
    }

    env
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::component::Component;
    use crate::core::extension::extract_component_extension_settings;

    #[test]
    fn build_exec_env_includes_runtime_runner_helper_paths() {
        crate::test_support::with_isolated_home(|_| {
            let env = build_exec_env("rust", None, None, "{}", Some("/tmp/ext"), None, None, None);

            for (env_var, filename) in [
                (runtime_helper::RUNNER_STEPS_ENV, "runner-steps.sh"),
                (runtime_helper::RUNNER_PRELUDE_ENV, "runner-prelude.sh"),
                (runtime_helper::COMMAND_CAPTURE_ENV, "command-capture.sh"),
                (runtime_helper::BASH_PREFLIGHT_ENV, "bash-preflight.sh"),
            ] {
                let helper = env
                    .iter()
                    .find(|(k, _)| k == env_var)
                    .map(|(_, v)| v.clone());

                assert!(helper.is_some(), "expected env to include {env_var}");
                assert!(helper.unwrap().ends_with(filename));
            }
        });
    }

    #[test]
    fn build_exec_env_includes_toolchain_path() {
        let env = build_exec_env(
            "fixture-extension",
            None,
            None,
            "{}",
            Some("/tmp/ext"),
            None,
            None,
            None,
        );

        let path = env
            .iter()
            .find(|(k, _)| k == "PATH")
            .map(|(_, v)| v.clone());

        assert!(path.is_some(), "expected extension env to include PATH");
    }

    #[test]
    fn build_capability_env_includes_extension_provider_output() {
        let extension = tempfile::tempdir().expect("extension dir");
        let component = tempfile::tempdir().expect("component dir");
        let extension_id = extension.path().file_name().unwrap().to_string_lossy();
        std::fs::write(
            extension.path().join(format!("{extension_id}.json")),
            r#"{
                "name": "Fixture",
                "version": "1.0.0",
                "env_provider": { "script": "env.sh" }
            }"#,
        )
        .expect("manifest");
        std::fs::write(
            extension.path().join("env.sh"),
            "#!/bin/sh\nprintf '{\"FIXTURE_ENV\":\"%s\"}' \"$HOMEBOY_COMPONENT_ID\"\n",
        )
        .expect("env provider");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut permissions = std::fs::metadata(extension.path().join("env.sh"))
                .expect("env provider metadata")
                .permissions();
            permissions.set_mode(0o755);
            std::fs::set_permissions(extension.path().join("env.sh"), permissions)
                .expect("env provider executable");
        }

        let env = build_capability_env(
            &extension_id,
            "fixture-component",
            extension.path(),
            component.path(),
            "{}",
            &[],
        )
        .expect("capability env");

        assert!(env
            .iter()
            .any(|(key, value)| key == "FIXTURE_ENV" && value == "fixture-component"));
    }

    #[test]
    fn build_capability_env_replays_persisted_setup_runtime_env() {
        // Regression for #5919: a Lab runner that discovers runtime env during
        // extension setup (e.g. the Managed Sandbox core module path) must replay
        // that same effective env into capability execution (fuzz) — without
        // manual operator injection — even when the env provider does not
        // re-resolve the value in the fresh capability process.
        crate::test_support::with_isolated_home(|_| {
            let extension = tempfile::tempdir().expect("extension dir");
            let component = tempfile::tempdir().expect("component dir");
            let extension_id = extension.path().file_name().unwrap().to_string_lossy();
            // No env provider script: the only source of the runtime env is the
            // value persisted during setup.
            std::fs::write(
                extension.path().join(format!("{extension_id}.json")),
                r#"{ "name": "Fixture", "version": "1.0.0" }"#,
            )
            .expect("manifest");

            super::super::setup_env::persist(
                &extension_id,
                &[(
                    "HOMEBOY_SAMPLE_RUNTIME_CORE_MODULE".to_string(),
                    "/runner/sample-runtime/core.mjs".to_string(),
                )],
            )
            .expect("persist setup env");

            let env = build_capability_env(
                &extension_id,
                "fixture-component",
                extension.path(),
                component.path(),
                "{}",
                &[],
            )
            .expect("capability env");

            assert!(
                env.iter()
                    .any(|(key, value)| key == "HOMEBOY_SAMPLE_RUNTIME_CORE_MODULE"
                        && value == "/runner/sample-runtime/core.mjs"),
                "fuzz capability env must replay persisted extension setup runtime env"
            );
        });
    }

    #[test]
    fn build_capability_env_lets_live_provider_override_persisted_setup_env() {
        // Live env-provider discovery is freshest: when both a persisted setup
        // value and a live provider value exist for the same key, the live
        // value must win so a rebuilt runner checkout is honored.
        crate::test_support::with_isolated_home(|_| {
            let extension = tempfile::tempdir().expect("extension dir");
            let component = tempfile::tempdir().expect("component dir");
            let extension_id = extension.path().file_name().unwrap().to_string_lossy();
            std::fs::write(
                extension.path().join(format!("{extension_id}.json")),
                r#"{
                    "name": "Fixture",
                    "version": "1.0.0",
                    "env_provider": { "script": "env.sh" }
                }"#,
            )
            .expect("manifest");
            std::fs::write(
                extension.path().join("env.sh"),
                "#!/bin/sh\nprintf '{\"SHARED_KEY\":\"live\"}'\n",
            )
            .expect("env provider");
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                let mut permissions = std::fs::metadata(extension.path().join("env.sh"))
                    .expect("env provider metadata")
                    .permissions();
                permissions.set_mode(0o755);
                std::fs::set_permissions(extension.path().join("env.sh"), permissions)
                    .expect("env provider executable");
            }

            super::super::setup_env::persist(
                &extension_id,
                &[("SHARED_KEY".to_string(), "persisted".to_string())],
            )
            .expect("persist setup env");

            let env = build_capability_env(
                &extension_id,
                "fixture-component",
                extension.path(),
                component.path(),
                "{}",
                &[],
            )
            .expect("capability env");

            // Both entries are present; std Command applies env last-wins, so the
            // live provider value (appended after the persisted value) wins.
            let last_shared = env
                .iter()
                .filter(|(key, _)| key == "SHARED_KEY")
                .next_back()
                .map(|(_, value)| value.clone());
            assert_eq!(last_shared.as_deref(), Some("live"));
        });
    }

    #[test]
    fn build_settings_json_preserves_array_values() {
        // Regression test for #844: array values in extension settings
        // were serialized as empty strings.
        let manifest = serde_json::json!({
            "settings": [
                { "id": "string_setting", "default": "hello" },
                { "id": "array_default", "default": ["a", "b"] }
            ]
        });

        let extension_settings: Vec<(String, serde_json::Value)> = vec![
            (
                "validation_dependencies".to_string(),
                serde_json::json!(["sample-plugin"]),
            ),
            (
                "plain_string".to_string(),
                serde_json::Value::String("value".to_string()),
            ),
        ];

        let overrides: Vec<(String, String)> = vec![];
        let json_overrides: Vec<(String, serde_json::Value)> = vec![];

        let json = build_settings_json_from_manifest(
            &manifest,
            &extension_settings,
            &overrides,
            &json_overrides,
        )
        .expect("should serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");

        // Array from extension settings is preserved
        assert_eq!(
            parsed["validation_dependencies"],
            serde_json::json!(["sample-plugin"]),
            "Array setting should be preserved, not flattened to empty string"
        );

        // String from extension settings is preserved
        assert_eq!(parsed["plain_string"], serde_json::json!("value"));

        // String default from manifest is preserved
        assert_eq!(parsed["string_setting"], serde_json::json!("hello"));

        // Array default from manifest is preserved
        assert_eq!(parsed["array_default"], serde_json::json!(["a", "b"]));
    }

    #[test]
    fn build_settings_json_includes_nested_homeboy_json_extension_settings() {
        let component: Component = serde_json::from_value(serde_json::json!({
            "id": "roadie",
            "local_path": "/tmp/roadie",
            "extensions": {
                "sample-runtime": {
                    "settings": {
                        "test_backend": "host-smoke"
                    }
                }
            }
        }))
        .expect("component config");
        let manifest = serde_json::json!({
            "settings": [
                { "id": "test_backend", "default": "custom-provider" }
            ]
        });
        let extension_settings = extract_component_extension_settings(&component, "sample-runtime");

        let json = build_settings_json_from_manifest(&manifest, &extension_settings, &[], &[])
            .expect("settings json");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse settings json");

        assert_eq!(parsed["test_backend"], serde_json::json!("host-smoke"));
        assert!(parsed.get("settings").is_none());
    }

    #[test]
    fn build_settings_json_cli_overrides_replace_values() {
        let manifest = serde_json::json!({});
        let extension_settings: Vec<(String, serde_json::Value)> =
            vec![("key".to_string(), serde_json::json!(["original"]))];
        let overrides = vec![("key".to_string(), "override_value".to_string())];
        let json_overrides: Vec<(String, serde_json::Value)> = vec![];

        let json = build_settings_json_from_manifest(
            &manifest,
            &extension_settings,
            &overrides,
            &json_overrides,
        )
        .expect("should serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");

        // CLI override replaces the array value with a string
        assert_eq!(parsed["key"], serde_json::json!("override_value"));
    }

    #[test]
    fn build_settings_json_typed_overrides_preserve_objects() {
        // The whole point of --setting-json: object values stay objects,
        // unlike --setting which would coerce them to a JSON-string-of-an-
        // object. Mirrors the wp_config_defines / bench_env use case
        // (homeboy-extensions #248 / #250).
        let manifest = serde_json::json!({
            "settings": [
                { "id": "bench_env", "default": {} }
            ]
        });
        let extension_settings: Vec<(String, serde_json::Value)> = vec![];
        let overrides: Vec<(String, String)> = vec![];
        let json_overrides = vec![(
            "bench_env".to_string(),
            serde_json::json!({"BENCH_CORPUS_SIZE": "1000"}),
        )];

        let json = build_settings_json_from_manifest(
            &manifest,
            &extension_settings,
            &overrides,
            &json_overrides,
        )
        .expect("should serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");

        // The override is the actual JSON object, not a string-encoded one.
        assert_eq!(
            parsed["bench_env"],
            serde_json::json!({"BENCH_CORPUS_SIZE": "1000"})
        );
        assert!(parsed["bench_env"].is_object());
    }

    #[test]
    fn build_settings_json_typed_override_wins_on_conflict() {
        // When the same key is targeted by both --setting and --setting-json,
        // the typed override wins (strictly more expressive, applied later).
        let manifest = serde_json::json!({});
        let extension_settings: Vec<(String, serde_json::Value)> = vec![];
        let overrides = vec![("key".to_string(), "string_value".to_string())];
        let json_overrides = vec![("key".to_string(), serde_json::json!({"nested": true}))];

        let json = build_settings_json_from_manifest(
            &manifest,
            &extension_settings,
            &overrides,
            &json_overrides,
        )
        .expect("should serialize");
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");

        assert_eq!(parsed["key"], serde_json::json!({"nested": true}));
    }

    #[test]
    fn build_exec_env_preserves_step_filter_contract() {
        let filter = RunnerStepFilter {
            step: Some("lint,test".to_string()),
            skip: Some("lint".to_string()),
        };

        let mut env = build_exec_env("rust", None, None, "{}", Some("/tmp/ext"), None, None, None);
        env.extend(filter.to_env_pairs());

        assert!(env
            .iter()
            .any(|(k, v)| k == "HOMEBOY_STEP" && v == "lint,test"));
        assert!(env.iter().any(|(k, v)| k == "HOMEBOY_SKIP" && v == "lint"));
    }

    #[test]
    fn test_execute_capability_script_supports_stderr_only_passthrough() {
        let dir = tempfile::tempdir().expect("tempdir");
        let output = execute_capability_script(
            dir.path(),
            "unused.sh",
            &[],
            &[],
            None,
            Some("printf '{\"ok\":true}\n'; printf 'progress turn=1\n' >&2"),
            CapabilityScriptOptions {
                passthrough: false,
                stderr_passthrough: true,
                timeout: None,
            },
        )
        .expect("script should run");

        assert!(output.success);
        assert_eq!(output.stdout, "{\"ok\":true}\n");
        assert_eq!(output.stderr, "progress turn=1\n");
    }

    #[test]
    fn execute_capability_script_timeout_returns_partial_output() {
        let dir = tempfile::tempdir().expect("tempdir");
        let output = execute_capability_script(
            dir.path(),
            "unused.sh",
            &[],
            &[],
            None,
            Some("printf 'started\\n'; sleep 2"),
            CapabilityScriptOptions {
                passthrough: false,
                stderr_passthrough: false,
                timeout: Some(Duration::from_millis(50)),
            },
        )
        .expect("script should run until timeout");

        assert!(!output.success);
        assert!(output.timed_out);
        assert_eq!(output.exit_code, 124);
        assert_eq!(output.stdout, "started\n");
        assert!(output.stderr.contains("Homeboy command timed out"));
    }

    fn lint_execution_context() -> crate::core::extension::ExtensionExecutionContext {
        crate::core::extension::ExtensionExecutionContext {
            component: Component::new(
                "fixture".to_string(),
                "/configured/path".to_string(),
                "fixture-extension".to_string(),
                None,
            ),
            capability: crate::core::extension::ExtensionCapability::Lint,
            extension_id: "fixture-extension".to_string(),
            extension_path: std::path::PathBuf::from("/tmp/fixture-extension"),
            script_path: "lint.sh".to_string(),
            settings: Vec::new(),
            accepted_setting_keys: Vec::new(),
        }
    }

    #[test]
    fn absolutize_path_override_makes_relative_paths_absolute() {
        // A non-existent relative override still resolves to an absolute path so
        // it never reaches a runner verbatim.
        let resolved = absolutize_path_override("some/relative/component");
        assert!(
            Path::new(&resolved).is_absolute(),
            "expected absolute path, got {resolved}"
        );
        assert!(resolved.ends_with("some/relative/component"));
    }

    #[test]
    fn absolutize_path_override_canonicalizes_existing_dir() {
        let dir = tempfile::tempdir().expect("component dir");
        let resolved = absolutize_path_override(&dir.path().to_string_lossy());
        let canonical = dir.path().canonicalize().expect("canonical dir");
        assert_eq!(Path::new(&resolved), canonical.as_path());
    }

    #[test]
    fn absolutize_path_override_preserves_absolute_nonexistent_path() {
        // An absolute path that does not exist yet stays exactly as supplied
        // instead of being rebased onto the working directory.
        let resolved = absolutize_path_override("/does/not/exist/component");
        assert_eq!(resolved, "/does/not/exist/component");
    }

    #[test]
    fn path_override_resolves_to_absolute_component_root() {
        // Regression for Extra-Chill/homeboy#6818: a non-absolute / non-canonical
        // `--path` must be normalized so the component root handed to the runner
        // resolves regardless of the working directory the runner (or a tool it
        // invokes) ultimately executes from. A non-canonical absolute override
        // (`<root>/sub/..`) exercises the same `canonicalize` normalization that
        // a relative `--path .` triggers, without mutating the process-global
        // working directory. The assertion stays framework-agnostic: core only
        // guarantees an absolute component root and that a config file living at
        // that root is locatable from the normalized path.
        let component_root = tempfile::tempdir().expect("component dir");
        std::fs::create_dir(component_root.path().join("sub")).expect("nested dir");
        std::fs::write(component_root.path().join("config.toml"), "")
            .expect("component-root config file");

        // Non-canonical absolute override pointing back at the component root.
        let noncanonical = component_root.path().join("sub").join("..");

        let result = build_capability_execution_context(
            &lint_execution_context(),
            Component::new(
                "fixture".to_string(),
                "/configured/path".to_string(),
                "fixture-extension".to_string(),
                None,
            ),
            Some(&noncanonical.to_string_lossy()),
        );

        let resolved_root = Path::new(&result.component.local_path);
        assert!(
            resolved_root.is_absolute(),
            "--path must resolve to an absolute component root, got {}",
            result.component.local_path
        );
        assert_eq!(
            resolved_root,
            component_root
                .path()
                .canonicalize()
                .expect("canonical root")
        );
        assert!(
            resolved_root.join("config.toml").is_file(),
            "normalized component root must locate component-root files"
        );
    }

    #[test]
    fn resolve_capability_component_normalizes_override() {
        let component_root = tempfile::tempdir().expect("component dir");
        std::fs::create_dir(component_root.path().join("sub")).expect("nested dir");
        let noncanonical = component_root.path().join("sub").join("..");

        let preloaded = Component::new(
            "fixture".to_string(),
            "/configured/path".to_string(),
            "fixture-extension".to_string(),
            None,
        );
        let resolved = resolve_capability_component(
            &lint_execution_context(),
            Some(&preloaded),
            Some(&noncanonical.to_string_lossy()),
        )
        .expect("resolve component");

        assert_eq!(
            Path::new(&resolved.local_path),
            component_root
                .path()
                .canonicalize()
                .expect("canonical root")
        );
    }
}
