// Project:   hyperi-rustlib
// File:      src/deployment/generate.rs
// Purpose:   Generate deployment artifacts from DeploymentContract
// Language:  Rust
//
// License:   BUSL-1.1
// Copyright: (c) 2026 HYPERI PTY LIMITED

//! Generate deployment artifacts (Dockerfile, Helm chart, Compose fragment)
//! from a [`DeploymentContract`].
//!
//! Apps provide ~20% customisation (ports, secrets, config); this module
//! generates ~80% boilerplate (Dockerfile, Helm chart, Compose fragment).

#![allow(clippy::format_push_string)] // Template generators naturally build strings via push_str(&format!(...))

use std::path::Path;

use super::contract::{DeploymentContract, ImageProfile};
use super::error::DeploymentError;

// ============================================================================
// Dockerfile
// ============================================================================

/// Generate a Dockerfile from the deployment contract.
///
/// When `native_deps` is populated (via [`NativeDepsContract::for_rustlib_features`](crate::deployment::NativeDepsContract::for_rustlib_features)),
/// the generated Dockerfile automatically includes custom APT repo setup and
/// runtime package installation. If `native_deps` is empty, only base utilities
/// are installed.
///
/// `identity`, when provided, stamps three `io.hyperi.contract.*` LABEL
/// lines on the image per the Contract Identity Annotation Scheme v1
/// (see [`ContractIdentity`](crate::deployment::ContractIdentity)). Phase 1
/// rollout: optional; callers SHOULD pass `Some(&identity)`. Phase 2 will
/// flip this to a required parameter.
#[must_use]
pub fn generate_dockerfile(
    contract: &DeploymentContract,
    identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
    let binary = contract.binary();

    // EXPOSE line: metrics_port + extra ports
    let expose_ports = {
        let mut ports = vec![contract.metrics_port.to_string()];
        for p in &contract.extra_ports {
            ports.push(p.port.to_string());
        }
        ports.join(" ")
    };

    // CMD line
    let cmd = if contract.entrypoint_args.is_empty() {
        String::new()
    } else {
        let args: Vec<String> = contract
            .entrypoint_args
            .iter()
            .map(|a| format!("\"{a}\""))
            .collect();
        format!("\nCMD [{}]", args.join(", "))
    };

    // Build the apt-get RUN block dynamically from native_deps + image profile
    let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);

    let profile_label = match contract.image_profile {
        ImageProfile::Production => "production",
        ImageProfile::Development => "development",
    };

    // Contract Identity Annotation Scheme v1 -- three LABEL lines.
    // Phase 1: silent on None for backwards compat (see module doc).
    let identity_block = identity
        .map(|id| format!("\n{labels}", labels = id.as_dockerfile_labels()))
        .unwrap_or_default();

    format!(
        r#"# Project:   {app_name}
# File:      Dockerfile
# Purpose:   {profile_label} container image
#
# License:   BUSL-1.1
# Copyright: (c) 2026 HYPERI PTY LIMITED
#
# AUTOGENERATED -- do not edit by hand.
# Generated by hyperi-rustlib::deployment::generate_dockerfile()
# Schema version: {schema_version}
# Source contract: {app_name}::deployment::contract()
# Regenerate with: `{binary} emit-dockerfile > Dockerfile`

FROM {base_image}

LABEL io.hyperi.profile="{profile_label}"{identity_block}

{apt_block}
COPY {binary} /usr/local/bin/{binary}
RUN chmod +x /usr/local/bin/{binary}

# Ubuntu 24.04 ships with ubuntu user at UID 1000 -- remove before creating appuser
RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
USER appuser

EXPOSE {expose_ports}

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1

ENTRYPOINT ["{binary}"]{cmd}
"#,
        app_name = contract.app_name,
        base_image = contract.base_image,
        binary = binary,
        profile_label = profile_label,
        apt_block = apt_block,
        expose_ports = expose_ports,
        metrics_port = contract.metrics_port,
        liveness_path = contract.health.liveness_path,
        cmd = cmd,
        schema_version = contract.schema_version,
        identity_block = identity_block,
    )
}

// ============================================================================
// Container Manifest (CI-consumable JSON)
// ============================================================================

/// Generate a container manifest JSON for CI consumption.
///
/// This is the minimal subset of the deployment contract that CI needs to
/// build the container image. No secrets, no K8s-specific config.
///
/// # Errors
///
/// Returns an error string if JSON serialisation fails.
pub fn generate_container_manifest(contract: &DeploymentContract) -> Result<String, String> {
    let binary = contract.binary();

    let apt_repos: Vec<serde_json::Value> = contract
        .native_deps
        .apt_repos
        .iter()
        .map(|r| {
            serde_json::json!({
                "key_url": r.key_url,
                "keyring": r.keyring,
                "url": r.url,
                "codename": r.codename,
                "packages": r.packages,
            })
        })
        .collect();

    let mut expose_ports: Vec<u16> = vec![contract.metrics_port];
    expose_ports.extend(contract.extra_ports.iter().map(|p| p.port));

    let profile_str = match contract.image_profile {
        ImageProfile::Production => "production",
        ImageProfile::Development => "development",
    };

    let title = if contract.oci_labels.title.is_empty() {
        &contract.app_name
    } else {
        &contract.oci_labels.title
    };

    let manifest = serde_json::json!({
        "schema_version": "1",
        "app_name": contract.app_name,
        "binary_name": binary,
        "base_image": contract.base_image,
        "image_registry": contract.image_registry,
        "image_profile": profile_str,
        "runtime_packages": {
            "apt_repos": apt_repos,
            "apt_packages": contract.native_deps.apt_packages,
        },
        "expose_ports": expose_ports,
        "healthcheck": {
            "path": contract.health.liveness_path,
            "port": contract.metrics_port,
            "interval": "30s",
            "timeout": "3s",
            "start_period": "5s",
            "retries": 3,
        },
        "entrypoint": [binary],
        "cmd": contract.entrypoint_args,
        "user": "appuser",
        "uid": 1000,
        "labels": {
            "io.hyperi.profile": profile_str,
            "io.hyperi.app": contract.app_name,
            "io.hyperi.metrics_port": contract.metrics_port.to_string(),
            "org.opencontainers.image.title": title,
            "org.opencontainers.image.description": contract.oci_labels.description,
            "org.opencontainers.image.vendor": contract.oci_labels.vendor,
            "org.opencontainers.image.licenses": contract.oci_labels.licenses,
        },
    });

    serde_json::to_string_pretty(&manifest)
        .map_err(|e| format!("container manifest JSON failed: {e}"))
}

// ============================================================================
// Runtime Stage Fragment (for CI Dockerfile composition)
// ============================================================================

/// Generate only the runtime stage of a Dockerfile as a fragment.
///
/// CI composes the full Dockerfile by prepending its own build stages
/// (cargo-chef pattern) and appending this runtime stage. This keeps
/// the boundary clean: rustlib owns what's *in* the container, CI owns
/// how to *build* the binary.
#[must_use]
pub fn generate_runtime_stage(contract: &DeploymentContract) -> String {
    let binary = contract.binary();
    let apt_block = build_apt_block(&contract.native_deps, contract.image_profile);

    let profile_label = match contract.image_profile {
        ImageProfile::Production => "production",
        ImageProfile::Development => "development",
    };

    let title = if contract.oci_labels.title.is_empty() {
        &contract.app_name
    } else {
        &contract.oci_labels.title
    };

    let expose_ports = {
        let mut ports = vec![contract.metrics_port.to_string()];
        for p in &contract.extra_ports {
            ports.push(p.port.to_string());
        }
        ports.join(" ")
    };

    let cmd = if contract.entrypoint_args.is_empty() {
        String::new()
    } else {
        let args: Vec<String> = contract
            .entrypoint_args
            .iter()
            .map(|a| format!("\"{a}\""))
            .collect();
        format!("\nCMD [{}]", args.join(", "))
    };

    format!(
        r#"# --- Runtime stage (generated by hyperi-rustlib deployment contract) ---
FROM {base_image} AS runtime

# Static OCI labels (from contract)
LABEL org.opencontainers.image.title="{title}"
LABEL org.opencontainers.image.description="{description}"
LABEL org.opencontainers.image.vendor="{vendor}"
LABEL org.opencontainers.image.licenses="{licenses}"
LABEL io.hyperi.profile="{profile_label}"

{apt_block}
# Dynamic OCI labels (injected by CI at build time)
ARG OCI_SOURCE=""
ARG OCI_REVISION=""
ARG OCI_VERSION=""
ARG OCI_CREATED=""
LABEL org.opencontainers.image.source="${{OCI_SOURCE}}"
LABEL org.opencontainers.image.revision="${{OCI_REVISION}}"
LABEL org.opencontainers.image.version="${{OCI_VERSION}}"
LABEL org.opencontainers.image.created="${{OCI_CREATED}}"

COPY --from=builder /app/target/release/{binary} /usr/local/bin/{binary}
RUN chmod +x /usr/local/bin/{binary}

RUN userdel -r ubuntu && useradd --create-home --uid 1000 appuser
USER appuser

EXPOSE {expose_ports}

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -sf http://localhost:{metrics_port}{liveness_path} > /dev/null || exit 1

ENTRYPOINT ["{binary}"]{cmd}
"#,
        base_image = contract.base_image,
        title = title,
        description = contract.oci_labels.description,
        vendor = contract.oci_labels.vendor,
        licenses = contract.oci_labels.licenses,
        profile_label = profile_label,
        apt_block = apt_block,
        binary = binary,
        expose_ports = expose_ports,
        metrics_port = contract.metrics_port,
        liveness_path = contract.health.liveness_path,
        cmd = cmd,
    )
}

