use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use std::path::PathBuf;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExcludeConfig {
    #[serde(default)]
    pub types: Vec<String>,
    #[serde(default)]
    pub functions: Vec<String>,
    /// Exclude specific methods: "TypeName.method_name"
    #[serde(default)]
    pub methods: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IncludeConfig {
    #[serde(default)]
    pub types: Vec<String>,
    #[serde(default)]
    pub functions: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OutputConfig {
    pub python: Option<PathBuf>,
    pub node: Option<PathBuf>,
    pub ruby: Option<PathBuf>,
    pub php: Option<PathBuf>,
    pub elixir: Option<PathBuf>,
    pub wasm: Option<PathBuf>,
    pub ffi: Option<PathBuf>,
    pub go: Option<PathBuf>,
    pub java: Option<PathBuf>,
    pub kotlin: Option<PathBuf>,
    pub kotlin_android: Option<PathBuf>,
    pub dart: Option<PathBuf>,
    pub swift: Option<PathBuf>,
    pub gleam: Option<PathBuf>,
    pub csharp: Option<PathBuf>,
    pub r: Option<PathBuf>,
    pub zig: Option<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScaffoldConfig {
    pub description: Option<String>,
    pub license: Option<String>,
    pub repository: Option<String>,
    pub homepage: Option<String>,
    #[serde(default)]
    pub authors: Vec<String>,
    #[serde(default)]
    pub keywords: Vec<String>,
    /// Generated-file header text overrides.
    #[serde(default)]
    pub generated_header: Option<GeneratedHeaderConfig>,
    /// Pre-commit scaffold overrides.
    #[serde(default)]
    pub precommit: Option<PrecommitConfig>,
    /// Opt-in workspace `.cargo/config.toml` management. When present, alef writes
    /// the full file with hash-based drift detection. Absent = legacy behavior
    /// (wasm32 block only, create-if-missing, unmanaged).
    pub cargo: Option<ScaffoldCargo>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct GeneratedHeaderConfig {
    /// URL shown in generated-file headers for issue reporting and docs.
    #[serde(default)]
    pub issues_url: Option<String>,
    /// Regeneration command shown in generated-file headers.
    #[serde(default)]
    pub regenerate_command: Option<String>,
    /// Freshness verification command shown in generated-file headers.
    #[serde(default)]
    pub verify_command: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrecommitConfig {
    /// Whether to include the shared shell/Docker/docs hooks block.
    #[serde(default)]
    pub include_shared_hooks: Option<bool>,
    /// Repository URL for the shared hooks block.
    #[serde(default)]
    pub shared_hooks_repo: Option<String>,
    /// Revision for the shared hooks block.
    #[serde(default)]
    pub shared_hooks_rev: Option<String>,
    /// Whether to include the alef hook block.
    #[serde(default)]
    pub include_alef_hooks: Option<bool>,
    /// Repository URL for the alef hook block.
    #[serde(default)]
    pub alef_hooks_repo: Option<String>,
    /// Revision for the alef hook block.
    #[serde(default)]
    pub alef_hooks_rev: Option<String>,
}

/// Opt-in management of workspace-level `.cargo/config.toml`.
///
/// All fields default to canonical values that produce the same `.cargo/config.toml`
/// across polyglot repos. Override individual targets via `targets`, or inject
/// repo-specific `[env]` entries via `env`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScaffoldCargo {
    /// Per-target cross-compile / rustflags overrides. Defaults emit the canonical
    /// 6-target template (macOS dynamic_lookup, Windows MSVC rust-lld x64+i686,
    /// aarch64-linux-gnu cross-gcc, x86_64-linux-musl, wasm32 bulk-memory).
    #[serde(default)]
    pub targets: ScaffoldCargoTargets,
    /// Free-form `[env]` entries copied verbatim into the generated file.
    /// Values can be a plain string or `{ value, relative }`. Empty by default.
    #[serde(default)]
    pub env: HashMap<String, ScaffoldCargoEnvValue>,
}

/// Per-target opt-out flags. All default to `true`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScaffoldCargoTargets {
    #[serde(default = "default_true")]
    pub macos_dynamic_lookup: bool,
    #[serde(default = "default_true")]
    pub x86_64_pc_windows_msvc: bool,
    #[serde(default = "default_true")]
    pub i686_pc_windows_msvc: bool,
    #[serde(default = "default_true")]
    pub aarch64_unknown_linux_gnu: bool,
    #[serde(default = "default_true")]
    pub x86_64_unknown_linux_musl: bool,
    #[serde(default = "default_true")]
    pub wasm32_unknown_unknown: bool,
}

impl Default for ScaffoldCargoTargets {
    fn default() -> Self {
        Self {
            macos_dynamic_lookup: true,
            x86_64_pc_windows_msvc: true,
            i686_pc_windows_msvc: true,
            aarch64_unknown_linux_gnu: true,
            x86_64_unknown_linux_musl: true,
            wasm32_unknown_unknown: true,
        }
    }
}

fn default_true() -> bool {
    true
}

/// Value for a `[scaffold.cargo.env]` entry. Either a bare string (renders as
/// `KEY = "value"`) or a structured form with `value` + optional `relative`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ScaffoldCargoEnvValue {
    Plain(String),
    Structured {
        value: String,
        #[serde(default)]
        relative: bool,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadmeConfig {
    pub template_dir: Option<PathBuf>,
    pub snippets_dir: Option<PathBuf>,
    /// Deprecated: path to an external YAML config file. Prefer inline fields below.
    pub config: Option<PathBuf>,
    pub output_pattern: Option<String>,
    /// Discord invite URL used in README templates.
    pub discord_url: Option<String>,
    /// Banner image URL used in README templates.
    pub banner_url: Option<String>,
    /// Per-language README configuration, keyed by language code
    /// (e.g. "python", "typescript", "ruby"). Values are flexible JSON objects
    /// that map directly to minijinja template context variables.
    #[serde(default)]
    pub languages: HashMap<String, JsonValue>,
}

/// A value that can be either a single string or a list of strings.
///
/// Deserializes from both `"cmd"` and `["cmd1", "cmd2"]` in TOML/JSON.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum StringOrVec {
    Single(String),
    Multiple(Vec<String>),
}

impl StringOrVec {
    /// Return all commands as a slice-like iterator.
    pub fn commands(&self) -> Vec<&str> {
        match self {
            StringOrVec::Single(s) => vec![s.as_str()],
            StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LintConfig {
    /// Shell command that must exit 0 for lint to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main lint commands; aborts on failure.
    pub before: Option<StringOrVec>,
    pub format: Option<StringOrVec>,
    pub check: Option<StringOrVec>,
    pub typecheck: Option<StringOrVec>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateConfig {
    /// Shell command that must exit 0 for update to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main update commands; aborts on failure.
    pub before: Option<StringOrVec>,
    /// Command(s) for safe dependency updates (compatible versions only).
    pub update: Option<StringOrVec>,
    /// Command(s) for aggressive updates (including incompatible/major bumps).
    pub upgrade: Option<StringOrVec>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct TestConfig {
    /// Shell command that must exit 0 for test to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main test commands; aborts on failure.
    pub before: Option<StringOrVec>,
    /// Command to run unit/integration tests for this language.
    pub command: Option<StringOrVec>,
    /// Command to run e2e tests for this language.
    pub e2e: Option<StringOrVec>,
    /// Command to run tests with coverage for this language.
    pub coverage: Option<StringOrVec>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SetupConfig {
    /// Shell command that must exit 0 for setup to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main setup commands; aborts on failure.
    pub before: Option<StringOrVec>,
    /// Command(s) to install dependencies for this language.
    pub install: Option<StringOrVec>,
    /// Timeout in seconds for the complete setup (precondition + before + install).
    #[serde(default = "default_setup_timeout")]
    pub timeout_seconds: u64,
    /// Optional working directory (relative to repo root) for setup commands.
    ///
    /// When set, install commands run from `base_dir.join(workdir)` instead of
    /// `base_dir`. Required for languages whose manifest does not live at the
    /// workspace root (Swift's `Package.swift`, Kotlin-Android's `gradlew`,
    /// Dart's `pubspec.yaml`, Zig's `build.zig`). Defaults to `None` (run from
    /// repo root).
    #[serde(default)]
    pub workdir: Option<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CleanConfig {
    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main clean commands; aborts on failure.
    pub before: Option<StringOrVec>,
    /// Command(s) to clean build artifacts for this language.
    pub clean: Option<StringOrVec>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BuildCommandConfig {
    /// Shell command that must exit 0 for build to run; skip with warning on failure.
    pub precondition: Option<String>,
    /// Command(s) to run before the main build commands; aborts on failure.
    pub before: Option<StringOrVec>,
    /// Command(s) to build in debug mode.
    pub build: Option<StringOrVec>,
    /// Command(s) to build in release mode.
    pub build_release: Option<StringOrVec>,
}

impl BuildCommandConfig {
    /// Overlay `other` onto this config field-by-field.
    ///
    /// Used for build command defaults where built-ins, workspace defaults, and
    /// crate overrides should compose without forcing callers to restate every
    /// command field.
    pub fn merge_overlay(mut self, other: &Self) -> Self {
        if other.precondition.is_some() {
            self.precondition = other.precondition.clone();
        }
        if other.before.is_some() {
            self.before = other.before.clone();
        }
        if other.build.is_some() {
            self.build = other.build.clone();
        }
        if other.build_release.is_some() {
            self.build_release = other.build_release.clone();
        }
        self
    }
}

fn default_setup_timeout() -> u64 {
    600
}

/// Per-language output path templates for multi-crate workspaces.
///
/// Each entry is a path string that may contain `{crate}` and `{lang}` placeholders.
/// Resolved by [`OutputTemplate::resolve`] to produce a concrete path for one
/// `(crate, language)` pair.
///
/// Defaults (when a language entry is absent and no per-crate explicit override is set):
/// - Single-crate workspaces resolve to `packages/{lang}/`.
/// - Multi-crate workspaces resolve to `packages/{lang}/{crate}/`.
///
/// Per-crate explicit paths in [`OutputConfig`] always win over a workspace template.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutputTemplate {
    pub python: Option<String>,
    pub node: Option<String>,
    pub ruby: Option<String>,
    pub php: Option<String>,
    pub elixir: Option<String>,
    pub wasm: Option<String>,
    pub ffi: Option<String>,
    pub go: Option<String>,
    pub java: Option<String>,
    pub kotlin: Option<String>,
    pub kotlin_android: Option<String>,
    pub dart: Option<String>,
    pub swift: Option<String>,
    pub gleam: Option<String>,
    pub csharp: Option<String>,
    pub r: Option<String>,
    pub zig: Option<String>,
}

impl OutputTemplate {
    /// Resolve a `(crate, language)` pair to a concrete output path.
    ///
    /// Resolution order (highest priority first):
    /// 1. Per-language template entry on `self`, if set, with `{crate}` and `{lang}`
    ///    placeholders substituted.
    /// 2. Default fallback: `packages/{lang}/{crate}/` if `multi_crate`, else
    ///    language-specific historical defaults (`packages/python`, `packages/node`,
    ///    `packages/ruby`, `packages/php`, `packages/elixir`) or `packages/{lang}` for
    ///    languages without a historical default.
    ///
    /// # Panics
    ///
    /// Panics if `crate_name` contains a NUL byte, path separator (`/`, `\`),
    /// or is a bare relative reference (`..`), and if the resolved path would
    /// escape the project root via `..` components or an absolute root.
    pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
        validate_output_segment(crate_name, "crate_name");
        validate_output_segment(lang, "lang");

        let path = if let Some(template) = self.entry(lang) {
            PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
        } else if multi_crate {
            PathBuf::from(format!("packages/{lang}/{crate_name}"))
        } else {
            match lang {
                "python" => PathBuf::from("packages/python"),
                "node" => PathBuf::from("packages/node"),
                "ruby" => PathBuf::from("packages/ruby"),
                "php" => PathBuf::from("packages/php"),
                "elixir" => PathBuf::from("packages/elixir"),
                other => PathBuf::from(format!("packages/{other}")),
            }
        };

        validate_output_path(&path);
        path
    }

    /// Return the raw template string for a language code, if set.
    pub fn entry(&self, lang: &str) -> Option<&str> {
        match lang {
            "python" => self.python.as_deref(),
            "node" => self.node.as_deref(),
            "ruby" => self.ruby.as_deref(),
            "php" => self.php.as_deref(),
            "elixir" => self.elixir.as_deref(),
            "wasm" => self.wasm.as_deref(),
            "ffi" => self.ffi.as_deref(),
            "go" => self.go.as_deref(),
            "java" => self.java.as_deref(),
            "kotlin" => self.kotlin.as_deref(),
            "kotlin_android" => self.kotlin_android.as_deref(),
            "dart" => self.dart.as_deref(),
            "swift" => self.swift.as_deref(),
            "gleam" => self.gleam.as_deref(),
            "csharp" => self.csharp.as_deref(),
            "r" => self.r.as_deref(),
            "zig" => self.zig.as_deref(),
            _ => None,
        }
    }
}

/// Validate that a user-supplied path segment (crate name or language code) does not
/// contain characters that could enable path traversal.
///
/// # Panics
///
/// Panics if the segment contains a NUL byte, a forward slash, or a backslash.
fn validate_output_segment(segment: &str, label: &str) {
    if segment.contains('\0') {
        panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
    }
    if segment.contains('/') || segment.contains('\\') {
        panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
    }
}

/// Validate that a resolved output `PathBuf` does not escape the project root.
///
/// # Panics
///
/// Panics if the path contains a `..` component or is absolute.
fn validate_output_path(path: &std::path::Path) {
    use std::path::Component;
    for component in path.components() {
        match component {
            Component::ParentDir => {
                panic!(
                    "resolved output path `{}` contains `..` and would escape the project root",
                    path.display()
                );
            }
            Component::RootDir | Component::Prefix(_) => {
                panic!(
                    "resolved output path `{}` is absolute and would escape the project root",
                    path.display()
                );
            }
            _ => {}
        }
    }
}

/// A single text replacement rule for version sync.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextReplacement {
    /// Glob pattern for files to process.
    pub path: String,
    /// Regex pattern to search for (may contain `{version}` placeholder).
    pub search: String,
    /// Replacement string (may contain `{version}` placeholder).
    pub replace: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn string_or_vec_single_from_toml() {
        let toml_str = r#"format = "ruff format""#;
        #[derive(Deserialize)]
        struct T {
            format: StringOrVec,
        }
        let t: T = toml::from_str(toml_str).unwrap();
        assert_eq!(t.format.commands(), vec!["ruff format"]);
    }

    #[test]
    fn string_or_vec_multiple_from_toml() {
        let toml_str = r#"format = ["cmd1", "cmd2", "cmd3"]"#;
        #[derive(Deserialize)]
        struct T {
            format: StringOrVec,
        }
        let t: T = toml::from_str(toml_str).unwrap();
        assert_eq!(t.format.commands(), vec!["cmd1", "cmd2", "cmd3"]);
    }

    #[test]
    fn lint_config_backward_compat_string() {
        let toml_str = r#"
format = "ruff format ."
check = "ruff check ."
typecheck = "mypy ."
"#;
        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.format.unwrap().commands(), vec!["ruff format ."]);
        assert_eq!(cfg.check.unwrap().commands(), vec!["ruff check ."]);
        assert_eq!(cfg.typecheck.unwrap().commands(), vec!["mypy ."]);
    }

    #[test]
    fn lint_config_array_commands() {
        let toml_str = r#"
format = ["cmd1", "cmd2"]
check = "single-check"
"#;
        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.format.unwrap().commands(), vec!["cmd1", "cmd2"]);
        assert_eq!(cfg.check.unwrap().commands(), vec!["single-check"]);
        assert!(cfg.typecheck.is_none());
    }

    #[test]
    fn lint_config_all_optional() {
        let toml_str = "";
        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.format.is_none());
        assert!(cfg.check.is_none());
        assert!(cfg.typecheck.is_none());
    }

    #[test]
    fn update_config_from_toml() {
        let toml_str = r#"
update = "cargo update"
upgrade = ["cargo upgrade --incompatible", "cargo update"]
"#;
        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.update.unwrap().commands(), vec!["cargo update"]);
        assert_eq!(
            cfg.upgrade.unwrap().commands(),
            vec!["cargo upgrade --incompatible", "cargo update"]
        );
    }

    #[test]
    fn update_config_all_optional() {
        let toml_str = "";
        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.update.is_none());
        assert!(cfg.upgrade.is_none());
    }

    #[test]
    fn string_or_vec_empty_array_from_toml() {
        let toml_str = "format = []";
        #[derive(Deserialize)]
        struct T {
            format: StringOrVec,
        }
        let t: T = toml::from_str(toml_str).unwrap();
        assert!(matches!(t.format, StringOrVec::Multiple(_)));
        assert!(t.format.commands().is_empty());
    }

    #[test]
    fn string_or_vec_single_element_array_from_toml() {
        let toml_str = r#"format = ["cmd"]"#;
        #[derive(Deserialize)]
        struct T {
            format: StringOrVec,
        }
        let t: T = toml::from_str(toml_str).unwrap();
        assert_eq!(t.format.commands(), vec!["cmd"]);
    }

    #[test]
    fn setup_config_single_string() {
        let toml_str = r#"install = "uv sync --no-install-project --no-install-workspace""#;
        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(
            cfg.install.unwrap().commands(),
            vec!["uv sync --no-install-project --no-install-workspace"]
        );
    }

    #[test]
    fn setup_config_array_commands() {
        let toml_str = r#"install = ["step1", "step2"]"#;
        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.install.unwrap().commands(), vec!["step1", "step2"]);
    }

    #[test]
    fn setup_config_all_optional() {
        let toml_str = "";
        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.install.is_none());
    }

    #[test]
    fn clean_config_single_string() {
        let toml_str = r#"clean = "rm -rf dist""#;
        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.clean.unwrap().commands(), vec!["rm -rf dist"]);
    }

    #[test]
    fn clean_config_array_commands() {
        let toml_str = r#"clean = ["step1", "step2"]"#;
        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.clean.unwrap().commands(), vec!["step1", "step2"]);
    }

    #[test]
    fn clean_config_all_optional() {
        let toml_str = "";
        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.clean.is_none());
    }

    #[test]
    fn build_command_config_single_strings() {
        let toml_str = r#"
build = "cargo build"
build_release = "cargo build --release"
"#;
        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.build.unwrap().commands(), vec!["cargo build"]);
        assert_eq!(cfg.build_release.unwrap().commands(), vec!["cargo build --release"]);
    }

    #[test]
    fn build_command_config_array_commands() {
        let toml_str = r#"
build = ["step1", "step2"]
build_release = ["step1 --release", "step2 --release"]
"#;
        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.build.unwrap().commands(), vec!["step1", "step2"]);
        assert_eq!(
            cfg.build_release.unwrap().commands(),
            vec!["step1 --release", "step2 --release"]
        );
    }

    #[test]
    fn build_command_config_all_optional() {
        let toml_str = "";
        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.build.is_none());
        assert!(cfg.build_release.is_none());
    }

    #[test]
    fn test_config_backward_compat_string() {
        let toml_str = r#"command = "pytest""#;
        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
        assert!(cfg.e2e.is_none());
        assert!(cfg.coverage.is_none());
    }

    #[test]
    fn test_config_array_command() {
        let toml_str = r#"command = ["cmd1", "cmd2"]"#;
        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.command.unwrap().commands(), vec!["cmd1", "cmd2"]);
    }

    #[test]
    fn test_config_with_coverage() {
        let toml_str = r#"
command = "pytest"
coverage = "pytest --cov=. --cov-report=term-missing"
"#;
        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
        assert_eq!(
            cfg.coverage.unwrap().commands(),
            vec!["pytest --cov=. --cov-report=term-missing"]
        );
        assert!(cfg.e2e.is_none());
    }

    #[test]
    fn test_config_all_optional() {
        let toml_str = "";
        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.command.is_none());
        assert!(cfg.e2e.is_none());
        assert!(cfg.coverage.is_none());
    }

    #[test]
    fn full_alef_toml_with_lint_and_update() {
        // Parse via WorkspaceConfig (new schema) — lint/update maps live there.
        let toml_str = r#"
languages = ["python", "node"]

[lint.python]
format = "ruff format ."
check = "ruff check --fix ."

[lint.node]
format = ["npx oxfmt", "npx oxlint --fix"]

[update.python]
update = "uv sync --upgrade"
upgrade = "uv sync --all-packages --all-extras --upgrade"

[update.node]
update = "pnpm up -r"
upgrade = ["corepack up", "pnpm up --latest -r -w"]
"#;
        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.lint.contains_key("python"));
        assert!(cfg.lint.contains_key("node"));

        let py_lint = cfg.lint.get("python").unwrap();
        assert_eq!(py_lint.format.as_ref().unwrap().commands(), vec!["ruff format ."]);

        let node_lint = cfg.lint.get("node").unwrap();
        assert_eq!(
            node_lint.format.as_ref().unwrap().commands(),
            vec!["npx oxfmt", "npx oxlint --fix"]
        );

        assert!(cfg.update.contains_key("python"));
        assert!(cfg.update.contains_key("node"));

        let node_update = cfg.update.get("node").unwrap();
        assert_eq!(node_update.update.as_ref().unwrap().commands(), vec!["pnpm up -r"]);
        assert_eq!(
            node_update.upgrade.as_ref().unwrap().commands(),
            vec!["corepack up", "pnpm up --latest -r -w"]
        );
    }

    #[test]
    fn lint_config_with_precondition_and_before() {
        let toml_str = r#"
precondition = "test -f target/release/libfoo.so"
before = "cargo build --release -p foo-ffi"
format = "gofmt -w packages/go"
check = "golangci-lint run ./..."
"#;
        let cfg: LintConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.precondition.as_deref(), Some("test -f target/release/libfoo.so"));
        assert_eq!(cfg.before.unwrap().commands(), vec!["cargo build --release -p foo-ffi"]);
        assert!(cfg.format.is_some());
        assert!(cfg.check.is_some());
    }

    #[test]
    fn test_config_with_before_list() {
        let toml_str = r#"
before = ["cd packages/python && maturin develop", "echo ready"]
command = "pytest"
"#;
        let cfg: TestConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.precondition.is_none());
        assert_eq!(
            cfg.before.unwrap().commands(),
            vec!["cd packages/python && maturin develop", "echo ready"]
        );
        assert_eq!(cfg.command.unwrap().commands(), vec!["pytest"]);
    }

    #[test]
    fn setup_config_with_precondition() {
        let toml_str = r#"
precondition = "which rustup"
install = "rustup update"
"#;
        let cfg: SetupConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.precondition.as_deref(), Some("which rustup"));
        assert!(cfg.before.is_none());
        assert!(cfg.install.is_some());
    }

    #[test]
    fn build_command_config_with_before() {
        let toml_str = r#"
before = "cargo build --release -p my-lib-ffi"
build = "cd packages/go && go build ./..."
"#;
        let cfg: BuildCommandConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.precondition.is_none());
        assert_eq!(
            cfg.before.unwrap().commands(),
            vec!["cargo build --release -p my-lib-ffi"]
        );
        assert!(cfg.build.is_some());
    }

    #[test]
    fn clean_config_precondition_and_before_optional() {
        let toml_str = r#"clean = "cargo clean""#;
        let cfg: CleanConfig = toml::from_str(toml_str).unwrap();
        assert!(cfg.precondition.is_none());
        assert!(cfg.before.is_none());
        assert!(cfg.clean.is_some());
    }

    #[test]
    fn update_config_with_precondition() {
        let toml_str = r#"
precondition = "test -f Cargo.lock"
update = "cargo update"
"#;
        let cfg: UpdateConfig = toml::from_str(toml_str).unwrap();
        assert_eq!(cfg.precondition.as_deref(), Some("test -f Cargo.lock"));
        assert!(cfg.before.is_none());
        assert!(cfg.update.is_some());
    }

    #[test]
    fn full_alef_toml_with_precondition_and_before_across_sections() {
        // Parse via WorkspaceConfig (new schema).
        let toml_str = r#"
languages = ["go", "python"]

[lint.go]
precondition = "test -f target/release/libmylib_ffi.so"
before = "cargo build --release -p mylib-ffi"
format = "gofmt -w packages/go"
check = "golangci-lint run ./..."

[lint.python]
format = "ruff format packages/python"
check = "ruff check --fix packages/python"

[test.go]
precondition = "test -f target/release/libmylib_ffi.so"
before = ["cargo build --release -p mylib-ffi", "cp target/release/libmylib_ffi.so packages/go/"]
command = "cd packages/go && go test ./..."

[test.python]
command = "cd packages/python && uv run --no-sync pytest"

[build_commands.go]
precondition = "which go"
before = "cargo build --release -p mylib-ffi"
build = "cd packages/go && go build ./..."
build_release = "cd packages/go && go build -ldflags='-s -w' ./..."

[update.go]
precondition = "test -d packages/go"
update = "cd packages/go && go get -u ./..."

[setup.python]
precondition = "which uv"
install = "cd packages/python && uv sync --no-install-project --no-install-workspace"

[clean.go]
before = "echo cleaning go"
clean = "cd packages/go && go clean -cache"
"#;
        let cfg: super::super::workspace::WorkspaceConfig = toml::from_str(toml_str).unwrap();

        // lint.go: precondition and before set
        let go_lint = cfg.lint.get("go").unwrap();
        assert_eq!(
            go_lint.precondition.as_deref(),
            Some("test -f target/release/libmylib_ffi.so"),
            "lint.go precondition should be preserved"
        );
        assert_eq!(
            go_lint.before.as_ref().unwrap().commands(),
            vec!["cargo build --release -p mylib-ffi"],
            "lint.go before should be preserved"
        );
        assert!(go_lint.format.is_some());
        assert!(go_lint.check.is_some());

        // lint.python: no precondition or before
        let py_lint = cfg.lint.get("python").unwrap();
        assert!(
            py_lint.precondition.is_none(),
            "lint.python should have no precondition"
        );
        assert!(py_lint.before.is_none(), "lint.python should have no before");

        // test.go: precondition and multi-command before
        let go_test = cfg.test.get("go").unwrap();
        assert_eq!(
            go_test.precondition.as_deref(),
            Some("test -f target/release/libmylib_ffi.so"),
            "test.go precondition should be preserved"
        );
        assert_eq!(
            go_test.before.as_ref().unwrap().commands(),
            vec![
                "cargo build --release -p mylib-ffi",
                "cp target/release/libmylib_ffi.so packages/go/"
            ],
            "test.go before list should be preserved"
        );

        // build_commands.go: precondition and before
        let go_build = cfg.build_commands.get("go").unwrap();
        assert_eq!(
            go_build.precondition.as_deref(),
            Some("which go"),
            "build_commands.go precondition should be preserved"
        );
        assert_eq!(
            go_build.before.as_ref().unwrap().commands(),
            vec!["cargo build --release -p mylib-ffi"],
            "build_commands.go before should be preserved"
        );

        // update.go: precondition only, no before
        let go_update = cfg.update.get("go").unwrap();
        assert_eq!(
            go_update.precondition.as_deref(),
            Some("test -d packages/go"),
            "update.go precondition should be preserved"
        );
        assert!(go_update.before.is_none(), "update.go before should be None");

        // setup.python: precondition only
        let py_setup = cfg.setup.get("python").unwrap();
        assert_eq!(
            py_setup.precondition.as_deref(),
            Some("which uv"),
            "setup.python precondition should be preserved"
        );
        assert!(py_setup.before.is_none(), "setup.python before should be None");

        // clean.go: before only, no precondition
        let go_clean = cfg.clean.get("go").unwrap();
        assert!(go_clean.precondition.is_none(), "clean.go precondition should be None");
        assert_eq!(
            go_clean.before.as_ref().unwrap().commands(),
            vec!["echo cleaning go"],
            "clean.go before should be preserved"
        );
    }

    #[test]
    fn output_template_resolves_explicit_entry() {
        let tmpl = OutputTemplate {
            python: Some("crates/{crate}-py/src/".to_string()),
            ..Default::default()
        };
        assert_eq!(
            tmpl.resolve("spikard", "python", true),
            PathBuf::from("crates/spikard-py/src/")
        );
    }

    #[test]
    fn output_template_substitutes_lang_and_crate() {
        let tmpl = OutputTemplate {
            go: Some("packages/{lang}/{crate}/".to_string()),
            ..Default::default()
        };
        assert_eq!(
            tmpl.resolve("spikard-runtime", "go", true),
            PathBuf::from("packages/go/spikard-runtime/")
        );
    }

    #[test]
    fn output_template_falls_back_to_multi_crate_default() {
        let tmpl = OutputTemplate::default();
        assert_eq!(
            tmpl.resolve("spikard-runtime", "python", true),
            PathBuf::from("packages/python/spikard-runtime")
        );
    }

    #[test]
    fn output_template_falls_back_to_single_crate_historical_default() {
        let tmpl = OutputTemplate::default();
        assert_eq!(
            tmpl.resolve("spikard", "python", false),
            PathBuf::from("packages/python")
        );
        assert_eq!(tmpl.resolve("spikard", "node", false), PathBuf::from("packages/node"));
        assert_eq!(tmpl.resolve("spikard", "ruby", false), PathBuf::from("packages/ruby"));
        assert_eq!(tmpl.resolve("spikard", "php", false), PathBuf::from("packages/php"));
        assert_eq!(
            tmpl.resolve("spikard", "elixir", false),
            PathBuf::from("packages/elixir")
        );
    }

    #[test]
    fn output_template_falls_back_to_lang_dir_for_unknown_languages() {
        let tmpl = OutputTemplate::default();
        assert_eq!(tmpl.resolve("spikard", "go", false), PathBuf::from("packages/go"));
        assert_eq!(tmpl.resolve("spikard", "swift", false), PathBuf::from("packages/swift"));
    }

    #[test]
    fn output_template_deserializes_from_toml() {
        let toml_str = r#"
python = "packages/python/{crate}/"
go     = "packages/go/{crate}/"
"#;
        let tmpl: OutputTemplate = toml::from_str(toml_str).unwrap();
        assert_eq!(tmpl.python.as_deref(), Some("packages/python/{crate}/"));
        assert_eq!(tmpl.go.as_deref(), Some("packages/go/{crate}/"));
        assert!(tmpl.node.is_none());
    }

    #[test]
    #[should_panic(expected = "path separators are not allowed")]
    fn resolve_rejects_crate_name_with_path_separator() {
        let tmpl = OutputTemplate::default();
        tmpl.resolve("../foo", "python", false);
    }

    #[test]
    #[should_panic(expected = "path separators are not allowed")]
    fn resolve_rejects_crate_name_with_backslash() {
        let tmpl = OutputTemplate::default();
        tmpl.resolve("..\\foo", "python", false);
    }

    #[test]
    #[should_panic(expected = "NUL byte is not allowed")]
    fn resolve_rejects_crate_name_with_nul_byte() {
        let tmpl = OutputTemplate::default();
        tmpl.resolve("foo\0bar", "python", false);
    }

    #[test]
    #[should_panic(expected = "would escape the project root")]
    fn resolve_rejects_template_that_produces_parent_dir() {
        // A malicious template that uses .. directly.
        let tmpl = OutputTemplate {
            python: Some("../../etc/{crate}".to_string()),
            ..Default::default()
        };
        tmpl.resolve("mylib", "python", false);
    }

    #[test]
    fn resolve_accepts_normal_crate_name() {
        let tmpl = OutputTemplate::default();
        let path = tmpl.resolve("my-lib", "python", false);
        assert_eq!(path, PathBuf::from("packages/python"));
    }
}

