//! Sparse `[run]` settings layer definitions.

use std::collections::HashMap;

use fabro_types::settings::run::{
    ApprovalMode, HookEvent, McpHttpProtocol, MergeStrategy, RunMode,
};
use fabro_types::settings::{Duration, InterpString, ModelRef};
use serde::de::{self, Deserializer};
use serde::{Deserialize, Serialize, Serializer};

use super::combine::Combine;
use super::environment::RunEnvironmentLayer;
use super::maps::{MergeMap, ReplaceMap, StickyMap};
use super::splice_array::SPLICE_MARKER;

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub goal:          Option<RunGoalLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub working_dir:   Option<String>,
    /// Flat string-to-string map. Replaces wholesale across layers.
    #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
    pub metadata:      ReplaceMap<String>,
    /// Run inputs: typed scalar values. Replaces wholesale across layers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub inputs:        Option<HashMap<String, toml::Value>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model:         Option<RunModelLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git:           Option<RunGitLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prepare:       Option<RunPrepareLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub execution:     Option<RunExecutionLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub checkpoint:    Option<RunCheckpointLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub clone:         Option<RunCloneLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub run_branch:    Option<RunRunBranchLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub meta_branch:   Option<RunMetaBranchLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub environment:   Option<RunEnvironmentLayer>,
    #[serde(default, skip_serializing_if = "MergeMap::is_empty")]
    pub notifications: MergeMap<NotificationRouteLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub interviews:    Option<InterviewsLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent:         Option<RunAgentLayer>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub hooks:         Vec<HookEntry>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scm:           Option<RunScmLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pull_request:  Option<RunPullRequestLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub artifacts:     Option<RunArtifactsLayer>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub integrations:  Option<RunIntegrationsLayer>,
}

/// `[run.integrations]` — run-level integration knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunIntegrationsLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub github: Option<RunIntegrationsGithubLayer>,
}

/// `[run.integrations.github]` — runtime GitHub token shape.
///
/// `Combine` is hand-rolled (not derived) for two reasons:
/// 1. `HashMap<String, InterpString>: Combine` is not implemented in this
///    crate, so `#[derive(Combine)]` would not even compile.
/// 2. The `ReplaceMap` "empty inherits from below" semantics (`maps.rs:76-80`)
///    are the wrong fit: we want `Some({})` from a higher layer to be honored
///    as an explicit clear (no token requested) rather than fall through to a
///    lower layer's permissions.
///
/// Note: this diverges from sibling map fields like `RunLayer::metadata`
/// (`ReplaceMap`), where empty-table-means-inherit. Document the
/// difference for workflow authors reading the schema by analogy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunIntegrationsGithubLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub permissions: Option<HashMap<String, InterpString>>,
}

impl Combine for RunIntegrationsGithubLayer {
    fn combine(self, other: Self) -> Self {
        Self {
            permissions: self.permissions.or(other.permissions),
        }
    }
}

/// The source of a run's goal, either inline literal text or a reference to
/// a file on disk.
///
/// TOML surface:
///
/// ```toml
/// # Inline form
/// [run]
/// goal = "Diagnose and fix CI build failures"
///
/// # File form
/// [run.goal]
/// file = "prompts/fix_build.md"
/// ```
///
/// Relative paths inside the `file` variant are resolved against the
/// directory of the config file that declared them at load time (see
/// `fabro_config::resolve_goal_file_paths`). Interpolation tokens are not
/// supported inside the `file` path; a tokenized path fails to resolve.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub enum RunGoalLayer {
    Inline(InterpString),
    File { file: InterpString },
}