/// Diagnostic tools installed in development images.
const DEV_TOOLS: &[&str] = &[
    "bash",
    "strace",
    "tcpdump",
    "procps",
    "dnsutils",
    "net-tools",
    "less",
    "jq",
];

/// Build the apt-get RUN block from native deps contract and image profile.
///
/// When custom APT repos are needed (e.g., Confluent for librdkafka), emits
/// the GPG key download, sources list entry, and repo-specific packages.
/// Development profile adds diagnostic tools (strace, tcpdump, etc.).
fn build_apt_block(deps: &super::native_deps::NativeDepsContract, profile: ImageProfile) -> String {
    let mut out = String::with_capacity(512);
    let is_dev = profile == ImageProfile::Development;

    // Base packages always installed (curl needed for healthcheck, ca-certificates for TLS)
    let mut base_pkgs = vec!["ca-certificates", "curl", "netcat-openbsd", "iputils-ping"];

    // If we have custom APT repos, we need gnupg for key import
    if !deps.apt_repos.is_empty() {
        base_pkgs.push("gnupg");
    }

    // Dev profile adds diagnostic tools
    if is_dev {
        base_pkgs.extend_from_slice(DEV_TOOLS);
    }

    if deps.is_empty() {
        // No native deps -- simple install
        out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
        out.push_str(&format!("    {} \\\n", base_pkgs.join(" ")));
        out.push_str("    && rm -rf /var/lib/apt/lists/*\n");
        return out;
    }

    // Collect all runtime packages (repo-specific + default)
    let mut runtime_pkgs: Vec<&str> = Vec::new();
    for repo in &deps.apt_repos {
        for pkg in &repo.packages {
            runtime_pkgs.push(pkg);
        }
    }
    for pkg in &deps.apt_packages {
        runtime_pkgs.push(pkg);
    }

    // Build multi-step RUN: base install → repo setup → update → runtime install → cleanup
    out.push_str("# Runtime shared libraries for dynamically-linked Rust crates.\n");

    out.push_str("RUN apt-get update && apt-get install -y --no-install-recommends \\\n");
    out.push_str(&format!("    {} \\\n", base_pkgs.join(" ")));

    // Add each custom APT repo
    for repo in &deps.apt_repos {
        out.push_str(&format!(
            "    && curl -fsSL {} \\\n\
             \x20      | gpg --dearmor -o {} \\\n\
             \x20   && echo \"deb [signed-by={}] \\\n\
             \x20      {} {} main\" \\\n\
             \x20      > /etc/apt/sources.list.d/{}.list \\\n",
            repo.key_url,
            repo.keyring,
            repo.keyring,
            repo.url,
            repo.codename,
            // Derive a stable filename from the keyring path
            std::path::Path::new(&repo.keyring)
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or("custom-repo"),
        ));
    }

    // Second apt-get update + install runtime packages
    out.push_str("    && apt-get update && apt-get install -y --no-install-recommends \\\n");
    out.push_str(&format!("       {} \\\n", runtime_pkgs.join(" ")));
    out.push_str("    && rm -rf /var/lib/apt/lists/*\n");

    out
}

// ============================================================================
// Docker Compose fragment
// ============================================================================

/// Generate a Docker Compose service fragment from the deployment contract.
#[must_use]
pub fn generate_compose_fragment(contract: &DeploymentContract) -> String {
    let binary = contract.binary();
    let mut out = String::with_capacity(512);

    // Service definition
    out.push_str(&format!(
        "# Generated by hyperi-rustlib deployment module\nservices:\n  {}:\n",
        contract.app_name
    ));

    // Image
    out.push_str(&format!(
        "    image: {}/{}:${{{}_VERSION:-latest}}\n",
        contract.image_registry,
        contract.app_name,
        contract.env_prefix.replace("__", "_")
    ));

    // depends_on
    if !contract.depends_on.is_empty() {
        out.push_str("    depends_on:\n");
        for dep in &contract.depends_on {
            out.push_str(&format!(
                "      {dep}:\n        condition: service_healthy\n"
            ));
        }
    }

    // Ports
    out.push_str("    ports:\n");
    out.push_str(&format!(
        "      - \"{}:{}\"\n",
        contract.metrics_port, contract.metrics_port
    ));
    for p in &contract.extra_ports {
        out.push_str(&format!("      - \"{}:{}\"\n", p.port, p.port));
    }

    // Volumes -- config file mount
    out.push_str("    volumes:\n");
    out.push_str(&format!(
        "      - ./config/{}:{}:ro\n",
        contract.config_filename(),
        contract.config_mount_path,
    ));

    // Healthcheck
    out.push_str(&format!(
        "    healthcheck:\n\
         \x20     test: [\"CMD\", \"curl\", \"-sf\", \"http://localhost:{}{}\"]
      interval: 10s\n\
         \x20     timeout: 3s\n\
         \x20     retries: 5\n",
        contract.metrics_port, contract.health.liveness_path,
    ));

    // Entrypoint args
    if !contract.entrypoint_args.is_empty() {
        out.push_str(&format!("    command: [\"{binary}\""));
        for arg in &contract.entrypoint_args {
            out.push_str(&format!(", \"{arg}\""));
        }
        out.push_str("]\n");
    }

    out
}

// ============================================================================
// Helm chart
// ============================================================================

/// Generate a complete Helm chart directory from the deployment contract.
///
/// Writes `Chart.yaml`, `values.yaml`, and all template files to `output_dir`.
///
/// `identity`, when provided, stamps the three `io.hyperi.contract.*`
/// annotations into `Chart.yaml`'s top-level `annotations:` block per the
/// Contract Identity Annotation Scheme v1. Phase 1 rollout: optional;
/// callers SHOULD pass `Some(&identity)`.
///
/// # Errors
///
/// Returns `DeploymentError` if files or directories cannot be created.
pub fn generate_chart(
    contract: &DeploymentContract,
    output_dir: impl AsRef<Path>,
    identity: Option<&crate::deployment::ContractIdentity>,
) -> Result<(), DeploymentError> {
    let dir = output_dir.as_ref();
    let templates_dir = dir.join("templates");

    // Create directories
    std::fs::create_dir_all(&templates_dir).map_err(|e| DeploymentError::CreateDir {
        path: templates_dir.display().to_string(),
        source: e,
    })?;

    // Write all chart files
    write_file(dir.join("Chart.yaml"), &gen_chart_yaml(contract, identity))?;
    write_file(dir.join("values.yaml"), &gen_values_yaml(contract))?;
    write_file(
        templates_dir.join("_helpers.tpl"),
        &gen_helpers_tpl(contract),
    )?;
    write_file(
        templates_dir.join("deployment.yaml"),
        &gen_deployment_yaml(contract),
    )?;
    write_file(
        templates_dir.join("service.yaml"),
        &gen_service_yaml(contract),
    )?;
    write_file(
        templates_dir.join("serviceaccount.yaml"),
        &gen_serviceaccount_yaml(contract),
    )?;
    write_file(
        templates_dir.join("configmap.yaml"),
        &gen_configmap_yaml(contract),
    )?;
    write_file(
        templates_dir.join("secret.yaml"),
        &gen_secret_yaml(contract),
    )?;
    write_file(templates_dir.join("hpa.yaml"), &gen_hpa_yaml(contract))?;

    if contract.keda.is_some() {
        write_file(
            templates_dir.join("keda-scaledobject.yaml"),
            &gen_keda_scaledobject_yaml(contract),
        )?;
        write_file(
            templates_dir.join("keda-triggerauth.yaml"),
            &gen_keda_triggerauth_yaml(contract),
        )?;
    }

    write_file(templates_dir.join("NOTES.txt"), &gen_notes_txt(contract))?;

    Ok(())
}

// ============================================================================
// Chart file generators
// ============================================================================

