use std::collections::{BTreeMap, BTreeSet, HashMap};

use fabro_types::GitHubRepositorySlug;
use fabro_types::settings::InterpString;
use fabro_types::settings::interp::ResolveCtx;
use fabro_types::settings::run::{
    ArtifactsSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings,
    McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings,
    NotificationRouteSettings, PreparedStep, PreparedStepRun, PullRequestSettings,
    ResolvedMcpEntry, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, RunCloneSettings,
    RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings,
    RunIntegrationsSettings, RunInterviewsSettings, RunMetaBranchSettings, RunModelControls,
    RunModelSettings, RunNamespace, RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode,
};
use fabro_util::workspace_glob::WorkspaceGlob;

use super::{ResolveError, resolve_run_environment};
use crate::{
    EnvironmentLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer,
    InterviewsLayer, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer,
    NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer,
    RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer,
    RunLayer, RunMetaBranchLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
    RunRunBranchLayer, RunScmLayer, StickyMap, StringOrSplice,
};

pub fn resolve_run(
    layer: &RunLayer,
    environments: &MergeMap<EnvironmentLayer>,
    mcp_server_catalog: &HashMap<String, McpServerSettings>,
    errors: &mut Vec<ResolveError>,
) -> RunNamespace {
    let clone = resolve_clone(layer.clone.as_ref(), errors);
    let run_branch = resolve_run_branch(layer.run_branch.as_ref());
    let mut meta_branch = resolve_meta_branch(layer.meta_branch.as_ref());
    if !run_branch.enabled {
        if meta_branch.enabled || meta_branch.push {
            tracing::debug!(
                run_branch_enabled = run_branch.enabled,
                "Disabling metadata branch because run branch is disabled"
            );
        }
        meta_branch = RunMetaBranchSettings {
            enabled: false,
            push:    false,
        };
    }
    let pull_request = resolve_pull_request(layer.pull_request.as_ref());
    if pull_request.is_some() && (!run_branch.enabled || !run_branch.push) {
        errors.push(ResolveError::Invalid {
            path:   "run.pull_request".to_string(),
            reason: "run.pull_request.enabled requires run.run_branch.enabled and \
                     run.run_branch.push"
                .to_string(),
        });
    }

    super::warn_if_demoted_template("run.working_dir", layer.working_dir.as_deref());

    RunNamespace {
        goal: resolve_goal(layer.goal.as_ref()),
        working_dir: layer.working_dir.clone(),
        metadata: layer.metadata.clone().into_inner(),
        inputs: layer.inputs.clone().unwrap_or_default(),
        model: resolve_model(layer.model.as_ref()),
        git: resolve_git(layer.git.as_ref()),
        prepare: resolve_prepare(layer.prepare.as_ref(), errors),
        execution: resolve_execution(layer.execution.as_ref()),
        checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()),
        clone,
        run_branch,
        meta_branch,
        environment: resolve_run_environment(layer.environment.as_ref(), environments, errors),
        notifications: layer
            .notifications
            .iter()
            .map(|(name, route)| (name.clone(), resolve_notification_route(route)))
            .collect(),
        interviews: resolve_interviews(layer.interviews.as_ref()),
        agent: resolve_agent(layer.agent.as_ref(), mcp_server_catalog, errors),
        hooks: layer
            .hooks
            .iter()
            .enumerate()
            .map(|(index, hook)| resolve_hook(hook, index, errors))
            .collect(),
        scm: resolve_scm(layer.scm.as_ref()),
        pull_request,
        artifacts: resolve_artifacts(layer.artifacts.as_ref(), errors),
        integrations: resolve_integrations(layer.integrations.as_ref(), errors),
    }
}

fn resolve_integrations(
    layer: Option<&RunIntegrationsLayer>,
    errors: &mut Vec<ResolveError>,
) -> RunIntegrationsSettings {
    let github = layer
        .and_then(|integrations| integrations.github.as_ref())
        .map(|github| resolve_integrations_github(github, errors))
        .unwrap_or_default();
    RunIntegrationsSettings { github }
}

/// GitHub caps one installation token at 500 repositories; the implicit run
/// origin takes one slot.
const MAX_ADDITIONAL_REPOSITORIES: usize = 499;