/// `[run.model]` — provider-neutral default model selection.
#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Serialize,
    Deserialize,
    fabro_macros::Combine,
    fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunModelLayer {
    /// Provider name for workflow model selection.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(value_type = "string")]
    pub provider:  Option<String>,
    /// Model name for workflow runs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(value_type = "string")]
    pub name:      Option<String>,
    /// Model-keyed fallback chains. Each value is an ordered list of bare
    /// providers, bare model IDs or aliases, or provider-qualified
    /// `provider:selector` values. A qualified selector may be a model ID,
    /// alias, or provider API ID. Legacy `provider/model` values remain
    /// accepted. Each list supports the `...` splice marker at layering time.
    /// Fabro selects one chain from the original requested model. A fallback
    /// target never activates another model's chain.
    ///
    /// Model keys stay unresolved in this sparse layer because `fabro
    /// validate` is offline and has no server model catalog.
    #[serde(default, skip_serializing_if = "MergeMap::is_empty")]
    #[option(default = "{}", value_type = "table<string, array<string>>")]
    pub fallbacks: MergeMap<Vec<ModelRefOrSplice>>,
    /// Run-level default values for typed model controls. Node attributes
    /// and style-applied attributes still win over these defaults.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub controls:  Option<RunModelControlsLayer>,
}

/// `[run.model.controls]` — run-level default control values.
///
/// Stored as plain strings here; concrete enum validation
/// (`ReasoningEffort`, `Speed`) happens at request-time when the resolved
/// catalog is available.
#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Serialize,
    Deserialize,
    fabro_macros::Combine,
    fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunModelControlsLayer {
    /// Default reasoning-effort value for nodes that don't override it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(value_type = "string")]
    pub reasoning_effort: Option<String>,
    /// Default speed value for nodes that don't override it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(value_type = "string")]
    pub speed:            Option<String>,
}

/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRefOrSplice {
    ModelRef(ModelRef),
    Splice,
}

impl Serialize for ModelRefOrSplice {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        match self {
            Self::ModelRef(m) => m.serialize(serializer),
            Self::Splice => serializer.serialize_str(SPLICE_MARKER),
        }
    }
}

impl<'de> Deserialize<'de> for ModelRefOrSplice {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        use serde::de::Error;
        let raw = String::deserialize(deserializer)?;
        if raw == SPLICE_MARKER {
            return Ok(Self::Splice);
        }
        let model = raw.parse::<ModelRef>().map_err(D::Error::custom)?;
        Ok(Self::ModelRef(model))
    }
}

/// `[run.git]` — local git behavior such as commit author.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunGitLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub author: Option<GitAuthorLayer>,
}

#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Serialize,
    Deserialize,
    fabro_macros::Combine,
    fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct GitAuthorLayer {
    /// Git author name for checkpoint commits.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "\"fabro\"", value_type = "string")]
    pub name:  Option<String>,
    /// Git author email for checkpoint commits.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "\"fabro@local\"", value_type = "string")]
    pub email: Option<String>,
}

/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces
/// across layers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunPrepareLayer {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub steps:   Vec<PrepareStep>,
    /// Optional timeout applied to each prepare step.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout: Option<Duration>,
}

/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PrepareStep {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub script:  Option<InterpString>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<Vec<InterpString>>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub env:     HashMap<String, InterpString>,
}

/// `[run.execution]` — run posture knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunExecutionLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mode:     Option<RunMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approval: Option<ApprovalMode>,
}

/// `[run.checkpoint]` — checkpoint policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunCheckpointLayer {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub exclude_globs:  Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skip_git_hooks: Option<bool>,
    /// Optional timeout for the per-node run-branch checkpoint commit.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commit_timeout: Option<Duration>,
}

/// `[run.clone]` — source workspace clone policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunCloneLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
}

/// `[run.run_branch]` — Fabro-managed checkpoint branch policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunRunBranchLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub push:    Option<bool>,
}

/// `[run.meta_branch]` — Fabro-managed checkpoint metadata branch policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunMetaBranchLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub push:    Option<bool>,
}

/// `[run.notifications.<name>]` — a keyed notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct NotificationRouteLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled:  Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    /// Raw Fabro event names. Splice marker supported at layering time.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub events:   Vec<StringOrSplice>,
    /// Provider-specific destination subtables.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub slack:    Option<NotificationProviderLayer>,
}