fn gen_chart_yaml(
    c: &DeploymentContract,
    identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
    // Contract Identity Annotation Scheme v1 -- top-level annotations block.
    let identity_block = identity
        .map(|id| format!("\nannotations:\n{ann}\n", ann = id.as_yaml_annotations(2)))
        .unwrap_or_default();

    format!(
        "apiVersion: v2\n\
         name: {name}\n\
         description: {desc}\n\
         type: application\n\
         version: 0.1.0\n\
         appVersion: \"1.0.0\"\n\
         {identity_block}\n\
         keywords:\n\
         \x20 - hyperi\n\
         \x20 - dfe\n\
         \n\
         maintainers:\n\
         \x20 - name: HyperI\n\
         \x20   url: https://github.com/hyperi-io\n",
        name = c.app_name,
        desc = if c.description.is_empty() {
            &c.app_name
        } else {
            &c.description
        },
        identity_block = identity_block,
    )
}

#[allow(clippy::too_many_lines)]
fn gen_values_yaml(c: &DeploymentContract) -> String {
    let mut out = String::with_capacity(2048);

    // Header comment
    out.push_str(&format!(
        "# {app} Helm chart values\n\
         #\n\
         # Generated by hyperi-rustlib deployment module.\n\
         # Contract points validated by cargo test.\n\
         \n",
        app = c.app_name,
    ));

    // Replicas, image, overrides
    out.push_str(&format!(
        "# -- Number of replicas (ignored when KEDA is enabled)\n\
         replicaCount: 1\n\
         \n\
         image:\n\
         \x20 repository: {registry}/{app}\n\
         \x20 # -- Defaults to Chart appVersion\n\
         \x20 tag: \"\"\n\
         \x20 pullPolicy: IfNotPresent\n\
         \n\
         imagePullSecrets: []\n\
         nameOverride: \"\"\n\
         fullnameOverride: \"\"\n\
         \n",
        registry = c.image_registry,
        app = c.app_name,
    ));

    // Service account
    out.push_str(
        "serviceAccount:\n\
         \x20 create: true\n\
         \x20 annotations: {}\n\
         \x20 # -- If not set, name is generated from fullname\n\
         \x20 name: \"\"\n\
         \n",
    );

    // Pod annotations (Prometheus)
    out.push_str(&format!(
        "# -- Pod annotations (Prometheus scrape config included by default)\n\
         podAnnotations:\n\
         \x20 prometheus.io/scrape: \"true\"\n\
         \x20 prometheus.io/port: \"{port}\"\n\
         \x20 prometheus.io/path: \"{metrics_path}\"\n\
         \n\
         podLabels: {{}}\n\
         \n",
        port = c.metrics_port,
        metrics_path = c.health.metrics_path,
    ));

    // Resources
    out.push_str(
        "resources:\n\
         \x20 requests:\n\
         \x20   cpu: 250m\n\
         \x20   memory: 256Mi\n\
         \x20 limits:\n\
         \x20   cpu: \"2\"\n\
         \x20   memory: 1Gi\n\
         \n",
    );

    // Service
    out.push_str(&format!(
        "# -- Metrics and health endpoint service\n\
         service:\n\
         \x20 type: ClusterIP\n\
         \x20 port: {port}\n\
         \n",
        port = c.metrics_port,
    ));

    // App config section
    out.push_str(&format!(
        "# -- Application configuration (mounted as {})\n",
        c.config_mount_path
    ));
    if let Some(ref config) = c.default_config {
        out.push_str("config:\n");
        // Serialise the config value as YAML and indent by 2
        if let Ok(yaml) = serde_yaml_ng::to_string(config) {
            for line in yaml.lines() {
                if line == "---" {
                    continue;
                }
                out.push_str(&format!("  {line}\n"));
            }
        }
    } else {
        out.push_str("config: {}\n");
    }
    out.push('\n');

    // Secret sections
    for group in &c.secrets {
        out.push_str(&format!(
            "# -- {} credentials\n\
             {}:\n\
             \x20 existingSecret: \"\"\n\
             \x20 secretKeys:\n",
            group.group_name, group.group_name,
        ));
        for env in &group.env_vars {
            out.push_str(&format!("    {}: {}\n", env.key_name, env.secret_key));
        }
        for env in &group.env_vars {
            out.push_str(&format!("  {}: \"\"\n", env.key_name));
        }
        out.push('\n');
    }

    // KEDA section. Always emit a `keda:` block in values.yaml even when
    // the contract has no KEDA config -- templates reference
    // `.Values.keda.enabled` unconditionally, so the key must exist or
    // `helm lint` panics with "nil pointer evaluating interface
    // {}.enabled". When the contract opts out, the block is just
    // `enabled: false`.
    if let Some(ref keda) = c.keda {
        out.push_str(&format!(
            "# -- KEDA autoscaling (requires KEDA operator installed)\n\
             keda:\n\
             \x20 enabled: true\n\
             \x20 minReplicaCount: {min}\n\
             \x20 maxReplicaCount: {max}\n\
             \x20 pollingInterval: {poll}\n\
             \x20 cooldownPeriod: {cool}\n\
             \x20 kafka:\n\
             \x20   # -- Scale when consumer group lag exceeds this per partition\n\
             \x20   lagThreshold: \"{lag}\"\n\
             \x20   # -- Wake from zero replicas when lag exceeds this\n\
             \x20   activationLagThreshold: \"{activation}\"\n\
             \x20   # -- Override topic (default: first topic from config)\n\
             \x20   topic: \"\"\n\
             \x20   # -- Override consumer group (default: from config)\n\
             \x20   consumerGroup: \"\"\n\
             \x20 cpu:\n\
             \x20   enabled: {cpu_enabled}\n\
             \x20   # -- CPU utilisation percentage threshold\n\
             \x20   threshold: \"{cpu_threshold}\"\n\
             \n",
            min = keda.min_replicas,
            max = keda.max_replicas,
            poll = keda.polling_interval,
            cool = keda.cooldown_period,
            lag = keda.kafka_lag_threshold,
            activation = keda.activation_lag_threshold,
            cpu_enabled = keda.cpu_enabled,
            cpu_threshold = keda.cpu_threshold,
        ));
    } else {
        out.push_str(
            "# -- KEDA autoscaling disabled by contract; HPA fallback below.\n\
             keda:\n\
             \x20 enabled: false\n\
             \n",
        );
    }

    // HPA fallback
    out.push_str(
        "# -- Standard HPA fallback (when KEDA is not installed)\n\
         # Mutually exclusive with keda.enabled\n\
         autoscaling:\n\
         \x20 enabled: false\n\
         \x20 minReplicas: 1\n\
         \x20 maxReplicas: 10\n\
         \x20 targetCPUUtilizationPercentage: 80\n\
         \n\
         nodeSelector: {}\n\
         tolerations: []\n\
         affinity: {}\n",
    );

    out
}