fn resolve_integrations_github(
    github: &RunIntegrationsGithubLayer,
    errors: &mut Vec<ResolveError>,
) -> RunIntegrationsGithubSettings {
    // Collapse `Option<HashMap<...>>` -> `HashMap<...>`: both `None`
    // and `Some({})` resolve to an empty map (no token requested).
    // The presence distinction is only meaningful at merge time. The same
    // collapse applies to `additional_repositories` (`Some(vec![])` is an
    // explicit clear that resolves to the empty set).
    let permissions = github.permissions.clone().unwrap_or_default();
    let raw_repositories = github
        .additional_repositories
        .as_deref()
        .unwrap_or_default();

    if raw_repositories.len() > MAX_ADDITIONAL_REPOSITORIES {
        errors.push(ResolveError::Invalid {
            path:   "run.integrations.github.additional_repositories".to_string(),
            reason: format!(
                "at most {MAX_ADDITIONAL_REPOSITORIES} additional repositories are supported (the \
                 run origin takes the remaining slot of GitHub's 500-repository token limit), got \
                 {}",
                raw_repositories.len()
            ),
        });
    }

    let mut additional_repositories: BTreeSet<GitHubRepositorySlug> = BTreeSet::new();
    for (index, value) in raw_repositories.iter().enumerate() {
        let path = format!("run.integrations.github.additional_repositories[{index}]");
        let Ok(slug) = value.parse::<GitHubRepositorySlug>() else {
            errors.push(ResolveError::Invalid {
                path,
                reason: format!(
                    "`{value}` is not a full GitHub `owner/repository` slug (no scheme, host, \
                     ref, or extra path component)"
                ),
            });
            continue;
        };
        if let Some(existing) = additional_repositories.get(&slug) {
            errors.push(ResolveError::Invalid {
                path,
                reason: format!(
                    "`{value}` duplicates `{existing}` (repository identity is case-insensitive)"
                ),
            });
            continue;
        }
        if let Some(first) = additional_repositories.first() {
            if !first.same_owner(&slug) {
                errors.push(ResolveError::Invalid {
                    path,
                    reason: format!(
                        "`{value}` has owner `{}` but `{first}` has owner `{}`; all repositories \
                         must share one owner because one GitHub App installation covers one \
                         account",
                        slug.owner(),
                        first.owner()
                    ),
                });
                continue;
            }
        }
        additional_repositories.insert(slug);
    }

    if !additional_repositories.is_empty() {
        validate_additional_repository_permissions(&permissions, errors);
    }

    RunIntegrationsGithubSettings {
        permissions,
        additional_repositories,
    }
}

/// A non-empty additional-repository set needs a token that can reach
/// repository contents. Only a literal `contents` value is checked here; a
/// templated value is re-checked after interpolation at the runtime boundary.
fn validate_additional_repository_permissions(
    permissions: &HashMap<String, InterpString>,
    errors: &mut Vec<ResolveError>,
) {
    if permissions.is_empty() {
        errors.push(ResolveError::Invalid {
            path:   "run.integrations.github.additional_repositories".to_string(),
            reason: "additional repositories require [run.integrations.github.permissions] with \
                     a `contents` permission; a higher layer may have cleared the permissions"
                .to_string(),
        });
        return;
    }
    let Some(contents) = permissions.get("contents") else {
        errors.push(ResolveError::Invalid {
            path:   "run.integrations.github.permissions".to_string(),
            reason: "additional repositories require the `contents` permission (`read` or \
                     `write`)"
                .to_string(),
        });
        return;
    };
    if let Ok(literal) = contents.resolve_with(&mut ResolveCtx::new()) {
        if !RunIntegrationsGithubSettings::contents_permission_allows_repository_access(&literal) {
            errors.push(ResolveError::Invalid {
                path:   "run.integrations.github.permissions.contents".to_string(),
                reason: format!(
                    "additional repositories require `contents = \"read\"` or `contents = \
                     \"write\"`, got `{literal}`"
                ),
            });
        }
    }
}

fn resolve_goal(goal: Option<&RunGoalLayer>) -> Option<RunGoal> {
    match goal? {
        RunGoalLayer::Inline(value) => Some(RunGoal::Inline(value.clone())),
        RunGoalLayer::File { file } => Some(RunGoal::File(file.clone())),
    }
}