/// A single string array entry that may be the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StringOrSplice {
    Value(String),
    Splice,
}

impl Serialize for StringOrSplice {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        match self {
            Self::Value(s) => serializer.serialize_str(s),
            Self::Splice => serializer.serialize_str(SPLICE_MARKER),
        }
    }
}

impl<'de> Deserialize<'de> for StringOrSplice {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        if s == SPLICE_MARKER {
            Ok(Self::Splice)
        } else {
            Ok(Self::Value(s))
        }
    }
}

/// Provider-specific destination fields for a notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NotificationProviderLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<InterpString>,
}

/// `[run.interviews]` — external interview delivery.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct InterviewsLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub slack:    Option<InterviewProviderLayer>,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InterviewProviderLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<InterpString>,
}

/// `[run.agent]` — agent knobs only (Fabro tools and MCPs).
#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Serialize,
    Deserialize,
    fabro_macros::Combine,
    fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunAgentLayer {
    /// Allow workflow agents to use Fabro run-management tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "false", value_type = "boolean")]
    pub fabro_tools: Option<bool>,

    /// Agent-scoped MCP server entries, keyed by name.
    #[serde(default, skip_serializing_if = "StickyMap::is_empty")]
    #[option(value_type = "table")]
    pub mcps: StickyMap<McpEntryLayer>,
}

/// A single MCP entry. `type` selects the transport; `script`/`command` are
/// mutually exclusive for process-launching transports. Non-launching HTTP
/// transports use neither field.
#[derive(Debug, Clone, PartialEq)]
pub enum McpEntryLayer {
    Http {
        enabled:         Option<bool>,
        protocol:        McpHttpProtocol,
        url:             InterpString,
        headers:         HashMap<String, InterpString>,
        startup_timeout: Option<Duration>,
        tool_timeout:    Option<Duration>,
    },
    Stdio {
        enabled:         Option<bool>,
        script:          Option<InterpString>,
        command:         Option<Vec<InterpString>>,
        env:             HashMap<String, InterpString>,
        startup_timeout: Option<Duration>,
        tool_timeout:    Option<Duration>,
    },
    Sandbox {
        enabled:         Option<bool>,
        protocol:        McpHttpProtocol,
        script:          Option<InterpString>,
        command:         Option<Vec<InterpString>>,
        port:            u16,
        env:             HashMap<String, InterpString>,
        startup_timeout: Option<Duration>,
        tool_timeout:    Option<Duration>,
    },
    Reference {
        id:      String,
        enabled: Option<bool>,
    },
}

impl Serialize for McpEntryLayer {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        #[derive(Serialize)]
        #[serde(tag = "type", rename_all = "snake_case")]
        enum TaggedMcpEntryLayer<'a> {
            Http {
                enabled:         &'a Option<bool>,
                protocol:        &'a McpHttpProtocol,
                url:             &'a InterpString,
                headers:         &'a HashMap<String, InterpString>,
                startup_timeout: &'a Option<Duration>,
                tool_timeout:    &'a Option<Duration>,
            },
            Stdio {
                enabled:         &'a Option<bool>,
                script:          &'a Option<InterpString>,
                command:         &'a Option<Vec<InterpString>>,
                env:             &'a HashMap<String, InterpString>,
                startup_timeout: &'a Option<Duration>,
                tool_timeout:    &'a Option<Duration>,
            },
            Sandbox {
                enabled:         &'a Option<bool>,
                protocol:        &'a McpHttpProtocol,
                script:          &'a Option<InterpString>,
                command:         &'a Option<Vec<InterpString>>,
                port:            &'a u16,
                env:             &'a HashMap<String, InterpString>,
                startup_timeout: &'a Option<Duration>,
                tool_timeout:    &'a Option<Duration>,
            },
        }

        #[derive(Serialize)]
        struct McpReferenceLayer<'a> {
            id:      &'a str,
            #[serde(skip_serializing_if = "Option::is_none")]
            enabled: &'a Option<bool>,
        }

        match self {
            Self::Http {
                enabled,
                protocol,
                url,
                headers,
                startup_timeout,
                tool_timeout,
            } => TaggedMcpEntryLayer::Http {
                enabled,
                protocol,
                url,
                headers,
                startup_timeout,
                tool_timeout,
            }
            .serialize(serializer),
            Self::Stdio {
                enabled,
                script,
                command,
                env,
                startup_timeout,
                tool_timeout,
            } => TaggedMcpEntryLayer::Stdio {
                enabled,
                script,
                command,
                env,
                startup_timeout,
                tool_timeout,
            }
            .serialize(serializer),
            Self::Sandbox {
                enabled,
                protocol,
                script,
                command,
                port,
                env,
                startup_timeout,
                tool_timeout,
            } => TaggedMcpEntryLayer::Sandbox {
                enabled,
                protocol,
                script,
                command,
                port,
                env,
                startup_timeout,
                tool_timeout,
            }
            .serialize(serializer),
            Self::Reference { id, enabled } => {
                McpReferenceLayer { id, enabled }.serialize(serializer)
            }
        }
    }
}