fn gen_helpers_tpl(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    let mut out = String::with_capacity(2048);

    // Standard helpers
    out.push_str(&format!(
        r#"{{{{/*
Expand the name of the chart.
*/}}}}
{{{{- define "{app}.name" -}}}}
{{{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}

{{{{/*
Create a default fully qualified app name.
Truncated at 63 chars because some K8s name fields are limited.
*/}}}}
{{{{- define "{app}.fullname" -}}}}
{{{{- if .Values.fullnameOverride }}}}
{{{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}}}
{{{{- else }}}}
{{{{- $name := default .Chart.Name .Values.nameOverride }}}}
{{{{- if contains $name .Release.Name }}}}
{{{{- .Release.Name | trunc 63 | trimSuffix "-" }}}}
{{{{- else }}}}
{{{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}
{{{{- end }}}}
{{{{- end }}}}

{{{{/*
Create chart name and version as used by the chart label.
*/}}}}
{{{{- define "{app}.chart" -}}}}
{{{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}}}
{{{{- end }}}}

{{{{/*
Common labels.
*/}}}}
{{{{- define "{app}.labels" -}}}}
helm.sh/chart: {{{{ include "{app}.chart" . }}}}
{{{{ include "{app}.selectorLabels" . }}}}
{{{{- if .Chart.AppVersion }}}}
app.kubernetes.io/version: {{{{ .Chart.AppVersion | quote }}}}
{{{{- end }}}}
app.kubernetes.io/managed-by: {{{{ .Release.Service }}}}
{{{{- end }}}}

{{{{/*
Selector labels.
*/}}}}
{{{{- define "{app}.selectorLabels" -}}}}
app.kubernetes.io/name: {{{{ include "{app}.name" . }}}}
app.kubernetes.io/instance: {{{{ .Release.Name }}}}
{{{{- end }}}}

{{{{/*
Service account name.
*/}}}}
{{{{- define "{app}.serviceAccountName" -}}}}
{{{{- if .Values.serviceAccount.create }}}}
{{{{- default (include "{app}.fullname" .) .Values.serviceAccount.name }}}}
{{{{- else }}}}
{{{{- default "default" .Values.serviceAccount.name }}}}
{{{{- end }}}}
{{{{- end }}}}
"#,
    ));

    // Secret name helpers -- one per secret group
    for group in &c.secrets {
        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
        out.push_str(&format!(
            r#"
{{{{/*
{group} secret name -- use existing or generate from fullname.
*/}}}}
{{{{- define "{app}.{helper}" -}}}}
{{{{- if .Values.{group}.existingSecret }}}}
{{{{- .Values.{group}.existingSecret }}}}
{{{{- else }}}}
{{{{- printf "%s-{group}" (include "{app}.fullname" .) }}}}
{{{{- end }}}}
{{{{- end }}}}
"#,
            app = app,
            group = group.group_name,
            helper = helper_name,
        ));
    }

    out
}

fn gen_deployment_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    let mut out = String::with_capacity(4096);

    // Header
    out.push_str(&format!(
        r#"apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{{{ include "{app}.fullname" . }}}}
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
  {{{{- if not (or .Values.keda.enabled .Values.autoscaling.enabled) }}}}
  replicas: {{{{ .Values.replicaCount }}}}
  {{{{- end }}}}
  selector:
    matchLabels:
      {{{{- include "{app}.selectorLabels" . | nindent 6 }}}}
  template:
    metadata:
      annotations:
        checksum/config: {{{{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}}}
        {{{{- with .Values.podAnnotations }}}}
        {{{{- toYaml . | nindent 8 }}}}
        {{{{- end }}}}
      labels:
        {{{{- include "{app}.labels" . | nindent 8 }}}}
        {{{{- with .Values.podLabels }}}}
        {{{{- toYaml . | nindent 8 }}}}
        {{{{- end }}}}
    spec:
      {{{{- with .Values.imagePullSecrets }}}}
      imagePullSecrets:
        {{{{- toYaml . | nindent 8 }}}}
      {{{{- end }}}}
      serviceAccountName: {{{{ include "{app}.serviceAccountName" . }}}}
      containers:
        - name: {{{{ .Chart.Name }}}}
          image: "{{{{ .Values.image.repository }}}}:{{{{ .Values.image.tag | default .Chart.AppVersion }}}}"
          imagePullPolicy: {{{{ .Values.image.pullPolicy }}}}
"#,
    ));

    // Args
    if !c.entrypoint_args.is_empty() {
        out.push_str("          args:\n");
        for arg in &c.entrypoint_args {
            out.push_str(&format!("            - \"{arg}\"\n"));
        }
    }

    // Ports
    out.push_str(
        "          ports:\n\
         \x20           - name: metrics\n\
         \x20             containerPort: {{ .Values.service.port }}\n\
         \x20             protocol: TCP\n",
    );
    for port in &c.extra_ports {
        out.push_str(&format!(
            "            - name: {name}\n\
             \x20             containerPort: {port}\n\
             \x20             protocol: {proto}\n",
            name = port.name,
            port = port.port,
            proto = port.protocol,
        ));
    }

    // Env vars from secrets
    if !c.secrets.is_empty() {
        out.push_str("          env:\n");
        for group in &c.secrets {
            let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));
            out.push_str(&format!(
                "            # {} credentials via Secret (figment env cascade overrides file config)\n",
                group.group_name
            ));
            for env in &group.env_vars {
                // See gen_secret_yaml -- hyphenated keys must use index form.
                let key_lookup = safe_template_lookup(
                    &format!(".Values.{}.secretKeys", group.group_name),
                    &env.key_name,
                );
                out.push_str(&format!(
                    "            - name: {env_var}\n\
                     \x20             valueFrom:\n\
                     \x20               secretKeyRef:\n\
                     \x20                 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
                     \x20                 key: {{{{ {key_lookup} }}}}\n",
                    env_var = env.env_var,
                    app = app,
                    helper = helper_name,
                ));
            }
        }
    }

    // Probes
    out.push_str(&format!(
        "          livenessProbe:\n\
         \x20           httpGet:\n\
         \x20             path: {liveness}\n\
         \x20             port: metrics\n\
         \x20           initialDelaySeconds: 10\n\
         \x20           periodSeconds: 10\n\
         \x20           failureThreshold: 3\n\
         \x20         readinessProbe:\n\
         \x20           httpGet:\n\
         \x20             path: {readiness}\n\
         \x20             port: metrics\n\
         \x20           initialDelaySeconds: 5\n\
         \x20           periodSeconds: 5\n\
         \x20           failureThreshold: 2\n\
         \x20         startupProbe:\n\
         \x20           httpGet:\n\
         \x20             path: {liveness}\n\
         \x20             port: metrics\n\
         \x20           failureThreshold: 30\n\
         \x20           periodSeconds: 5\n",
        liveness = c.health.liveness_path,
        readiness = c.health.readiness_path,
    ));

    // Volume mounts
    out.push_str(&format!(
        "          volumeMounts:\n\
         \x20           - name: config\n\
         \x20             mountPath: {config_dir}\n\
         \x20             readOnly: true\n",
        config_dir = c.config_dir(),
    ));

    // Resources
    out.push_str(
        "          {{- with .Values.resources }}\n\
         \x20         resources:\n\
         \x20           {{- toYaml . | nindent 12 }}\n\
         \x20         {{- end }}\n",
    );

    // Volumes
    out.push_str(&format!(
        "      volumes:\n\
         \x20       - name: config\n\
         \x20         configMap:\n\
         \x20           name: {{{{ include \"{app}.fullname\" . }}}}-config\n",
    ));

    // Node selector, affinity, tolerations
    out.push_str(
        "      {{- with .Values.nodeSelector }}\n\
         \x20     nodeSelector:\n\
         \x20       {{- toYaml . | nindent 8 }}\n\
         \x20     {{- end }}\n\
         \x20     {{- with .Values.affinity }}\n\
         \x20     affinity:\n\
         \x20       {{- toYaml . | nindent 8 }}\n\
         \x20     {{- end }}\n\
         \x20     {{- with .Values.tolerations }}\n\
         \x20     tolerations:\n\
         \x20       {{- toYaml . | nindent 8 }}\n\
         \x20     {{- end }}\n",
    );

    out
}

fn gen_service_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    let mut out = format!(
        r#"apiVersion: v1
kind: Service
metadata:
  name: {{{{ include "{app}.fullname" . }}}}
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
  type: {{{{ .Values.service.type }}}}
  ports:
    - port: {{{{ .Values.service.port }}}}
      targetPort: metrics
      protocol: TCP
      name: metrics
"#,
    );

    // Extra ports
    for port in &c.extra_ports {
        out.push_str(&format!(
            "    - port: {port}\n\
             \x20     targetPort: {port}\n\
             \x20     protocol: {proto}\n\
             \x20     name: {name}\n",
            port = port.port,
            proto = port.protocol,
            name = port.name,
        ));
    }

    out.push_str(&format!(
        "  selector:\n\
         \x20   {{{{- include \"{app}.selectorLabels\" . | nindent 4 }}}}\n",
    ));

    out
}

fn gen_serviceaccount_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    format!(
        r#"{{{{- if .Values.serviceAccount.create -}}}}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{{{ include "{app}.serviceAccountName" . }}}}
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
  {{{{- with .Values.serviceAccount.annotations }}}}
  annotations:
    {{{{- toYaml . | nindent 4 }}}}
  {{{{- end }}}}
automountServiceAccountToken: false
{{{{- end }}}}
"#,
    )
}

fn gen_configmap_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;

    let mut out = format!(
        r#"apiVersion: v1
kind: ConfigMap
metadata:
  name: {{{{ include "{app}.fullname" . }}}}-config
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
data:
  {filename}: |
    {{{{- toYaml .Values.config | nindent 4 }}}}
"#,
        app = app,
        filename = c.config_filename(),
    );

    let _ = &mut out; // keep borrow checker happy
    out
}

fn gen_secret_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    let mut out = String::new();
    let mut first = true;

    for group in &c.secrets {
        if !first {
            out.push_str("---\n");
        }
        first = false;

        let helper_name = format!("{}SecretName", to_camel_suffix(&group.group_name));

        out.push_str(&format!(
            "{{{{- if not .Values.{group}.existingSecret }}}}\n\
             apiVersion: v1\n\
             kind: Secret\n\
             metadata:\n\
             \x20 name: {{{{ include \"{app}.{helper}\" . }}}}\n\
             \x20 labels:\n\
             \x20   {{{{- include \"{app}.labels\" . | nindent 4 }}}}\n\
             type: Opaque\n\
             data:\n",
            group = group.group_name,
            app = app,
            helper = helper_name,
        ));

        for env in &group.env_vars {
            // Hyphenated or otherwise non-Go-identifier-safe key names
            // require `(index .Values.x "key")` form instead of
            // `.Values.x.key` -- Go-template parser rejects hyphens etc.
            let key_lookup = safe_template_lookup(
                &format!(".Values.{}.secretKeys", group.group_name),
                &env.key_name,
            );
            let val_lookup =
                safe_template_lookup(&format!(".Values.{}", group.group_name), &env.key_name);
            out.push_str(&format!(
                "  {{{{ {key_lookup} }}}}: {{{{ {val_lookup} | b64enc | quote }}}}\n"
            ));
        }

        out.push_str("{{- end }}\n");
    }

    if c.secrets.is_empty() {
        out.push_str("# No secrets defined in deployment contract\n");
    }

    out
}