fn resolve_model(model: Option<&RunModelLayer>) -> RunModelSettings {
    let Some(model) = model else {
        return RunModelSettings::default();
    };

    super::warn_if_demoted_template("run.model.provider", model.provider.as_deref());
    super::warn_if_demoted_template("run.model.name", model.name.as_deref());

    RunModelSettings {
        provider:  model.provider.clone(),
        name:      model.name.clone(),
        fallbacks: model
            .fallbacks
            .iter()
            .map(|(requested_model, chain)| {
                let chain = chain
                    .iter()
                    .filter_map(|entry| match entry {
                        ModelRefOrSplice::ModelRef(model_ref) => Some(model_ref.clone()),
                        ModelRefOrSplice::Splice => None,
                    })
                    .collect();
                (requested_model.clone(), chain)
            })
            .collect::<BTreeMap<_, _>>(),
        controls:  model
            .controls
            .as_ref()
            .map(|c| RunModelControls {
                reasoning_effort: c.reasoning_effort.clone(),
                speed:            c.speed.clone(),
            })
            .unwrap_or_default(),
    }
}

fn resolve_git(git: Option<&RunGitLayer>) -> RunGitSettings {
    let author = git.and_then(|git| git.author.as_ref());
    if let Some(author) = author {
        super::warn_if_demoted_template("run.git.author.name", author.name.as_deref());
        super::warn_if_demoted_template("run.git.author.email", author.email.as_deref());
    }
    RunGitSettings {
        author: author.map(|author| GitAuthorSettings {
            name:  author.name.clone(),
            email: author.email.clone(),
        }),
    }
}

#[expect(
    clippy::disallowed_methods,
    reason = "intentional source preservation: prepare step commands and per-step env are carried \
              in source form so `fabro validate` stays portable; secret tokens resolve and \
              unsupported tokens fail at the run boundary in \
              fabro_types::settings::run::RunPrepareSettings::resolve_step_secrets"
)]
fn resolve_prepare(
    prepare: Option<&RunPrepareLayer>,
    errors: &mut Vec<ResolveError>,
) -> RunPrepareSettings {
    let prepare = prepare.expect("defaults.toml should provide run.prepare defaults");

    let mut steps = Vec::new();
    for (index, step) in prepare.steps.iter().enumerate() {
        let run = match (&step.script, &step.command) {
            // A `script` is a raw shell snippet. Carry it verbatim so the shell
            // interprets it after late secret resolution.
            (Some(script), None) => PreparedStepRun::Script {
                script: script.as_source(),
            },
            // A `command` is an argv: carry it as a vector of element source
            // strings — neither pre-joined nor shell-quoted here. Secret tokens
            // resolve at the run boundary, and only the resolved value is
            // shell-quoted (resolve-then-quote), so an interpolated value can
            // never break out of its argument and inject shell syntax.
            (None, Some(argv)) => PreparedStepRun::Command {
                command: argv.iter().map(InterpString::as_source).collect(),
            },
            (Some(_), Some(_)) | (None, None) => {
                errors.push(ResolveError::Invalid {
                    path:   format!("run.prepare.steps[{index}]"),
                    reason: "exactly one of script or command must be set".to_string(),
                });
                continue;
            }
        };
        steps.push(PreparedStep {
            run,
            env: step
                .env
                .iter()
                .map(|(key, value)| (key.clone(), value.as_source()))
                .collect(),
        });
    }

    RunPrepareSettings {
        steps,
        timeout_ms: prepare
            .timeout
            .map_or(300_000, |timeout| timeout.as_millis()),
    }
}

fn resolve_execution(execution: Option<&RunExecutionLayer>) -> RunExecutionSettings {
    let execution = execution.expect("defaults.toml should provide run.execution defaults");

    RunExecutionSettings {
        mode:     execution
            .mode
            .expect("defaults.toml should provide run.execution.mode"),
        approval: execution
            .approval
            .expect("defaults.toml should provide run.execution.approval"),
    }
}

fn resolve_checkpoint(checkpoint: Option<&RunCheckpointLayer>) -> RunCheckpointSettings {
    RunCheckpointSettings {
        exclude_globs:     checkpoint
            .map(|checkpoint| checkpoint.exclude_globs.clone())
            .unwrap_or_default(),
        skip_git_hooks:    checkpoint
            .and_then(|checkpoint| checkpoint.skip_git_hooks)
            .unwrap_or(false),
        commit_timeout_ms: checkpoint
            .and_then(|checkpoint| checkpoint.commit_timeout)
            .map_or(
                RunCheckpointSettings::DEFAULT_COMMIT_TIMEOUT_MS,
                |timeout| timeout.as_millis(),
            ),
    }
}