impl McpEntryLayer {
    /// Whether this entry should be activated. Absent `enabled` defaults to
    /// `true`; only an explicit `enabled = false` disables the entry.
    #[must_use]
    pub fn is_enabled(&self) -> bool {
        let enabled = match self {
            Self::Http { enabled, .. }
            | Self::Stdio { enabled, .. }
            | Self::Sandbox { enabled, .. }
            | Self::Reference { enabled, .. } => enabled,
        };
        enabled.unwrap_or(true)
    }
}

impl<'de> Deserialize<'de> for McpEntryLayer {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(deny_unknown_fields)]
        struct McpReferenceLayer {
            id:      String,
            #[serde(default)]
            enabled: Option<bool>,
        }

        #[derive(Deserialize)]
        #[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
        enum TaggedMcpEntryLayer {
            Http {
                #[serde(default)]
                enabled:         Option<bool>,
                #[serde(default)]
                protocol:        McpHttpProtocol,
                url:             InterpString,
                #[serde(default)]
                headers:         HashMap<String, InterpString>,
                #[serde(default)]
                startup_timeout: Option<Duration>,
                #[serde(default)]
                tool_timeout:    Option<Duration>,
            },
            Stdio {
                #[serde(default)]
                enabled:         Option<bool>,
                #[serde(default)]
                script:          Option<InterpString>,
                #[serde(default)]
                command:         Option<Vec<InterpString>>,
                #[serde(default)]
                env:             HashMap<String, InterpString>,
                #[serde(default)]
                startup_timeout: Option<Duration>,
                #[serde(default)]
                tool_timeout:    Option<Duration>,
            },
            Sandbox {
                #[serde(default)]
                enabled:         Option<bool>,
                #[serde(default)]
                protocol:        McpHttpProtocol,
                #[serde(default)]
                script:          Option<InterpString>,
                #[serde(default)]
                command:         Option<Vec<InterpString>>,
                port:            u16,
                #[serde(default)]
                env:             HashMap<String, InterpString>,
                #[serde(default)]
                startup_timeout: Option<Duration>,
                #[serde(default)]
                tool_timeout:    Option<Duration>,
            },
        }

        let value = serde_json::Value::deserialize(deserializer)?;
        let serde_json::Value::Object(map) = &value else {
            return Err(de::Error::custom("MCP entry must be a table"));
        };

        let has_reference_fields = map.contains_key("id");
        let has_inline_fields = map.contains_key("type")
            || map.contains_key("script")
            || map.contains_key("command")
            || map.contains_key("url")
            || map.contains_key("headers")
            || map.contains_key("port")
            || map.contains_key("env")
            || map.contains_key("startup_timeout")
            || map.contains_key("tool_timeout");

        if has_reference_fields && has_inline_fields {
            return Err(de::Error::custom(
                "MCP entry cannot mix catalog reference fields (`id`, `enabled`) with inline \
                 server fields",
            ));
        }

        if has_reference_fields {
            let reference: McpReferenceLayer =
                serde_json::from_value(value).map_err(de::Error::custom)?;
            return Ok(Self::Reference {
                id:      reference.id,
                enabled: reference.enabled,
            });
        }

        match serde_json::from_value(value).map_err(de::Error::custom)? {
            TaggedMcpEntryLayer::Http {
                enabled,
                protocol,
                url,
                headers,
                startup_timeout,
                tool_timeout,
            } => Ok(Self::Http {
                enabled,
                protocol,
                url,
                headers,
                startup_timeout,
                tool_timeout,
            }),
            TaggedMcpEntryLayer::Stdio {
                enabled,
                script,
                command,
                env,
                startup_timeout,
                tool_timeout,
            } => Ok(Self::Stdio {
                enabled,
                script,
                command,
                env,
                startup_timeout,
                tool_timeout,
            }),
            TaggedMcpEntryLayer::Sandbox {
                enabled,
                protocol,
                script,
                command,
                port,
                env,
                startup_timeout,
                tool_timeout,
            } => Ok(Self::Sandbox {
                enabled,
                protocol,
                script,
                command,
                port,
                env,
                startup_timeout,
                tool_timeout,
            }),
        }
    }
}