/// Configuration for the `sync-versions` command.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SyncConfig {
    /// Extra file paths to update version in (glob patterns).
    #[serde(default)]
    pub extra_paths: Vec<String>,
    /// Arbitrary text replacements applied during version sync.
    #[serde(default)]
    pub text_replacements: Vec<TextReplacement>,
}

/// A single author entry in a `CITATION.cff` file. Per the Citation File Format
/// schema, each entry is either a person (uses `family_names` + `given_names`)
/// or a legal entity (uses `name`). Validation lives in the renderer rather
/// than in serde because the choice is mutually exclusive.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CitationAuthor {
    /// Person author: family name(s).
    #[serde(default, alias = "family-names")]
    pub family_names: Option<String>,
    /// Person author: given name(s).
    #[serde(default, alias = "given-names")]
    pub given_names: Option<String>,
    /// Entity author: organisation or legal-entity name.
    #[serde(default)]
    pub name: Option<String>,
    /// Optional contact email (applies to either person or entity).
    #[serde(default)]
    pub email: Option<String>,
    /// Optional ORCID iD URL (`https://orcid.org/0000-0000-0000-0000`).
    #[serde(default)]
    pub orcid: Option<String>,
}

/// Configuration for the alef-generated `CITATION.cff` file at the repo root.
///
/// When this section is present in `alef.toml`, `alef sync-versions` writes a
/// fully-rendered Citation File Format YAML using these fields plus the current
/// workspace version (read from `Cargo.toml`). When absent, alef falls back to
/// updating the `version:` line of a hand-authored CITATION.cff in place.
///
/// All field names follow Rust convention; the renderer emits the canonical
/// CFF kebab-case keys (`cff-version`, `repository-code`, `date-released`,
/// `family-names`, `given-names`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CitationConfig {
    /// Software title (`title:`). Required.
    pub title: String,
    /// One-paragraph summary (`abstract:`). Required.
    #[serde(rename = "abstract")]
    pub abstract_: String,
    /// Authors list — at least one entry required. Persons and legal entities
    /// can be mixed (e.g. `Na'aman Hirschfeld` + `Kreuzberg, Inc.`).
    pub authors: Vec<CitationAuthor>,
    /// Canonical citation message shown to consumers (`message:`).
    #[serde(default = "default_citation_message")]
    pub message: String,
    /// Source-code repository URL (`repository-code:`). Required.
    #[serde(rename = "repository-code", alias = "repository_code")]
    pub repository_code: String,
    /// Project landing-page URL (`url:`). Optional.
    #[serde(default)]
    pub url: Option<String>,
    /// SPDX license identifier (`license:`). When omitted, the renderer falls
    /// back to `Cargo.toml [workspace.package].license`.
    #[serde(default)]
    pub license: Option<String>,
    /// Release date in `YYYY-MM-DD` form (`date-released:`). Optional.
    #[serde(default, rename = "date-released", alias = "date_released")]
    pub date_released: Option<String>,
    /// Persistent DOI for the cited release (`doi:`). Optional.
    #[serde(default)]
    pub doi: Option<String>,
}

fn default_citation_message() -> String {
    "If you use this software, please cite it using the metadata below.".to_string()
}