fn resolve_clone(
    clone: Option<&RunCloneLayer>,
    errors: &mut Vec<ResolveError>,
) -> RunCloneSettings {
    let mut depth = clone
        .and_then(|clone| clone.depth)
        .unwrap_or(RunCloneSettings::DEFAULT_DEPTH);
    if depth < 0 {
        errors.push(ResolveError::Invalid {
            path:   "run.clone.depth".to_string(),
            reason: "depth must be at least 0".to_string(),
        });
        depth = RunCloneSettings::DEFAULT_DEPTH;
    }

    RunCloneSettings {
        enabled: clone.and_then(|clone| clone.enabled).unwrap_or(true),
        depth,
    }
}

fn resolve_run_branch(run_branch: Option<&RunRunBranchLayer>) -> RunBranchSettings {
    RunBranchSettings {
        enabled: run_branch
            .and_then(|run_branch| run_branch.enabled)
            .unwrap_or(true),
        push:    run_branch
            .and_then(|run_branch| run_branch.push)
            .unwrap_or(true),
    }
}

fn resolve_meta_branch(meta_branch: Option<&RunMetaBranchLayer>) -> RunMetaBranchSettings {
    RunMetaBranchSettings {
        enabled: meta_branch
            .and_then(|meta_branch| meta_branch.enabled)
            .unwrap_or(true),
        push:    meta_branch
            .and_then(|meta_branch| meta_branch.push)
            .unwrap_or(true),
    }
}

fn resolve_notification_route(route: &NotificationRouteLayer) -> NotificationRouteSettings {
    NotificationRouteSettings {
        enabled:  route.enabled.unwrap_or(false),
        provider: route.provider.clone(),
        events:   route
            .events
            .iter()
            .filter_map(|event| match event {
                StringOrSplice::Value(value) => Some(value.clone()),
                StringOrSplice::Splice => None,
            })
            .collect(),
        slack:    route.slack.as_ref().map(resolve_notification_provider),
    }
}

fn resolve_notification_provider(
    provider: &NotificationProviderLayer,
) -> NotificationProviderSettings {
    NotificationProviderSettings {
        channel: provider.channel.clone(),
    }
}

fn resolve_interviews(interviews: Option<&InterviewsLayer>) -> RunInterviewsSettings {
    let Some(interviews) = interviews else {
        return RunInterviewsSettings::default();
    };

    RunInterviewsSettings {
        provider: interviews.provider.clone(),
        slack:    interviews.slack.as_ref().map(resolve_interview_provider),
    }
}

fn resolve_interview_provider(provider: &InterviewProviderLayer) -> InterviewProviderSettings {
    InterviewProviderSettings {
        channel: provider.channel.clone(),
    }
}

fn resolve_agent(
    agent: Option<&RunAgentLayer>,
    mcp_server_catalog: &HashMap<String, McpServerSettings>,
    errors: &mut Vec<ResolveError>,
) -> RunAgentSettings {
    let Some(agent) = agent else {
        return RunAgentSettings::default();
    };

    RunAgentSettings {
        fabro_tools: agent.fabro_tools.unwrap_or(false),
        mcps:        resolve_mcp_entries(&agent.mcps, mcp_server_catalog, errors),
    }
}

fn resolve_mcp_entries(
    mcps: &StickyMap<McpEntryLayer>,
    mcp_server_catalog: &HashMap<String, McpServerSettings>,
    errors: &mut Vec<ResolveError>,
) -> HashMap<String, ResolvedMcpEntry> {
    let mut resolved = HashMap::new();
    for (name, entry) in mcps.iter() {
        if !entry.is_enabled() {
            continue;
        }
        match entry {
            McpEntryLayer::Reference { id, .. } => match mcp_server_catalog.get(id) {
                Some(server) => {
                    resolved.insert(name.clone(), ResolvedMcpEntry::Resolved(server.clone()));
                }
                None => {
                    errors.push(ResolveError::Invalid {
                        path:   format!("run.agent.mcps.{name}.id"),
                        reason: format!("unknown MCP server: {id}"),
                    });
                }
            },
            _ => {
                resolved.insert(
                    name.clone(),
                    ResolvedMcpEntry::Resolved(resolve_mcp_entry(name, entry)),
                );
            }
        }
    }
    resolved
}