fn gen_hpa_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;
    format!(
        r#"{{{{- if and .Values.autoscaling.enabled (not .Values.keda.enabled) }}}}
# Standard HPA fallback -- use when KEDA operator is not installed.
# Mutually exclusive with keda.enabled (KEDA creates its own HPA).
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{{{ include "{app}.fullname" . }}}}
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{{{ include "{app}.fullname" . }}}}
  minReplicas: {{{{ .Values.autoscaling.minReplicas }}}}
  maxReplicas: {{{{ .Values.autoscaling.maxReplicas }}}}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{{{ .Values.autoscaling.targetCPUUtilizationPercentage }}}}
{{{{- end }}}}
"#,
    )
}

fn gen_keda_scaledobject_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;

    // Find kafka secret group for trigger auth reference
    let has_kafka_secret = c.secrets.iter().any(|g| g.group_name == "kafka");

    let auth_ref = if has_kafka_secret {
        format!(
            "      authenticationRef:\n\
             \x20       name: {{{{ include \"{app}.fullname\" . }}}}-kafka-auth\n"
        )
    } else {
        String::new()
    };

    format!(
        r#"{{{{- if .Values.keda.enabled }}}}
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: {{{{ include "{app}.fullname" . }}}}
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
  scaleTargetRef:
    name: {{{{ include "{app}.fullname" . }}}}
  minReplicaCount: {{{{ .Values.keda.minReplicaCount }}}}
  maxReplicaCount: {{{{ .Values.keda.maxReplicaCount }}}}
  pollingInterval: {{{{ .Values.keda.pollingInterval }}}}
  cooldownPeriod: {{{{ .Values.keda.cooldownPeriod }}}}
  triggers:
    # Kafka consumer group lag (primary scaler)
    - type: kafka
{auth_ref}      metadata:
        bootstrapServers: {{{{ .Values.config.kafka.brokers | quote }}}}
        consumerGroup: {{{{ .Values.keda.kafka.consumerGroup | default .Values.config.kafka.group_id | quote }}}}
        {{{{- /* `default (index X 0)` would eagerly evaluate `index nil 0` and fail
            lint when no topics are set. Use explicit conditional instead. */}}}}
        {{{{- if .Values.keda.kafka.topic }}}}
        topic: {{{{ .Values.keda.kafka.topic | quote }}}}
        {{{{- else if .Values.config.kafka.topics }}}}
        topic: {{{{ (index .Values.config.kafka.topics 0) | quote }}}}
        {{{{- else }}}}
        topic: ""
        {{{{- end }}}}
        lagThreshold: {{{{ .Values.keda.kafka.lagThreshold | quote }}}}
        activationLagThreshold: {{{{ .Values.keda.kafka.activationLagThreshold | quote }}}}
        saslType: scram_sha512
        tls: disable
    {{{{- if .Values.keda.cpu.enabled }}}}
    # CPU utilisation (secondary scaler)
    - type: cpu
      metricType: Utilization
      metadata:
        value: {{{{ .Values.keda.cpu.threshold | quote }}}}
    {{{{- end }}}}
{{{{- end }}}}
"#,
    )
}

fn gen_keda_triggerauth_yaml(c: &DeploymentContract) -> String {
    let app = &c.app_name;

    // Find the kafka secret group
    let kafka_group = c.secrets.iter().find(|g| g.group_name == "kafka");

    if kafka_group.is_none() {
        return "# No kafka secret group -- KEDA TriggerAuthentication not generated\n".to_string();
    }

    let helper_name = format!("{}SecretName", to_camel_suffix("kafka"));

    format!(
        r#"{{{{- if .Values.keda.enabled }}}}
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: {{{{ include "{app}.fullname" . }}}}-kafka-auth
  labels:
    {{{{- include "{app}.labels" . | nindent 4 }}}}
spec:
  secretTargetRef:
    - parameter: sasl
      name: {{{{ include "{app}.{helper_name}" . }}}}
      key: {{{{ .Values.kafka.secretKeys.username }}}}
    - parameter: password
      name: {{{{ include "{app}.{helper_name}" . }}}}
      key: {{{{ .Values.kafka.secretKeys.password }}}}
{{{{- end }}}}
"#,
    )
}

fn gen_notes_txt(c: &DeploymentContract) -> String {
    let app = &c.app_name;

    format!(
        r#"{app} has been deployed.

1. Get the metrics/health endpoint:
   kubectl port-forward svc/{{{{ include "{app}.fullname" . }}}} {{{{ .Values.service.port }}}}:{{{{ .Values.service.port }}}}
   curl http://localhost:{{{{ .Values.service.port }}}}{liveness}
   curl http://localhost:{{{{ .Values.service.port }}}}{metrics}

{{{{- if .Values.keda.enabled }}}}

2. Check KEDA autoscaling status:
   kubectl get scaledobject {{{{ include "{app}.fullname" . }}}}
   kubectl get hpa
{{{{- end }}}}

3. View logs:
   kubectl logs -l app.kubernetes.io/name={{{{ include "{app}.name" . }}}} -f
"#,
        app = app,
        liveness = c.health.liveness_path,
        metrics = c.health.metrics_path,
    )
}

// ============================================================================
// ArgoCD Application
// ============================================================================

/// Configuration for ArgoCD `Application` generation.
///
/// All fields have sensible defaults. The Helm chart that the Application
/// points at is the one [`generate_chart`] writes to a repo path.
#[derive(Debug, Clone)]
pub struct ArgocdConfig {
    /// ArgoCD namespace (where the Application CR lives). Default: `argocd`.
    pub argocd_namespace: String,
    /// Destination namespace for the deployed app. Default: `dfe`.
    pub dest_namespace: String,
    /// Destination cluster (`server` field). Default: `https://kubernetes.default.svc`.
    pub dest_server: String,
    /// Source git repo URL (where the Helm chart lives). Required.
    pub repo_url: String,
    /// Source git revision (branch/tag). Default: `main`.
    pub target_revision: String,
    /// Path within the repo to the Helm chart. Default: `chart`.
    pub chart_path: String,
    /// ArgoCD project. Default: `default`.
    pub project: String,
    /// Sync wave (lower runs first). Default: [`crate::deployment::WAVE_APPS`].
    pub sync_wave: i32,
    /// Additional `ignoreDifferences` entries appended to the canonical
    /// defaults (HPA replicas, ClusterIP, webhook caBundle). Each entry
    /// is a raw YAML fragment matching the ArgoCD `ignoreDifferences` item
    /// shape. The fragment must start with `- group:` and use two-space
    /// indentation internally (the generator indents each line by four
    /// spaces to nest under `ignoreDifferences:`).
    ///
    /// Example:
    /// ```text
    /// "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image"
    /// ```
    ///
    /// Defaults to empty (no extra entries beyond the canonical defaults).
    pub extra_ignore_differences: Vec<String>,
}

impl Default for ArgocdConfig {
    fn default() -> Self {
        Self {
            argocd_namespace: "argocd".into(),
            dest_namespace: "dfe".into(),
            dest_server: "https://kubernetes.default.svc".into(),
            repo_url: String::new(),
            target_revision: "main".into(),
            chart_path: "chart".into(),
            project: "default".into(),
            sync_wave: crate::deployment::WAVE_APPS,
            extra_ignore_differences: Vec::new(),
        }
    }
}