/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or
/// `agent` fields determines the hook behavior. The `id` field, when set, is
/// used for cross-layer replace-by-id merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HookEntry {
    /// Optional merge identity. Hooks with the same `id` replace in place.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id:              Option<String>,
    /// Display-only human name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name:            Option<String>,
    pub event:           HookEvent,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub matcher:         Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub blocking:        Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout:         Option<Duration>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sandbox:         Option<bool>,
    // Exactly one of the following groups is expected:
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub script:          Option<InterpString>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command:         Option<Vec<InterpString>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url:             Option<InterpString>,
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub headers:         HashMap<String, InterpString>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tls:             Option<HookTlsMode>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt:          Option<InterpString>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model:           Option<InterpString>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_tool_rounds: Option<u32>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent:           Option<HookAgentMarker>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookTlsMode {
    #[default]
    Verify,
    NoVerify,
    Off,
}

/// Reserved marker for hook entries that use the `agent` hook type. Having
/// this as its own field rather than a flag lets `HookEntry` remain a flat
/// struct without a discriminator.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookAgentMarker {
    #[default]
    Enabled,
}

/// `[run.scm]` — remote SCM host/provider behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
pub struct RunScmLayer {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider:   Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub owner:      Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repository: Option<String>,
    /// Provider-specific SCM leaves. First-pass providers.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub github:     Option<ScmGitHubLayer>,
}

/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in
/// the first pass; additional branch/checkout context stays on `run` or
/// `run.pull_request` until a concrete use case lands.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScmGitHubLayer;

/// `[run.pull_request]` — provider-neutral PR behavior.
#[derive(
    Debug,
    Clone,
    Default,
    PartialEq,
    Serialize,
    Deserialize,
    fabro_macros::Combine,
    fabro_macros::OptionsMetadata,
)]
#[serde(deny_unknown_fields)]
pub struct RunPullRequestLayer {
    /// Automatically create a PR after successful runs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "false", value_type = "boolean")]
    pub enabled:        Option<bool>,
    /// Open created pull requests as drafts.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "true", value_type = "boolean")]
    pub draft:          Option<bool>,
    /// Enable GitHub auto-merge for created pull requests. Implies `draft =
    /// false`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(default = "false", value_type = "boolean")]
    pub auto_merge:     Option<bool>,
    /// Merge method to configure for the pull request.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[option(
        default = "\"squash\"",
        value_type = "\"merge\" | \"squash\" | \"rebase\""
    )]
    pub merge_strategy: Option<MergeStrategy>,
}

/// `[run.artifacts]` — run artifact collection policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunArtifactsLayer {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub include: Vec<String>,
}