/// Resolve an agent layer's inline MCP entries into runtime settings, dropping
/// any entry with an explicit `enabled = false`. Shared by the `run.agent` and
/// `cli.exec.agent` resolution paths so the enable check lives in one place and
/// any future inline-MCP site inherits it for free.
pub(crate) fn resolve_enabled_mcps(
    mcps: &StickyMap<McpEntryLayer>,
    path: &str,
    errors: &mut Vec<ResolveError>,
) -> HashMap<String, McpServerSettings> {
    let mut resolved = HashMap::new();
    for (name, entry) in mcps.iter() {
        if !entry.is_enabled() {
            continue;
        }
        if matches!(entry, McpEntryLayer::Reference { .. }) {
            errors.push(ResolveError::Invalid {
                path:   format!("{path}.{name}.id"),
                reason: "MCP catalog references are only supported in run.agent.mcps".to_string(),
            });
            continue;
        }
        resolved.insert(name.clone(), resolve_mcp_entry(name, entry));
    }
    resolved
}

#[expect(
    clippy::disallowed_methods,
    reason = "intentional source preservation: MCP transport strings are carried in source form \
              so `fabro validate` stays portable; secret tokens resolve and unsupported tokens \
              fail at the run boundary in \
              fabro_workflow::operations::start::runtime_mcp_server"
)]
pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerSettings {
    let transport = match entry {
        McpEntryLayer::Stdio {
            script,
            command,
            env,
            ..
        } => McpTransport::Stdio {
            command: resolve_mcp_command(
                script.as_ref(),
                command.as_ref(),
                ScriptInterpreter::HostShell,
            ),
            env:     env
                .iter()
                .map(|(key, value)| (key.clone(), value.as_source()))
                .collect(),
        },
        McpEntryLayer::Http {
            protocol,
            url,
            headers,
            ..
        } => McpTransport::Http {
            protocol: *protocol,
            url:      url.as_source(),
            headers:  headers
                .iter()
                .map(|(key, value)| (key.clone(), value.as_source()))
                .collect(),
        },
        McpEntryLayer::Sandbox {
            protocol,
            script,
            command,
            port,
            env,
            ..
        } => McpTransport::Sandbox {
            protocol: *protocol,
            command:  resolve_mcp_command(
                script.as_ref(),
                command.as_ref(),
                ScriptInterpreter::SandboxBash,
            ),
            port:     *port,
            env:      env
                .iter()
                .map(|(key, value)| (key.clone(), value.as_source()))
                .collect(),
        },
        McpEntryLayer::Reference { .. } => {
            unreachable!("MCP catalog references are resolved before inline entry conversion")
        }
    };

    let (startup_timeout_secs, tool_timeout_secs) = match entry {
        McpEntryLayer::Http {
            startup_timeout,
            tool_timeout,
            ..
        }
        | McpEntryLayer::Stdio {
            startup_timeout,
            tool_timeout,
            ..
        }
        | McpEntryLayer::Sandbox {
            startup_timeout,
            tool_timeout,
            ..
        } => (
            startup_timeout.map_or(10, |timeout| timeout.as_std().as_secs()),
            tool_timeout.map_or(60, |timeout| timeout.as_std().as_secs()),
        ),
        McpEntryLayer::Reference { .. } => {
            unreachable!("MCP catalog references are resolved before inline entry conversion")
        }
    };

    McpServerSettings {
        name: name.to_string(),
        transport,
        current_dir: None,
        clear_env: false,
        startup_timeout_secs,
        tool_timeout_secs,
    }
}

#[expect(
    clippy::disallowed_methods,
    reason = "intentional source preservation: MCP transport strings are carried in source form \
              so `fabro validate` stays portable; secret tokens resolve and unsupported tokens \
              fail at the run boundary in \
              fabro_workflow::operations::start::runtime_mcp_server"
)]
fn resolve_mcp_command(
    script: Option<&InterpString>,
    command: Option<&Vec<InterpString>>,
    interpreter: ScriptInterpreter,
) -> Vec<String> {
    if let Some(script) = script {
        let executable: &'static str = interpreter.into();
        return vec![executable.to_string(), "-c".to_string(), script.as_source()];
    }
    command
        .map(|command| command.iter().map(InterpString::as_source).collect())
        .unwrap_or_default()
}