/// Generate an ArgoCD `Application` custom-resource YAML from the deployment
/// contract.
///
/// The CR points at a Helm chart in a git repo (typically the chart that
/// [`generate_chart`] produces). Apply with:
///
/// ```bash
/// kubectl apply -n argocd -f application.yaml
/// ```
///
/// # Example
///
/// ```rust,no_run
/// use hyperi_rustlib::deployment::{ArgocdConfig, generate_argocd_application};
/// # use hyperi_rustlib::deployment::DeploymentContract;
/// # let contract: DeploymentContract = unimplemented!();
/// let argo = ArgocdConfig {
///     repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
///     ..Default::default()
/// };
/// let yaml = generate_argocd_application(&contract, &argo, None);
/// ```
#[must_use]
pub fn generate_argocd_application(
    contract: &DeploymentContract,
    argo: &ArgocdConfig,
    identity: Option<&crate::deployment::ContractIdentity>,
) -> String {
    // Contract Identity Annotation Scheme v1 -- three extra annotations
    // alongside the existing sync-wave entry. Indented to match the
    // 4-space `metadata.annotations:` block below.
    let identity_block = identity
        .map(|id| format!("\n{ann}", ann = id.as_yaml_annotations(4)))
        .unwrap_or_default();

    // Build the extras block: each entry is a raw YAML fragment starting with
    // `- group: ...`. Indent every line by 4 spaces to nest under
    // `ignoreDifferences:`.
    let extras_block = if argo.extra_ignore_differences.is_empty() {
        String::new()
    } else {
        let mut buf = String::new();
        for entry in &argo.extra_ignore_differences {
            for line in entry.lines() {
                buf.push_str("    ");
                buf.push_str(line);
                buf.push('\n');
            }
        }
        buf
    };

    format!(
        r#"# AUTOGENERATED -- do not edit by hand.
# Generated by hyperi-rustlib::deployment::generate_argocd_application()
# Schema version: {schema_version}
# Source contract: {app_name}::deployment::contract()
# Regenerate with: `{binary} emit-argocd > application.yaml`
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: {app_name}
  namespace: {argocd_namespace}
  annotations:
    argocd.argoproj.io/sync-wave: "{sync_wave}"{identity_block}
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: {project}

  source:
    repoURL: {repo_url}
    targetRevision: {target_revision}
    path: {chart_path}
    helm:
      releaseName: {app_name}

  destination:
    server: {dest_server}
    namespace: {dest_namespace}

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
      allowEmpty: false
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - PruneLast=true
      - ServerSideApply=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m

  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas
    - group: ""
      kind: Service
      jsonPointers:
        - /spec/clusterIP
        - /spec/clusterIPs
    - group: admissionregistration.k8s.io
      kind: ValidatingWebhookConfiguration
      jqPathExpressions:
        - .webhooks[].clientConfig.caBundle
{extras_block}"#,
        schema_version = contract.schema_version,
        app_name = contract.app_name,
        binary = contract.binary(),
        argocd_namespace = argo.argocd_namespace,
        sync_wave = argo.sync_wave,
        project = argo.project,
        repo_url = argo.repo_url,
        target_revision = argo.target_revision,
        chart_path = argo.chart_path,
        dest_server = argo.dest_server,
        dest_namespace = argo.dest_namespace,
        extras_block = extras_block,
        identity_block = identity_block,
    )
}

// ============================================================================
// Helpers
// ============================================================================

/// Convert a group name to camelCase suffix (e.g., "kafka" -> "kafka", "click_house" -> "clickHouse").
fn to_camel_suffix(name: &str) -> String {
    let mut result = String::new();
    let mut capitalize_next = false;

    for ch in name.chars() {
        if ch == '_' || ch == '-' {
            capitalize_next = true;
        } else if capitalize_next {
            result.push(ch.to_ascii_uppercase());
            capitalize_next = false;
        } else {
            result.push(ch);
        }
    }

    result
}