/// Which interpreter evaluates an MCP `script`.
///
/// The two transports run in different places, so they keep different
/// contracts: a stdio script runs on the host outside the sandbox API, while a
/// sandbox script is evaluated by the sandbox's Bash.
#[derive(Clone, Copy, strum::IntoStaticStr)]
enum ScriptInterpreter {
    /// Host shell, unchanged for `type = "stdio"` servers.
    #[strum(serialize = "sh")]
    HostShell,
    /// The sandbox's non-login Bash. Resolved through `PATH` rather than
    /// spelled `/bin/bash` so local sandboxes keep working on NixOS.
    #[strum(serialize = "bash")]
    SandboxBash,
}

fn resolve_hook(hook: &HookEntry, index: usize, errors: &mut Vec<ResolveError>) -> HookDefinition {
    let variants = [
        hook.script.is_some() || hook.command.is_some(),
        hook.url.is_some(),
        hook.prompt.is_some() && hook.agent.is_none(),
        hook.agent == Some(HookAgentMarker::Enabled),
    ]
    .into_iter()
    .filter(|flag| *flag)
    .count();

    if variants != 1 {
        errors.push(ResolveError::Invalid {
            path:   format!("run.hooks[{index}]"),
            reason: "exactly one hook transport must be configured".to_string(),
        });
    }

    let hook_type = resolve_hook_type(hook);
    let command = if let Some(script) = &hook.script {
        Some(script.clone())
    } else {
        hook.command.as_ref().map(|command| join_command(command))
    };

    HookDefinition {
        name: hook.name.clone().or_else(|| hook.id.clone()),
        event: hook.event,
        command,
        hook_type,
        matcher: hook.matcher.clone(),
        blocking: hook.blocking,
        timeout_ms: hook.timeout.map(|timeout| timeout.as_millis()),
        sandbox: hook.sandbox,
    }
}

/// Join an argv-style `command` into a single space-separated [`InterpString`],
/// preserving every `{{ ... }}` token so the executor resolves it at hook fire
/// time. The join reconstructs the source form once, in one audited place.
#[expect(
    clippy::disallowed_methods,
    reason = "deliberate source reconstruction: argv parts are reassembled into one InterpString \
              whose tokens stay typed for resolution at hook fire time"
)]
fn join_command(command: &[InterpString]) -> InterpString {
    InterpString::parse(
        &command
            .iter()
            .map(InterpString::as_source)
            .collect::<Vec<_>>()
            .join(" "),
    )
}

fn resolve_hook_type(hook: &HookEntry) -> Option<HookType> {
    if hook.script.is_some() || hook.command.is_some() {
        return None;
    }

    if let Some(url) = &hook.url {
        let headers = if hook.headers.is_empty() {
            None
        } else {
            Some(
                hook.headers
                    .iter()
                    .map(|(key, value)| (key.clone(), value.clone()))
                    .collect(),
            )
        };
        let tls = match hook.tls {
            Some(HookTlsMode::Verify) => TlsMode::Verify,
            Some(HookTlsMode::NoVerify) => TlsMode::NoVerify,
            Some(HookTlsMode::Off) => TlsMode::Off,
            None => TlsMode::default(),
        };
        return Some(HookType::Http {
            url: url.clone(),
            headers,
            tls,
        });
    }

    if hook.agent == Some(HookAgentMarker::Enabled) {
        return Some(HookType::Agent {
            prompt:          hook
                .prompt
                .clone()
                .unwrap_or_else(|| InterpString::parse("")),
            model:           hook.model.clone(),
            max_tool_rounds: hook.max_tool_rounds,
        });
    }

    hook.prompt.as_ref().map(|prompt| HookType::Prompt {
        prompt: prompt.clone(),
        model:  hook.model.clone(),
    })
}

fn resolve_scm(scm: Option<&RunScmLayer>) -> RunScmSettings {
    let Some(scm) = scm else {
        return RunScmSettings::default();
    };

    super::warn_if_demoted_template("run.scm.owner", scm.owner.as_deref());
    super::warn_if_demoted_template("run.scm.repository", scm.repository.as_deref());

    RunScmSettings {
        provider:   scm.provider.clone(),
        owner:      scm.owner.clone(),
        repository: scm.repository.clone(),
        github:     scm.github.as_ref().map(|_| ScmGitHubSettings {}),
    }
}

fn resolve_pull_request(pull_request: Option<&RunPullRequestLayer>) -> Option<PullRequestSettings> {
    let pull_request = pull_request?;
    if !pull_request.enabled.unwrap_or(false) {
        return None;
    }

    Some(PullRequestSettings {
        enabled:        true,
        draft:          pull_request.draft.unwrap_or(true),
        auto_merge:     pull_request.auto_merge.unwrap_or(false),
        merge_strategy: pull_request.merge_strategy.unwrap_or(MergeStrategy::Squash),
    })
}

fn resolve_artifacts(
    artifacts: Option<&RunArtifactsLayer>,
    errors: &mut Vec<ResolveError>,
) -> ArtifactsSettings {
    if let Some(artifacts) = artifacts {
        for (index, pattern) in artifacts.include.iter().enumerate() {
            if let Err(error) = WorkspaceGlob::try_new(pattern) {
                errors.push(ResolveError::Invalid {
                    path:   format!("run.artifacts.include[{index}]"),
                    reason: error.to_string(),
                });
            }
        }
    }

    ArtifactsSettings {
        include: artifacts
            .map(|artifacts| artifacts.include.clone())
            .unwrap_or_default(),
    }
}

#[cfg(test)]
mod resolve_prepare_tests {
    use std::collections::HashMap;

    use fabro_types::settings::InterpString;
    use fabro_types::settings::run::PreparedStepRun;

    use super::{ResolveError, resolve_prepare};
    use crate::{PrepareStep, RunPrepareLayer};

    fn resolve(layer: &RunPrepareLayer) -> Vec<super::PreparedStep> {
        let mut errors: Vec<ResolveError> = Vec::new();
        let settings = resolve_prepare(Some(layer), &mut errors);
        assert!(errors.is_empty(), "unexpected resolve errors: {errors:?}");
        settings.steps
    }

    #[test]
    fn carries_per_step_env_through() {
        let layer = RunPrepareLayer {
            steps:   vec![PrepareStep {
                script:  Some(InterpString::parse("setup")),
                command: None,
                env:     HashMap::from([(
                    "TOKEN".to_string(),
                    InterpString::parse("{{ env.DEPLOY_TOKEN }}"),
                )]),
            }],
            timeout: None,
        };

        let steps = resolve(&layer);

        assert_eq!(steps.len(), 1);
        assert_eq!(steps[0].run, PreparedStepRun::Script {
            script: "setup".to_string(),
        });
        // The per-step env survives config resolution in source form. The
        // unsupported token fails later at the run boundary.
        assert_eq!(
            steps[0].env.get("TOKEN").map(String::as_str),
            Some("{{ env.DEPLOY_TOKEN }}")
        );
    }

    #[test]
    fn argv_command_elements_are_carried_in_source_form() {
        let layer = RunPrepareLayer {
            steps:   vec![PrepareStep {
                script:  None,
                command: Some(vec![
                    InterpString::parse("echo"),
                    InterpString::parse("hello world"),
                    InterpString::parse("{{ env.USER_INPUT }}"),
                ]),
                env:     HashMap::new(),
            }],
            timeout: None,
        };

        let steps = resolve(&layer);

        // Argv elements are carried as separate source strings, NOT joined and
        // NOT shell-quoted here. Quoting happens after late interpolation at
        // the run boundary, so a resolved value (not the source token) is what
        // gets quoted.
        assert_eq!(steps[0].run, PreparedStepRun::Command {
            command: vec![
                "echo".to_string(),
                "hello world".to_string(),
                "{{ env.USER_INPUT }}".to_string(),
            ],
        });
    }

    #[test]
    fn script_is_kept_verbatim() {
        let layer = RunPrepareLayer {
            steps:   vec![PrepareStep {
                script:  Some(InterpString::parse("echo hello && ls -la")),
                command: None,
                env:     HashMap::new(),
            }],
            timeout: None,
        };

        let steps = resolve(&layer);

        // A script is a raw shell snippet, not an argv: it is carried verbatim.
        assert_eq!(steps[0].run, PreparedStepRun::Script {
            script: "echo hello && ls -la".to_string(),
        });
    }
}