/// True if `s` is a valid Go-template identifier (matches
/// `[A-Za-z_][A-Za-z0-9_]*`). Go templates accept the dot-walked
/// `.foo.bar` syntax only for keys matching this shape; any other
/// key (hyphens, dots, digit-leading, etc.) must use the
/// `(index .foo "key")` form or `helm lint` fails with
/// `bad character U+002D '-'` (and similar).
fn is_go_identifier(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// Render a Go-template lookup expression that's safe for any key.
///
/// `base` must be a dot-prefixed path that itself only contains
/// Go-safe identifiers (e.g. `.Values.auth.secretKeys`). `key` may
/// contain anything; the function picks the dot-walked form when
/// it's safe and the `(index ... "key")` form otherwise.
///
/// Examples:
///   - `safe_template_lookup(".Values.auth", "username")`
///     → `.Values.auth.username`
///   - `safe_template_lookup(".Values.auth", "bearer-tokens")`
///     → `(index .Values.auth "bearer-tokens")`
fn safe_template_lookup(base: &str, key: &str) -> String {
    if is_go_identifier(key) {
        format!("{base}.{key}")
    } else {
        format!("(index {base} \"{key}\")")
    }
}

fn write_file(path: impl AsRef<Path>, content: &str) -> Result<(), DeploymentError> {
    let path = path.as_ref();
    std::fs::write(path, content).map_err(|e| DeploymentError::WriteFile {
        path: path.display().to_string(),
        source: e,
    })
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::deployment::contract::{
        OciLabels, PortContract, SecretEnvContract, SecretGroupContract,
    };
    use crate::deployment::keda::KedaContract;
    use crate::deployment::native_deps::NativeDepsContract;

    fn test_contract() -> DeploymentContract {
        DeploymentContract {
            app_name: "dfe-loader".into(),
            binary_name: "dfe-loader".into(),
            description: "High-performance Kafka to ClickHouse data loader".into(),
            metrics_port: 9090,
            health: super::super::HealthContract::default(),
            env_prefix: "DFE_LOADER".into(),
            metric_prefix: "loader".into(),
            config_mount_path: "/etc/dfe/loader.yaml".into(),
            image_registry: "ghcr.io/hyperi-io".into(),
            extra_ports: vec![],
            entrypoint_args: vec!["--config".into(), "/etc/dfe/loader.yaml".into()],
            secrets: vec![
                SecretGroupContract {
                    group_name: "kafka".into(),
                    env_vars: vec![
                        SecretEnvContract {
                            env_var: "DFE_LOADER__KAFKA__USERNAME".into(),
                            key_name: "username".into(),
                            secret_key: "kafka-username".into(),
                        },
                        SecretEnvContract {
                            env_var: "DFE_LOADER__KAFKA__PASSWORD".into(),
                            key_name: "password".into(),
                            secret_key: "kafka-password".into(),
                        },
                    ],
                },
                SecretGroupContract {
                    group_name: "clickhouse".into(),
                    env_vars: vec![SecretEnvContract {
                        env_var: "DFE_LOADER__CLICKHOUSE__PASSWORD".into(),
                        key_name: "password".into(),
                        secret_key: "clickhouse-password".into(),
                    }],
                },
            ],
            default_config: None,
            depends_on: vec!["kafka".into(), "clickhouse".into()],
            keda: Some(KedaContract::default()),
            base_image: "ubuntu:24.04".into(),
            native_deps: NativeDepsContract::default(),
            image_profile: ImageProfile::default(),
            schema_version: 2,
            oci_labels: OciLabels::default(),
        }
    }

    #[test]
    fn test_generate_dockerfile() {
        let contract = test_contract();
        let dockerfile = generate_dockerfile(&contract, None);

        assert!(dockerfile.contains("FROM ubuntu:24.04"));
        assert!(dockerfile.contains("COPY dfe-loader /usr/local/bin/dfe-loader"));
        assert!(dockerfile.contains("EXPOSE 9090"));
        assert!(dockerfile.contains("localhost:9090/healthz"));
        assert!(dockerfile.contains("ENTRYPOINT [\"dfe-loader\"]"));
        assert!(dockerfile.contains("CMD [\"--config\", \"/etc/dfe/loader.yaml\"]"));
    }

    #[test]
    fn test_generate_dockerfile_with_native_deps() {
        let mut contract = test_contract();
        contract.native_deps = NativeDepsContract::for_rustlib_features(
            &["transport-kafka", "spool", "tiered-sink"],
            "ubuntu:24.04",
        );

        let dockerfile = generate_dockerfile(&contract, None);

        // Should contain Confluent APT repo setup
        assert!(dockerfile.contains("packages.confluent.io"));
        assert!(dockerfile.contains("confluent-clients.gpg"));
        // Should contain runtime packages
        assert!(dockerfile.contains("librdkafka1"));
        assert!(dockerfile.contains("libssl3"));
        assert!(dockerfile.contains("libzstd1"));
        // Should include gnupg for key import
        assert!(dockerfile.contains("gnupg"));
    }

    #[test]
    fn test_generate_dockerfile_no_native_deps() {
        let mut contract = test_contract();
        contract.native_deps = NativeDepsContract::for_rustlib_features(
            &["cli", "deployment", "logger"],
            "ubuntu:24.04",
        );

        let dockerfile = generate_dockerfile(&contract, None);

        // No Confluent repo, no runtime packages
        assert!(!dockerfile.contains("confluent"));
        assert!(!dockerfile.contains("librdkafka1"));
        assert!(!dockerfile.contains("gnupg"));
    }

    #[test]
    fn test_generate_dockerfile_bookworm_codename() {
        let mut contract = test_contract();
        contract.base_image = "debian:bookworm-slim".into();
        contract.native_deps =
            NativeDepsContract::for_rustlib_features(&["transport-kafka"], "debian:bookworm-slim");

        let dockerfile = generate_dockerfile(&contract, None);
        assert!(dockerfile.contains("bookworm main"));
    }

    #[test]
    fn test_generate_dockerfile_production_profile() {
        let contract = test_contract();
        let dockerfile = generate_dockerfile(&contract, None);

        assert!(dockerfile.contains("Purpose:   production container image"));
        assert!(dockerfile.contains("io.hyperi.profile=\"production\""));
        assert!(!dockerfile.contains("strace"));
        assert!(!dockerfile.contains("tcpdump"));
    }

    #[test]
    fn test_generate_dockerfile_dev_profile() {
        let contract = test_contract().with_dev_profile();
        let dockerfile = generate_dockerfile(&contract, None);

        assert!(dockerfile.contains("Purpose:   development container image"));
        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
        assert!(dockerfile.contains("strace"));
        assert!(dockerfile.contains("tcpdump"));
        assert!(dockerfile.contains("procps"));
        assert!(dockerfile.contains("bash"));
        assert!(dockerfile.contains("jq"));
    }

    #[test]
    fn test_generate_dockerfile_dev_with_native_deps() {
        let mut contract = test_contract();
        contract.native_deps =
            NativeDepsContract::for_rustlib_features(&["transport-kafka", "spool"], "ubuntu:24.04");
        let dev = contract.with_dev_profile();
        let dockerfile = generate_dockerfile(&dev, None);

        // Dev tools present alongside native deps
        assert!(dockerfile.contains("strace"));
        assert!(dockerfile.contains("librdkafka1"));
        assert!(dockerfile.contains("libzstd1"));
        assert!(dockerfile.contains("io.hyperi.profile=\"development\""));
    }

    #[test]
    fn test_with_dev_profile_preserves_contract() {
        let contract = test_contract();
        let dev = contract.with_dev_profile();

        assert_eq!(dev.app_name, contract.app_name);
        assert_eq!(dev.metrics_port, contract.metrics_port);
        assert_eq!(dev.image_profile, ImageProfile::Development);
        assert_eq!(contract.image_profile, ImageProfile::Production);
    }

    #[test]
    fn test_generate_dockerfile_extra_ports() {
        let mut contract = test_contract();
        contract.extra_ports = vec![PortContract {
            name: "http".into(),
            port: 8080,
            protocol: "TCP".into(),
        }];

        let dockerfile = generate_dockerfile(&contract, None);
        assert!(dockerfile.contains("EXPOSE 9090 8080"));
    }

    #[test]
    fn test_generate_compose_fragment() {
        let contract = test_contract();
        let compose = generate_compose_fragment(&contract);

        assert!(compose.contains("dfe-loader:"));
        assert!(compose.contains("ghcr.io/hyperi-io/dfe-loader"));
        assert!(compose.contains("kafka:"));
        assert!(compose.contains("clickhouse:"));
        assert!(compose.contains("condition: service_healthy"));
        assert!(compose.contains("\"9090:9090\""));
        assert!(compose.contains("loader.yaml:/etc/dfe/loader.yaml:ro"));
    }

    #[test]
    fn test_generate_chart() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();

        generate_chart(&contract, dir.path(), None).unwrap();

        // Verify files exist
        assert!(dir.path().join("Chart.yaml").exists());
        assert!(dir.path().join("values.yaml").exists());
        assert!(dir.path().join("templates/_helpers.tpl").exists());
        assert!(dir.path().join("templates/deployment.yaml").exists());
        assert!(dir.path().join("templates/service.yaml").exists());
        assert!(dir.path().join("templates/serviceaccount.yaml").exists());
        assert!(dir.path().join("templates/configmap.yaml").exists());
        assert!(dir.path().join("templates/secret.yaml").exists());
        assert!(dir.path().join("templates/hpa.yaml").exists());
        assert!(dir.path().join("templates/keda-scaledobject.yaml").exists());
        assert!(dir.path().join("templates/keda-triggerauth.yaml").exists());
        assert!(dir.path().join("templates/NOTES.txt").exists());
    }

    #[test]
    fn test_chart_yaml_content() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let content = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
        assert!(content.contains("name: dfe-loader"));
        assert!(content.contains("description: High-performance Kafka to ClickHouse data loader"));
    }

    #[test]
    fn test_values_yaml_content() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let content = std::fs::read_to_string(dir.path().join("values.yaml")).unwrap();
        assert!(content.contains("port: 9090"));
        assert!(content.contains("prometheus.io/port: \"9090\""));
        assert!(content.contains("prometheus.io/path: \"/metrics\""));
        assert!(content.contains("lagThreshold: \"1000\""));
        assert!(content.contains("kafka-username"));
        assert!(content.contains("kafka-password"));
        assert!(content.contains("clickhouse-password"));
    }

    #[test]
    fn test_helpers_contain_secret_helpers() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let content = std::fs::read_to_string(dir.path().join("templates/_helpers.tpl")).unwrap();
        assert!(content.contains("kafkaSecretName"));
        assert!(content.contains("clickhouseSecretName"));
    }

    #[test]
    fn test_deployment_contains_env_vars() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let content =
            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();
        assert!(content.contains("DFE_LOADER__KAFKA__USERNAME"));
        assert!(content.contains("DFE_LOADER__KAFKA__PASSWORD"));
        assert!(content.contains("DFE_LOADER__CLICKHOUSE__PASSWORD"));
        assert!(content.contains("path: /healthz"));
        assert!(content.contains("path: /readyz"));
        assert!(content.contains("/etc/dfe"));
    }

    #[test]
    fn test_is_go_identifier() {
        // Valid Go identifiers
        assert!(is_go_identifier("foo"));
        assert!(is_go_identifier("FOO"));
        assert!(is_go_identifier("foo_bar"));
        assert!(is_go_identifier("_underscore_start"));
        assert!(is_go_identifier("foo123"));
        assert!(is_go_identifier("a"));

        // Invalid -- would break Go templates
        assert!(!is_go_identifier("bearer-tokens")); // hyphen
        assert!(!is_go_identifier("foo.bar")); // dot
        assert!(!is_go_identifier("123foo")); // digit-leading
        assert!(!is_go_identifier("")); // empty
        assert!(!is_go_identifier("foo bar")); // space
        assert!(!is_go_identifier("foo:bar")); // colon
    }

    #[test]
    fn test_safe_template_lookup_chooses_form() {
        assert_eq!(
            safe_template_lookup(".Values.auth", "username"),
            ".Values.auth.username"
        );
        assert_eq!(
            safe_template_lookup(".Values.auth", "bearer-tokens"),
            "(index .Values.auth \"bearer-tokens\")"
        );
        assert_eq!(
            safe_template_lookup(".Values.kafka.secretKeys", "kafka-username"),
            "(index .Values.kafka.secretKeys \"kafka-username\")"
        );
    }

    /// Regression for the dfe-receiver canary 2026-05-25 finding:
    /// keda-scaledobject.yaml previously used
    /// `default (index .Values.config.kafka.topics 0)` which `helm lint`
    /// rejects with `error calling index: index of untyped nil` because
    /// Sprig's `default` evaluates both operands. The render must now
    /// use a conditional `if/else if/else` block instead.
    #[test]
    fn test_keda_scaledobject_topic_lookup_is_lint_safe() {
        let contract = test_contract();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let keda_yaml =
            std::fs::read_to_string(dir.path().join("templates/keda-scaledobject.yaml")).unwrap();

        // Old broken form must not appear
        assert!(
            !keda_yaml.contains(
                ".Values.keda.kafka.topic | default (index .Values.config.kafka.topics 0)"
            ),
            "keda-scaledobject.yaml still uses the eagerly-evaluated `default (index ...)` form:\n{keda_yaml}"
        );

        // New conditional form must appear
        assert!(
            keda_yaml.contains("if .Values.keda.kafka.topic"),
            "keda-scaledobject.yaml missing if/else guard for topic lookup:\n{keda_yaml}"
        );
        assert!(
            keda_yaml.contains("else if .Values.config.kafka.topics"),
            "keda-scaledobject.yaml missing fallback branch for config.kafka.topics:\n{keda_yaml}"
        );
    }

    /// Regression for the dfe-receiver canary 2026-05-25 finding:
    /// secret.yaml previously emitted `.Values.x.bearer-tokens` which
    /// Go templates reject ("bad character U+002D '-'"). The render
    /// must now use the `(index .Values.x "bearer-tokens")` form.
    #[test]
    fn test_secret_yaml_handles_hyphenated_key_names() {
        let mut contract = test_contract();
        // dfe-receiver-style hyphenated key_name (token group)
        contract.secrets.push(SecretGroupContract {
            group_name: "auth".into(),
            env_vars: vec![SecretEnvContract {
                env_var: "DFE_RECEIVER__AUTH__BEARER_TOKENS".into(),
                key_name: "bearer-tokens".into(),
                secret_key: "bearer-tokens".into(),
            }],
        });

        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        let secret_yaml =
            std::fs::read_to_string(dir.path().join("templates/secret.yaml")).unwrap();
        let deployment_yaml =
            std::fs::read_to_string(dir.path().join("templates/deployment.yaml")).unwrap();

        // Old broken form must not appear anywhere
        assert!(
            !secret_yaml.contains(".Values.auth.bearer-tokens"),
            "secret.yaml still uses broken dot-walked form for hyphenated key:\n{secret_yaml}"
        );
        assert!(
            !secret_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
            "secret.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{secret_yaml}"
        );
        assert!(
            !deployment_yaml.contains(".Values.auth.secretKeys.bearer-tokens"),
            "deployment.yaml still uses broken dot-walked form for hyphenated secretKeys lookup:\n{deployment_yaml}"
        );

        // Safe index form must appear
        assert!(
            secret_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
            "secret.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{secret_yaml}"
        );
        assert!(
            secret_yaml.contains("(index .Values.auth \"bearer-tokens\")"),
            "secret.yaml missing index-form lookup for value bearer-tokens:\n{secret_yaml}"
        );
        assert!(
            deployment_yaml.contains("(index .Values.auth.secretKeys \"bearer-tokens\")"),
            "deployment.yaml missing index-form lookup for secretKeys.bearer-tokens:\n{deployment_yaml}"
        );

        // Sanity: Go-safe keys (e.g. existing kafka.username) still use dot form
        assert!(
            secret_yaml.contains(".Values.kafka.secretKeys.username"),
            "Go-safe key 'username' should still use dot-walked form:\n{secret_yaml}"
        );
    }

    #[test]
    fn test_generate_argocd_application_default() {
        let contract = test_contract();
        let argo = ArgocdConfig {
            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
            ..Default::default()
        };
        let yaml = generate_argocd_application(&contract, &argo, None);

        assert!(yaml.contains("apiVersion: argoproj.io/v1alpha1"));
        assert!(yaml.contains("kind: Application"));
        assert!(yaml.contains("name: dfe-loader"));
        assert!(yaml.contains("namespace: argocd"));
        assert!(yaml.contains("repoURL: https://github.com/hyperi-io/dfe-loader"));
        assert!(yaml.contains("targetRevision: main"));
        assert!(yaml.contains("path: chart"));
        assert!(yaml.contains("CreateNamespace=true"));
        assert!(yaml.contains("Schema version: "));
    }

    #[test]
    fn test_generate_argocd_custom_namespace_and_path() {
        let contract = test_contract();
        let argo = ArgocdConfig {
            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
            dest_namespace: "production".into(),
            chart_path: "deploy/chart".into(),
            target_revision: "v1.0.0".into(),
            sync_wave: 5,
            ..Default::default()
        };
        let yaml = generate_argocd_application(&contract, &argo, None);
        assert!(yaml.contains("namespace: production"));
        assert!(yaml.contains("path: deploy/chart"));
        assert!(yaml.contains("targetRevision: v1.0.0"));
        assert!(yaml.contains("sync-wave: \"5\""));
    }

    #[test]
    fn argocd_config_default_uses_wave_apps() {
        let cfg = ArgocdConfig::default();
        assert_eq!(cfg.sync_wave, crate::deployment::WAVE_APPS);
    }

    #[test]
    fn argocd_config_default_has_no_extra_ignore_differences() {
        let cfg = ArgocdConfig::default();
        assert!(cfg.extra_ignore_differences.is_empty());
    }

    #[test]
    fn generate_argocd_application_emits_default_ignore_differences() {
        let contract = test_contract();
        let argo = ArgocdConfig {
            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
            ..Default::default()
        };
        let yaml = generate_argocd_application(&contract, &argo, None);
        assert!(yaml.contains("ignoreDifferences:"));
        assert!(yaml.contains("/spec/replicas"));
        assert!(yaml.contains("/spec/clusterIP"));
        assert!(yaml.contains(".webhooks[].clientConfig.caBundle"));
    }

    #[test]
    fn generate_argocd_application_appends_extra_ignore_differences() {
        let contract = test_contract();
        let argo = ArgocdConfig {
            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
            extra_ignore_differences: vec![
                "- group: apps\n  kind: Deployment\n  jsonPointers:\n    - /spec/template/spec/containers/0/image".into(),
            ],
            ..Default::default()
        };
        let yaml = generate_argocd_application(&contract, &argo, None);
        assert!(yaml.contains("/spec/template/spec/containers/0/image"));
    }

    #[test]
    fn generate_argocd_application_sync_wave_annotation_uses_config_value() {
        let contract = test_contract();
        let argo = ArgocdConfig {
            repo_url: "https://github.com/hyperi-io/dfe-loader".into(),
            sync_wave: crate::deployment::WAVE_TOPICS,
            ..Default::default()
        };
        let yaml = generate_argocd_application(&contract, &argo, None);
        assert!(yaml.contains(r#"argocd.argoproj.io/sync-wave: "-5""#));
    }

    #[test]
    fn test_no_keda_files_when_disabled() {
        let mut contract = test_contract();
        contract.keda = None;

        let dir = tempfile::tempdir().unwrap();
        generate_chart(&contract, dir.path(), None).unwrap();

        assert!(!dir.path().join("templates/keda-scaledobject.yaml").exists());
        assert!(!dir.path().join("templates/keda-triggerauth.yaml").exists());
    }

    #[test]
    fn test_to_camel_suffix() {
        assert_eq!(to_camel_suffix("kafka"), "kafka");
        assert_eq!(to_camel_suffix("clickhouse"), "clickhouse");
        assert_eq!(to_camel_suffix("click_house"), "clickHouse");
        assert_eq!(to_camel_suffix("my-service"), "myService");
    }

    // ============================================================================
    // Contract Identity Annotation Scheme v1 -- end-to-end wiring tests.
    // The unit tests for ContractIdentity itself live in
    // src/deployment/contract_identity.rs; these verify the three
    // generators each emit the three keys in the right surface.
    // ============================================================================

    fn test_identity() -> crate::deployment::ContractIdentity {
        crate::deployment::ContractIdentity::new(
            "0123456789abcdef0123456789abcdef01234567",
            "ghcr.io/hyperi-io/dfe-loader:v2.7.2",
        )
        .expect("test fixture must be valid")
    }

    #[test]
    fn dockerfile_omits_identity_block_when_none() {
        let dockerfile = generate_dockerfile(&test_contract(), None);
        assert!(!dockerfile.contains("io.hyperi.contract"));
    }

    #[test]
    fn dockerfile_emits_three_identity_labels_when_some() {
        let id = test_identity();
        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
        assert!(dockerfile.contains("LABEL io.hyperi.contract.version=\"v1\""));
        assert!(dockerfile.contains(
            "LABEL io.hyperi.contract.source-commit=\"0123456789abcdef0123456789abcdef01234567\""
        ));
        assert!(dockerfile.contains(
            "LABEL io.hyperi.contract.image-ref=\"ghcr.io/hyperi-io/dfe-loader:v2.7.2\""
        ));
        // The existing io.hyperi.profile label is unaffected.
        assert!(dockerfile.contains("LABEL io.hyperi.profile=\"production\""));
    }

    #[test]
    fn chart_yaml_omits_identity_block_when_none() {
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&test_contract(), dir.path(), None).unwrap();
        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
        assert!(!chart.contains("io.hyperi.contract"));
    }

    #[test]
    fn chart_yaml_emits_three_identity_annotations_when_some() {
        let id = test_identity();
        let dir = tempfile::tempdir().unwrap();
        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
        // Top-level annotations block present.
        assert!(chart.contains("\nannotations:\n"));
        assert!(chart.contains("io.hyperi.contract.version: \"v1\""));
        assert!(chart.contains(
            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
        ));
        assert!(
            chart.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
        );
    }

    #[test]
    fn argocd_application_omits_identity_block_when_none() {
        let argo = ArgocdConfig::default();
        let yaml = generate_argocd_application(&test_contract(), &argo, None);
        assert!(!yaml.contains("io.hyperi.contract"));
        // sync-wave is unaffected.
        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
    }

    #[test]
    fn argocd_application_emits_three_identity_annotations_when_some() {
        let id = test_identity();
        let argo = ArgocdConfig::default();
        let yaml = generate_argocd_application(&test_contract(), &argo, Some(&id));
        // Both the existing sync-wave AND the three identity keys must appear
        // under the same metadata.annotations block.
        assert!(yaml.contains("argocd.argoproj.io/sync-wave:"));
        assert!(yaml.contains("io.hyperi.contract.version: \"v1\""));
        assert!(yaml.contains(
            "io.hyperi.contract.source-commit: \"0123456789abcdef0123456789abcdef01234567\""
        ));
        assert!(
            yaml.contains("io.hyperi.contract.image-ref: \"ghcr.io/hyperi-io/dfe-loader:v2.7.2\"")
        );
    }

    #[test]
    fn all_three_surfaces_share_the_same_key_prefix() {
        let id = test_identity();
        let argo = ArgocdConfig::default();
        let dir = tempfile::tempdir().unwrap();

        let dockerfile = generate_dockerfile(&test_contract(), Some(&id));
        generate_chart(&test_contract(), dir.path(), Some(&id)).unwrap();
        let chart = std::fs::read_to_string(dir.path().join("Chart.yaml")).unwrap();
        let app = generate_argocd_application(&test_contract(), &argo, Some(&id));

        // The documented grep payoff: every surface mentions the prefix
        // exactly three times (once per key).
        assert_eq!(dockerfile.matches("io.hyperi.contract").count(), 3);
        assert_eq!(chart.matches("io.hyperi.contract").count(), 3);
        assert_eq!(app.matches("io.hyperi.contract").count(), 3);
    }
}
